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}