jevstrudel.git / worker / src / votes.ts
votes.tsannotatedvotes.tssource124 lines · 6.0 KB · raw
1// "Do you agree with Jev?" votes (website/src/jev/agree.mjs), counted
2// anonymously:
3//
4//   POST /jev/votes            { a, b, pick }: two song ids as shown, and the
5//                              one the listener picked as more art
6//   GET  /jev/votes/summary    { votes: [{ a, b, pick, n }] }: a count per
7//                              (a, b, pick), for `nix run .#agreement`
8//
9// A vote is kept only as a count against its (a, b, pick) in the site's D1
10// database (votes-store.ts): no time, no address, no visitor. Songs
11// must be the deploy's own (the site's /jev/songs.json), so the counts hold
12// nothing a caller made up; the pick must be one of the two. Agreement with
13// the art critic is not worked out here: it is always against the critic's
14// current scores, which live in the repo (tools/agreement).
15import type { Env } from './env';
16import config from '../wrangler.json';
17
18export const VOTES_PATH = '/jev/votes';
19export const SUMMARY_PATH = '/jev/votes/summary';
20// The site's song ids, built by website/src/pages/jev/songs.json.js.
21export const SONGS_PATH = '/jev/songs.json';
22const MAX_BYTES = 1024; // two ids and a pick
23// The ids as songs.json writes them: "<theme>/<song>" folder names.
24const SONG_ID = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;
25
26// Per visitor, set in wrangler.json. A vote is a click after reading two
27// titles, and "another pair" is a click more, so a listener votes a few
28// times a minute at most; the summary is read by a tool, once a run. Apart
29// from JEV_LIMIT, so voting never spends a song's Jev calls.
30export const VOTE_LIMIT = config.ratelimits.find((r) => r.name === 'VOTE_LIMIT')!.simple;
31
32export type Vote = { a: string; b: string; pick: string };
33export type Count = Vote & { n: number };
34export interface VoteStore {
35  add(vote: Vote): Promise<void>;
36  counts(): Promise<Count[]>;
37}
38
39const isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x);
40
41// null when `body` is a vote on two known songs; otherwise why not.
42export function whyNotVote(body: unknown, known: ReadonlySet<string>): string | null {
43  if (!isObject(body)) return 'body must be a JSON object';
44  const { a, b, pick, ...rest } = body;
45  if (Object.keys(rest).length) return `unexpected fields: ${Object.keys(rest).join(', ')}`;
46  if (typeof a !== 'string' || typeof b !== 'string' || typeof pick !== 'string') return 'a, b and pick are song ids';
47  if (a === b) return 'a and b must be two songs';
48  for (const id of [a, b]) if (!SONG_ID.test(id) || !known.has(id)) return `not a song on this site: ${id.slice(0, 64)}`;
49  if (pick !== a && pick !== b) return 'pick must be a or b';
50  return null;
51}
52
53// The deploy's song ids: its own static file through the ASSETS binding in
54// production (and `buck2 run //:preview`), or the dev server's live one
55// (SONGS_URL, env.dev) in dev, where a new song is votable on reload.
56// Kept for a minute per isolate.
57const SONGS_TTL_MS = 60_000;
58let songs: { ids: ReadonlySet<string>; until: number } | null = null;
59
60export async function knownSongs(env: Env): Promise<ReadonlySet<string>> {
61  if (songs && Date.now() < songs.until) return songs.ids;
62  let res: Response;
63  if (env.SONGS_URL) res = await fetch(env.SONGS_URL);
64  else if (env.ASSETS) res = await env.ASSETS.fetch(new Request(new URL(SONGS_PATH, 'https://assets.invalid')));
65  else throw new Error('no song list: neither SONGS_URL nor ASSETS is bound');
66  if (!res.ok) throw new Error(`the song list answered ${res.status}`);
67  const { songs: ids } = (await res.json()) as { songs?: unknown };
68  if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string')) throw new Error('the song list is malformed');
69  songs = { ids: new Set(ids), until: Date.now() + SONGS_TTL_MS };
70  return songs.ids;
71}
72
73// For tests: forget the cached list.
74export const forgetSongs = () => void (songs = null);
75
76const answer = (status: number, text: string, headers: Record<string, string> = {}) =>
77  new Response(text, { status, headers: { 'Cache-Control': 'no-store', ...headers } });
78
79export async function votes(request: Request, env: Env, store: VoteStore): Promise<Response> {
80  const { pathname } = new URL(request.url);
81  const summary = pathname === SUMMARY_PATH;
82  const method = summary ? 'GET' : 'POST';
83  if (request.method !== method) return answer(405, `${method} only`, { Allow: method });
84
85  const visitor = request.headers.get('CF-Connecting-IP') ?? 'local';
86  const { success } = await env.VOTE_LIMIT.limit({ key: visitor });
87  if (!success) return answer(429, 'too many votes; try again in a minute', { 'Retry-After': String(VOTE_LIMIT.period) });
88
89  if (summary) {
90    return Response.json({ votes: await store.counts() }, { headers: { 'Cache-Control': 'no-store' } });
91  }
92
93  const raw = await request.arrayBuffer();
94  if (raw.byteLength > MAX_BYTES) return answer(413, 'vote too large');
95  let body: unknown;
96  try {
97    body = JSON.parse(new TextDecoder().decode(raw));
98  } catch {
99    return answer(400, 'body must be JSON');
100  }
101  const cast = await castVote(env, store, body);
102  if (!cast.ok) return answer(cast.status, cast.why);
103  return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
104}
105
106// One vote counted, once the caller has held it to VOTE_LIMIT: on the
107// deploy's own songs, the pick one of the two. The page's POST /jev/votes
108// (above, per address) and the MCPs' `vote` (shared-mcp.ts, per account)
109// both count through here, so both keep the same rules.
110export type Cast = { ok: true } | { ok: false; status: number; why: string };
111export async function castVote(env: Env, store: VoteStore, body: unknown): Promise<Cast> {
112  let known: ReadonlySet<string>;
113  try {
114    known = await knownSongs(env);
115  } catch (e) {
116    console.error({ event: 'jev.votes', error: (e as Error).message });
117    return { ok: false, status: 503, why: 'votes are not being counted right now' };
118  }
119  const why = whyNotVote(body, known);
120  if (why) return { ok: false, status: 400, why };
121  const { a, b, pick } = body as Vote;
122  await store.add({ a, b, pick });
123  return { ok: true };
124}