Frontend Labs
⋯

server.mjs

// Support Desk — a small, realistic "agentic" web app with zero dependencies.
//
//   node server.mjs            -> http://127.0.0.1:3100
//
// What it demonstrates (each tagged with the guide chapter that explains it):
//   - session-cookie auth with scrypt password hashes, CSRF tokens   (§67)
//   - object-level access control: you can only touch your own orders (§65 A01)
//   - an agent endpoint that streams AG-UI-style events over SSE       (§59, §61)
//   - strict security headers: CSP + Trusted Types, COOP, nosniff     (§65)
//   - rate limits on login and on the (expensive) chat endpoint        (§66 LLM10)
//   - one central error handler: no stack traces leak to the client    (§65 A10)
//
// The "model" is a deterministic, rule-based stand-in so the app (and its
// Playwright suite, §74–§78) runs offline. §63 shows how to swap in WebLLM or an
// edge-hosted open-weight model behind the same event protocol.

import http from "node:http";
import { readFile } from "node:fs/promises";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { extname, join, normalize, sep } from "node:path";
import { fileURLToPath } from "node:url";

const PORT = Number(process.env.PORT ?? 3100);
const HOST = process.env.HOST ?? "127.0.0.1";
const PROD = process.env.NODE_ENV === "production"; // adds `Secure` to cookies
const TEST = process.env.NODE_ENV === "test"; // enables the /__test__/ data factory
const PUBLIC_DIR = fileURLToPath(new URL("./public/", import.meta.url));
const SESSION_TTL_MS = 30 * 60 * 1000;

// ---------------------------------------------------------------- data ----

function hashPassword(password, salt = randomBytes(16)) {
  return { salt, hash: scryptSync(password, salt, 32) };
}

function verifyPassword(password, { salt, hash }) {
  const candidate = scryptSync(password, salt, 32);
  return timingSafeEqual(candidate, hash); // constant-time compare
}

const users = new Map([
  ["alice@example.com", { id: "u1", name: "Alice", email: "alice@example.com", pw: hashPassword("correct-horse-1") }],
  ["bob@example.com", { id: "u2", name: "Bob", email: "bob@example.com", pw: hashPassword("battery-staple-2") }],
]);
// Verifying against a dummy hash for unknown emails keeps the response time
// the same, so timing does not reveal which emails have accounts.
const DUMMY = hashPassword("dummy-password");

const orders = new Map(
  [
    { id: "A-1001", owner: "u1", item: "Noise-cancelling headphones", total: 199, status: "delivered" },
    { id: "A-1002", owner: "u1", item: "Mechanical keyboard", total: 129, status: "in transit" },
    { id: "B-2001", owner: "u2", item: "4K monitor", total: 449, status: "delivered" },
  ].map((o) => [o.id, o]),
);

const sessions = new Map(); // sid -> { userId, csrf, expires }

// ------------------------------------------------------------ helpers ----

const MIME = {
  ".html": "text/html; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".svg": "image/svg+xml",
  ".json": "application/json",
};

const SECURITY_HEADERS = {
  "Content-Security-Policy": [
    "default-src 'self'",
    "script-src 'self'",
    "style-src 'self'",
    "img-src 'self' data:",
    "connect-src 'self'",
    "base-uri 'none'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    "object-src 'none'",
    "require-trusted-types-for 'script'",
    "trusted-types 'none'",
  ].join("; "),
  "X-Content-Type-Options": "nosniff",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Cross-Origin-Opener-Policy": "same-origin",
  "Cross-Origin-Resource-Policy": "same-origin",
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
};

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

function log(event, fields = {}) {
  // One JSON line per security-relevant event: easy to ship to a SIEM (§65 A09).
  console.log(JSON.stringify({ t: new Date().toISOString(), event, ...fields }));
}

function send(res, status, body, headers = {}) {
  const isJSON = typeof body !== "string" && !Buffer.isBuffer(body);
  res.writeHead(status, {
    ...SECURITY_HEADERS,
    "Cache-Control": "no-store",
    ...(isJSON ? { "Content-Type": "application/json" } : {}),
    ...headers,
  });
  res.end(isJSON ? JSON.stringify(body) : body);
}

async function readJSON(req, limit = 4096) {
  if (!String(req.headers["content-type"] ?? "").startsWith("application/json")) {
    throw new HttpError(415, "Expected application/json");
  }
  let size = 0;
  const chunks = [];
  for await (const chunk of req) {
    size += chunk.length;
    if (size > limit) throw new HttpError(413, "Request body too large");
    chunks.push(chunk);
  }
  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    throw new HttpError(400, "Malformed JSON");
  }
}

function parseCookies(header = "") {
  return Object.fromEntries(
    header.split(";").map((p) => p.trim().split("=")).filter(([k, v]) => k && v).map(([k, v]) => [k, decodeURIComponent(v)]),
  );
}

function sessionCookie(sid, maxAgeSec) {
  return [`sid=${sid}`, "HttpOnly", "SameSite=Lax", "Path=/", `Max-Age=${maxAgeSec}`, PROD && "Secure"]
    .filter(Boolean)
    .join("; ");
}

