npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@run402/functions

v4.17.0

Published

In-function helper library for Run402 serverless functions - db, adminDb, auth, email, ai, assets, verifyWebhook. Auto-bundled into deployed functions; also installable for local TypeScript autocomplete.

Readme

@run402/functions

In-function helper library for Run402 serverless functions. Imported inside a deployed function — gives you typed access to the caller's database (RLS-respecting) and the project's admin database, the caller's auth, the project's mailbox, AI helpers, runtime asset uploads, the project's cursored event feed, web push notifications to the app's users' phones, and the project's app repos (git repositories the app reads and commits).

Run402's first-class people/agent control-plane principals are distinct from the deployed app's tenant callers described in this package. Here, auth.user(), verified tenant identity, and RLS identify an end user of the application; they do not expose or replace the Run402 organization membership, grant, grant key, or Buzz identity model.

import { db, adminDb, auth, email, ai, assets } from "@run402/functions";

export default async (req: Request) => {
  const user = await auth.user();
  if (!user) return new Response("unauthorized", { status: 401 });

  const mine = await db(req).from("items").select("*").eq("user_id", user.id);
  return Response.json(mine);
};

This package is auto-bundled into every deployed function zip at deploy time — you don't need to declare it in --deps. Install it locally only when you want TypeScript autocomplete in your editor while authoring function code.

Install (local autocomplete)

npm install @run402/functions

The two DB clients

The most important distinction in this library: db(req) runs as the caller, adminDb() bypasses RLS.

db(req).from(table) — caller-context

