import { Hono } from "hono"; import { createAuth } from "./auth"; import { normalizeText, normalizeUniqueTextList } from "./domain/hand-validation"; import type { CloudflareBindings } from "./env"; export { Hand } from "./hand"; type Variables = { auth: ReturnType; me: { id: string; name: string } | null }; const app = new Hono<{ Bindings: CloudflareBindings; Variables: Variables }>(); const LIMITS = { name: 50, question: 280, option: 100, constraint: 200, constraints: 8 } as const; const HAND_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const SECURITY_HEADERS = { "Content-Security-Policy": "default-src 'self'; script-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", } as const; async function jsonBody(c: any): Promise | null> { try { const value = await c.req.json(); return value && typeof value === "object" && !Array.isArray(value) ? value : null; } catch { return null; } } function handStub(c: any) { const id = c.req.param("id"); return HAND_ID.test(id) ? c.env.HAND.get(c.env.HAND.idFromName(id)) : null; } app.use("*", async (c, next) => { const auth = createAuth(c.env, (c.req.raw as any).cf || {}, new URL(c.req.url).origin); c.set("auth", auth); const session = await auth.api.getSession({ headers: c.req.raw.headers }).catch(() => null); c.set("me", session ? { id: session.user.id, name: session.user.name || "anon" } : null); await next(); for (const [name, value] of Object.entries(SECURITY_HEADERS)) c.header(name, value); }); app.all("/api/auth/*", c => c.get("auth").handler(c.req.raw)); // Name is per-table display name; stored on the user for now. app.post("/api/me/name", async c => { const me = c.get("me"); if (!me) return c.json({ error: "sign in first" }, 401); const body = await jsonBody(c); const name = normalizeText(body?.name, LIMITS.name); if (!name) return c.json({ error: `name must be 1–${LIMITS.name} characters` }, 400); await c.get("auth").api.updateUser({ headers: c.req.raw.headers, body: { name } }); return c.json({ ok: true }); }); // Create a hand (draft -> open in one step for the prototype) app.post("/api/hands", async c => { const me = c.get("me"); if (!me) return c.json({ error: "sign in first" }, 401); const body = await jsonBody(c); if (!body) return c.json({ error: "invalid JSON body" }, 400); const question = normalizeText(body.question, LIMITS.question); const options = normalizeUniqueTextList(body.options, 4, LIMITS.option); const constraints = body.constraints === undefined ? [] : normalizeUniqueTextList(body.constraints, LIMITS.constraints, LIMITS.constraint); if (!question) return c.json({ error: `question must be 1–${LIMITS.question} characters` }, 400); if (!options || options.length < 2) return c.json({ error: "options must contain 2–4 unique labels" }, 400); if (!constraints) return c.json({ error: `constraints must contain at most ${LIMITS.constraints} unique labels` }, 400); const id = crypto.randomUUID(); const stub = c.env.HAND.get(c.env.HAND.idFromName(id)); return stub.fetch("https://hand/init", { method: "POST", body: JSON.stringify({ id, question, options, constraints }) }); }); app.get("/api/hands/:id", async c => { const stub = handStub(c); if (!stub) return c.json({ error: "invalid hand id" }, 400); return stub.fetch("https://hand/state"); }); app.post("/api/hands/:id/act", async c => { const me = c.get("me"); if (!me) return c.json({ error: "sign in first" }, 401); const body = await jsonBody(c); if (!body) return c.json({ error: "invalid JSON body" }, 400); if (typeof body.op !== "string" || !["join", "place", "veto", "lock", "pull", "again", "more", "accept"].includes(body.op)) { return c.json({ error: "unknown operation" }, 400); } if ((body.op === "place" || body.op === "veto") && (typeof body.option !== "string" || body.option.length > 64)) { return c.json({ error: "invalid option" }, 400); } if (body.op === "place" && (!Number.isInteger(body.n) || (body.n as number) === 0 || Math.abs(body.n as number) > 10)) { return c.json({ error: "chip change must be a non-zero integer from -10 to 10" }, 400); } if (body.op === "more") { const labels = normalizeUniqueTextList(body.labels, 4, LIMITS.option); if (!labels?.length) return c.json({ error: "labels must contain 1–4 unique options" }, 400); body.labels = labels; } const stub = handStub(c); if (!stub) return c.json({ error: "invalid hand id" }, 400); return stub.fetch("https://hand/act", { method: "POST", body: JSON.stringify({ ...body, member: me }) }); }); app.get("/api/hands/:id/ws", async c => { if (c.req.header("Upgrade")?.toLowerCase() !== "websocket") { return c.json({ error: "WebSocket upgrade required" }, 426, { Upgrade: "websocket" }); } const stub = handStub(c); if (!stub) return c.json({ error: "invalid hand id" }, 400); return stub.fetch("https://hand/ws", { headers: c.req.raw.headers }); }); app.get("/health", c => c.json({ ok: true, at: new Date().toISOString() })); // Mobile web client: same page for / and /h/:id app.get("/h/:id", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url)))); export default app;