Menu

Guides

Three task-oriented guides built from the reference pages: login with sessions, pagination, error handling. For testing see Testing sites.

Login with sessions

This builds registration, login, a protected account endpoint and logout on top of microhost/auth and microhost/session. Read the status note there first: sessions work on the Wasm engine only at the time of writing; the site's engine is chosen by policy (Engine differences).

You need: the database grant, sdk: ["auth", "db", "session"], and a migration. The visitor-facing page (a form that fetches these routes) is ordinary static HTML and is not shown.

private/migrations/0001_auth.sql:

CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  display_name TEXT NOT NULL
);

CREATE TABLE login_attempts (
  email TEXT PRIMARY KEY,
  failures INTEGER NOT NULL,
  window_start INTEGER NOT NULL
);

-- Required by microhost/session; the names are fixed.
CREATE TABLE site_sessions (
  token_hash BLOB PRIMARY KEY CHECK (length(token_hash) = 32),
  user_id TEXT NOT NULL,
  data_json TEXT NOT NULL DEFAULT '{}',
  expires_at INTEGER NOT NULL
);
CREATE INDEX site_sessions_user_id ON site_sessions(user_id);

microhost.json:

{
  "schemaVersion": 1,
  "sdk": ["auth", "db", "session"],
  "permissionsRequested": { "database": true },
  "entrypoints": {
    "routes": [
      { "path": "/auth/register", "script": "api/auth.js", "methods": ["POST"] },
      { "path": "/auth/login",    "script": "api/auth.js", "methods": ["POST"] },
      { "path": "/auth/logout",   "script": "api/auth.js", "methods": ["POST"] },
      { "path": "/auth/account",  "script": "api/auth.js", "methods": ["GET"] }
    ]
  }
}

api/auth.js:

import { sql } from "microhost/sql";
import { one, run } from "microhost/db";
import { hashPassword, verifyPassword } from "microhost/auth";
import { create, get, destroy } from "microhost/session";

// A VALID Argon2id hash in the platform profile (m=19456, t=2, p=1, 16-byte salt,
// 32-byte hash). Generate your own; see "Unknown users" in the auth reference.
const DUMMY_HASH = "$argon2id$v=19$m=19456,t=2,p=1$<22 chars>$<43 chars>";
const MAX_FAILURES = 5;
const WINDOW_SECONDS = 900;

const json = (body, status = 200) => Response.json(body, { status });

// CSRF layer 1: the request must come from our own origin.
function sameOrigin(request) {
  const origin = request.headers.get("origin");
  return origin !== null && origin === new URL(request.url).origin;
}

async function readJson(request) {
  try {
    const body = await request.json();
    return body && typeof body === "object" && !Array.isArray(body) ? body : null;
  } catch {
    return null;
  }
}

function randomToken() {
  const bytes = crypto.getRandomValues(new Uint8Array(24));
  return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}

export default async function handle(request, ctx) {
  if (!ctx.db) return json({ error: "unavailable" }, 503);
  const { pathname } = new URL(request.url);

  if (request.method === "POST" && !sameOrigin(request)) {
    return json({ error: "forbidden" }, 403);
  }

  if (pathname === "/auth/register") {
    const { email, password, displayName } = (await readJson(request)) ?? {};
    const passwordBytes = typeof password === "string" ? new TextEncoder().encode(password).length : 0;
    if (typeof email !== "string" || !email.includes("@") || email.length > 254 ||
        passwordBytes < 12 || passwordBytes > 1024 ||
        typeof displayName !== "string" || !displayName.trim() || displayName.length > 80) {
      return json({ error: "invalid_registration" }, 400);
    }
    const normalized = email.trim().toLowerCase();
    if (await one(sql`SELECT id FROM users WHERE email = ${normalized}`)) {
      return json({ error: "registration_failed" }, 409);
    }
    const hash = await hashPassword(password);          // auth call 1 of 2
    try {
      await run(sql`INSERT INTO users (email, password_hash, display_name)
                    VALUES (${normalized}, ${hash}, ${displayName.trim()})`);
    } catch {
      return json({ error: "registration_failed" }, 409);   // lost a race on UNIQUE
    }
    return json({ registered: true }, 201);
  }

  if (pathname === "/auth/login") {
    const { email, password } = (await readJson(request)) ?? {};
    if (typeof email !== "string" || typeof password !== "string") {
      return json({ error: "invalid_login" }, 401);
    }
    const key = email.trim().toLowerCase();

    // Throttle by account: the client IP is not visible to handlers.
    const attempts = await one(sql`
      SELECT failures FROM login_attempts
      WHERE email = ${key} AND window_start > unixepoch() - ${WINDOW_SECONDS}`);
    if (attempts && attempts.failures >= MAX_FAILURES) return json({ error: "too_many_attempts" }, 429);

    const user = await one(sql`SELECT id, password_hash, display_name FROM users WHERE email = ${key}`);
    const ok = await verifyPassword(password, user ? user.password_hash : DUMMY_HASH);
    if (!user || !ok) {
      await run(sql`
        INSERT INTO login_attempts (email, failures, window_start)
        VALUES (${key}, 1, unixepoch())
        ON CONFLICT(email) DO UPDATE SET
          failures = CASE WHEN window_start > unixepoch() - ${WINDOW_SECONDS} THEN failures + 1 ELSE 1 END,
          window_start = CASE WHEN window_start > unixepoch() - ${WINDOW_SECONDS} THEN window_start ELSE unixepoch() END`);
      return json({ error: "invalid_login" }, 401);     // same answer for both cases
    }
    await run(sql`DELETE FROM login_attempts WHERE email = ${key}`);

    // CSRF layer 2: a per-session token, kept in the (non-credential) session data.
    const csrf = randomToken();
    const cookie = await create(String(user.id), { data: { displayName: user.display_name, csrf } });
    const response = json({ loggedIn: true, csrf });
    response.headers.append("Set-Cookie", cookie);
    return response;
  }

  if (pathname === "/auth/account") {
    const session = await get(request);
    if (!session) return json({ error: "unauthenticated" }, 401);
    return json({ userId: session.userId, displayName: session.data.displayName, csrf: session.data.csrf });
  }

  if (pathname === "/auth/logout") {
    const session = await get(request);
    if (!session || request.headers.get("x-csrf-token") !== session.data.csrf) {
      return json({ error: "forbidden" }, 403);
    }
    const clearing = await destroy(request);
    const response = json({ loggedOut: true });
    response.headers.append("Set-Cookie", clearing);
    return response;
  }

  return json({ error: "not_found" }, 404);
}

