index.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { Hono } from "hono";
  2. import { createAuth } from "./auth";
  3. import { normalizeText, normalizeUniqueTextList } from "./domain/hand-validation";
  4. import type { CloudflareBindings } from "./env";
  5. export { Hand } from "./hand";
  6. type Variables = { auth: ReturnType<typeof createAuth>; me: { id: string; name: string } | null };
  7. const app = new Hono<{ Bindings: CloudflareBindings; Variables: Variables }>();
  8. const LIMITS = { name: 50, question: 280, option: 100, constraint: 200, constraints: 8 } as const;
  9. 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;
  10. const SECURITY_HEADERS = {
  11. "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'",
  12. "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
  13. "Referrer-Policy": "no-referrer",
  14. "X-Content-Type-Options": "nosniff",
  15. } as const;
  16. async function jsonBody(c: any): Promise<Record<string, unknown> | null> {
  17. try {
  18. const value = await c.req.json();
  19. return value && typeof value === "object" && !Array.isArray(value) ? value : null;
  20. } catch {
  21. return null;
  22. }
  23. }
  24. function handStub(c: any) {
  25. const id = c.req.param("id");
  26. return HAND_ID.test(id) ? c.env.HAND.get(c.env.HAND.idFromName(id)) : null;
  27. }
  28. app.use("*", async (c, next) => {
  29. const auth = createAuth(c.env, (c.req.raw as any).cf || {}, new URL(c.req.url).origin);
  30. c.set("auth", auth);
  31. const session = await auth.api.getSession({ headers: c.req.raw.headers }).catch(() => null);
  32. c.set("me", session ? { id: session.user.id, name: session.user.name || "anon" } : null);
  33. await next();
  34. for (const [name, value] of Object.entries(SECURITY_HEADERS)) c.header(name, value);
  35. });
  36. app.all("/api/auth/*", c => c.get("auth").handler(c.req.raw));
  37. // Name is per-table display name; stored on the user for now.
  38. app.post("/api/me/name", async c => {
  39. const me = c.get("me");
  40. if (!me) return c.json({ error: "sign in first" }, 401);
  41. const body = await jsonBody(c);
  42. const name = normalizeText(body?.name, LIMITS.name);
  43. if (!name) return c.json({ error: `name must be 1–${LIMITS.name} characters` }, 400);
  44. await c.get("auth").api.updateUser({ headers: c.req.raw.headers, body: { name } });
  45. return c.json({ ok: true });
  46. });
  47. // Create a hand (draft -> open in one step for the prototype)
  48. app.post("/api/hands", async c => {
  49. const me = c.get("me");
  50. if (!me) return c.json({ error: "sign in first" }, 401);
  51. const body = await jsonBody(c);
  52. if (!body) return c.json({ error: "invalid JSON body" }, 400);
  53. const question = normalizeText(body.question, LIMITS.question);
  54. const options = normalizeUniqueTextList(body.options, 4, LIMITS.option);
  55. const constraints = body.constraints === undefined ? [] : normalizeUniqueTextList(body.constraints, LIMITS.constraints, LIMITS.constraint);
  56. if (!question) return c.json({ error: `question must be 1–${LIMITS.question} characters` }, 400);
  57. if (!options || options.length < 2) return c.json({ error: "options must contain 2–4 unique labels" }, 400);
  58. if (!constraints) return c.json({ error: `constraints must contain at most ${LIMITS.constraints} unique labels` }, 400);
  59. const id = crypto.randomUUID();
  60. const stub = c.env.HAND.get(c.env.HAND.idFromName(id));
  61. return stub.fetch("https://hand/init", { method: "POST", body: JSON.stringify({ id, question, options, constraints }) });
  62. });
  63. app.get("/api/hands/:id", async c => {
  64. const stub = handStub(c);
  65. if (!stub) return c.json({ error: "invalid hand id" }, 400);
  66. return stub.fetch("https://hand/state");
  67. });
  68. app.post("/api/hands/:id/act", async c => {
  69. const me = c.get("me");
  70. if (!me) return c.json({ error: "sign in first" }, 401);
  71. const body = await jsonBody(c);
  72. if (!body) return c.json({ error: "invalid JSON body" }, 400);
  73. if (typeof body.op !== "string" || !["join", "place", "veto", "lock", "pull", "again", "more", "accept"].includes(body.op)) {
  74. return c.json({ error: "unknown operation" }, 400);
  75. }
  76. if ((body.op === "place" || body.op === "veto") && (typeof body.option !== "string" || body.option.length > 64)) {
  77. return c.json({ error: "invalid option" }, 400);
  78. }
  79. if (body.op === "place" && (!Number.isInteger(body.n) || (body.n as number) === 0 || Math.abs(body.n as number) > 10)) {
  80. return c.json({ error: "chip change must be a non-zero integer from -10 to 10" }, 400);
  81. }
  82. if (body.op === "more") {
  83. const labels = normalizeUniqueTextList(body.labels, 4, LIMITS.option);
  84. if (!labels?.length) return c.json({ error: "labels must contain 1–4 unique options" }, 400);
  85. body.labels = labels;
  86. }
  87. const stub = handStub(c);
  88. if (!stub) return c.json({ error: "invalid hand id" }, 400);
  89. return stub.fetch("https://hand/act", { method: "POST", body: JSON.stringify({ ...body, member: me }) });
  90. });
  91. app.get("/api/hands/:id/ws", async c => {
  92. if (c.req.header("Upgrade")?.toLowerCase() !== "websocket") {
  93. return c.json({ error: "WebSocket upgrade required" }, 426, { Upgrade: "websocket" });
  94. }
  95. const stub = handStub(c);
  96. if (!stub) return c.json({ error: "invalid hand id" }, 400);
  97. return stub.fetch("https://hand/ws", { headers: c.req.raw.headers });
  98. });
  99. app.get("/health", c => c.json({ ok: true, at: new Date().toISOString() }));
  100. // Mobile web client: same page for / and /h/:id
  101. app.get("/h/:id", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url))));
  102. export default app;