@run402/astro
v3.0.3
Published
Astro integration for Run402. Auth-aware SSR (<SignIn /> / <UserButton /> / <SignedIn> / <SignedOut> + cookie-session integration), pre-encoded image variants (3-width WebP ladder + HEIC display_jpeg + blurhash), and one-line wiring for run402-hosted proj
Readme
@run402/astro
One-line Astro preset for Run402. SSR with sub-second admin-edit visibility, full image-variant pipeline, and full agent-DX: structured errors, debug headers, and CLI integration. Use it as your entire astro.config.mjs:
// astro.config.mjs
import run402 from "@run402/astro";
export default run402();That returns a complete AstroUserConfig with:
output: 'server'(per-route prerender viaexport const prerender = true;)- The Run402 SSR adapter — Lambda-backed (cold starts: Cold starts), AsyncLocalStorage-wrapped request context, ISR cache layer with sub-second invalidation
- The build-time image integration —
<Image>upload pipeline + WebP variant ladder + HEICdisplay_jpeg+ blurhash + CDN-served immutable URLs - Build-time hard-fail detectors for unsupported Astro features (dynamic
<Image src={expr}>, server islands, sessions API) with structuredR402_ASTRO_*errors
Two entry shapes
| | image integration only | full preset |
|---|---|---|
| Export | named run402 (returns AstroIntegration) | default run402 (returns AstroUserConfig) |
| What you get | image integration only | image integration + SSR adapter + Run402-aware defaults |
| astro.config.mjs shape | integrations: [run402()] | export default run402(); |
| Compatibility | — | named run402 is aliased to run402Image, so integrations: [run402()] keeps working |
If you only want the image integration without SSR, use the run402Image() named export.
The SSR runtime
When the preset runs in default mode (output: 'server'), every .astro page is server-rendered by an AWS Lambda function with full Run402 backend access in scope. Inside frontmatter:
---
import { db, auth, cache } from "@run402/functions";
import { Run402Picture } from "@run402/astro/components";
const { slug } = Astro.params;
const user = await auth.user(); // ALS-aware; taints cache so this response is uncacheable
const page = await db()
.from("pages")
.select("*")
.eq("slug", slug)
.maybeSingle();
if (!page) return new Response("Not Found", { status: 404 });
// Opt into the ISR cache layer:
Astro.response.headers.set(
"Cache-Control",
"public, s-maxage=60, stale-while-revalidate=300",
);
---
<Layout title={page.title}>
<Run402Picture asset={page.hero_asset} alt={page.title} priority />
<article set:html={page.html} />
</Layout>When an admin saves a page:
import { db, cache } from "@run402/functions";
await db().from("pages").upsert({ slug, title, html });
await cache.invalidate(`/${slug}`); // sub-second freshnessEvery SSR response includes x-run402-request-id, x-run402-release-id, x-run402-function, x-run402-cache (HIT / MISS / BYPASS), x-run402-cache-reason (on bypass), x-run402-cache-age (on hit), x-run402-locale. When the function throws an uncaught exception, the response carries x-run402-error-code: R402_SSR_RUNTIME_ERROR and x-run402-request-id that you pass to run402 logs --request-id <req> for the full stack.
Build-time env vars do NOT propagate to the SSR runtime
The SSR Lambda runs in a separate process from your build step. Anything exported in your astro build shell (e.g. KYCHON_ANON_KEY, STRIPE_PUBLISHABLE_KEY, a CI-injected secret) is visible during the build only — process.env.YOUR_VAR from inside an SSR-rendered page returns an empty string at request time. This is the correct security posture (build secrets shouldn't ship to a multi-tenant runtime), but it's surprising in retrospect.
Three options if your SSR route needs request-time config:
- Run402 secrets — values you store via
run402 secrets set <key>are injected into the Lambda env asprocess.env.<KEY>at deploy activation. This is the canonical request-time secret path. - Request headers — for per-tenant / per-request values that the gateway already knows (project_id, release_id, locale, user_id, role), read them directly from the Web
Requestheaders:request.headers.get("x-run402-project-id"),request.headers.get("x-run402-user-id"),request.headers.get("x-run402-user-role"). - Bake into the bundle at build — for values that are public and stable across the lifetime of a release (e.g. an analytics site ID), import them in the page module so they get inlined into the bundled SSR source.
The Run402 anon key + service key + project ID + JWT secret + API base ARE auto-injected at deploy time (you'll see RUN402_ANON_KEY, RUN402_SERVICE_KEY, RUN402_PROJECT_ID, RUN402_JWT_SECRET, RUN402_API_BASE in process.env from inside the SSR runtime — those are the platform-managed channel).
Set a stable ASTRO_KEY for every build
Astro encrypts server-island props and action payloads with a key it bakes into the SSR bundle. When ASTRO_KEY is unset, it mints a random key on every build, so the ssr function's code changes every time and is redeployed on every deploy, even when no source changed. The adapter logs a warning (ASTRO_KEY is not set: ...) when an SSR build runs without one.
Generate a key once and export it for every build:
npx astro create-keyStore the printed value as a CI secret (for GitHub Actions, a repository secret mapped with env: ASTRO_KEY: ${{ secrets.ASTRO_KEY }} on the build step), and set the same value for local deploys. Keep it secret: it protects the encrypted props. Rotating it is safe but redeploys ssr once.
The adapter also sorts the asset list in Astro's serialized server manifest (Astro collects it with an unordered glob), so with a stable ASTRO_KEY two builds of the same commit in the same checkout path produce a byte-identical SSR bundle. Builds from different checkout paths still differ: Astro's compiler bakes absolute source paths into the server bundle.
The SSR bundle is production code, minified
buildAstroReleaseSlice (and run402 up / run402 deploy --dir) bundles Astro's server output into the single source of the ssr function. That source counts against your tier's per-function code-size cap, measured in uncompressed bytes before the platform bundles it, and it is parsed on every cold start.
NODE_ENVfollows the build. The adapter records theNODE_ENVthatastro buildran under indist/run402/adapter.json(nodeEnv). The bundler definesprocess.env.NODE_ENVas that value, so packages that pick a build on it, such as react and react-dom, ship only the production build. Astro builds asproductionunless your environment setsNODE_ENV. Any other value compiles JSX for React's development runtime; the adapter logs a warning, and the bundle keeps the development builds to match.- Text stays UTF-8. Non-ASCII characters (a dataset in Hebrew, say) are written as themselves, not as six-byte
\uXXXXescapes, which can nearly double the source of a non-English site. - The bundle is minified. Function and class names are kept, so stack traces still name your components. Pass
buildAstroReleaseSlice(dist, { minify: false })when you need readable output while debugging.
A deploy whose ssr source exceeds the cap is refused when the plan is created, with TIER_LIMIT_EXCEEDED naming the cap and the size.
Memory and timeout of the ssr function
Lambda gives a function CPU in proportion to its memory (1,769 MB is one vCPU), so the ssr function's memory is also how fast your pages render. An SSR function that sets no memory gets its tier's SSR cap: 1,769 MB on team, 256 MB on hobby, 128 MB on prototype. Its timeout defaults to the tier's cap (60 s on team).
Set either one in the config:
// astro.config.mjs
export default run402({
ssr: { memoryMb: 1024, timeoutSeconds: 30 },
});ssr: false still turns the adapter off; an object turns it on with these settings. They are whole numbers (memory 128 to 10,240 MB, timeout 1 to 900 s, checked when the config loads), recorded in dist/run402/adapter.json and sent as the ssr function's config by run402 deploy --dir dist. A value above your tier's cap is refused when the plan is created, with TIER_LIMIT_EXCEEDED. From the SDK, buildAstroReleaseSlice(dist, { memoryMb, timeoutSeconds }) overrides the build's values.
Cold starts
A cold request is one that starts a new Lambda instance: the first after a deploy, the first after about ten idle minutes, or one that arrives while every warm instance is busy. On a site with a handful of visitors nearly every visit is cold, and personal pages cannot come from the ISR cache, so the cold path is the one visitors wait for.
Lambda has no SnapStart for Node.js, so a cold instance starts from nothing. Its init phase gets a much larger share of the CPU than an invocation does: at 512 MB, module work ran about four times faster during init than inside the first request. The runtime therefore does the first request's work during init. It creates the Astro app and evaluates your middleware and every page module before the first request arrives, so module scope runs once per instance, outside any request:
- Put one-time work at module scope. Loading a dataset, building indexes, and creating clients cost the least there.
- Keep request-scoped calls in handlers.
auth.*,cache.*and payment helpers belong in frontmatter, endpoints and middleware. Called at module scope they throwR402_SDK_OUTSIDE_REQUEST_CONTEXT; the runtime logsrun402.ssr.warm_failedwith the module's name, and the pages that need that module fail. - Size still costs. Every byte of the bundle is loaded and parsed on each cold start, and data a page imports is parsed again when its module evaluates. On one site, dropping 2.3 MB of a 4.4 MB JSON dataset from the bundle cut init from 0.83 s to 0.59 s. Import what every page needs; fetch what only some pages need (from the database, or a private asset) when a page asks for it.
- Memory is CPU. Lambda's CPU share grows with the function's memory, for init and invocation alike (Memory and timeout of the
ssrfunction).
Rendering-mode pattern matrix
Astro supports four rendering modes; auth.* calls have different semantics in each. Pick the right mode per page and the rest follows.
| Mode | How to opt in | When to use | Auth + cache |
| ---------------- | ---------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| SSR (default) | The default; no flag needed. | Personalized pages that read the actor. | auth.user() returns the actor; auth.* helpers taint the response so cache bypasses on Set-Cookie / auth. |
| Prerendered | export const prerender = true; in the page. | Pure marketing / docs pages that never see the actor. | auth.* throws R402_AUTH_PRERENDERED. The page is built once and served as a static asset. |
| Server island | Unsupported by this adapter | Build fails with R402_ASTRO_SERVER_ISLAND_UNSUPPORTED | Use SSR or client-hydrated components instead. |
| Client hydrate | A supported framework component with Astro client hydration | Application-specific browser interaction | SignedIn / SignedOut are server components and call auth.user(); adding client:load does not turn them into browser auth clients. |
Pattern picker:
---
// Personalized SSR (default in this scaffold)
import { auth } from "@run402/functions";
const user = await auth.requireUser(); // 303 to /auth/sign-in if anonymous
---
<h1>Hello, {user.email}</h1>---
// Prerendered marketing page
export const prerender = true;
// Do NOT call auth.user() here — it throws R402_AUTH_PRERENDERED at build time
---
<h1>Welcome to the product</h1>Server islands (server:defer and server:only) are rejected at build time by the current adapter. Use the SSR or client-hydrated patterns shown here.
Use SignedIn / SignedOut only in request-time SSR. For browser-only applications, follow the hosted-auth route contract below; do not call server helpers from browser code.
Authentication
Pinned-version caveat: the published 2.5.0 SignIn.astro fails compilation with Astro 7, including when reached through the component barrel. Until a compatible package is released, use the hosted-auth routes and direct component subpaths demonstrated by the complete notes/CMS fixture. That fixture builds on patched Astro 7.3.2; do not downgrade to an unpatched compiler to hide the error. The APIs below describe the native component contract.
Run402 ships a complete multi-tenant auth surface — password, OAuth (Google), passkeys, magic-link, hosted sign-up, and full account management — every ceremony minting a host-only session cookie on the your app's origin. In an Astro project you almost never touch a route or a fetch: you render a component. The four headless components (<SignIn>, <SignUp>, <UserButton>, <AccountSecurity>) own CSRF, freshness step-up, re-auth redirects, session rotation, and the passkey/OAuth ceremonies for you.
Requires @run402/[email protected]+ (the components) and @run402/[email protected]+ (the server-side auth.* namespace, bundled into your SSR runtime automatically).
Which auth tier do I use? (decision tree)
Pick the FIRST row that matches. The everyday Astro answer is always a component — stop at row 1.
- Browser auth in an Astro site → use the components.
<SignIn>/<SignUp>/<UserButton>/<AccountSecurity>. Zero route code, zero fetch, no client JS by default. This covers sign-in, sign-up, sign-out, password change, passkeys, sessions, and identity link/unlink. This is the lede — reach for anything below only when a component genuinely can't express your case. - Browser auth, but NOT Astro (React-only, vanilla, another framework) → drive the hosted
/auth/*routes directly (the documented-advanced contract). The same hosted ceremonies the components POST to:/auth/sign-in,/auth/sign-up,/auth/sign-out,/auth/magic-link/send→/auth/magic-link→/auth/magic-link/confirm,/auth/passkeys/{login,register}/options+/verify,/auth/account/*. (A framework-neutral@run402/browser-authhelper that wraps these is a planned future follow-up — not shipped yet; the hosted routes are the contract until then.) - Machine / server-to-server / mobile (no browser, no cookie) → use the HTTP
/auth/v1/*API with a Bearer JWT. This is the classic non-browser path and is unchanged. - You verify credentials against your OWN store (bcrypt, a custom users table, an external IdP) and want a Run402 browser session →
auth.sessions.createResponseFromTenantAssertion(...)from inside a routed function, gated by theauth.sessionMintcapability. Your app vouches for the user after its own check. See App assertion vs cryptographic proof below. - You hold a verifiable cryptographic proof (a wallet SIWX signature, an OIDC JWT, an admin-registered provider proof) →
auth.sessions.createResponseFromIdentity(...). Run402 itself verifies the proof. This is a distinct trust class from row 4 — not sugar over it.
The components are the answer to the common case. Never hand-roll a
fetch("/auth/...")as your happy path — if you find yourself writing one in an Astro page, you've skipped row 1.
The four components
---
import { SignIn, SignUp, UserButton, AccountSecurity, SignedIn, SignedOut } from "@run402/astro/components";
---
<!-- Sign-in. `methods` renders the email/password form plus any of OAuth,
magic-link, and passkey — each wired to its hosted ceremony for you.
The default <slot/> stays available for white-label extras (e.g. a
"forgot password" link). -->
<SignedOut>
<SignIn returnTo="/portal" methods={["password", "google", "magic_link", "passkey"]} />
</SignedOut>
<!-- Signed-in chrome with a sign-out form (CSRF token embedded for you). -->
<SignedIn>
<UserButton returnTo="/" />
</SignedIn>
<!-- Account-management panel — render only the sections you want, in order. -->
<SignedIn>
<AccountSecurity sections={["password", "passkeys", "sessions", "identities"]} />
</SignedIn>Shipped component surface.
<SignIn>acceptsreturnTo,class, a default<slot/>,methods?: Array<"password" | "magic_link" | "google" | "passkey">, andemailDelivery?: "link" | "code" | "both". Password remains the zero-config method. When magic-link is selected andemailDeliveryis omitted, managed SSR reads provider discovery and requests both only when supported; older gateways fall back to link. Both mode keeps the challenge handle in the originating page, shows one accessible six-digit input, and leaves the emailed link available as fallback. Server form submission works without JS; the enhancement preserves the current page and safely replaces resend handles. Omittingmethods(or passing exactly["password"]) still renders the prior email/password form byte-identically. Never hand-roll the ceremony fetch.
<SignIn>— anonymous-only sign-in. Passmethods={[…]}to render password plus any of OAuth / passwordless email / passkey. For email, the managed default is code-primary with a link fallback when discovery advertises both; setemailDelivery="link"to preserve link-only presentation. The default<slot/>remains for white-label extras.returnTois server-validated.<SignUp>— mirror of<SignIn>, posts to/auth/sign-up. Duplicate-email is non-enumerating (same response shape as a fresh sign-up).<UserButton>— signed-in chrome: the user's email/label + a POST sign-out form carrying the double-submit CSRF token. Renders nothing when anonymous (gate it inside<SignedIn>).<AccountSecurity sections={[…]} />— the component-first account panel. Each requested section is a plain-HTML form POSTing to the hosted/auth/account/*routes; the gateway enforces freshness step-up, session rotation, and host-gating server-side. Sections:"password"— set or change the Run402 password (renders "Set" vs "Change" fromhas_run402_password). Freshness-gated; rotates other sessions on success."passkeys"— show the registered-passkey count + remove one; Add links to the hosted register ceremony (WebAuthnnavigator.credentialsruns on the hosted page, not in the server-rendered form)."sessions"— revoke a single session or Sign out everywhere."identities"— list linked OAuth identities + unlink; Connect runs the link-to-existing-account OAuth ceremony (thestartLinkredirect+proof flow) against the already-signed-in account — this is link-to-existing, NOT sign-in-with.
<SignedIn> / <SignedOut> are conditional-render gates that read await auth.user() server-side; use them to choose the pre- vs post-sign-in surface.
Cold-prompt answers (the canonical one-liners):
- "Add passkey sign-in" →
<SignIn returnTo="/portal" methods={["password", "passkey"]} />— the component renders the passkey button and drives the WebAuthn/options→/verifyround-trip for you. - "Let users change their password" →
<AccountSecurity sections={["password"]} /> - "Sign out everywhere" →
<AccountSecurity sections={["sessions"]} /> - "Sign in against our own users table after bcrypt" → declare
"capabilities": ["auth.sessionMint"]on the function +throw auth.invalidCredentials()on a bad password +return auth.sessions.createResponseFromTenantAssertion({ tenant, user, method: "password" }).
Reading the actor in a page
---
import { auth } from "@run402/functions";
const user = await auth.user(); // Actor | null — the cheap per-request read
// const user = await auth.requireUser(); // 303 to sign-in (HTML) / 401 (JSON) if anonymous
---
{user ? <p>Hello, {user.email}</p> : <a href="/auth/sign-in">Sign in</a>}Gating every page: src/middleware.ts
Astro middleware runs on Run402 inside the same request context as the page, so auth.* works there. Put a members-only gate in one place instead of repeating it in every page and endpoint. auth.role({ from }) resolves the signed-in user from the session cookie and reads their role from your own table (null when they have no row):
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { auth } from "@run402/functions";
const MEMBERS = { table: "members", idColumn: "user_id", roleColumn: "role" };
const OPEN = ["/auth/", "/not-invited"];
export const onRequest = defineMiddleware(async (context, next) => {
const path = context.url.pathname;
if (context.isPrerendered || OPEN.some((p) => path.startsWith(p))) return next();
const user = await auth.user();
if (!user) return context.redirect(`/auth/sign-in?returnTo=${encodeURIComponent(path)}`, 303);
const role = await auth.role({ from: MEMBERS });
if (!role) return context.rewrite("/not-invited");
context.locals.member = { user, role };
return next();
});Pages then read Astro.locals.member without checking again. Cookies set in middleware through context.cookies reach the browser like those set in a page. Prerendered pages are built ahead of time and never pass through the gate, so keep members-only content off them.
Previewing signed in during astro dev
Under plain astro dev there is no session, so auth.user() is null and a members-only site shows only its signed-out side. Don't fake a member under import.meta.env.DEV; run:
run402 dev --as alicerun402 dev mints a 2-hour test session for alice@<project_id>.test.invalid (a watermarked test user, never a real member) and the Run402 adapter runs each astro dev request as that user: auth.user() returns it with is_test: true, auth.requireUser() and auth.role({ from }) pass through your real gate, and db() reads under its row-level security. The banner prints alice's user id beside the email: give alice a role the way you give any member one, a row keyed by that id in your role table (e.g. run402 projects sql "INSERT INTO members (user_id, role) VALUES ('<user id>', 'member')"). To find any project user's id by email, a test user or a real member, run run402 auth users get --email <addr> (r.auth.getUser in the SDK); tenant SQL cannot read the auth schema. Needs RUN402_ANON_KEY in .env.local beside RUN402_PROJECT_ID and RUN402_SERVICE_KEY (print all three with run402 credentials project-keys export --project <project_id> --reveal), and the project's test mode (on by default for prototype; on hobby and team run run402 auth settings --test-mode true). The token never reaches the browser, astro build output never contains the dev middleware, and after two hours requests are served signed out until you restart the command. Rerunning run402 dev (with or without --as) restarts a dev server already running for the project, so the identity you asked for is the one served.
auth.user() is the minimal per-request actor (id, email, sessionId, authTime, amr, …). For the rich settings read — the ownership-qualified account state your account UI branches on — use auth.account.getSecurity() (below). Don't call getSecurity() on every request; auth.user() is the hot-path read.
Authority-aware reality. Credential ownership is not just "is there a session." There are three distinct concerns: a browser session (the cookie that says who's signed in), account-security state (Run402-OWNED credentials — the user's Run402 password, Run402 passkeys, Run402-verified OAuth identities), and app-vouched assertions (a subject your app vouched for via
createResponseFromTenantAssertion, which Run402 did NOT itself verify).getSecurity()keeps these separate by construction.
auth.account.getSecurity() — ownership-qualified account state
The everyday server-side read for a custom account UI (the <AccountSecurity> component reads it for you). Returns AccountSecurity | null (null when anonymous). Every credential field is qualified to Run402 ownership so there's no ambiguous "has_password":
| Field | Meaning |
|---|---|
| has_run402_password | The user has a Run402-owned password set. Drives "Set password" vs "Change password". A app-vouched user with no Run402 password reads false. |
| run402_passkey_count | Number of Run402-registered passkeys across all this user's rpIds. Drives "Add a passkey" / "you have N passkeys". |
| has_run402_passkey_for_current_rp | true / false / null — whether the user has a passkey registered for THIS exact host's rpId (passkeys are per-host; see below). null when not determinable. |
| run402_identities | Run402Identity[] — OAuth/cryptographic identities Run402 itself verified and linked (provider, provider_sub, provider_email, created_at). These are "connected accounts". |
| current_rp_id | The rpId (host) of the current request. |
| passkey_rp_scope | Always "host" today (the forward-compatible "realm" value is reserved but not shipped — see rpId policy below). |
| tenant_assertions | TenantAssertionRef[] — the app-vouched links (issuer, last_amr e.g. ["tenant_password"]). Deliberately separate from run402_identities: a app-vouched identity is NOT a Run402-verified credential. The tenant_* amr prefix makes the provenance visible. |
Why "ownership-qualified." A flat has_password / identities shape can't distinguish "Run402 verified this credential" from "the app told us about this user." That ambiguity is a real footgun: an account-security UI that offers "remove password" against a app-vouched user (who never had a Run402 password) is broken. The qualified fields make every UI branch unambiguous and keep app-vouched provenance from masquerading as a Run402-verified identity.
---
import { auth } from "@run402/functions";
const sec = await auth.account.getSecurity();
---
{sec && (
<ul>
<li>{sec.has_run402_password ? "Password set" : "No password yet"}</li>
<li>{sec.run402_passkey_count} passkey(s){sec.has_run402_passkey_for_current_rp ? " (one for this site)" : ""}</li>
<li>{sec.run402_identities.length} connected account(s)</li>
</ul>
)}The mutation primitives that back the panel (
auth.account.setPassword,auth.account.passkeys.{list,remove},auth.account.identities.{list,unlink,startLink},auth.account.sessions.{list,revoke},auth.account.signOutEverywhere()) exist as an advanced tier for non-Astro / power-user flows, with callee-enforced freshness. They are not the everyday path — render<AccountSecurity>instead. They're documented in the comprehensive SDK reference, not here.
Passkey rpId policy — per-tenant isolation
Run402 passkeys are bound to the exact request host as their WebAuthn rpId. A passkey registered on tenant-a.run402.app is unusable on tenant-b.run402.app — distinct portals are distinct credential realms. This is the shipped behavior and it is intentional: distinct tenants are distinct communities.
- The rpId is the literal host:
tenant-a.run402.app,myportal.run402.com, or a verified custom domain likekychon.com. - Managed subdomains under
*.run402.comand*.run402.appare supported, as are verified custom domains attached to the project. - Public / platform suffixes are rejected as an rpId: bare
run402.com,api.run402.com,www, andpr-*preview hosts all fail origin validation (you can't register a passkey that would span every tenant). - A shared "realm" rpId (one passkey usable across a verified parent domain's children) is out of scope — not shipped.
passkey_rp_scopealways reports"host". It's reserved as a possible future opt-in project config if a real consumer ever needs it; today there's no realm.
If a passkey ceremony 400s with R402_AUTH_PASSKEY_CHALLENGE_INVALID, you almost certainly tried to verify on a different host than you minted the challenge on, or skipped the /options call — drive it through the hosted passkey ceremony (linked from the <SignIn> slot, or /auth/passkeys/login) so /options always precedes /verify on the same host.
Two mint primitives: app assertion vs cryptographic proof
Both produce a host-bound session cookie, but they encode fundamentally different trust and must not be confused:
| | auth.sessions.createResponseFromTenantAssertion(...) | auth.sessions.createResponseFromIdentity(...) |
|---|---|---|
| Trust model | The app vouches. You verified the credential against your OWN store (bcrypt, custom DB, external IdP); Run402 trusts your assertion. | Run402 verifies. You hand Run402 a verifiable cryptographic proof; the platform checks it against the project's registered verifier. |
| Use it for | bcrypt / custom-DB credential bridges, external IdP you've already validated | wallet SIWX signatures, OIDC JWTs, admin-registered custom-provider proofs |
| Gate | Capability-gated: the function must declare "capabilities": ["auth.sessionMint"] in its apply-spec entry. A service key alone is not sufficient (→ R402_AUTH_UNTRUSTED_CONTEXT). Audited (function id, route, host, issuer, subject, amr, IP, UA, request id). | Proof verification is the gate; no auth.sessionMint capability needed. |
| Resulting amr | Platform-derived from method: "password" → tenant_password, "sso" → tenant_sso. The tenant_ prefix marks the provenance. | The amr you pass (the methods the proof attests). |
| Shape | { tenant, user: { id, email, emailVerified, displayName?, avatarUrl? }, method: "password" | "sso" }. user.id must be a stable primary key, not a bare email (→ R402_AUTH_TENANT_SUBJECT_INVALID). | { provider: "wallet" | "oidc" | "custom", subject, proof, amr, createUser? }. |
Rule of thumb: if you ran bcrypt.compare(...) (or any check Run402 can't re-verify), reach for the app assertion. If you hold bytes Run402 can cryptographically verify itself (a signature, a JWT), reach for createResponseFromIdentity. createResponseFromIdentity is not sugar over the app assertion — it's the verifiable-proof class and stays distinct.
Tenant-assertion bcrypt sign-in, end to end:
// run402.config.json — declare the capability on this function's entry:
// { "functions": [{ "name": "sign-in", "capabilities": ["auth.sessionMint"] }] }
import { auth } from "@run402/functions";
export default async (req) => {
const { email, password } = await req.json();
const row = await lookupUser(email); // your own users table
if (!row || !(await bcrypt.compare(password, row.password_hash))) {
throw auth.invalidCredentials(); // → R402_AUTH_INVALID_CREDENTIALS
}
// The app vouches; Run402 mints the host-bound cookie + writes an audit row.
return auth.sessions.createResponseFromTenantAssertion({
tenant: "acme", // → issuer "tenant:acme"
user: { id: row.id, email: row.email, emailVerified: true },
method: "password", // → amr ["tenant_password"]
});
};throw auth.invalidCredentials() is a function call, not a constructor — never new auth.InvalidCredentialsError(). It renders the canonical R402_AUTH_INVALID_CREDENTIALS envelope without minting a session.
Auth error codes & fixes
Every auth error carries a structured envelope with a next_actions[].fix payload — a component snippet, an apply-spec snippet, or a one-line runtime fix — so the failure teaches the exact edit. Full reference for the whole R402_AUTH_* family lives at https://run402.com/errors/. The codes introduced with hosted-surface-parity:
| Code | HTTP | Fires when | Fix |
|---|---|---|---|
| R402_AUTH_INVALID_CREDENTIALS | 401 | A tenant-owned credential check failed (your bcrypt/custom verify returned false). | if (!ok) throw auth.invalidCredentials(); |
| R402_AUTH_MAGIC_LINK_INVALID | 400/410 | The magic-link token is unknown, expired, or already consumed (uniform body — single-use replay looks identical to unknown). | Re-send a fresh link: <SignIn methods={["magic_link"]} />. |
| R402_AUTH_EMAIL_CODE_INVALID | 401 | The challenge/code did not verify and attempts may remain. | Correct the code or resend through <SignIn methods={["magic_link"]} />; never put it in a URL/log. |
| R402_AUTH_EMAIL_CODE_EXHAUSTED | 410 | Five well-formed incorrect codes burned the challenge. | Request a new email; the component replaces the handle only after acceptance. |
| R402_AUTH_UNTRUSTED_CONTEXT | 403 | createResponseFromTenantAssertion ran in a function that didn't declare the auth.sessionMint capability (a service key is NOT sufficient). | Add "capabilities": ["auth.sessionMint"] to the function's apply-spec entry. |
| R402_AUTH_PASSKEY_CHALLENGE_INVALID | 400 | A passkey /verify ran without a valid, same-origin, actor-or-pending-signup-bound challenge (skipped /options, or wrong host). | Drive the ceremony through the component: <SignIn methods={["passkey"]} />. |
| R402_AUTH_TENANT_SUBJECT_INVALID | 400 | A app assertion was missing tenant/user, or user.id was a bare email instead of a stable primary key. | Pass a structured user: createResponseFromTenantAssertion({ tenant, user: { id: user.id, email: user.email, emailVerified: true }, method: "password" }). |
| R402_AUTH_RENAMED_EXPORT | 400 | Called the removed auth.identities.link. Identity link/unlink moved under auth.account.identities.*; linking is the startLink ceremony. | auth.account.identities.startLink({ provider: "google", redirectUrl: "/settings/security" }) or <AccountSecurity sections={["identities"]} />. |
Two pre-existing codes were enriched with fix payloads in this release (names unchanged): R402_AUTH_CSRF_ORIGIN_MISMATCH (submit from a Run402 component / same-origin form) and R402_AUTH_PRERENDERED (export const prerender = false;). At deploy time, run402 doctor statically detects a createResponseFromTenantAssertion call whose function lacks the auth.sessionMint capability and emits R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING with the exact spec edit — catching the footgun before it becomes a runtime 403.
Hosted sign-in errors render themselves (<SignIn>)
A failed hosted OAuth sign-in — e.g. a Google account whose domain is not in the project's allowed_email_domains (set via run402 auth settings --allowed-email-domains …) — is returned to your sign-in page with a server-readable ?r402_auth_error=<code> query param, and <SignIn> renders the message for you. Server-side, no client JS, zero extra code:
---
import { SignIn } from "@run402/astro/components";
---
<SignIn returnTo="/admin" methods={["google"]} />A @gmail.com user rejected from a Workspace-restricted admin tool lands back on this page with "This site is restricted to approved email domains. Sign in with your work account." rendered above the form. <SignIn> emits its own URL as the error-return target on the OAuth start link, so the round-trip is automatic — you never touch a query param or the URL hash. Known codes (domain_not_allowed, account_exists_requires_link, identity_already_linked) get specific copy; anything else (transient/infra) falls back to a generic "Sign-in could not be completed. Please try again." The block carries role="alert" and the .r402-auth-error class — restyle via your own CSS or the --r402-error-fg / --r402-error-bg / --r402-error-border custom properties. With no error param the render is byte-identical to before — nothing extra ships.
<Run402Picture> — runtime CMS images
For images coming from a DB row at SSR time (the common CMS pattern), use <Run402Picture asset={page.hero_asset}>. The asset prop is the AssetRef JSONB that r.assets.put() returned at upload time — store the whole object, not just the URL, then render directly.
---
import { Run402Picture } from "@run402/astro/components";
---
<Run402Picture
asset={page.hero_asset}
alt={page.title}
sizes="(min-width: 768px) 50vw, 100vw"
priority
/>Behavior:
- Emits
<picture>with WebP srcset (thumb320w /medium800w /large1920w) when the AssetRef has the full variant ladder - HEIC sources fall back to
variants.display_jpeg.cdn_urlfor the<img> - Missing variants → safe single
<img>fromdisplay_url/cdn_url, plus a runtimeconsole.warn - URLs validated against unsafe schemes (
javascript:etc.) at render time priority→fetchpriority="high"+loading="eager"+decoding="sync"blurhashdata-attribute emitted when present (decoder ships at@run402/astro/blurhashfor client hydration)width/heightattributes fromasset.width_px/asset.height_pxfor CLS prevention
For static template-literal images (e.g., <Image src="./hero.jpg">), use the build-time <Image> — same upload pipeline, build-time srcset emission, no runtime data needed.
<Run402Image> — pre-decoded placeholders + strict-mode degradation detection
<Run402Image> is the sibling of <Run402Picture> — same shape (asset={AssetRef} + alt + sizes), but with three additions that matter at scale:
- Pre-decoded blurhash placeholder. The gateway pipeline pre-computes the blurhash → PNG data URL at upload time and stamps it on
AssetRef.blurhash_data_url.<Run402Image>emits it as the<img>element'sbackground-imageso the placeholder is visible during fetch with zero client-side decode + zero SSR-render CPU cost. - Strict mode.
imageDefaults: { strict: { onSchema: ">=v1.49" } }makes the component hard-fail when an AssetRef would render below that schema target — catches the "28 of 30 assets render correctly and 2 silently degrade" failure mode at build time rather than at user-visible time. The schema-filter form skips AssetRefs below the target, so mixed-vintage CMS projects can adopt safely. - React entry point. Same component shape, importable from
@run402/astro/reactfor React islands or React-only consumers. Byte-identical HTML output to the Astro path, except that React writes some attribute names in camelCase (srcSet=,fetchPriority=,referrerPolicy=,imageSrcSet=,imageSizes=). HTML attribute names are case-insensitive, so browsers parse both the same way.
Quick start
---
// Astro entry
import { Run402Image } from "@run402/astro/components";
---
<Run402Image asset={page.hero_asset} alt={page.title} sizes="100vw" priority />// React entry
import { Run402Image } from "@run402/astro/react";
import type { AssetRef } from "@run402/functions";
export function Hero({ asset }: { asset: AssetRef }) {
return <Run402Image asset={asset} alt="..." sizes="100vw" priority />;
}When to use <Run402Image> vs <Run402Picture>
| | <Run402Picture> | <Run402Image> |
|---|---|---|
| Pre-decoded blurhash placeholder | ✗ | ✅ |
| Strict-mode degradation detection | ✗ | ✅ |
| React entry point | ✗ (Astro only) | ✅ Astro + React |
| Default-mode degradation manifest | ✗ | ✅ |
| Behavior on legacy AssetRefs | renders best-effort silently | renders best-effort + records to manifest (or hard-fails under strict) |
If your tenant has heavy mixed-vintage data + you want a CI regression gate against silent degradation, use <Run402Image> with imageDefaults: { strict: { onSchema: ">=v1.49" } }. For simple existing pages where best-effort silent rendering is fine, <Run402Picture> stays the lighter choice.
Configuration
// astro.config.mjs
import run402 from "@run402/astro";
export default run402({
imageDefaults: {
strict: { onSchema: ">=v1.49" }, // mixed-vintage projects (Kychon shape)
// OR: strict: true, // greenfield, every render strict-checked
// OR: strict: false, // explicit lenient (default)
placeholder: "auto", // render placeholder if blurhash_data_url present
},
});HEIC precondition
If your tenant has HEIC AssetRefs with no display_jpeg variant and you want to enable schema-filtered strict mode, run the asset-image-variants-v1-51 backfill with --regenerate-heic-transcodes first. Without it, <Run402Image> hard-fails with R402_ASTRO_IMAGE_HEIC_NO_TRANSCODE on every such HEIC render. See the run402-private repo's docs/migrations/asset-image-variants-v1-51-backfill.md for the person workflow.
Error codes
All R402_ASTRO_IMAGE_* codes are documented at https://run402.com/errors/ — see the index for the full list including:
_ASSET_MISSING/_ASSET_STRING_URL/_ASSET_WRONG_SHAPE— input validation failures_NON_IMAGE_ASSET—content_typeis notimage/*_ALT_REQUIRED—altprop missing_CONFLICTING_CLASS_PROPS— bothclassandclassNamepassed_CONFLICTING_LOADING_PROPS—priority={true}+loading="lazy"_HEIC_NO_TRANSCODE— HEIC source missingdisplay_jpeg(hard-fail floor)_SIZES_REQUIRED— multi-variant AssetRef withoutsizesprop_STRICT_DEGRADED— strict-mode hit a missing field (carriessubcode)_RESERVED_DATA_ATTR— caller passed reserveddata-run402-image_WRONG_ENTRY_POINT— JS consumer imported the wrong entry point
CLI
The Run402 CLI ships everything an agent needs to scaffold, develop, deploy, and debug an Astro project:
run402 doctor --json # verify environment
run402 init astro my-portal # scaffold a deployable project
run402 dev # local dev with Run402 env injected
run402 deploy --json --verify /,/the-guys # build + deploy + smoke-test
run402 cache inspect https://eagles.kychon.com/the-guys --json
run402 cache invalidate https://eagles.kychon.com/the-guys
run402 logs --request-id req_xyz123 --json # debug a failed renderrun402 init astro creates a working project with package.json (dev/deploy scripts), astro.config.mjs (one-line preset), sample [slug].astro with the full DB-fetch + cache pattern, an admin save endpoint demonstrating cache invalidation, and .env.example. See run402 init astro --help.
Deploying with the SDK directly (and from CI)
Most projects deploy with run402 deploy (above) or run402 deploy --dir dist. If you write your own deploy script with @run402/sdk — e.g. a CI job that assembles a custom ReleaseSpec — turn the build into a deploy slice with the one canonical helper. Do not hand-roll site / public_paths.
import { run402 } from "@run402/sdk/node";
import { buildAstroReleaseSlice } from "@run402/astro/release-slice";
const slice = await buildAstroReleaseSlice("dist"); // point at the BUILD ROOT, not dist/run402/client
const project = await run402().project(projectId);
await project.apply({
database: { migrations }, // your own cross-cutting slices
...slice, // site + functions (routes intentionally omitted)
});buildAstroReleaseSlice is the only supported way to map an Astro build to a ReleaseSpec. It:
- roots the site at
dist/run402/client/(the served output) — notdist/; a build with no static output (every page server-rendered, nopublic/) leaves that directory empty, and the site is then the empty file set, so the release has no static files and every path reaches thessrfunction; - sets
site.public_paths: { mode: "implicit" }so prerendered pages are reachable by filename; - bundles the SSR entry into a single
class: "ssr"function; - omits
routes(it is not in the returned object) so base-release routes — e.g. a separately-declared/api/*function — carry forward instead of being cleared, and the slice stays safe to submit from a CI OIDC session that has no route scopes.
Anti-pattern: do not point fileSetFromDir/dir() at dist/ and build public_paths by hand. That ships the adapter build tree (run402/adapter.json, run402/server/**) as static assets and lands every page under a run402/client/ path prefix — so the static manifest has no reachable pages, every URL falls through to the SSR catchall and 404s, and the SSR bundle becomes publicly downloadable. The SDK now rejects this locally with ASTRO_ADAPTER_TREE_IN_SITE before any upload, and the gateway warns (SITE_NO_REACHABLE_HTML) when a release ships HTML that isn't reachable at any public path.
Full vs CI/patch deploys use the same slice. CI sessions are content-only (no subdomains/routes/i18n) — the slice already omits routes, so just don't add routes/subdomains/i18n to the spec in CI. The CAS substrate dedupes unchanged bytes, so a site.replace from the slice uploads only what actually changed; you don't need a hand-rolled site.patch diff to get incremental uploads.
R402_* error codes
Build / deploy / runtime / cache failures all return a structured envelope:
{
"ok": false,
"code": "R402_ASTRO_DYNAMIC_IMAGE_UNSUPPORTED",
"message": "...",
"suggestedFix": "Store the AssetRef returned by assets.put() and render with <Run402Picture asset={...}>",
"docs": "https://docs.run402.com/llms-cli.txt#r402_astro_dynamic_image_unsupported",
"file": "src/pages/[slug].astro",
"line": 14
}The 17 reserved codes for the SSR runtime:
- Build-time —
R402_ASTRO_BUILD_FAILED,R402_ASTRO_UNSUPPORTED_OUTPUT,R402_ASTRO_MIDDLEWARE_UNSUPPORTED,R402_ASTRO_SERVER_ISLAND_UNSUPPORTED,R402_ASTRO_SESSIONS_UNSUPPORTED,R402_ASTRO_DYNAMIC_IMAGE_UNSUPPORTED,R402_ASTRO_VERSION_UNSUPPORTED,R402_BUNDLE_UNRESOLVED_IMPORT,R402_BUNDLE_NATIVE_DEP_UNSUPPORTED - Runtime —
R402_SDK_OUTSIDE_REQUEST_CONTEXT,R402_SSR_RUNTIME_ERROR,R402_SNAPSTART_INIT_IO - Cache —
R402_CACHE_UNSUPPORTED_VARY,R402_CACHE_AUTH_TAINTED(informational),R402_CACHE_INVALIDATION_HOST_REQUIRED,R402_CACHE_INVALIDATION_HOST_FORBIDDEN - Deploy —
R402_DEPLOY_STAGE_FAILED
Every code has a suggestedFix field that an AI coding agent can act on directly.
R402_ASTRO_MIDDLEWARE_UNSUPPORTED is reserved and nothing raises it: src/middleware.ts is supported, and it is the recommended place for an auth or membership gate (see "Gating every page" above).
Image integration only
The rest of this README covers the build-time image integration. It works under the full preset (which composes it automatically) AND is available as the standalone run402Image() named export for users who want it without the SSR adapter.
Before you start
Four prerequisites must be true before astro build produces working <picture> markup. If any of these is missing, the build fails with an actionable error pointing at the exact CLI command to run — but skimming this checklist first saves a round-trip.
1. Project ID is set
# Either env var:
export RUN402_PROJECT_ID="prj_..."
# Or pass via the integration:
# astro.config.mjs → run402({ projectId: 'prj_...' })The project ID is needed only when the build has images to upload: a site with no <Image> references and no assetsDir runs astro dev and astro build without one, and a build that does have images fails with RUN402_ASTRO_MISSING_PROJECT_ID naming how many.
2. Auth path matches your environment
The integration auto-detects which path you're on:
# Locally — provisions ~/.config/run402/projects.json
run402 login <project_id>
# In CI (GitHub Actions) — workflow needs id-token: write AND a Run402 binding for the repo
run402 ci link github --project <project_id> --repo <owner/repo>GitHub Actions detection is automatic when GITHUB_ACTIONS=true is set (which GitHub sets for you). For non-GitHub CI, pass an explicit credentials provider via run402({ credentials: ... }).
3. CI binding has asset_key_scopes for your prefix
CI bindings are closed-by-default for the spec.assets slice. Grant the integration's default astro/ prefix once per binding:
run402 ci list --project <project_id> # find the binding id
run402 ci set-asset-scopes <binding_id> 'astro/*' # grant the prefixIf you customized assetPrefix in run402({ assetPrefix: 'my-app/' }), grant 'my-app/*' instead. Local-laptop wallet deploys skip this check; only CI sessions hit it.
Transferred a project? Re-link, don't re-scope (cause of kychee-com/run402#470). If the build fails with CI_BINDING_REVOKED ("the CI/OIDC binding was revoked"), the binding was revoked — most often because the project was transferred or handed to a new owner, which suspends the prior org's CI bindings. The fix is to re-create the binding, not to widen asset scopes (set-asset-scopes returns 409 on a revoked binding):
run402 ci link github --project <project_id> --repo <owner/repo>4. Image CSS uses height: auto (or aspect-ratio)
The <Image> component emits explicit width/height HTML attributes from the source's intrinsic dimensions to prevent cumulative layout shift (CLS). Pair this with height: auto (or aspect-ratio: <w>/<h>) in your CSS, otherwise responsive width: 100% rules will stretch images vertically:
/* In your global stylesheet — required for any responsive <Image> usage */
img {
max-width: 100%;
height: auto;
}This is the same CLS-prevention contract as Next.js's <Image>. v0.1.x doesn't check this at build time; it's docs-only because consumer CSS can be arbitrarily complex.
Two consumer shapes
@run402/astro covers both shapes of Astro site:
Static-template sites (hero on the home page, logos in nav, hand-authored landing pages). Image references are string literals in .astro templates. Use <Image src="./images/hero.jpg" alt="...">. The integration scans your templates at build time, uploads each unique source, and rewrites the markup to consume v1.49 variants. See the Use section below.
Data-driven sites (CMS-backed content, DB-backed seeds, MDX collections with frontmatter images, admin-editable pages). Image references live in runtime values — JSONB rows, content collection entries, fetch responses. There are no <Image> candidates for a build-time scan. Two patterns cover this shape: persist the full AssetRef returned by r.assets.put in your data row (recommended whenever you control the schema — no manifest, no lookup, no cache), or use the assetsDir + manifest pattern when the data shape isn't yours to change. See the Data-driven consumers section below.
A real Astro site usually has both. Set both options; they share the same upload pipeline, the same cache, the same CDN.
Why
Run402 pre-encodes 3 WebP variants (320w / 800w / 1920w) + a display-friendly JPEG for HEIC sources + a blurhash placeholder for every image uploaded via the assets slice. Variants serve from CloudFront like any other static URL. This package wires that pipeline into Astro's build: walk your <Image> references, upload each unique source, render <picture> markup that consumes the variants.
Compared to Next.js's <Image> model: Vercel transforms images lazily via Lambda on cache miss. Run402's variants are encoded once at upload time and served as static immutable assets - no per-request transform cost.
Install
npm install @run402/astro @run402/sdkAstro 6 or Astro 7 is supported (peer dependency, optional declaration so install never blocks). The SSR adapter requires Astro 6+ at runtime because its server entry imports astro/app/entrypoint; the build-time image integration alone may run on older Astro releases, but Astro 5 is outside the supported peer range. Astro 7 compatibility does not add support for Astro 7-only route caching or advanced routing through src/fetch.ts in Run402; Run402's auth-aware SSR cache and deploy route model remain the supported path.
Configure
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { run402 } from '@run402/astro';
export default defineConfig({
integrations: [run402()],
});Set RUN402_PROJECT_ID in your environment (or pass run402({ projectId: 'prj_...' })). See the "Before you start" section above for the full credential + binding setup.
For non-GitHub CI (GitLab, CircleCI, etc.), or to wire a custom credential provider, pass credentials explicitly:
import { githubActionsCredentials } from '@run402/sdk/node';
// or your own credential factory
export default defineConfig({
integrations: [run402({
projectId: 'prj_...',
credentials: yourCustomCredentialProvider,
})],
});Locally (no GITHUB_ACTIONS), the SDK's NodeCredentialsProvider reads ~/.config/run402/projects.json — same as the rest of the Run402 SDK / CLI tooling.
Use
---
import Image from '@run402/astro/Image.astro';
---
<Image src="./images/hero.jpg" alt="Sunset over the Pacific" sizes="100vw" priority />
<Image src="./images/team-photo.heic" alt="Team retreat 2026" sizes="(min-width: 768px) 50vw, 100vw" />src is resolved relative to the importing .astro file. TypeScript path aliases (@/*) also work if you have them in tsconfig.json.
Note on the import shape. .astro components have a single default export, so import Image from '@run402/astro/Image.astro' (default-import, subpath) is the only correct form. There is no import { Image } from '@run402/astro' named export — anything imported from @run402/astro must evaluate cleanly under vanilla Node so it can be loaded from astro.config.mjs before Vite is alive, and a top-level re-export of an .astro module breaks that boundary.
Data-driven consumers (v0.2+)
For sites where image references live in runtime values (CMS-backed content, DB-backed seeds, JSON content, MDX frontmatter, admin-uploaded media), there are two patterns. Pick AssetRef persistence whenever you control the data shape; reach for the build-time manifest when you don't, or when you have data-driven keys to surface inside .astro templates.
Persistence pattern: store the AssetRef, not the URL (recommended)
r.assets.put returns the full AssetRef — cdn_url, intrinsic width_px / height_px, blurhash, the WebP variant ladder, the HEIC display_jpeg when present. Persist the whole ref in the same row as everything else about the asset, instead of keeping only the URL string. At render time the row IS the variant data: no manifest, no lookup, no cache, no synchronization layer between the row and the manifest. The row is internally consistent with what gets rendered.
This pattern covers both runtime-uploaded media (admin MediaPicker calling r.assets.put directly) and static seed data (a build step that walks assetsDir and writes the resolved ref into the seed JSON instead of, or alongside, the URL string).
Schema shift.
Before — URL string + manifest lookup at render time:
// row in DB / seed JSON
type Section = { bg_image: string }; // "/assets/hero.jpg"
// render
const section = await db.sections.findOne(...);
const key = section.bg_image.replace(/^\/assets\//, '');
const ref = resolveVariants(manifest, key);
const html = ref
? renderPicture(ref, { alt, sizes: '100vw' })
: `<img src="${section.bg_image}" alt="${alt}">`;After — full AssetRef stored on write:
// row in DB / seed JSON — what r.assets.put returned
type Section = { bg_image: AssetRef };
// { cdn_url, width_px, height_px, blurhash, variants: { thumb, medium, large, display_jpeg? }, ... }
// render — no manifest, no lookup, no cache
const section = await db.sections.findOne(...);
const html = renderPicture(section.bg_image, { alt, sizes: '100vw' });The MediaPicker / admin upload flow already has the AssetRef in hand — it's the return value of r.assets.put. Today's code typically drops everything except cdn_url; the persistence pattern is "save what put() returned, render directly from it."
Trade-offs.
- Row size grows by ~600–1000 bytes per image. Most of that is the variants ladder (3–4 entries × ~150 bytes each). For JSONB columns and content-collection JSON this is rarely an issue; for narrow indexed text columns it matters more.
- Immutable URLs are content-addressed. Re-uploading to the same key while old refs remain embedded in rows will not refresh those rows — they continue serving old bytes. Either upload to a fresh key on each edit (the typical pattern), or rewrite the row at upload time so its embedded ref points at the new content.
- Migrating existing string-URL rows is one-shot and mechanical. If you already use
assetsDir, the build-time manifest already contains the canonical ref for every seeded image: a small script walks your rows, looks each URL up withresolveVariants(manifest, key), and writes the ref back. After that runs once, the runtime manifest lookup is dead code. For URLs that aren't in the manifest (admin uploads from before this pattern landed), re-callr.assets.put(key, source)with the source bytes — CAS dedup makes this idempotent (same bytes → same ref) and you get the full AssetRef back without a duplicate upload.
Private images: sign the AssetRef
An image uploaded with visibility: "private" gets the same variant ladder, but its AssetRef has every URL field null, so nothing above can render it. signAssetRef(ref, signMany) signs the original and each variant in one request (POST /storage/v1/blobs/sign) and returns an AssetRef with url / cdn_url / display_url and every variants.<kind> URL filled in, immutable URLs dropped. <Run402Picture>, <Run402Image> and renderPicture render it unchanged, srcset and HEIC display_jpeg fallback included:
---
import { assets, auth } from "@run402/functions";
import { signAssetRef } from "@run402/astro/manifest";
import { Run402Picture } from "@run402/astro/components";
await auth.requireUser();
const photo = await signAssetRef(row.photo, assets.signMany);
---
<Run402Picture asset={photo} alt={row.caption} />A page of photos signs every ref in one request with signAssetRefs(refs, signMany), which returns the signed refs in order (a gallery of 57 photos is one round trip, not 228):
---
const photos = await signAssetRefs(rows.map((r) => r.photo), (items) =>
assets.signMany(items, { ttl_seconds: 3600 }));
---
{photos.map((photo, i) => <Run402Picture asset={photo} alt={rows[i].caption} />)}The page now carries URLs that expire, so render it per request (reading the session already keeps it out of the ISR cache) and pick a TTL longer than a visit. The signer is any (items) => Promise<{ items }> that answers { key, variant? } items in order with signed_url: assets.signMany from @run402/functions as it stands, and outside a function (items) => r.assets.signMany(projectId, items) from @run402/sdk. An item the signer could not sign (a key deleted since the row was written) throws, naming the key and variant.
Build-time manifest pattern
Useful when:
- The data shape is not yours to change (a CMS that only stores strings; a schema owned by another team).
- You're writing the one-shot migration described above (read URL → look up ref → write ref back).
- You have data-driven keys that need to be resolved from
<Image>markup in static.astrotemplates.
For new data-driven consumers, prefer the persistence pattern above — it has fewer moving parts and no stale-after-edit problem.
Set assetsDir in astro.config.mjs:
export default defineConfig({
integrations: [
run402({
assetsDir: 'src/cms-images', // or ['demo/eagles/assets', 'demo/silver-pines/assets']
manifestPath: 'dist/_assets-manifest.json', // optional; this is the default
}),
],
});buildStart walks the directory recursively, uploads every image file (extensions: .jpg/.jpeg/.png/.webp/.avif/.heic/.heif), and closeBundle writes a manifest JSON.
Manifest shape:
{
"version": 1,
"project_id": "prj_...",
"asset_prefix": "astro/",
"generated_at": "1970-01-01T00:00:00.000Z",
"assets": {
"hero.jpg": {
"key": "astro/hero.jpg",
"sha256": "abc123...",
"width_px": 1920,
"height_px": 1080,
"blurhash": "L6PZfSi_...",
"cdn_url": "https://cdn.run402.com/.../hero.jpg",
"display_url": "https://cdn.run402.com/.../hero.jpg",
"variants": {
"thumb": { "cdn_url": "...", "width_px": 320, "height_px": 180, "format": "webp", ... },
"medium": { ... },
"large": { ... }
}
}
}
}Keys are paths relative to the assetsDir (preserving nesting: avatars/01.jpg → "avatars/01.jpg").
The manifest is deterministic: keys are sorted and generated_at is SOURCE_DATE_EPOCH when set, otherwise the Unix epoch, so rebuilding the same commit produces a byte-identical manifest and SSR bundle (the manifest is also bundled into the SSR function, and a changing timestamp would redeploy it on every build).
Render-time consumption:
import { resolveVariants, renderPicture } from '@run402/astro/manifest';
import manifest from '../../dist/_assets-manifest.json';
function renderHeroImage(imageUrl: string, alt: string): string {
// imageUrl came from a database row: '/assets/hero.jpg'
const key = imageUrl.replace(/^\/assets\//, '');
const ref = resolveVariants(manifest, key);
if (!ref) {
// Fallback: not in manifest (admin-uploaded post-deploy, etc.)
return `<img src="${imageUrl}" alt="${alt}">`;
}
return renderPicture(ref, { alt, sizes: '100vw', priority: true });
}renderPicture produces the same <picture> HTML the static <Image> component does, with the same CLS-prevention contract (#4 in Before you start). No Vite or Astro runtime dependency — safe to import from any SSR / SSG / API-route module. It accepts any AssetRef, whether resolved from the manifest or read straight off a row, so the persistence pattern and the manifest pattern share the same renderer.
Attaching app-specific attrs to the <picture> element (v0.2.5+). Pass pictureAttrs to splice data-*, id, role, or any other HTML attribute onto the outer wrapper without forking the renderer:
renderPicture(ref, {
alt,
sizes: '100vw',
priority: true,
pictureAttrs: { 'data-hero-picture': '', 'data-hero-aspect': '21/9' },
});
// → <picture data-hero-picture="" data-hero-aspect="21/9">…</picture>Keys are validated against [a-zA-Z][a-zA-Z0-9-]* and silently dropped if invalid; values are HTML-attribute-escaped. When the source falls back to a single <img> (sub-320 / no variants), the attrs land on the <img> instead — the wrapper-most element either way.
Decoding a blurhash on your own (v0.2.5+). If you're emitting custom <picture> markup that bypasses renderPicture entirely, the LQIP helpers ship as a subpath export:
import { decodeBlurhashToDataUri, averageColorFromBlurhash } from '@run402/astro/blurhash';
const lqip = ref.blurhash ? decodeBlurhashToDataUri(ref.blurhash) : null;
// → 'data:image/png;base64,…' (32×32 PNG, ≈600 bytes)Both functions are pure over the blurhash string — no I/O, no Vite virtual modules, byte-equivalent to Wolt's [email protected] reference (see blurhash-decoder.test.ts). Closes run402-private#414.
Combining both paths. Set BOTH assetsDir and use <Image> for static-template images. The integration deduplicates by absolute path + CAS dedup at the gateway, so an image referenced via both paths uploads once.
Reading the manifest during astro build (v0.2.4+)
The manifest JSON is written at closeBundle time — after Astro renders pages. If your bake step needs the manifest during page render (e.g. you're emitting <picture> HTML directly into dist/index.html from a typed seed), you can't read the file from disk in .astro frontmatter because it doesn't exist yet. Use getBuildTimeManifest() from @run402/astro/build-manifest instead — it returns the same shape, sourced from the integration's virtual module:
---
// src/pages/index.astro
import Portal from '../layouts/Portal.astro';
import { getBuildTimeManifest } from '@run402/astro/build-manifest';
import { resolveVariants, renderPicture } from '@run402/astro/manifest';
import { mySectionsFromSeed } from '../lib/my-bake';
const manifest = getBuildTimeManifest();
const sectionsHtml = mySectionsFromSeed(manifest)
.map(s => renderSection(s, manifest))
.join('');
function renderSection(s, manifest) {
const ref = manifest ? resolveVariants(manifest, s.image_key) : null;
const img = ref
? renderPicture(ref, { alt: s.alt, sizes: '100vw', priority: s.priority })
: `<img src="${s.image_url}" alt="${s.alt}">`;
return `<section class="${s.cls}">${img}</section>`;
}