Points worth noting:

Pagination

Two situations, two tools.

Within one request, use the table query: where().orderBy(...).limit(n).all() returns Rows with hasMore, and next() fetches the following page. Always give orderBy, and stay in a handful of pages: each page is a database call and counts toward the 64 host calls of the execution (Database SDK).

Across requests (a "load more" button, an API), page with a cursor that the client sends back. Rows.next() cannot be serialized, and offset re-scans rows, so use a keyset cursor on a unique, NOT NULL column and write the predicate with sql, because table filters only support equality:

import { sql } from "microhost/sql";
import { all } from "microhost/db";

function queryParam(search, name) {
  for (const pair of search.replace(/^\?/, "").split("&")) {
    const [key, value = ""] = pair.split("=");
    if (key === name) {
      try { return decodeURIComponent(value.replace(/\+/g, " ")); } catch { return null; }
    }
  }
  return null;
}

export default async function handle(request, ctx) {
  if (!ctx.db) return Response.json({ error: "unavailable" }, { status: 503 });
  const search = new URL(request.url).search;

  const requested = Number(queryParam(search, "limit") ?? 20);
  const limit = Number.isInteger(requested) ? Math.min(Math.max(requested, 1), 50) : 20;

  const beforeText = queryParam(search, "before");
  const before = beforeText === null ? null : Number(beforeText);
  if (before !== null && !Number.isSafeInteger(before)) {
    return Response.json({ error: "invalid_cursor" }, { status: 400 });
  }

  const rows = await all(sql`
    SELECT id, title, published_at FROM posts
    WHERE status = 'published' ${before === null ? sql`` : sql`AND id < ${before}`}
    ORDER BY id DESC
    LIMIT ${limit + 1}`);

  const hasMore = rows.length > limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  return Response.json({ items, nextBefore: hasMore ? items[items.length - 1].id : null });
}

The client calls /api/posts?limit=20, then /api/posts?limit=20&before=<nextBefore> until nextBefore is null. Asking for limit + 1 rows is how you learn hasMore without a count query. Paging this way is not a snapshot: rows inserted while a visitor pages appear at the top of the first page, not in the middle.

For small, rarely changing lists, where().orderBy("id").limit(20).offset(40).all() is fine. For page numbers, run count() too and compute the number of pages yourself.

Error handling

Goals: the visitor gets a sane status and a stable JSON shape, the operator log gets the stable code, and nothing internal leaks.

const json = (body, status = 200) => Response.json(body, { status });

// Map coded host and SDK errors to a response. Branch on error.code, never the message.
function failure(error) {
  switch (error && error.code) {
    case "capability_denied":
    case "CAPABILITY_DENIED":
    case "HOST_UNAVAILABLE":
      return json({ error: "service_unavailable" }, 503);
    case "QUOTA_EXCEEDED":
      return json({ error: "busy" }, 503);
    case "INVALID_INPUT":
    case "invalid_input":
      return json({ error: "invalid_request" }, 400);
    default:
      console.error("unexpected failure", (error && error.code) || (error && error.name) || "unknown");
      return json({ error: "internal_error" }, 500);
  }
}

export default async function handle(request, ctx) {
  try {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405, headers: { allow: "POST" } });
    }
    if (!ctx.db) return json({ error: "service_unavailable" }, 503);

    let input;
    try {
      input = await request.json();
    } catch {
      return json({ error: "invalid_json" }, 400);
    }
    // ... validate input, then call the database ...
    return json({ ok: true });
  } catch (error) {
    return failure(error);
  }
}

Rules of thumb: