JSPM

  • Created
  • Published
  • Downloads 2977
  • Score
    100M100P100Q110245F
  • License MIT

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

Package Exports

  • @run402/functions

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, and AI helpers.

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

export default async (req: Request) => {
  const user = await getUser(req);
  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 /admin/v1/rest/* (the gateway rejects role=service_role on /rest/v1/*, so bypass traffic lives on its own surface).

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)

// 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")

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

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

For SELECT, rows is the result set and rowCount is the row count. For INSERT/UPDATE/DELETE, rows is [] and rowCount is the affected count.

getUser(req) — caller identity

Verifies the caller's JWT and returns the user, or null for unauthenticated requests.

const user = await getUser(req);
if (!user) return new Response("unauthorized", { status: 401 });
// user: { id: string, email: string, role: "authenticated" | "project_admin" | ... }

The function's own RUN402_PROJECT_ID is used to scope the verification.

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> or the create_mailbox MCP tool). After that the mailbox id is cached for the function's lifetime.

// Template mode
await email.send({
  to: "user@example.com",
  template: "notification",
  variables: { project_name: "My App", message: "Hello!" },
});

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

Templates: project_invite (project_name, invite_url), magic_link (project_name, link_url, expires_in), notification (project_name, message ≤ 500 chars). Throws on rate limit, suppression, or no-mailbox.

ai.translate / ai.moderate

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

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

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

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": [{ "pattern": "/api/*", "target": { "type": "function", "name": "api" } }] }. 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 your function with the run402.routed_http.v1 envelope. The gateway preserves the public URL, path, raw query, duplicate-safe lower-case headers, raw cookie header, optional base64 body, and route context. The matched prefix is not stripped from path.

Request fields:

  • version, method, url, path, rawPath, rawQuery
  • headers: Array<[string, string]>
  • cookies.raw
  • body: null | { encoding: "base64"; data; size }
  • context.source, projectId, releaseId, deploymentId, host, proto, routePattern, routeKind, routeTarget, requestId, plus optional clientIp and userAgent

Response fields:

  • status from 200 through 599
  • optional duplicate-safe headers
  • optional cookies: string[], preserved as separate Set-Cookie headers
  • optional base64 body

Helpers:

import {
  routedHttp,
  type RoutedHttpRequestV1,
  type RoutedHttpResponseV1,
} from "@run402/functions";

export default async function handler(
  event: RoutedHttpRequestV1,
): Promise<RoutedHttpResponseV1> {
  if (!routedHttp.isRequest(event)) {
    return routedHttp.json({ error: "unsupported_event" }, { status: 400 });
  }

  if (event.method === "OPTIONS") {
    return routedHttp.text("", {
      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 (event.method === "POST" && !event.headers.some(([k]) => k === "x-csrf-token")) {
    return routedHttp.json({ error: "csrf_required" }, { status: 403 });
  }

  return routedHttp.json(
    { ok: true, path: event.path, query: event.rawQuery },
    {
      headers: [["cache-control", "private, no-store"]],
      cookies: [
        "sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/",
        "theme=dark; Secure; SameSite=Lax; Path=/",
      ],
    },
  );
}

routedHttp.text, routedHttp.json, and routedHttp.bytes return RoutedHttpResponseV1. Named text, json, bytes, and isRequest exports are also available. Redirects are ordinary 3xx responses with a Location header. HEAD responses send headers without body bytes. WebSockets, 101 Switching Protocols, streaming, and SSE are not supported in Phase 1.

Limits and defaults: request and response bodies are capped at 6 MiB. 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; prefer the typed event.context metadata when it is present.

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. The error 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/functions is one of five surfaces in the run402 monorepo:

  • @run402/functions (this) — in-function helper, auto-bundled
  • @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

All five release in lockstep at the same version.

License

MIT