Forwards the request's Authorization header to PostgREST. Row-Level Security policies evaluate against the caller's role — anon, authenticated, project_admin, or whatever the JWT carries. This is the default choice. Routes to /rest/v1/*.

// Reads everything the caller is authorized to see — could be 0 rows for unauthenticated callers.
const mine = await db(req).from("items").select("title, done").eq("user_id", user.id);

// Writes go through RLS too. If the policy says the caller can't insert, it errors.
const [created] = await db(req).from("items").insert({ title: "New", done: false });

adminDb().from(table) — bypass RLS

Uses the project's service_key. Returns all rows regardless of RLS. Routes to /projects/v1/:project_id/rest/* (the gateway rejects role=service_role on /rest/v1/*, so bypass traffic lives on its own surface), and adminDb().sql() to /projects/v1/:project_id/sql. Both send Run402-Client: surface="function", function="<name>".

Use only when the function acts on behalf of the platform, not the caller — audit logs, cron cleanup, webhook handlers, fan-out writes after a Stripe event.

// Audit log — capture every event regardless of who triggered the function.
await adminDb().from("audit_log").insert({ event: "payment.succeeded", user_id: userId });

// Cron cleanup — there's no caller to evaluate RLS against.
await adminDb()
  .from("sessions")
  .delete()
  .lt("expires_at", new Date().toISOString());

Fluent surface (same on both clients)

.select(cols?)
.eq(col, val) / .neq() / .gt() / .lt() / .gte() / .lte()
.like(col, pattern) / .ilike(col, pattern)
.in(col, [vals])
.order(col, { ascending? })
.limit(n) / .offset(n)

// Terminal row modes (3.12.0+) — call last in the chain:
.single()           // resolves the row OBJECT; 0 or >1 rows → R402DbError (status 406)
.maybeSingle()      // resolves the row object or null; >1 rows → R402DbError (status 406)

// Writes return arrays of affected rows.
.insert(obj | obj[])
.update(obj)        // chain with .eq() to scope
.delete()           // chain with .eq() to scope

// Column narrowing on writes:
.insert({ title: "x" }).select("id, title")
// Fetch-one idiom — no more [row] destructuring or length checks:
const item = await db(req).from("items").select("*").eq("id", id).maybeSingle();
if (!item) return new Response("not found", { status: 404 });

Row-count violations throw the same R402DbError as other builder failures (code: "R402_DB_QUERY_ERROR", status: 406, fingerprint-stable messages like PostgREST error (406): single() got 0 rows) — branch on the properties, not the message. Note the builder throws on errors; it never returns a supabase-style { data, error } envelope.

adminDb().sql(query, params?) — raw SQL, always BYPASSRLS

const { rows, row_count } = await adminDb().sql(
  "SELECT count(*)::int AS n FROM items WHERE user_id = $1",
  [userId],
);
// { status: "ok", schema: "p0001", rows: [{ n: 42 }], row_count: 1,
//   fields: [{ name: "n", type: "int4" }] }

Returns AdminSqlResult (exported type): { status, schema, rows, row_count, fields } — snake_case on the wire. For SELECT, rows is the result set and row_count is the row count. For INSERT/UPDATE/DELETE, rows is [] and row_count is the affected count — unless the statement uses RETURNING, in which case rows carries the returned rows. fields lists the result columns ({ name, type }) so even an empty SELECT conveys its shape.

The result is the envelope, never a bare row array — iterating the result itself or reading .length on it silently yields nothing; destructure rows. A 2xx whose body is not { rows: [...] } throws R402DbError with code: "R402_DB_SQL_RESULT_SHAPE" rather than resolving. An omitted or empty params array sends the query as text/plain with no parameter binding, so $1 placeholders fail server-side — pass a non-empty array whenever the query uses placeholders.

auth.user() — caller identity

Reads the verified request-scoped actor, or returns null when unauthenticated. The deployed runtime supplies the context; do not pass a Request.

import { auth } from "@run402/functions";

const user = await auth.user();
if (!user) return new Response("unauthorized", { status: 401 });
return Response.json({ id: user.id, email: user.email });

For required identity, use await auth.requireUser() and let the platform handle its refusal. Actor identity is distinct from your application's membership or role: use auth.requireRole(...) / auth.requireMembership(...) for the supported cookie-session authorization flow, or a declarative function gate for direct API calls.

On tenant SSR routes, identity comes from the verified cookie-session envelope. Direct function invocations also support the project's verified user Bearer token. A browser SPA may use the public token API with an anon key and user access token; that is distinct from same-origin cookie-based SSR. Never put a service key in browser code.

The legacy bare helpers getUser, getUserId, and getRole are throwing sentinels, not compatibility implementations. Calling them raises R402_AUTH_UNKNOWN_EXPORT; deploy preflight rejects them too.

Function-level auth gates

A function can declare auth requirements directly on its FunctionSpec. When you set requireAuth: true or requireRole: { ... }, the gateway enforces them before invoking your function — unauthorized callers get 401 / 403 without your code running, and the gateway injects the resolved identity into request headers your function can trust.

This lets you delete the hand-rolled "fetch JWT, query members table, check role, return 403" boilerplate from every privileged function. Declare the gate in your FunctionSpec; read the verified actor with auth.user() and, when a declarative gate ran, its application role from the injected request headers.

Declaring a gate (deploy spec)

import { run402 } from "@run402/sdk/node";

const r = run402();
const project = await r.project(projectId);
await project.apply({
  functions: {
    patch: {
      set: {
        // 1. Authentication only — any valid JWT for this project passes.
        "list-my-items": {
          source: { sha256, size },
          requireAuth: true,
        },

        // 2. Authentication + role check against your members table.
        "delete-content": {
          source: { sha256, size },
          requireRole: {
            table: "members",         // project-schema table
            idColumn: "user_id",      // FK to the user.id from the JWT
            roleColumn: "role",       // column holding the role string
            allowed: ["admin"],       // case-sensitive byte-equality allowlist
            cacheTtl: 60,             // optional, seconds, default 60, max 600, 0 disables
          },
        },

        // 3. Multi-role — any role in `allowed` passes the gate.
        "moderate-content": {
          source: { sha256, size },
          requireRole: {
            table: "members",
            idColumn: "user_id",
            roleColumn: "role",
            allowed: ["admin", "moderator"],
          },
        },
      },
    },
  },
});

requireAuth and requireRole are independent. requireRole on its own implies authentication (no valid JWT → 401), then runs the role lookup. requireAuth: true alone does a session check with no DB lookup. Set neither to opt out of platform auth (your function owns the check).

Single role-table per release: all requireRole blocks in a single release must share the same (table, idColumn, roleColumn) triple. Different allowed sets are fine; different tables are not. The gateway rejects conflicting triples at plan time with INVALID_SPEC.

Reading the gate result inside your function

import { auth } from "@run402/functions";

export default async (req: Request): Promise<Response> => {
  const userId = (await auth.user())?.id ?? null;
  const role = req.headers.get("x-run402-user-role"); // application role, not JWT role

  // For a gated function reached through the gateway, both are guaranteed:
  //   - x-run402-user-id is set when requireAuth OR requireRole is on.
  //   - x-run402-user-role is set when requireRole is on (one of allowed).
  // The null case covers local invokes / direct Lambda tests / ungated functions.

  if (role === "admin") {
    // Privileged path — the gate already verified.
  } else {
    // role === "moderator" (the only other value `allowed` permits).
  }

  return Response.json({ ok: true, actor: userId, role });
};

The two headers (x-run402-user-id, x-run402-user-role) are injected by the gateway after the gate passes, and inbound x-run402-* headers from the browser are stripped before injection — so the values are trustworthy without any further verification.

Direct vs routed invocation

The gate applies to both routed (browser via /your/route) and direct (POST /functions/v1/:name with an API key plus a user JWT) invocation. Direct invocation still requires the project API key at the edge; the gate runs after API-key authentication, against the user JWT.

Deploy-time validation

If a requireRole block references a table or column that doesn't exist in the project schema at activation time, the deploy fails with DEPLOY_INVALID_ROLE_GATE (HTTP 422) before flipping the live release. Schema-qualified identifiers (public.members), empty allowed, and out-of-range cacheTtl are rejected earlier at plan time with INVALID_SPEC (HTTP 400).

Caching and staleness

Role lookups are cached per (projectId, userId) for cacheTtl seconds (default 60, max 600). A demoted user keeps the cached role until the TTL expires — for high-stakes operations where instant revocation matters, set cacheTtl: 0 to issue a fresh lookup on every request. The cache is bypassed when no requireRole gate runs.

Actor identity and application roles

auth.user() returns the verified actor, not an application role. A declarative gate supplies x-run402-user-id and (for requireRole) x-run402-user-role. Read these only inside the platform-invoked function, where the gateway strips spoofed headers and injects its verified result. Do not infer application authority from a JWT system role.

email.send(...) — send mail from the project's mailbox

Auto-discovers the project's mailbox on first call (the project must already have one — create it once with run402 email create <slug> --project <project-id>). After that the mailbox id is cached for the function's lifetime.

On Run402 Core, this uses the same /mailboxes/v1 contract as Cloud. Deploy still happens through run402 deploy apply --manifest; outbound email is enabled separately by configuring the Core gateway's provider (for example SES) and creating a project mailbox/default. If Core has mailboxes but no outbound provider configured, email.send() throws EmailConfigurationError with code PROVIDER_NOT_CONFIGURED and setup next_actions.

When the gateway rejects the send itself, email.send() throws Run402EmailPlatformError (exported) carrying the gateway's code, status, details, next_actions, and body; see Errors below.

// Template mode
await email.send({
  to: "[email protected]",
  template: "notification",
  variables: { project_name: "My App", message: "Hello!" },
});

// Raw HTML mode
await email.send({
  to: "[email protected]",
  subject: "Welcome!",
  html: "<h1>Hi</h1>",
  from_name: "My App",
});

// Blind copies (either mode): one address or up to 5
await email.send({
  to: "[email protected]",
  bcc: "[email protected]",
  subject: "We found something",
  html: "<p>...</p>",
});

bcc sends the same message to each address, and no header of any copy names a blind copy. Each blind copy counts as one send against the project's email limits. A suppressed blind copy refuses the whole send with RECIPIENT_SUPPRESSED (details.field: "bcc"). The result's bcc lists each address. A blind copy's later bounce shows on the message's bcc entry and never marks the message failed. There is no cc, and Run402 Core refuses bcc and cc.

Templates: project_invite (project_name, invite_url), magic_link (project_name, expires_in, plus link_url, code, or both according to the selected email-auth delivery mode), notification (project_name, message ≤ 500 chars). For magic_link, at least one of link_url or code is required; code-only rendering never synthesizes or exposes a link. Throws on rate limit, suppression, or no-mailbox.

auth.user() preserves gateway authentication provenance in the open-ended Actor.amr and Actor.amrTimes fields. An email-code session contains email_code and its UNIX timestamp. It is an AAL1 email proof: it can satisfy auth.requireFresh({ maxAge: "10m", amr: ["email_code"] }), but it never satisfies a check for amr: ["passkey"].

ai.translate / ai.moderate / ai.generateImage

const { text, from } = await ai.translate("Hello world", {
  to: "es",
  context: "marketing tagline",
});

const { flagged, categories } = await ai.moderate("Some user-generated text");

const image = await ai.generateImage({
  prompt: "a moonlit dream journal illustration",
  aspect: "landscape",
});
// { image: "<base64 PNG>", content_type: "image/png", aspect: "landscape" }

ai.generateImage supports aspect: "square" | "landscape" | "portrait" and returns base64 image bytes plus content_type and aspect. It uses the function's RUN402_SERVICE_KEY against the project runtime image endpoint; it does not need allowance wallets, x402 wrapping, or local signing inside the function. Runtime image generation is billed and rate-limited against the project's organization. Quota, rate-limit, and spend-cap failures are ordinary thrown errors such as Image generation failed (403): QUOTA_EXCEEDED: ...; handle them in your app response instead of forwarding raw details to the browser.

Translation requires the AI Translation add-on on the project; moderation is free for all projects.

assets.put(...) — upload runtime assets

Upload bytes from inside a deployed function using the project's service key. This routes through the same CAS-backed apply substrate as deploy-time assets, so public/private visibility, immutable URLs, retention, quota checks, and storage billing match r.project(id).apply({ assets: { put: [...] } }).

import { assets } from "@run402/functions";

const asset = await assets.put("generated/avatar.png", pngBytes, {
  contentType: "image/png",
  visibility: "public",
  immutable: true,
});

return Response.json({ url: asset.immutableUrl ?? asset.url });

source can be a string, Uint8Array, { content: string }, or { bytes: Uint8Array }. The returned AssetRef includes both snake_case wire fields (immutable_url, size_bytes, content_type) and SDK-style camelCase aliases (immutableUrl, size, contentType).

assets.list(...) and assets.delete(key) — a media library

assets.list pages through the project's blobs (prefix, limit, cursor, sort, filter). Each row carries the same URL fields as the AssetRef put returned (url, cdn_url, immutable_url, cdn_immutable_url, and for encoded images display_url and variants), so a function can render a thumbnail grid without rebuilding URLs. Public URL fields are null for private keys. assets.delete(key) removes a key, revokes its immutable URLs (including every image variant's), and queues a CDN invalidation for a public key; a missing key throws.

import { assets } from "@run402/functions";

const { blobs, next_cursor } = await assets.list({
  prefix: "media/",
  sort: "createdAt:desc",
  filter: { is_image: true },
  limit: 40,
});
const grid = blobs.map((b) => ({
  key: b.key,
  thumb: b.variants?.thumb?.cdn_url ?? b.display_url ?? b.cdn_url,
  full: b.cdn_immutable_url ?? b.cdn_url,
}));

const { revoked_immutable_refs } = await assets.delete("media/old-banner.jpg");

assets.sign(key, { variant?, ttl_seconds? }) — deliver a private image

A private key's AssetRef carries its whole variant set, but every URL field is null. assets.sign mints a time-boxed signed GET URL (default 1 hour, 60 to 604800 seconds) for the original or, with variant, for one encoded variant (thumb / medium / large WebP, or a HEIC source's display_jpeg); a variant URL answers with the variant's own content type. A key without that variant throws Asset sign failed (404): RESOURCE_NOT_FOUND: ….

assets.signMany(items, { ttl_seconds? }) — a page of private images in one request

assets.signMany mints many URLs with one POST /storage/v1/blobs/sign: each item is { key, variant? } (1 to 1000), and the result's items answer them in order with signed_url, or with error (RESOURCE_NOT_FOUND; details.available lists the variants a key has) for a key or variant the project lacks, so one stale row does not fail the page. One ttl_seconds covers every URL. Use it for a srcset or a gallery instead of one assets.sign per URL.

import { assets, auth } from "@run402/functions";

await auth.requireUser();
const photo = assets.fromRef(row.photo); // a private AssetRef stored on the row
const kinds = (["thumb", "medium", "large"] as const).filter((v) => photo.variants?.[v]);
const { items } = await assets.signMany(kinds.map((variant) => ({ key: photo.key, variant })));
const srcset = items.map((item, i) => `${item.signed_url} ${photo.variants![kinds[i]!]!.width_px}w`).join(", ");

With @run402/astro, signAssetRefs(refs, assets.signMany) from @run402/astro/manifest signs every ref on a page in one request (signAssetRef(ref, assets.signMany) for one), and <Run402Picture> / <Run402Image> render the result.

Routed image generation example

Use a routed function when the browser should request an image at app runtime. Keep app-level auth/rate limits in your handler before calling ai.generateImage, especially for public routes.

import { ai, auth } from "@run402/functions";

export default async function handler(req: Request): Promise<Response> {
  if (req.method !== "POST") {
    return new Response("method not allowed", { status: 405 });
  }

  const user = await auth.user();
  if (!user) return new Response("unauthorized", { status: 401 });

  const { prompt } = await req.json() as { prompt?: string };
  if (!prompt || prompt.length > 500) {
    return Response.json({ error: "prompt_required" }, { status: 400 });
  }

  try {
    const result = await ai.generateImage({ prompt, aspect: "landscape" });
    return Response.json(result, {
      headers: { "cache-control": "private, no-store" },
    });
  } catch (err) {
    return Response.json(
      { error: "image_generation_unavailable", detail: (err as Error).message },
      { status: 503 },
    );
  }
}

snapshots.* — restore points for your own project

Take named restore points and restore them from inside your app, authorized by the function's service key. Every call targets the function's own project; there is no project id or credential argument. Labels and metadata are stored outside the project's database, so snapshots.list() stays a complete ledger of restore points after a restore. Run402 Cloud only: on a host without snapshots (Run402 Core) every call throws R402SnapshotsError with code: "SNAPSHOTS_UNSUPPORTED".

import { snapshots, R402SnapshotsError } from "@run402/functions";

// Before a risky change:
const point = await snapshots.create({ label: "before re-import", metadata: { source: "csv" } });

// Later, from your admin UI (decide in your app who may do this):
const plan = await snapshots.restorePlan(point.snapshot_id, { release: "snapshot" });
// Show plan.data_loss_statement and plan.release (restorable, warnings) to the admin, then:
const handle = await snapshots.restore(point.snapshot_id, plan.confirm.token, { release: "snapshot" });
// The restore runs on the gateway; poll for the outcome.
const status = await snapshots.getRestore(point.snapshot_id, handle.restore_id);
  • create({ label?, metadata? }) — a manual snapshot. label is 1–120 characters with no control characters; metadata is a flat object of string, number, boolean, or string[] values, at most 4 KB. Both are immutable. Never put secrets in metadata: anyone who can read the project's snapshots can read it. Manual snapshots are capped at 20 per project.
  • list({ limit?, after?, kind? }), get(snapshotId), delete(snapshotId) — delete works on manual snapshots only; platform snapshots (pre_migration, pre_restore, scheduled) expire on their own and are refused SNAPSHOT_KIND_NOT_DELETABLE_BY_SERVICE_KEY.
  • restorePlan(snapshotId, { release? }) — no mutation. Returns the data-loss statement, the release line, and a confirm token. Pass the same release to restore.
  • restore(snapshotId, confirm, { release? }) — restores app data and returns the handle (restore_id, status: "running") at once, because a restore can outlast a function's timeout. A pre_restore snapshot is taken first, so every restore can be undone. Restoring auth identities is refused (SNAPSHOT_AUTH_RESTORE_REQUIRES_PRINCIPAL); an org admin does that with the CLI. One restore runs per project at a time (SNAPSHOT_RESTORE_IN_PROGRESS).
  • getRestore(snapshotId, restoreId) — status is running, ready (with the full result), or failed (with error).

release: "snapshot" re-activates the release that was live when the snapshot was taken, in the same transaction as the data: the live release pointer, static site, routes, and subdomains move back. Function code does not: functions are not versioned per release and keep running their current code, which the plan lists in release.warnings as FUNCTION_VERSION_MISMATCH. A restore is destructive; gate the route that calls it with your own admin check.

events.emit(type, payload?, opts?) — emit into the project's event feed

Write an event into this project's cursored event feed (the internal.project_events outbox) from inside a deployed function. Every existing and future feed consumer — run402 events, the MCP list_project_events tool, the console's Activity view — reads it back for free the moment your code calls events.emit.

import { events } from "@run402/functions";

await events.emit("signature_completed", { request_id, signer }, {
  idempotencyKey: `sig:${requestId}`,
});

Read it back with run402 events --source app --project <project-id> — app events share the exact cursor/pagination/retention machinery as platform events (deploys, suspensions, transfers), just filtered to source=app.

Vocabulary. type must be flat snake_case matching /^[a-z][a-z0-9_]{2,63}$/ — no dots, no app_ prefix. Platform-registered type names (deploy_activated, mailbox_suspended, ...) are reserved: an app cannot impersonate a platform event. This is enforced server-side only — events.emit does not pre-validate the grammar or check the reservation list locally; it sends type exactly as given. A bad grammar or a reserved name comes back as a thrown Run402EventsPlatformError with code: "INVALID_EVENT_TYPE" or code: "RESERVED_EVENT_TYPE" (both HTTP 400) — never a silently rewritten or dropped call.

Idempotency. Pass idempotencyKey on any code path that might run more than once for the same real-world event — webhook retries, function-run retries, anything at-least-once. The gateway dedupes on (project_id, idempotency_key) forever: this is a durable identity for the event, not a short-lived retry-window token like an HTTP Idempotency-Key header. Reusing a key days or years later still replays the original stored event (deduplicated: true on the response) instead of creating a new one.

Severity. Pass severity to say how significant the event is — info (routine), notice (unusual but not known to be wrong), warn (probably wrong), critical (wrong now):

await events.emit("payment_failed", { order_id }, { severity: "critical" });

It is your judgement and is sent unchanged: omitted is stored as info, and the platform never infers or raises it. It is what a person's alert rule filters on (min_severity), and what reduces to one message per window or pages the escalation ladder — so declare it here instead of writing your own throttling or paging branch. The response echoes the stored severity (on a replay, the original's). Needs Run402 Cloud; Run402 Core ignores the field.

Response shape — both a fresh emit (201) and an idempotent replay (200) return this:

{
  cursor: "evc_...",          // opaque feed position — pass to ?cursor= on a feed read
  event_type: "signature_completed",
  class: "app",               // always "app" for events emitted through this namespace
  source: "app",
  payload: { request_id: "req_123", signer: "0x..." },
  payload_truncated: true,    // present only if payload exceeded the 8 KiB bound
  occurred_at: "2026-07-15T12:00:00.000Z",
  deduplicated: false,        // true on an idempotency-key replay
  next_actions: [ /* platform-synthesized drill-downs, incl. a feed poll entry */ ],
}

