jevstrudel.git / worker / src / relay.ts
relay.tsannotatedrelay.tssource300 lines · 13.0 KB · raw

The Jev relay: POST /jev/v1/systemone from the page, forwarded to TypeSafe's API with the site's own key. TypeSafe refuses browser origins (CORS), so the page cannot call it directly; and visitors shouldn't have to hand their key to someone else's Worker, so the site pays.

Because anyone who finds this path spends that key, it forwards only the question shapes TypeSafe defines (choice, score, noul) within TypeSafe's own limits, for one model, in a bounded body, at a bounded rate per visitor. Anything else is refused before TypeSafe sees it. A request byte for byte the same as one answered in the last hour gets that answer from KV instead (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';
21export const RELAY_PATH = '/jev/v1/systemone';
22const UPSTREAM = 'https://api.typesafe.ai/v1/systemone';
23export const MODEL = 'jev-1.13.0';

The size bounds a call's cost (TypeSafe charges by input token), however 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

The rate limit per visitor, set in wrangler.json (JSON, so its reasoning lives here). A song asks each of its jev()s once a section, so it makes (bpm / 4 cycles a minute) / (cycles a section) × (jev()s) calls a minute. The busiest, Task Failed Successfully, is at most 180 / 4 / 4 × 2 = 22.5 (played, 16: a section with one allowed next asks only its parts). The budget: that song in two tabs (45), plus the mood picker, the art check and the radio's next pick, about one a minute each (48). The limit is 60 a 60 s period, and allSongs.test.mjs measures every song and fails one above half of it, so one song can never starve the rest. The binding says only yes or no, so a refused caller is told to wait out a whole period.

41export const LIMIT = config.ratelimits.find((r) => r.name === 'JEV_LIMIT')!.simple;
42const LIMIT_PERIOD_S = LIMIT.period;
44const refuse = (status: number, why: string, headers: Record<string, string> = {}) =>
45  new Response(why, { status, headers: { 'Cache-Control': 'no-store', ...headers } });

TypeSafe's own rate-limit answer, passed back to the page as is.

48const PASS_HEADERS = /^(retry-after(-ms)?|ratelimit(-.*)?|x-ratelimit-.*)$/i;

A signed-in caller's daily budget (budget.ts), on every answer that charged it: <used>/<limit>, or spent on the 429 that refuses a call once it is spent (website/src/jev/ask.mjs reads both).

53export const BUDGET_HEADER = 'Jev-Budget';
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}

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}

The page's own timing (website/src/jev/ask.mjs), as headers the relay reads for its log and never forwards: seconds until the section asked about begins when the call went out (negative once it has begun), and 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;

One structured line per call, for Workers Logs (wrangler.json's observability) and wrangler tail. It holds timing and sizes only: no address, no visitor key, nothing from the request's contents, and the page's headers only as bounded numbers. colo is the Cloudflare data centre that ran the call, which is where upstream latency is measured from. outcome says who answered: the relay itself (refused), TypeSafe (forwarded, whatever its status), the answer cache (cached), or nobody (unreachable). The same fields go to Analytics Engine as a jev.relay event (events-schema.ts), 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};
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}

The upstream path: one request to TypeSafe with the site's key, the body forwarded as given. The relay forwards the page's calls through it, and the Worker's own questions (content-jobs.ts: screening listeners' content and scoring their songs) go the same way, after the same whyNot check. 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}

Signed-in callers (a session cookie, auth.ts) are charged one call of their daily budget for each call forwarded to TypeSafe: a cached answer costs nothing, so it is not charged. Once spent, calls are refused with a 429 until UTC midnight. The per-minute JEV_LIMIT applies to everyone, keyed by the account when signed in (so listeners behind one address do 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}