1/// <reference types="@cloudflare/workers-types" />2/**3 * REST transport for the route map in @tribes/protocol api.ts.4 *5 * Errors are always JSON {error: <code>, message}. Zod-validated bodies.6 * 401 for unauthenticated requests; GET /api/sims/:id/bundle is public.7 *8 * CORS decision: the web app is same-origin (served by this Worker's assets),9 * so it needs no CORS. We still send permissive CORS (`*`, Authorization10 * allowed) on all /api responses so remote agents can call the API with11 * bearer tokens from anywhere. `*` never permits credentialed (cookie)12 * cross-origin requests, so the cookie session stays same-origin-only.13 */14import { z } from 'zod';15import {16 BrowseInput,17 CreateSimInput,18 HeartInput,19 LeaderboardInput,20 ListModelsInput,21 SetSharingInput,22 StarInput,23 SubmitLogicInput,24 UpdateLogicMetaInput,25} from '@tribes/protocol';26import type { AuthedUser, UserInfo } from '@tribes/protocol';27import type { Env } from './env';28import { EXAMPLES, exampleHashes } from './examples';29import {30 METRICS_MAX_GZ_BYTES,31 computeSimMetrics,32 getStoredMetricsGz,33 gunzipJson,34 looksLikeStoredMetrics,35 storeMetrics,36} from './simrun';37import {38 authenticate,39 clearSessionCookieHeader,40 SESSION_TTL_SECONDS,41 sessionCookieHeader,42 sessionSecret,43 signSession,44 verifyShooIdToken,45} from './auth';46import {47 getSimById,48 getUserById,49 listIdentities,50 removeIdentity,51 upsertUser,52} from './db';53import { createService, ServiceError } from './service';54import type { ServiceDeps } from './service';55import { base64UrlToBytes, isoOf, nowSec } from './util';56import { keyAuth, linkByCode, linkKey, mintLinkCode, parseKeyProof } from './keys';5758export interface ApiDeps {59 validate: ServiceDeps['validate'];60 background?: ServiceDeps['background'];61}6263const CORS_HEADERS: Record<string, string> = {64 'access-control-allow-origin': '*',65 'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',66 'access-control-allow-headers': 'authorization,content-type',67 'access-control-max-age': '86400',68};6970/** Also used by index.ts for the /mcp preflight. */71export function corsPreflight(): Response {72 return new Response(null, { status: 204, headers: CORS_HEADERS });73}7475function json(body: unknown, status = 200, extra?: Record<string, string>): Response {76 return new Response(JSON.stringify(body), {77 status,78 headers: { 'content-type': 'application/json', ...CORS_HEADERS, ...extra },79 });80}8182function err(code: string, message: string, status: number, extra?: Record<string, string>): Response {83 return json({ error: code, message }, status, extra);84}8586const SERVICE_ERROR_STATUS: Record<ServiceError['code'], number> = {87 not_found: 404,88 forbidden: 403,89 validation_failed: 422,90 star_limit: 409,91 bad_input: 400,92 expired: 410,93};9495/** Generous ceiling: largest legal body is a 64 KiB script inside JSON. */96const MAX_BODY_BYTES = 256 * 1024;9798async function readJson(request: Request): Promise<unknown> {99 const declared = Number(request.headers.get('content-length') ?? '0');100 if (declared > MAX_BODY_BYTES) {101 throw new ServiceError('bad_input', `request body too large (max ${MAX_BODY_BYTES} bytes)`);102 }103 try {104 // Content-Length can lie/be absent on chunked bodies — cap the real bytes.105 const text = await request.text();106 if (text.length > MAX_BODY_BYTES) {107 throw new ServiceError('bad_input', `request body too large (max ${MAX_BODY_BYTES} bytes)`);108 }109 return JSON.parse(text);110 } catch (err) {111 if (err instanceof ServiceError) throw err;112 throw new ServiceError('bad_input', 'request body must be JSON');113 }114}115116function parseBody<T>(schema: z.ZodType<T>, body: unknown): T {117 const result = schema.safeParse(body);118 if (!result.success) {119 const issue = result.error.issues[0];120 const where = issue && issue.path.length > 0 ? `${issue.path.join('.')}: ` : '';121 throw new ServiceError('bad_input', `${where}${issue?.message ?? 'invalid input'}`);122 }123 return result.data;124}125126const SessionInput = z.object({127 idToken: z.string().min(1).optional(),128 local: z.string().min(1).max(64).optional(),129});130const AddKeyInput = z.object({ publicKey: z.string().min(1).max(64) });131const DeleteAccountInput = z.object({ confirm: z.string().min(1) });132133/**134 * Coerce query-string values into a BrowseInput-shaped object (zod-parse135 * after): numbers via Number(), booleans via 'true'/'1'/'false'/'0'; absent136 * or empty params are omitted so zod defaults apply. Exported for tests.137 */138export function coerceBrowseQuery(sp: URLSearchParams): Record<string, unknown> {139 const out: Record<string, unknown> = {};140 const set = (key: string, map?: (v: string) => unknown): void => {141 const v = sp.get(key);142 if (v !== null && v !== '') out[key] = map === undefined ? v : map(v);143 };144 const toNumber = (v: string): unknown => Number(v);145 const toBoolean = (v: string): unknown =>146 v === 'true' || v === '1' ? true : v === 'false' || v === '0' ? false : v;147 set('sort');148 set('window');149 set('lab');150 set('releasedAfter');151 set('releasedBefore');152 set('q');153 set('limit', toNumber);154 set('offset', toNumber);155 set('openWeights', toBoolean);156 return out;157}158159/** Content-Disposition filename value: strip quotes/backslashes/controls. */160const FILENAME_STRIP = new RegExp("[\"\\\\\\u0000-\\u001f\\u007f]", 'g');161function safeFilename(name: string): string {162 return name.replace(FILENAME_STRIP, '_');163}164165export async function handleApi(166 request: Request,167 env: Env,168 _ctx: ExecutionContext,169 deps: ApiDeps,170): Promise<Response> {171 const url = new URL(request.url);172 const path = url.pathname;173 const method = request.method.toUpperCase();174 const secure = url.protocol === 'https:';175176 if (method === 'OPTIONS') return corsPreflight();177178 try {179 // -- session (no auth required) --------------------------------------180 if (path === '/api/session') {181 if (method === 'DELETE') {182 return json({ ok: true }, 200, {183 'set-cookie': clearSessionCookieHeader(secure, env.COOKIE_DOMAIN || undefined),184 });185 }186 if (method === 'GET') {187 // Dev helper: /api/session?login=local:name sets a cookie and redirects home.188 const login = url.searchParams.get('login');189 if (env.DEV_AUTH === '1' && login && login.startsWith('local:')) {190 const { headers } = await loginAs(env, login, null, secure);191 headers.set('location', '/');192 return new Response(null, { status: 302, headers });193 }194 return err('not_found', 'unknown route', 404);195 }196 if (method === 'POST') {197 const body = parseBody(SessionInput, await readJson(request));198 let externalId: string;199 if (body.local !== undefined) {200 if (env.DEV_AUTH !== '1') return err('unauthorized', 'dev auth is disabled', 401);201 externalId = `local:${body.local}`;202 } else if (body.idToken !== undefined) {203 try {204 externalId = await verifyShooIdToken(body.idToken, url.origin);205 } catch {206 return err('unauthorized', 'invalid id token', 401);207 }208 } else {209 return err('bad_input', 'idToken or local required', 400);210 }211 const { headers, user } = await loginAs(env, externalId, null, secure);212 return json(user, 200, Object.fromEntries(headers));213 }214 return err('bad_input', 'method not allowed', 405);215 }216217 // -- public: runtime config for the SPA -------------------------------218 if (path === '/api/config' && method === 'GET') {219 return json({220 authOrigin: env.AUTH_ORIGIN || null,221 devAuth: env.DEV_AUTH === '1',222 });223 }224225 // -- public: frozen system example scripts (fork by resubmitting) -----226 if (path === '/api/examples' && method === 'GET') {227 const hashes = await exampleHashes();228 return json(EXAMPLES.map((e) => ({ ...e, hash: hashes[e.name] })));229 }230231 // -- public: precomputed sim metrics (instant stats for share links) --232 const metricsMatch = /^\/api\/sims\/([^/]+)\/metrics$/.exec(path);233 if (metricsMatch && metricsMatch[1]) {234 const simId = metricsMatch[1];235 if (method === 'GET') {236 const gz = await getStoredMetricsGz(env, simId);237 if (gz === null) {238 // Self-healing: the create-time replay can die (CPU limit, crash)239 // and public sims reject client backfill — so a missing result240 // re-triggers the server compute in the background. Idempotent241 // (metrics first-write + rating_events PK), so repeat views are242 // harmless while it finishes.243 const sim = await getSimById(env.DB, simId, null);244 if (sim !== null && sim.visibility === 'public' && deps.background) {245 deps.background(computeSimMetrics(env, simId));246 }247 return err('not_found', 'metrics not computed yet', 404);248 }249 // Stored pre-gzipped; the browser transparently decompresses.250 // encodeBody 'manual' tells workerd the body is ALREADY encoded per251 // the content-encoding header (default 'automatic' would mangle it).252 return new Response(gz, {253 encodeBody: 'manual',254 headers: {255 'content-type': 'application/json',256 'content-encoding': 'gzip',257 'cache-control': 'public, max-age=31536000, immutable',258 ...CORS_HEADERS,259 },260 });261 }262 if (method === 'POST') {263 // Client fallback cache: first-write-only, shape-checked, size-capped.264 // Deterministic replays make any honest client's result identical.265 const body = await request.arrayBuffer();266 if (body.byteLength > METRICS_MAX_GZ_BYTES) {267 return err('bad_input', 'metrics payload too large', 413);268 }269 let parsed: unknown;270 try {271 parsed = await gunzipJson(body);272 } catch {273 return err('bad_input', 'metrics payload must be gzipped JSON', 400);274 }275 if (!looksLikeStoredMetrics(parsed)) return err('bad_input', 'malformed metrics payload', 400);276 const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });277 const bundle = await service.getSimBundle(simId); // 404s for missing/expired sims278 if (bundle.sim.visibility === 'public') {279 // Rated sims: only the server replay may write metrics (they feed Elo).280 return err('conflict', 'metrics for public sims are computed by the server', 409);281 }282 const stored = await storeMetrics(env, simId, parsed, 'client');283 return json({ stored });284 }285 return err('bad_input', 'method not allowed', 405);286 }287288 // -- public: sim bundle for the share-link viewer ---------------------289 const bundleMatch = /^\/api\/sims\/([^/]+)\/bundle$/.exec(path);290 if (bundleMatch && bundleMatch[1] && method === 'GET') {291 const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });292 return json(await service.getSimBundle(bundleMatch[1]));293 }294295 // -- public: Browse / leaderboard / models -----------------------------296 // Soft auth: a valid cookie/bearer fills heartedByMe, but failures never297 // block these public reads.298 if (299 method === 'GET' &&300 (path === '/api/browse' ||301 path.startsWith('/api/browse/') ||302 path === '/api/leaderboard' ||303 path === '/api/models')304 ) {305 let viewer: AuthedUser | null = null;306 try {307 viewer = await authenticate(request, env);308 } catch {309 viewer = null;310 }311 const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });312 if (path === '/api/browse') {313 const input = parseBody(BrowseInput, coerceBrowseQuery(url.searchParams));314 return json(await service.browseScripts(input, viewer?.userId ?? null));315 }316 const browseMatch = /^\/api\/browse\/([^/]+)$/.exec(path);317 if (browseMatch && browseMatch[1]) {318 return json(await service.getPublicScript(browseMatch[1], viewer?.userId ?? null));319 }320 if (path === '/api/leaderboard') {321 const q = coerceBrowseQuery(url.searchParams);322 const input = parseBody(LeaderboardInput, { window: q['window'], limit: q['limit'] });323 return json(await service.leaderboard(input));324 }325 // path === '/api/models' (?q=&lab=&open=&limit= per the route map)326 const open = url.searchParams.get('open');327 const input = parseBody(ListModelsInput, {328 ...coerceBrowseQuery(url.searchParams),329 window: undefined,330 ...(open !== null && open !== ''331 ? { openWeights: open === 'true' || open === '1' ? true : open === 'false' || open === '0' ? false : open }332 : {}),333 });334 return json(await service.listModels(input));335 }336337 // -- public: keypair identity (docs/auth.md) ---------------------------338 if (path === '/api/keys/auth' && method === 'POST') {339 const proof = parseKeyProof(await readJson(request));340 if (proof === null) return err('bad_input', 'expected {publicKey, ts, sig}', 400);341 const minted = await keyAuth(env, proof, url.hostname);342 if (minted === null) {343 return err('unauthorized', 'invalid key proof (check the signing string, host, and clock)', 401);344 }345 return json(346 {347 token: minted.token,348 tokenId: minted.tokenId,349 user: {350 id: minted.user.id,351 externalId: minted.user.external_id,352 name: minted.user.name,353 publicSharing: minted.user.public_sharing === 1,354 },355 },356 201,357 );358 }359360 // -- everything below requires auth -----------------------------------361 const user = await authenticate(request, env);362 if (!user) return err('unauthorized', 'authentication required', 401);363 const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });364365 // Link accounts. Two shapes:366 // {code} — device side: merge the CALLING (key) account367 // into the account that minted the code.368 // {publicKey, ts, sig} — browser side: merge the key's account into369 // the signed-in one (programmatic fallback).370 if (path === '/api/keys/link' && method === 'POST') {371 const body = await readJson(request);372 const maybeCode = (body as Record<string, unknown> | null)?.['code'];373 if (typeof maybeCode === 'string') {374 const outcome = await linkByCode(env, user, maybeCode);375 if (outcome === 'invalid') return err('bad_input', 'invalid or expired link code', 400);376 return json({ linked: true, alreadyLinked: outcome === 'already' });377 }378 const proof = parseKeyProof(body);379 if (proof === null) return err('bad_input', 'expected {code} or {publicKey, ts, sig}', 400);380 const outcome = await linkKey(env, user, proof, url.hostname);381 if (outcome === 'invalid') {382 return err('unauthorized', 'invalid key proof (check the signing string, host, and clock)', 401);383 }384 return json({ linked: true, alreadyLinked: outcome === 'already' });385 }386387 // Mint a device-pairing code (shown in Connect; redeemed by the device).388 if (path === '/api/keys/link-code' && method === 'POST') {389 const minted = await mintLinkCode(env, user.userId);390 return json({ code: minted.code, expiresAt: isoOf(minted.expiresAt) }, 201);391 }392393 if (path === '/api/me') {394 if (method === 'GET') {395 const row = await getUserById(env.DB, user.userId);396 if (!row) return err('unauthorized', 'user no longer exists', 401);397 const info: UserInfo = {398 id: row.id,399 externalId: row.external_id,400 name: row.name,401 publicSharing: row.public_sharing === 1,402 nameLockedUntil:403 row.name_changed_at !== null && row.name_changed_at + 7 * 86400 > nowSec()404 ? isoOf(row.name_changed_at + 7 * 86400)405 : null,406 };407 return json(info);408 }409 if (method === 'PATCH') {410 const input = parseBody(SetSharingInput, await readJson(request));411 return json(await service.setSharing(user, input));412 }413 if (method === 'DELETE') {414 const input = parseBody(DeleteAccountInput, await readJson(request));415 await service.deleteAccount(user, input.confirm);416 return json({ ok: true }, 200, {417 'set-cookie': clearSessionCookieHeader(secure, env.COOKIE_DOMAIN || undefined),418 });419 }420 return err('bad_input', 'method not allowed', 405);421 }422423 if (path === '/api/logic') {424 if (method === 'POST') {425 const input = parseBody(SubmitLogicInput, await readJson(request));426 // Returns 200 with {script, report} even when report.ok is false —427 // rejection is a normal, inspectable outcome, not a transport error.428 return json(await service.submitLogic(user, input));429 }430 if (method === 'GET') {431 const trashed = url.searchParams.get('trashed');432 return json(await service.listLogic(user, { trashed: trashed === '1' || trashed === 'true' }));433 }434 return err('bad_input', 'method not allowed', 405);435 }436437 const sourceMatch = /^\/api\/logic\/([^/]+)\/source$/.exec(path);438 if (sourceMatch && sourceMatch[1]) {439 if (method !== 'GET') return err('bad_input', 'method not allowed', 405);440 const script = await service.getLogic(user, sourceMatch[1]);441 return new Response(script.source, {442 headers: {443 'content-type': 'text/javascript; charset=utf-8',444 'content-disposition': `attachment; filename="${safeFilename(script.name)}.js"`,445 ...CORS_HEADERS,446 },447 });448 }449450 const restoreMatch = /^\/api\/logic\/([^/]+)\/restore$/.exec(path);451 if (restoreMatch && restoreMatch[1]) {452 if (method !== 'POST') return err('bad_input', 'method not allowed', 405);453 return json(await service.restoreLogic(user, restoreMatch[1]));454 }455456 const logicMatch = /^\/api\/logic\/([^/]+)$/.exec(path);457 if (logicMatch && logicMatch[1]) {458 const id = logicMatch[1];459 if (method === 'GET') {460 const versionParam = url.searchParams.get('version');461 let version: number | undefined;462 if (versionParam !== null) {463 version = Number(versionParam);464 if (!Number.isInteger(version) || version < 1) {465 return err('bad_input', 'version must be a positive integer', 400);466 }467 }468 return json(await service.getLogic(user, id, version));469 }470 if (method === 'PATCH') {471 const body = await readJson(request);472 const input = parseBody(UpdateLogicMetaInput, {473 ...(typeof body === 'object' && body !== null ? body : {}),474 id,475 });476 return json(await service.updateLogicMeta(user, input));477 }478 if (method === 'DELETE') {479 // Soft delete into the 14-day trash; the deadline is the response.480 return json(await service.deleteLogic(user, id));481 }482 return err('bad_input', 'method not allowed', 405);483 }484485 if (path === '/api/hearts' && method === 'GET') {486 return json(await service.listHearts(user));487 }488489 const heartMatch = /^\/api\/hearts\/([^/]+)$/.exec(path);490 if (heartMatch && heartMatch[1]) {491 const input = parseBody(HeartInput, { id: heartMatch[1] });492 if (method === 'PUT') {493 await service.heart(user, input);494 return new Response(null, { status: 204, headers: CORS_HEADERS });495 }496 if (method === 'DELETE') {497 await service.unheart(user, input);498 return new Response(null, { status: 204, headers: CORS_HEADERS });499 }500 return err('bad_input', 'method not allowed', 405);501 }502503 if (path === '/api/stars') {504 if (method === 'GET') return json(await service.listStars(user));505 if (method === 'POST' || method === 'DELETE') {506 const input = parseBody(StarInput, await readJson(request));507 if (method === 'POST') await service.star(user, input);508 else await service.unstar(user, input);509 return new Response(null, { status: 204, headers: CORS_HEADERS });510 }511 return err('bad_input', 'method not allowed', 405);512 }513514 if (path === '/api/sims') {515 if (method === 'POST') {516 const input = parseBody(CreateSimInput, await readJson(request));517 return json(await service.createSim(user, input), 201);518 }519 if (method === 'GET') return json(await service.listSims(user));520 return err('bad_input', 'method not allowed', 405);521 }522523 // -- authorized keys (identities) --------------------------------------524 // The ssh mental model: your account IS its keys. GET lists them,525 // POST /add grants your account to a public key you hold (a new device),526 // DELETE revokes one (never the last). Wire tokens are invisible: each527 // device key holds one active token, replaced on every keys/auth mint.528 if (path === '/api/keys' && method === 'GET') {529 const rows = await listIdentities(env.DB, user.userId);530 return json(531 rows.map((r) => ({532 externalId: r.external_id,533 kind: r.external_id.startsWith('key:')534 ? 'key'535 : r.external_id.startsWith('local:')536 ? 'dev'537 : 'shoo',538 createdAt: isoOf(r.created_at),539 })),540 );541 }542 if (path === '/api/keys' && method === 'DELETE') {543 const body = parseBody(AddKeyInput, await readJson(request));544 const outcome = await removeIdentity(env.DB, user.userId, `key:${body.publicKey}`);545 if (outcome === 'last') return err('bad_input', 'cannot remove your last identity', 400);546 if (outcome === 'missing') return err('not_found', 'no such key on this account', 404);547 return new Response(null, { status: 204, headers: CORS_HEADERS });548 }549550 return err('not_found', 'unknown route', 404);551 } catch (e) {552 if (e instanceof ServiceError) {553 return err(e.code, e.message, SERVICE_ERROR_STATUS[e.code]);554 }555 console.error(JSON.stringify({ event: 'api_error', path, message: e instanceof Error ? e.message : String(e) }));556 return err('internal', 'internal error', 500);557 }558}559560/** Upserts the user and returns Set-Cookie headers + UserInfo. */561async function loginAs(562 env: Env,563 externalId: string,564 name: string | null,565 secure: boolean,566): Promise<{ headers: Headers; user: UserInfo }> {567 const row = await upsertUser(env.DB, externalId, name);568 const exp = nowSec() + SESSION_TTL_SECONDS;569 const cookieValue = await signSession(sessionSecret(env), row.id, exp);570 const headers = new Headers({571 'set-cookie': sessionCookieHeader(cookieValue, {572 secure,573 maxAge: SESSION_TTL_SECONDS,574 domain: env.COOKIE_DOMAIN || undefined,575 }),576 });577 return {578 headers,579 user: {580 id: row.id,581 externalId: row.external_id,582 name: row.name,583 publicSharing: row.public_sharing === 1,584 nameLockedUntil:585 row.name_changed_at !== null && row.name_changed_at + 7 * 86400 > nowSec()586 ? isoOf(row.name_changed_at + 7 * 86400)587 : null,588 },589 };590}