payload is a compact JSON event — ids and verdict fields, never bodies or secrets — bounded to 8 KiB server-side (oversize payloads are truncated with payload_truncated: true rather than rejected). next_actions is always platform-synthesized; there is no way to supply your own drill-downs from the emit call (an app-supplied action would be prompt-injection-by-schema for agents that treat next_actions as trusted).

Errors. Non-2xx responses throw Run402EventsPlatformError — see Errors below. In practice the two you're most likely to see are code: "QUOTA_EXCEEDED" (403, the organization's pooled daily quota is exhausted; details: {resource: "events_per_day", scope, used, limit}) and cross-project denials (code: "FORBIDDEN", 403), alongside the two vocabulary errors above. On a host without the event feed (Run402 Core) events.emit throws Run402EventsPlatformError with code: "EVENTS_UNSUPPORTED".

Always await it (and every other side-channel call). The function's sandbox freezes the moment your handler's response is returned — a fire-and-forget void events.emit(...) (or any un-awaited fetch to a webhook, analytics endpoint, etc.) is silently killed mid-flight and the event never lands. There is no error, no log, nothing: the request just evaporates with the freeze. If you don't want the caller's latency to include the emit, bound it instead of detaching it — await with a short AbortSignal.timeout(...) on hand-rolled calls — but never let the handler return before the side effect settles.

