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}`));
}