| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116 |
- 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<typeof createAuth>; 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<Record<string, unknown> | 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;
|