push.send(...) — web push notifications to your users' phones

Send a notification to the phones and browsers signed up to your app, from inside a deployed function. Needs Run402 Cloud: on a host without the push routes (Run402 Core) it throws Run402PushError with code: "PUSH_UNSUPPORTED".

import { push } from "@run402/functions";

const sent = await push.send({
  title: "Your order shipped",     // required, 1–100 characters
  body: "Arrives Thursday",        // optional, ≤ 1000
  url: "/orders/123",              // optional path on the app the tap opens, default "/"
  tag: "order-123",                // optional, ≤ 64: a newer message with the same tag replaces the older one
  to: { user_id: order.user_id },  // optional: omit to reach every phone signed up to the project
});
// { message_id, status: "queued", devices: 2, next_actions: [...] }

send queues the message and returns at once: status: "queued" (HTTP 202) with devices, the number of subscriptions it will reach, or status: "no_subscribers" (HTTP 200, devices: 0) when nobody it addresses has a phone signed up. The platform then encrypts the message for each device, hands it to each browser's push service, and drops subscriptions the push service says are gone. url is resolved against the origin each phone signed up on, so one message works across the app's subdomain and custom domains. Field limits are checked by the gateway, not locally.

Each organization has a daily delivery cap, counted per phone a message reaches and reset at midnight UTC: 100 on prototype, 1,000 on hobby, 10,000 on team. A message that does not fit in what is left is refused whole, never sent to some of its phones: send throws QUOTA_EXCEEDED (403, with details.used, limit, resets_at and an upgrade next action) below the top tier, and RATE_LIMITED (429, with Retry-After) on team.

