1// The Jev relay: POST /jev/v1/systemone from the page, forwarded to 2// TypeSafe's API with the site's own key. TypeSafe refuses browser origins 3// (CORS), so the page cannot call it directly; and visitors shouldn't have 4// to hand their key to someone else's Worker, so the site pays. 5// 6// Because anyone who finds this path spends that key, it forwards only the 7// question shapes TypeSafe defines (choice, score, noul) within TypeSafe's 8// own limits, for one model, in a bounded body, at a bounded rate per 9// visitor. Anything else is 10// refused before TypeSafe sees it. A request byte for byte the same as one 11// answered in the last hour gets that answer from KV instead 12// (answer-cache.ts), marked `Jev-Cache: hit`. 13import { d1Accounts, type Accounts, type User } from './accounts-store'; 14import { CACHE_HEADER, cached, cacheKey, keep } from './answer-cache'; 15import { signedIn } from './auth'; 16import type { Spend } from './budget-counter'; 17import type { Env } from './env'; 18import { event } from './events'; 19import config from '../wrangler.json'; 20 21export const RELAY_PATH = '/jev/v1/systemone'; 22const UPSTREAM = 'https://api.typesafe.ai/v1/systemone'; 23export const MODEL = 'jev-1.13.0'; 24// The size bounds a call's cost (TypeSafe charges by input token), however 25// many questions and options it holds; those two are TypeSafe's own limits. 26export const MAX_BYTES = 64 * 1024; // about 16k tokens, ~$0.0008 a call; the art check sends a song whole (46 KB at most, allSongs.test.mjs) 27const MAX_OPTIONS = 255; // TypeSafe's own limit for a Choice 28const MAX_LEVELS = 10; // TypeSafe's own limit for a Score 29 30// The rate limit per visitor, set in wrangler.json (JSON, so its reasoning 31// lives here). A song asks each of its jev()s once a section, so it makes 32// (bpm / 4 cycles a minute) / (cycles a section) × (jev()s) calls a minute. 33// The busiest, Task Failed Successfully, is at most 180 / 4 / 4 × 2 = 22.5 34// (played, 16: a section with one allowed next asks only its parts). The 35// budget: that song in two tabs (45), plus the mood picker, the art check 36// and the radio's next pick, about one a minute each (48). The limit is 60 37// a 60 s period, and allSongs.test.mjs measures every song and fails one 38// above half of it, so one song can never starve the rest. 39// The binding says only yes or no, so a refused caller is told to wait out 40// a whole period. 41export const LIMIT = config.ratelimits.find((r) => r.name === 'JEV_LIMIT')!.simple; 42const LIMIT_PERIOD_S = LIMIT.period; 43 44const refuse = (status: number, why: string, headers: Record<string, string> = {}) => 45 new Response(why, { status, headers: { 'Cache-Control': 'no-store', ...headers } }); 46 47// TypeSafe's own rate-limit answer, passed back to the page as is. 48const PASS_HEADERS = /^(retry-after(-ms)?|ratelimit(-.*)?|x-ratelimit-.*)$/i; 49 50// A signed-in caller's daily budget (budget.ts), on every answer that 51// charged it: `<used>/<limit>`, or `spent` on the 429 that refuses a call 52// once it is spent (website/src/jev/ask.mjs reads both). 53export const BUDGET_HEADER = 'Jev-Budget'; 54 55type Question = { type?: unknown; instructions?: unknown; criteria?: unknown }; 56 57const isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x); 58const allStrings = (xs: unknown[]) => xs.every((x) => typeof x === 'string'); 59 60function whyNotQuestion(q: Question): string | null { 61 if (typeof q?.instructions !== 'string') return 'instructions must be a string'; 62 switch (q.type) { 63 case 'choice': { 64 if (!isObject(q.criteria)) return 'a choice needs criteria: { option: description }'; 65 const options = Object.values(q.criteria); 66 if (options.length < 2 || options.length > MAX_OPTIONS) return `a choice has 2 to ${MAX_OPTIONS} options`; 67 return allStrings(options) ? null : 'choice descriptions must be strings'; 68 } 69 case 'score': { 70 const levels = q.criteria; 71 if (!Array.isArray(levels) || levels.length < 2 || levels.length > MAX_LEVELS) { 72 return `a score has 2 to ${MAX_LEVELS} levels`; 73 } 74 return allStrings(levels) ? null : 'score levels must be strings'; 75 } 76 case 'noul': { 77 if (q.criteria === undefined) return null; 78 if (!isObject(q.criteria)) return 'noul criteria must be { true, false }'; 79 const { true: yes, false: no, ...rest } = q.criteria; 80 if (Object.keys(rest).length || typeof yes !== 'string' || typeof no !== 'string') { 81 return 'noul criteria must be { true, false } strings'; 82 } 83 return null; 84 } 85 default: 86 return 'questions are choice, score or noul'; 87 } 88} 89 90// null when the body is a request the page could have sent; otherwise why not. 91export function whyNot(body: unknown): string | null { 92 if (!isObject(body)) return 'body must be a JSON object'; 93 const { model, questions, state, ...rest } = body; 94 if (Object.keys(rest).length) return `unexpected fields: ${Object.keys(rest).join(', ')}`; 95 if (model !== MODEL) return `model must be ${MODEL}`; 96 if (!isObject(state)) return 'state must be an object'; 97 if (!isObject(questions)) return 'questions must be an object'; 98 const qs = Object.values(questions) as Question[]; 99 if (qs.length < 1) return 'ask at least one question'; 100 for (const q of qs) { 101 const why = whyNotQuestion(q); 102 if (why) return why; 103 } 104 return null; 105} 106 107// The page's own timing (website/src/jev/ask.mjs), as headers the relay 108// reads for its log and never forwards: seconds until the section asked 109// about begins when the call went out (negative once it has begun), and 110// which attempt this is (0, then its retries). 111export const LEAD_HEADER = 'Jev-Section-In'; 112export const ATTEMPT_HEADER = 'Jev-Attempt'; 113const MAX_LEAD_S = 3600; 114const MAX_ATTEMPT = 9; 115 116// One structured line per call, for Workers Logs (wrangler.json's 117// `observability`) and `wrangler tail`. It holds timing and sizes only: no 118// address, no visitor key, nothing from the request's contents, and the 119// page's headers only as bounded numbers. `colo` is the Cloudflare data 120// centre that ran the call, which is where upstream latency is measured from. 121// `outcome` says who answered: the relay itself (refused), TypeSafe 122// (forwarded, whatever its status), the answer cache (cached), or nobody 123// (unreachable). The same 124// fields go to Analytics Engine as a jev.relay event (events-schema.ts), 125// where they can be counted and charted. 126export type RelayLog = { 127 event: 'jev.relay'; 128 outcome: 'refused' | 'forwarded' | 'cached' | 'unreachable'; 129 status: number; 130 upstreamMs: number | null; // until TypeSafe's response headers; null when refused before asking 131 requestBytes: number | null; // the body as forwarded; null when refused before reading it 132 sectionInS: number | null; 133 attempt: number | null; 134 colo: string | null; 135}; 136 137export function pageTiming(headers: Headers): { sectionInS: number | null; attempt: number | null } { 138 const lead = Number(headers.get(LEAD_HEADER) ?? NaN); 139 const attempt = Number(headers.get(ATTEMPT_HEADER) ?? NaN); 140 return { 141 sectionInS: Number.isFinite(lead) && Math.abs(lead) <= MAX_LEAD_S ? Math.round(lead * 100) / 100 : null, 142 attempt: Number.isInteger(attempt) && attempt >= 0 && attempt <= MAX_ATTEMPT ? attempt : null, 143 }; 144} 145 146// The upstream path: one request to TypeSafe with the site's key, the body 147// forwarded as given. The relay forwards the page's calls through it, and 148// the Worker's own questions (content-jobs.ts: screening listeners' content 149// and scoring their songs) go the same way, after the same whyNot check. 150// Throws when TypeSafe cannot be reached; any status is returned as is. 151export function upstream(env: Pick<Env, 'JEVSTRUDEL_TYPESAFE_API_KEY'>, body: BodyInit): Promise<Response> { 152 return fetch(UPSTREAM, { 153 method: 'POST', 154 headers: { 155 Authorization: `Bearer ${env.JEVSTRUDEL_TYPESAFE_API_KEY}`, 156 'Content-Type': 'application/json', 157 }, 158 body, 159 }); 160} 161 162// Signed-in callers (a session cookie, auth.ts) are charged one call of 163// their daily budget for each call forwarded to TypeSafe: a cached answer 164// costs nothing, so it is not charged. Once spent, calls are refused with 165// a 429 until UTC midnight. The per-minute JEV_LIMIT applies to everyone, 166// keyed by the account when signed in (so listeners behind one address do 167// not share it) and by the address otherwise, as before accounts. 168export async function relay( 169 request: Request, 170 env: Env, 171 ctx: Pick<ExecutionContext, 'waitUntil'>, 172 accounts: Accounts = d1Accounts(env.DB), 173): Promise<Response> { 174 const log = (outcome: RelayLog['outcome'], status: number, fields: Partial<RelayLog> = {}) => { 175 const colo = (request as { cf?: { colo?: unknown } }).cf?.colo; 176 const line: RelayLog = { 177 event: 'jev.relay', 178 outcome, 179 status, 180 upstreamMs: null, 181 requestBytes: null, 182 ...pageTiming(request.headers), 183 colo: typeof colo === 'string' ? colo : null, 184 ...fields, 185 }; 186 console.log(line); 187 event(env, 'jev.relay', { 188 blobs: { outcome: line.outcome, colo: line.colo }, 189 doubles: { 190 status: line.status, 191 upstreamMs: line.upstreamMs, 192 requestBytes: line.requestBytes, 193 sectionInS: line.sectionInS, 194 attempt: line.attempt, 195 }, 196 }); 197 }; 198 const refused = (status: number, why: string, headers: Record<string, string> = {}, fields: Partial<RelayLog> = {}) => { 199 log('refused', status, fields); 200 return refuse(status, why, headers); 201 }; 202 203 if (request.method !== 'POST') { 204 log('refused', 405); 205 return new Response('POST only', { status: 405, headers: { Allow: 'POST' } }); 206 } 207 if (!env.JEVSTRUDEL_TYPESAFE_API_KEY) return refused(503, 'the relay has no TypeSafe key'); 208 209 let user: User | null; 210 try { 211 user = await signedIn(request, accounts); 212 } catch (e) { 213 console.error({ event: 'jev.relay.session', error: (e as Error).message }); 214 return refused(503, 'sign-in is unavailable; try again in a minute', { 'Retry-After': '60' }); 215 } 216 const visitor = user ? `user:${user.id}` : (request.headers.get('CF-Connecting-IP') ?? 'local'); 217 const { success } = await env.JEV_LIMIT.limit({ key: visitor }); 218 if (!success) { 219 return refused(429, 'too many Jev calls; try again in a minute', { 'Retry-After': String(LIMIT_PERIOD_S) }); 220 } 221 222 const raw = await request.arrayBuffer(); 223 const requestBytes = raw.byteLength; 224 if (requestBytes > MAX_BYTES) return refused(413, 'request too large', {}, { requestBytes }); 225 let body: unknown; 226 try { 227 body = JSON.parse(new TextDecoder().decode(raw)); 228 } catch { 229 return refused(400, 'body must be JSON', {}, { requestBytes }); 230 } 231 const why = whyNot(body); 232 if (why) return refused(400, why, {}, { requestBytes }); 233 234 const key = await cacheKey(MODEL, raw); 235 const hit = await cached(env.CACHE, key); 236 if (hit) { 237 log('cached', 200, { requestBytes }); 238 return new Response(hit.body, { 239 status: 200, 240 headers: { 'Content-Type': hit.contentType, 'Cache-Control': 'no-store', [CACHE_HEADER]: 'hit' }, 241 }); 242 } 243 244 const budgetHeaders: Record<string, string> = {}; 245 if (user) { 246 let charge: Spend; 247 try { 248 charge = await env.BUDGET.get(env.BUDGET.idFromName(user.id)).spend(); 249 } catch (e) { 250 // not charged, so not forwarded: a budget that cannot be counted is not spent 251 console.error({ event: 'jev.budget', op: 'spend', error: (e as Error).message }); 252 return refused(503, 'your Jev budget cannot be checked right now', { 'Retry-After': '60' }, { requestBytes }); 253 } 254 const secondsToReset = Math.max(1, Math.ceil((charge.resetsAt - Date.now()) / 1000)); 255 if (!charge.ok) { 256 return refused( 257 429, 258 `your daily Jev budget (${charge.limit} calls) is spent; it resets at 00:00 UTC`, 259 { 'Retry-After': String(secondsToReset), [BUDGET_HEADER]: 'spent' }, 260 { requestBytes }, 261 ); 262 } 263 if (charge.exhausted) { 264 const colo = (request as { cf?: { colo?: unknown } }).cf?.colo; 265 console.log({ event: 'jev.budget.exhausted', limit: charge.limit, secondsToReset }); 266 event(env, 'jev.budget.exhausted', { 267 blobs: { colo: typeof colo === 'string' ? colo : null }, 268 doubles: { limit: charge.limit, secondsToReset }, 269 }); 270 } 271 budgetHeaders[BUDGET_HEADER] = `${charge.used}/${charge.limit}`; 272 } 273 274 const asked = Date.now(); 275 let res: Response; 276 try { 277 res = await upstream(env, raw); 278 } catch { 279 log('unreachable', 502, { requestBytes, upstreamMs: Date.now() - asked }); 280 return refuse(502, 'TypeSafe could not be reached'); 281 } 282 log('forwarded', res.status, { requestBytes, upstreamMs: Date.now() - asked }); 283 const contentType = res.headers.get('Content-Type') ?? 'application/json'; 284 const headers = new Headers({ 285 'Content-Type': contentType, 286 'Cache-Control': 'no-store', 287 [CACHE_HEADER]: 'miss', 288 ...budgetHeaders, 289 }); 290 res.headers.forEach((value, name) => { 291 if (PASS_HEADERS.test(name)) headers.set(name, value); 292 }); 293 let answer = res.body; 294 if (res.status === 200 && answer) { 295 const [page, store] = answer.tee(); 296 answer = page; 297 ctx.waitUntil(keep(env.CACHE, key, store, contentType)); 298 } 299 return new Response(answer, { status: res.status, headers }); 300}