function currentSession(req) {
  const sid = parseCookies(req.headers.cookie).sid;
  const s = sid && sessions.get(sid);
  if (!s) return null;
  if (s.expires < Date.now()) {
    sessions.delete(sid);
    return null;
  }
  s.expires = Date.now() + SESSION_TTL_MS; // sliding expiry
  return { sid, ...s, user: [...users.values()].find((u) => u.id === s.userId) };
}

function requireSession(req) {
  const s = currentSession(req);
  if (!s) throw new HttpError(401, "Not signed in");
  return s;
}

// State-changing requests must carry the CSRF token, and must not be
// cross-site according to Fetch Metadata (§65, §67).
function requireCsrf(req, session) {
  const site = req.headers["sec-fetch-site"];
  if (site && site !== "same-origin" && site !== "none") throw new HttpError(403, "Cross-site request blocked");
  const token = String(req.headers["x-csrf-token"] ?? "");
  const a = Buffer.from(token);
  const b = Buffer.from(session.csrf);
  if (a.length !== b.length || !timingSafeEqual(a, b)) throw new HttpError(403, "Missing or invalid CSRF token");
}

// Fixed-window rate limiter: good enough for one process (§62 shows the edge
// version). check() throws once a key is over its limit; hit() counts one.
function rateLimiter({ limit, windowMs }) {
  const hits = new Map();
  const live = (key) => {
    const h = hits.get(key);
    return h && h.reset > Date.now() ? h : null;
  };
  return {
    check(key) {
      const h = live(key);
      if (h && h.count >= limit) {
        const err = new HttpError(429, "Too many requests, slow down");
        err.headers = { "Retry-After": String(Math.ceil((h.reset - Date.now()) / 1000)) };
        throw err;
      }
    },
    hit(key) {
      const h = live(key);
      if (h) h.count++;
      else hits.set(key, { count: 1, reset: Date.now() + windowMs });
    },
  };
}
// Only *failed* logins count: a user who signs in correctly is never locked out.
const loginLimit = rateLimiter({ limit: Number(process.env.LOGIN_LIMIT ?? 5), windowMs: 60_000 });
const chatLimit = rateLimiter({ limit: Number(process.env.CHAT_LIMIT ?? 20), windowMs: 60_000 });

const publicOrder = ({ owner, ...o }) => o;

// --------------------------------------------------------------- agent ----
// A deterministic "model": it picks a tool from the user's words, runs it,
// and narrates. The event names follow the AG-UI style (§61): the UI never
// sees raw model output it has to parse; it sees typed events.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function* words(text) {
  for (const w of text.split(/(?<= )/)) yield w;
}

async function runAgent(user, message, emit, signal) {
  const id = message.match(/\b([A-Z]-\d{4})\b/i)?.[1]?.toUpperCase();
  const say = async (text) => {
    for (const w of words(text)) {
      if (signal.aborted) return;
      emit({ type: "text-delta", delta: w });
      await sleep(25);
    }
  };

  emit({ type: "run-started" });
  if (/refund/i.test(message) && id) {
    emit({ type: "tool-call", name: "lookupOrder", args: { orderId: id } });
    const order = orders.get(id);
    if (!order || order.owner !== user.id) {
      // Same answer for "missing" and "not yours": no enumeration (§65 A01).
      await say(`I couldn't find order ${id} on your account.`);
    } else if (order.status === "refund requested") {
      await say(`A refund for ${id} is already in progress.`);
    } else {
      await say(`I found ${id}. Refunds move money, so I need you to approve this one: `);
      // Excessive agency guard (§66 LLM06): the agent only *proposes*;
      // the human approves, and the server re-checks everything.
      emit({ type: "ui", component: "refund-approval", props: publicOrder(order) });
    }
  } else if (id) {
    emit({ type: "tool-call", name: "lookupOrder", args: { orderId: id } });
    const order = orders.get(id);
    if (!order || order.owner !== user.id) {
      await say(`I couldn't find order ${id} on your account.`);
    } else {
      await say(`Here is order ${id}: `);
      emit({ type: "ui", component: "order-card", props: publicOrder(order) });
    }
  } else if (/orders?|purchases?/i.test(message)) {
    emit({ type: "tool-call", name: "listOrders", args: {} });
    const mine = [...orders.values()].filter((o) => o.owner === user.id);
    await say(`You have ${mine.length} orders. `);
    for (const o of mine) emit({ type: "ui", component: "order-card", props: publicOrder(o) });
  } else {
    await say(
      `Hi ${user.name}! I can look up an order (try "where is A-1002?"), list your orders, or start a refund ("refund A-1001").`,
    );
  }
  emit({ type: "run-finished" });
}

// -------------------------------------------------------------- routes ----