Only a signed-in user can sign a phone up. A user holds up to 10 phones (a phone is one browser on one device); signing up an 11th replaces the one that re-confirmed longest ago.

Signing a phone up: /_run402/push.js

Every app host serves a browser helper at /_run402/push.js (same origin as your pages) and its service worker at /_run402/push-sw.js. Both ship in this package under browser/.

<script src="/_run402/push.js"></script>
<button id="notify">Notify me</button>
<script>
  // First time: from a tap, so the browser may ask for permission.
  document.getElementById("notify").onclick = () =>
    Run402Push.subscribe().catch((err) => console.warn(err.code));

  // At start-up, for a signed-in user: never prompts, re-confirms at most once a day.
  // (A call from a tap always re-confirms.)
  if (signedIn) Run402Push.subscribe().catch(() => {});
</script>
  • Run402Push.subscribe({ accessToken? }) registers the worker when the app has none, asks for permission when it has not been decided, subscribes the browser, and records the subscription for the signed-in user (the hosted-auth cookie, or accessToken as a Bearer token). It resolves { subscription_id, user_id, status: "subscribed", replaced_subscription_id, confirmed: true }, or { status: "subscribed", subscription_id, confirmed: false } when permission was already granted, the call did not come from a tap or click, and the server confirmed this subscription in the last 24 hours. A call from a tap always re-confirms, so a phone replaced by its user's 11th signs back up from the "Notify me" button at once, and from start-up within a day. Call it from a "Notify me" tap the first time, and again at start-up for a signed-in user so the subscription stays current.
  • Rejections carry err.code: unsupported, needs_install (iPhone, not the Home Screen app), permission_denied, needs_user_gesture (call it from a tap), worker_missing_snippet (see below), sign_in_required (no signed-in user), or the server's code (R402_AUTH_CSRF_ORIGIN_MISMATCH, VALIDATION_FAILED) with err.status and err.next_actions.
  • Run402Push.status() resolves "unsupported" | "needs_install" | "default" | "denied" | "subscribed" without prompting; use it to decide whether to show the button.
  • Run402Push.unsubscribe({ accessToken? }) removes this browser's subscription and resolves { status: "unsubscribed", subscription_id } (or { status: "not_subscribed" }). Call it before signing a user out, so a shared phone stops receiving their notifications.

An app with its own service worker adds one line to it, and subscribe() uses that worker instead of registering its own:

importScripts("/_run402/push-sw.js");

subscribe() never registers over an app's worker: without that line it rejects worker_missing_snippet with err.fix set to the line to add.

iPhone and iPad. Web push needs iOS 16.4 or later and works only from the Home Screen app (Share, then Add to Home Screen; the site needs a web app manifest with "display": "standalone"), so in a Safari tab status() is "needs_install". The permission prompt appears only from a tap. A denial can be undone only by removing the Home Screen icon and adding it again.

repo and repos — the project's app repos

App repos are plain git repositories that belong to the project and are written by its app: content an app edits at run time (pages, records, settings) with full history, readable and writable from functions, the CLI, and git clone. Needs Run402 Cloud: on a host without the app-repo routes (Run402 Core) every call throws R402AppRepoError with code: "APP_REPOS_UNSUPPORTED".

import { repo, repos } from "@run402/functions";

// `repo` is the `default` repo. It always exists, and reads as empty until its first commit.
const site = (await repo.readJson<{ title: string }>("site.json")) ?? { title: "Untitled" };

await repo.commit(
  {
    "site.json": { ...site, title: "Our club" }, // a JSON value, written canonically
    "pages/about.md": "# About us\n",            // a string, written as UTF-8
    "logo.png": pngBytes,                         // a Uint8Array, written as bytes
    "old/page.md": null,                          // null deletes the file
  },
  { message: "Rename the site" },
);

// Other repos are created first, optionally from another repo (objects are shared, not copied).
await repos.create("user-42", { from: "template" });
const notes = repos.open("user-42"); // a handle; makes no call
  • Reads. read(path) (UTF-8 text), readBytes(path) (Uint8Array), readJson(path) (parsed) each return null when the file is absent; list(dir?) returns [{ name, path, type: "file" | "dir", size, oid }], [] for an empty repo. A repo that does not exist throws APP_REPO_NOT_FOUND with a create_app_repo next action; nothing creates a repo implicitly.
  • Writes. commit(files, { message?, author?, parent? }) maps each path to a string, bytes, null (delete) or any other JSON value (written with sorted keys, two-space indent and a trailing newline). It resolves the gateway's answer: { committed: true, commit, parent, head, seq, paths, author }, or { committed: false, head } when nothing changed. revert(commit, { message? }) commits the inverse of a commit. A file over 4 MiB is refused with APP_REPO_FILE_TOO_LARGE: store it with assets.put and commit a reference.
  • Conflicts. Without parent, a commit lands on top of a newer head as long as none of its paths changed since the commit the request read; otherwise it throws APP_REPO_CONFLICT with details: { head, base, conflicting_paths }, and you re-read and retry. With parent, it lands only if parent is still the head.
  • History. log({ path?, limit?, cursor? }) returns { commits, head, has_more, next_cursor }, newest first; diff(from, to) returns { from, to, changes: [{ path, status, old_oid, new_oid }] }.
  • Who edited. The app is the committer. When a signed-in user made the request, the commit forwards their verified identity: they are the author (by display name, never their email) and the message carries a Run402-Actor: <user_id> trailer. author: { name, email } acts for someone else and never changes the trailer.
  • repos. open(name), create(name, { from? }), list() (every repo, default included), delete(name, { confirm: name }) (the confirmation must equal the name; default cannot be deleted).

One request reads one commit. Inside a request, the first read of a repo resolves its head once and pins that commit; every later read of that repo in the same request comes from it, so a page never shows half of two edits. A commit is built on the pinned commit, and a successful commit or revert moves the pin to the new head, so the request reads what it wrote. The next request resolves the head afresh. head() returns the pinned commit (null for an empty repo), resolving it if nothing is pinned yet; at(commit) returns a read-only handle on that commit. Outside a request context (a scheduled run without one), nothing is pinned and each call reads the head.

Static-site generation (build-time use)

The same library works at build time for static-site generation if you set RUN402_SERVICE_KEY and RUN402_PROJECT_ID in your .env:

// build-time render — feed the page with current data
const items = await adminDb().from("items").select("title, slug").order("created_at", { ascending: false });

Use adminDb() (not db(req)) here — there's no incoming request to forward.

Routed HTTP functions

Deploy-v2 web routes can map public same-origin browser paths to functions, for example routes.replace / "routes": { "replace": [{ "pattern": "/api/*", "target": { "type": "function", "name": "api" } }] }. Use exact /admin plus final-wildcard /admin/* when a dynamic section root and its children should route to the same function. A browser request to a routed path does not need a Run402 API key at the public edge. Direct /functions/v1/:name invocation is unchanged: it remains API-key protected and API-shaped.

Routed browser traffic invokes the same Node 22 Fetch Request -> Response handler used by direct functions:

export default async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (req.method === "OPTIONS") {
    return new Response(null, {
      status: 204,
      headers: {
        "access-control-allow-origin": "https://app.example.com",
        "access-control-allow-methods": "GET, POST, OPTIONS",
        "access-control-allow-headers": "content-type, authorization",
      },
    });
  }

  if (req.method === "POST" && !req.headers.has("x-csrf-token")) {
    return Response.json({ error: "csrf_required" }, { status: 403 });
  }

  const headers = new Headers({ "cache-control": "private, no-store" });
  headers.append("Set-Cookie", "sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/");
  headers.append("Set-Cookie", "theme=dark; Secure; SameSite=Lax; Path=/");
  return Response.json({ ok: true, path: url.pathname, query: url.search }, { headers });
}

Request fields:

  • req.method is the original browser method. GET routes also match HEAD; HEAD reaches the handler as HEAD.
  • req.url is the full public URL, including scheme, host, path, and query, on managed subdomains, hosts, and verified custom domains. Derive OAuth callback URLs from new URL(req.url).origin.
  • req.headers is a Fetch Headers object. Cookie data is available through the cookie header.
  • Run402 Cloud priced routes expose a confirmed x402 payment through getRoutedPaymentContext(req), backed by platform-owned x-run402-payment-* headers. The helper returns null for unpriced routes. Run402 Core does not populate payment context because tenant x402 settlement is a Cloud control-plane feature.
  • The returned payment keeps paymentId as the canonical tenant-side dedupe identity and also reports idempotencyKey (null for proof-keyed requests), deduplicated, and delivery (first or replay). Use paymentId to make application side effects idempotent; tenant execution remains at-least-once.
  • A Lightning payment (a route priced on lightning or lightning-testnet, settled over x402 exact on lnbtc) carries amountMsat, the invoice amount in millisatoshis, beside amountUsdMicros; its payer is null because a Lightning payment has no payer address. An EVM payment has no amountMsat.
  • scheme names the protocol that paid: "x402", or "mpp" for an MPP Tempo charge (a route priced on tempo or tempo-testnet), which carries the eip155:4217 / eip155:42431 network, the pathUSD asset, the payer, and the Tempo transaction hash. paymentId is the idempotency identity either way.
  • Paid direct invocations and durable function runs expose the platform idempotency key on x-run402-idempotency-key; getRun402Context(req).idempotencyKey returns that value or null. Sellers with external side effects should also accept their own business-level dedupe token when they need guarantees beyond Run402 billing idempotency.
  • await req.text(), await req.json(), and await req.arrayBuffer() read the buffered request body, capped at 6 MiB.

Response behavior:

  • Return a Web Response with status 200 through 599 except 101 Switching Protocols.

  • On a priced function route configured with pricing.receipt: "on_fulfillment", call payment.fulfilled(response) only after the application mutation commits:

    import { payment } from "@run402/functions";
    
    export default async function handler(req: Request): Promise<Response> {
      const order = await createOrder(await req.json());
      return payment.fulfilled(
        Response.json({ order_id: order.id }, { status: 201 }),
      );
    }

    It returns the same status, body, and public headers while adding a private gateway directive. It throws when the invocation is not a fresh, settled, receipt-enabled routed request; it never turns an unconfirmed response into a merchant claim.

  • Append each cookie with headers.append("Set-Cookie", value); Run402 preserves multiple Set-Cookie values as separate browser headers.

  • Redirects are ordinary 3xx responses with a Location header. HEAD responses send headers without body bytes.

  • Request and response bodies are capped at 6 MiB. WebSockets, 101 Switching Protocols, streaming, and SSE are not supported.

Limits and defaults: Run402 does not add wildcard CORS. Run402 does not store routed dynamic responses in a shared cache; if your function sets no Cache-Control, the gateway adds Cache-Control: private, no-store and x-run402-cache: dynamic-bypass.

Security notes: application auth, authorization, sessions, OAuth callbacks, CORS, and CSRF belong in your function code. For cookie-authenticated POST, PUT, PATCH, or DELETE, validate a CSRF token or an equivalent same-site defense. Do not trust spoofable forwarding headers for authorization.

The raw run402.routed_http.v1 envelope is an internal gateway transport. Low-level routedHttp helpers and RoutedHttpRequestV1 / RoutedHttpResponseV1 types remain exported for tests and gateway-adjacent utilities, but browser route handlers should use Fetch Request and Response.

Runtime route failure codes to branch on: ROUTE_MANIFEST_LOAD_FAILED (manifest/propagation), ROUTED_INVOKE_WORKER_SECRET_MISSING (custom-domain Worker secret), ROUTED_INVOKE_AUTH_FAILED (internal invoke signature), ROUTED_ROUTE_STALE (selected route failed release revalidation), ROUTE_METHOD_NOT_ALLOWED (method mismatch), and ROUTED_RESPONSE_TOO_LARGE (body over 6 MiB).

MCP tools (export const tool)

On Run402 Cloud, every app host serves an MCP endpoint at https://<host>/_run402/mcp. A routed function becomes one of its tools when it exports a static tool declaration next to its handler:

import type { ToolDeclaration } from "@run402/functions";

export const tool = {
  description: "Cancel one of the signed-in user's bookings.",
  input: {
    type: "object",
    properties: { booking_id: { type: "string" } },
    required: ["booking_id"],
  },
  annotations: { destructiveHint: true, idempotentHint: true },
} satisfies ToolDeclaration;

export default async function handler(req: Request): Promise<Response> {
  const { booking_id } = await req.json();
  // ...
  return Response.json({ cancelled: booking_id });
}
  • The tool name is the function name. The function needs exactly one exact POST route in the release (for example /api/cancel); tools/call sends the arguments to that route as the JSON body, through the same pipeline as a browser request.
  • The platform reads the declaration at deploy and never runs it, so it must be a literal: strings, numbers, booleans, null, arrays, and objects, with satisfies or a type annotation allowed. description is 1-1024 characters, title at most 128, input a JSON Schema whose type is "object". Deploy fails with MCP_TOOL_NOT_STATIC, MCP_TOOL_SCHEMA_INVALID (including a declaration over 64 KiB), MCP_TOOL_NAME_INVALID (the function name is not [A-Za-z0-9_.-]{1,128}), MCP_TOOL_ROUTE_MISSING, or MCP_TOOL_ROUTE_AMBIGUOUS.
  • Auth gates carry over. A tool whose function declares requireAuth / requireRole is called as the signed-in user of the app: the MCP client connects through the host's own OAuth sign-in and consent, and auth.user() and db(req) see that user.
  • ToolDeclaration, ToolInputSchema, and ToolAnnotations are type-only exports; nothing runs at runtime.

Run402 Core does not serve /_run402/mcp yet; the export is inert there (#7, #8).

Connected assistants: auth.grants

A person who connects an MCP client (ChatGPT, Claude, Codex) to your app approves an OAuth grant. Show them what is connected and let them disconnect it:

import { auth } from "@run402/functions";

// /api/connections: the signed-in person's connected assistants
export default async function handler(req: Request): Promise<Response> {
  if (req.method === "POST") {
    const { id } = await req.json();
    const { revoked } = await auth.grants.revoke(id); // false: not a live grant of this person
    return Response.json({ revoked });
  }
  const grants = await auth.grants.list();
  // [{ id, client_id, client_name, verified_host, created_at, last_used_at, expires_at, scopes }]
  return Response.json({ grants });
}
  • Both act only on the signed-in person's own grants in this project, and throw AuthRequiredError for an anonymous caller.
  • verified_host (for example chatgpt.com) is set when the client identified itself with a metadata document the platform fetched; when it is null, client_name is self-declared, so label it unverified.
  • After revoke, the client's next tool call answers HTTP 401 with WWW-Authenticate, so it asks the person to sign in again.
  • Without building a page, link to the hosted one at /_run402/account/connections (Run402 Cloud). It follows your release's site.sign_in_path for signed-out visitors.

Scheduled functions

Run402 Cloud and Run402 Core both use release manifest functions.replace.<name>.triggers[] entries for cron-style schedule triggers. A schedule trigger creates a durable function run, so handler code can use the same defineFunctionRuns(...) path for delayed work, webhook redrive, and scheduled sweeps. In Run402 Core this is a single-node gateway scheduler, not a managed distributed jobs system.

Scheduled function runs receive the same Fetch Request shape as other durable function runs. The request is a synthetic POST with X-Run402-Trigger: function_run, X-Run402-Run-Id, X-Run402-Attempt-Id, and X-Run402-Idempotency-Key; the JSON body is the standard function-run envelope and includes run_id, event_type, attempt, source, and payload.

export default async function handler(req: Request): Promise<Response> {
  if (req.headers.get("x-run402-trigger")) {
    const event = await req.json() as { trigger: "function_run"; run_id: string; payload: { scheduled_at?: string } };
    await runReminderSweep(event.payload.scheduled_at ?? event.run_id);
    return Response.json({ ok: true, trigger: event.trigger });
  }

  return Response.json({ ok: true });
}

Imports auto-resolved

Inside a deployed function you can import { ... } from "@run402/functions" directly — the gateway bundles this library plus any --deps you declared at deploy time. Do not list @run402/functions in your --deps — it's rejected. Native binary modules (sharp, canvas, native bcrypt, etc.) are also rejected.

The bundled version lands in the deploy response's runtime_version field; resolved --deps versions land in deps_resolved.

Errors

All helpers throw on non-2xx responses.

R402DbError — db() / adminDb() failures

The DB helpers throw a structured R402DbError (also exported as a type from the package). Both throw sites carry a stable SDK-level code:

  • adminDb().sql(...) → code: "R402_DB_SQL_ERROR" (non-2xx), or code: "R402_DB_SQL_RESULT_SHAPE" (a 2xx whose body is not the { rows: [...] } envelope — status is the HTTP status, body the full parsed body, and the message names only the top-level type / keys, e.g. SQL result shape (200): expected envelope { rows: [...] }, got array(length=2))
  • db(req).from(...) / adminDb().from(...) (the QueryBuilder) → code: "R402_DB_QUERY_ERROR"

Branch on the properties, not the message string:

import { R402DbError } from "@run402/functions";

try {
  await adminDb().sql("INSERT INTO items (name) VALUES ($1)", [name]);
} catch (err) {
  if (err instanceof R402DbError) {
    err.code;       // "R402_DB_SQL_ERROR" | "R402_DB_SQL_RESULT_SHAPE" | "R402_DB_QUERY_ERROR" (stable SDK code)
    err.status;     // HTTP status number, e.g. 402
    err.trace_id;   // gateway trace id (string) or null — for support tickets
    err.remote_code;// the gateway/PostgREST error code that shaped the message, or null
    err.body;       // full response body (parsed object, or raw string when unparseable)
    if (err.status === 402 && err.remote_code === "QUOTA_EXCEEDED") { /* … */ }
  }
  throw err;
}

Why the message is a stable template. err.message is intentionally low-cardinality so error monitors group failures by kind instead of by trace id. The high-cardinality material (a fresh trace_id per event, the full body) lives on properties. The message follows:

| Response body | message | remote_code | trace_id | |---|---|---|---| | JSON object with code | SQL error (402): QUOTA_EXCEEDED | QUOTA_EXCEEDED | from body, else null | | JSON object with only error | SQL error (401): PGRST301 | PGRST301 | from body, else null | | JSON object, no code/error | SQL error (500): <envelope> | <envelope> | from body, else null | | non-JSON (text/HTML/empty/array) | SQL error (502): <body verbatim> | null | null |

(PostgREST error (…) is the prefix for the QueryBuilder path; SQL error (…) for adminDb().sql().) Don't parse message — read err.code / err.status / err.trace_id / err.remote_code.

Run402EventsPlatformError — events.emit() failures

events.emit() throws a structured Run402EventsPlatformError (also exported from the package) on any non-2xx gateway response. The gateway owns event-type grammar, platform-vocabulary reservation, and the per-organization daily quota — this error is a faithful passthrough of whatever canonical envelope the gateway returned, never a client-fabricated message.

import { events, Run402EventsPlatformError } from "@run402/functions";

try {
  await events.emit("signature_completed", { request_id });
} catch (err) {
  if (err instanceof Run402EventsPlatformError) {
    err.code;    // "QUOTA_EXCEEDED" | "INVALID_EVENT_TYPE" | "RESERVED_EVENT_TYPE" | "FORBIDDEN" | ...
    err.status;  // HTTP status, e.g. 403
    err.details; // e.g. {resource: "events_per_day", scope: "organization", used: 1000, limit: 1000}
    err.next_actions; // platform-synthesized drill-downs (e.g. renew_tier, check_usage)
    err.body;    // full response body (parsed object, or raw string when unparseable)
    if (err.code === "QUOTA_EXCEEDED") { /* … */ }
  }
  throw err;
}

Run402EmailPlatformError — email.send() failures

email.send() throws Run402EmailPlatformError (exported) when the gateway answers the send with a non-2xx response. It keeps the gateway's error envelope: code (the envelope's code, or its error when that is the only field, else email_platform_error), status, details, next_actions, and the full body. message stays human-readable: Email send failed (<status>): <gateway message>. Mailbox selection problems found before any send keep throwing EmailConfigurationError.

import { email, Run402EmailPlatformError } from "@run402/functions";

try {
  await email.send({ to, subject, html });
} catch (err) {
  if (err instanceof Run402EmailPlatformError) {
    err.code;         // the gateway's code, e.g. "QUOTA_EXCEEDED"
    err.status;       // HTTP status
    err.details;      // envelope details, when present
    err.next_actions; // envelope next_actions, when present
  }
  throw err;
}

R402SnapshotsError — snapshots.* failures

snapshots.* throws R402SnapshotsError (exported) with code (the gateway's, e.g. SNAPSHOT_MANUAL_CAP_EXCEEDED, STALE_RESTORE_CONFIRMATION, SNAPSHOT_RELEASE_NOT_RESTORABLE; VALIDATION_FAILED with field for input checked before any request; SNAPSHOTS_UNSUPPORTED on a host without snapshots; PROJECT_ID_MISSING outside a function), status, details, next_actions, and body.

Run402PushError — push.send() failures

push.send() throws Run402PushError (exported) with code, status, details, next_actions, and body, passed through from the gateway:

  • VALIDATION_FAILED (400) — a field is missing, empty, or too long.
  • QUOTA_EXCEEDED (403) — the organization's daily delivery cap is reached; details is { resource: "push_deliveries_per_day", used, limit, resets_at }.
  • RATE_LIMITED (429) — the daily delivery cap is reached on the top tier, where no upgrade raises it; retry after Retry-After.
  • AUTH_REQUIRED (401) / FORBIDDEN (403) — the service key is missing or not this project's.
  • PUSH_UNSUPPORTED — the host does not serve the push routes (Run402 Core); push notifications need Run402 Cloud.
  • PROJECT_ID_MISSING — outside a deployed function with no RUN402_PROJECT_ID; no request is made and status is null.
import { push, Run402PushError } from "@run402/functions";

try {
  await push.send({ title: "Reminder", to: { user_id } });
} catch (err) {
  if (err instanceof Run402PushError && err.code === "QUOTA_EXCEEDED") {
    // err.details.resets_at says when sending works again
  } else throw err;
}

R402AppRepoError — repo / repos failures

repo.* and repos.* throw R402AppRepoError (exported) with code, status, details, next_actions, and body from the gateway: APP_REPO_NOT_FOUND (404, with a create_app_repo next action), APP_REPO_EXISTS (409), APP_REPO_CONFLICT (409, details: { head, base, conflicting_paths }), APP_REPO_FILE_TOO_LARGE (422), APP_REPO_DEFAULT_UNDELETABLE (403), QUOTA_EXCEEDED (403, the organization's pooled storage), VALIDATION_FAILED (400). Local codes: VALIDATION_FAILED with field for input checked before any request (an undefined file value, a confirm that does not match), APP_REPO_INVALID_JSON when readJson finds a file that is not JSON, APP_REPOS_UNSUPPORTED on a host without app repos (Run402 Core), and PROJECT_ID_MISSING outside a deployed function. A missing file is not an error: reads return null.

Other helpers still throw plain Error whose message includes the HTTP status and the response body so you can branch on code / category / retryable (the v1.34+ agent-operable error envelope).

Engines

Node 22 in deployed functions. >=18 for local use (autocomplete and SSG).

Other interfaces

Run402's public surfaces span two repositories:

  • run402-core - server/runtime core, including @run402/functions (this package)
  • run402 - agent/client surfaces:
    • @run402/sdk — typed TypeScript client for the platform API
    • run402 — the CLI
    • run402-mcp — MCP server for Claude Desktop / Cursor / Cline / Claude Code
    • OpenClaw skill — script-based skill for OpenClaw agents

Links

License

Apache-2.0