jevstrudel.git / worker / src / relay.ts
relay.tsannotatedrelay.tssource300 lines · 13.0 KB · raw
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}