const routes = {
  "GET /healthz": (req, res) => send(res, 200, { ok: true }),

  "POST /api/login": async (req, res) => {
    const { email = "", password = "" } = await readJSON(req);
    const ip = req.socket.remoteAddress;
    const limitKey = `${ip}|${String(email).toLowerCase()}`;
    loginLimit.check(limitKey);
    const user = users.get(String(email).toLowerCase());
    const ok = verifyPassword(String(password), user?.pw ?? DUMMY) && !!user;
    if (!ok) {
      loginLimit.hit(limitKey);
      log("login.failed", { email: String(email).slice(0, 100), ip });
      throw new HttpError(401, "Invalid email or password");
    }
    const sid = randomBytes(32).toString("base64url"); // new id on every login: no session fixation
    const csrf = randomBytes(32).toString("base64url");
    sessions.set(sid, { userId: user.id, csrf, expires: Date.now() + SESSION_TTL_MS });
    log("login.ok", { user: user.id, ip });
    send(res, 200, { user: { name: user.name, email: user.email }, csrf }, {
      "Set-Cookie": sessionCookie(sid, SESSION_TTL_MS / 1000),
    });
  },

  "GET /api/me": (req, res) => {
    const s = requireSession(req);
    send(res, 200, { user: { name: s.user.name, email: s.user.email }, csrf: s.csrf });
  },

  "POST /api/logout": (req, res) => {
    const s = requireSession(req);
    requireCsrf(req, s);
    sessions.delete(s.sid);
    send(res, 200, { ok: true }, { "Set-Cookie": sessionCookie("", 0) });
  },

  "GET /api/orders": (req, res) => {
    const s = requireSession(req);
    send(res, 200, [...orders.values()].filter((o) => o.owner === s.userId).map(publicOrder));
  },

  "POST /api/refunds": async (req, res) => {
    const s = requireSession(req);
    requireCsrf(req, s);
    const { orderId } = await readJSON(req);
    const order = orders.get(String(orderId));
    if (!order || order.owner !== s.userId) throw new HttpError(404, "Order not found");
    order.status = "refund requested";
    log("refund.requested", { user: s.userId, order: order.id });
    send(res, 200, publicOrder(order));
  },

  "POST /api/chat": async (req, res) => {
    const s = requireSession(req);
    requireCsrf(req, s);
    chatLimit.check(s.sid);
    chatLimit.hit(s.sid);
    const { message = "" } = await readJSON(req);
    if (typeof message !== "string" || !message.trim()) throw new HttpError(400, "Message is required");
    if (message.length > 2000) throw new HttpError(413, "Message too long");

    res.writeHead(200, {
      ...SECURITY_HEADERS,
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-store",
      "X-Accel-Buffering": "no", // tell nginx-style proxies not to buffer the stream
    });
    const ctrl = new AbortController();
    res.on("close", () => ctrl.abort()); // client pressed Stop or went away
    const emit = (event) => !ctrl.signal.aborted && res.write(`data: ${JSON.stringify(event)}\n\n`);
    await runAgent(s.user, message, emit, ctrl.signal);
    res.end();
  },
};

// Test-data factory (§76): each test creates the orders it needs, so tests
// never depend on each other or on run order. Never mounted outside tests.
let nextTestOrder = 3000;
if (TEST) {
  routes["POST /__test__/orders"] = async (req, res) => {
    const s = requireSession(req);
    const { item = "Test item", total = 10, status = "delivered" } = await readJSON(req);
    const order = { id: `T-${nextTestOrder++}`, owner: s.userId, item, total, status };
    orders.set(order.id, order);
    send(res, 201, publicOrder(order));
  };
}

async function serveStatic(req, res) {
  const url = new URL(req.url, "http://x");
  const rel = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
  const file = normalize(join(PUBLIC_DIR, rel));
  if (!file.startsWith(PUBLIC_DIR) || file.includes(`${sep}.`)) throw new HttpError(404, "Not found"); // path traversal
  try {
    const body = await readFile(file);
    send(res, 200, body, { "Content-Type": MIME[extname(file)] ?? "application/octet-stream", "Cache-Control": "no-cache" });
  } catch {
    throw new HttpError(404, "Not found");
  }
}

export function createServer() {
  return http.createServer(async (req, res) => {
    const path = new URL(req.url, "http://x").pathname;
    const handler = routes[`${req.method} ${path}`];
    try {
      if (handler) await handler(req, res);
      else if (path.startsWith("/api/")) throw new HttpError(404, "Not found");
      else if (req.method === "GET" || req.method === "HEAD") await serveStatic(req, res);
      else throw new HttpError(405, "Method not allowed");
    } catch (err) {
      // Mishandled exceptions are an OWASP 2025 category (A10): fail closed,
      // log the details, return a generic message with a request id.
      const status = err instanceof HttpError ? err.status : 500;
      const requestId = randomBytes(6).toString("hex");
      if (status === 500) log("error", { requestId, path, message: err.message, stack: err.stack });
      if (res.headersSent) return res.end();
      send(res, status, { error: status === 500 ? "Something went wrong" : err.message, requestId }, err.headers);
    }
  });
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  createServer().listen(PORT, HOST, () => console.log(`Support Desk on http://${HOST}:${PORT}`));
}

Generated from SOURCE.md · 8 Oct 2026 16:36