1/** Typed REST client for the routes in @tribes/protocol api.ts. */2import type {3 MetricsFrame,4 ModelRecord,5 ScriptRecord,6 ScriptWithSource,7 SimBundle,8 SimRecord,9 SimResult,10 StarKind,11 KeyIdentity,12 UserInfo,13 ValidationReport,14} from '@tribes/protocol';1516/** Owner-editable public metadata for PATCH /api/logic/:id. */17export interface LogicMetaPatch {18 title?: string | null;19 description?: string | null;20 /** models.dev id; must exist in the synced catalog. Null clears. */21 modelId?: string | null;22 sourceShared?: boolean;23}2425/** Precomputed sim outcome served by GET /api/sims/:id/metrics. */26export interface SimMetricsPayload {27 frames: MetricsFrame[];28 result: SimResult;29 stateHash: string;30}3132export class ApiError extends Error {33 constructor(34 public status: number,35 public body: { code?: string; message?: string } | null,36 ) {37 super(body?.message ?? `HTTP ${status}`);38 }39}4041async function req<T>(method: string, path: string, body?: unknown): Promise<T> {42 const res = await fetch(path, {43 method,44 headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,45 body: body !== undefined ? JSON.stringify(body) : undefined,46 credentials: 'same-origin',47 });48 if (!res.ok) {49 let parsed: { code?: string; message?: string } | null = null;50 try {51 parsed = (await res.json()) as { code?: string; message?: string };52 } catch {53 /* non-JSON error body */54 }55 throw new ApiError(res.status, parsed);56 }57 if (res.status === 204) return undefined as T;58 const text = await res.text();59 return (text ? JSON.parse(text) : undefined) as T;60}6162export const api = {63 // session64 loginWithIdToken: (idToken: string) => req<UserInfo>('POST', '/api/session', { idToken }),65 loginLocal: (name: string) => req<UserInfo>('POST', '/api/session', { local: name }),66 logout: () => req<void>('DELETE', '/api/session'),67 me: () => req<UserInfo>('GET', '/api/me'),68 patchMe: (patch: { publicSharing?: boolean; name?: string | null }) => req<UserInfo>('PATCH', '/api/me', patch),69 /** REST-only account deletion; confirm must equal the user id. */70 deleteAccount: (confirm: string) => req<void>('DELETE', '/api/me', { confirm }),7172 // runtime config (public)73 config: () => req<{ authOrigin: string | null; devAuth: boolean }>('GET', '/api/config'),7475 // system examples (public, frozen)76 listExamples: () =>77 req<Array<{ name: string; kind: 'bot' | 'library'; description: string; source: string; hash: string }>>(78 'GET',79 '/api/examples',80 ),8182 // logic (scripts)83 submitLogic: (name: string, source: string) =>84 req<{ script: ScriptRecord; report: ValidationReport }>('POST', '/api/logic', { name, source, lang: 'js' }),85 listLogic: (opts?: { trashed?: boolean }) =>86 req<ScriptRecord[]>('GET', `/api/logic${opts?.trashed ? '?trashed=1' : ''}`),87 getLogic: (id: string, version?: number) =>88 req<ScriptWithSource>('GET', `/api/logic/${encodeURIComponent(id)}${version ? `?version=${version}` : ''}`),89 /** Raw source as text (GET /api/logic/:id/source — served as text/javascript). */90 getLogicSource: async (id: string): Promise<string> => {91 const res = await fetch(`/api/logic/${encodeURIComponent(id)}/source`, { credentials: 'same-origin' });92 if (!res.ok) throw new ApiError(res.status, null);93 return res.text();94 },95 patchLogicMeta: (id: string, meta: LogicMetaPatch) =>96 req<ScriptRecord>('PATCH', `/api/logic/${encodeURIComponent(id)}`, meta),97 /** Soft delete: moves to the 14-day trash and unlists. */98 deleteLogic: (id: string) => req<{ trashedUntil: string }>('DELETE', `/api/logic/${encodeURIComponent(id)}`),99 restoreLogic: (id: string) => req<ScriptRecord>('POST', `/api/logic/${encodeURIComponent(id)}/restore`),100101 // models catalog (public)102 listModels: (params?: { q?: string; lab?: string; openWeights?: boolean; limit?: number }) => {103 const qs = new URLSearchParams();104 if (params?.q) qs.set('q', params.q);105 if (params?.lab) qs.set('lab', params.lab);106 if (params?.openWeights !== undefined) qs.set('open', params.openWeights ? '1' : '0');107 if (params?.limit !== undefined) qs.set('limit', String(params.limit));108 const s = qs.toString();109 return req<ModelRecord[]>('GET', `/api/models${s ? `?${s}` : ''}`);110 },111112 // stars113 star: (kind: StarKind, id: string) => req<void>('POST', '/api/stars', { kind, id }),114 unstar: (kind: StarKind, id: string) => req<void>('DELETE', '/api/stars', { kind, id }),115 listStars: () => req<Array<{ kind: StarKind; id: string }>>('GET', '/api/stars'),116117 // sims118 createSim: (scriptIds: string[], seed?: string) =>119 req<SimRecord>('POST', '/api/sims', seed ? { scriptIds, seed } : { scriptIds }),120 listSims: () => req<SimRecord[]>('GET', '/api/sims'),121 getSimBundle: (id: string) => req<SimBundle>('GET', `/api/sims/${encodeURIComponent(id)}/bundle`),122123 /**124 * Precomputed metrics for the instant chronicle. 200 → payload (server125 * gzips the wire; the browser decompresses transparently); 404 → null126 * (not computed yet — caller should replay and POST the result).127 */128 getSimMetrics: async (id: string): Promise<SimMetricsPayload | null> => {129 const res = await fetch(`/api/sims/${encodeURIComponent(id)}/metrics`, { credentials: 'same-origin' });130 if (res.status === 404) return null;131 if (!res.ok) {132 let parsed: { code?: string; message?: string } | null = null;133 try {134 parsed = (await res.json()) as { code?: string; message?: string };135 } catch {136 /* non-JSON error body */137 }138 throw new ApiError(res.status, parsed);139 }140 return (await res.json()) as SimMetricsPayload;141 },142143 /**144 * Store the replay outcome so the next viewer is instant. Body is raw145 * gzipped JSON bytes (CompressionStream) sent with content-type146 * application/json and NO content-encoding header — the server gunzips147 * explicitly. First write wins; later writes are no-ops server-side.148 */149 postSimMetrics: async (id: string, payload: SimMetricsPayload): Promise<void> => {150 const gz = new Blob([JSON.stringify(payload)]).stream().pipeThrough(new CompressionStream('gzip'));151 const body = await new Response(gz).arrayBuffer();152 const res = await fetch(`/api/sims/${encodeURIComponent(id)}/metrics`, {153 method: 'POST',154 headers: { 'content-type': 'application/json' },155 body,156 credentials: 'same-origin',157 });158 if (!res.ok) throw new ApiError(res.status, null);159 },160161 // tokens162 listKeys: () => req<KeyIdentity[]>('GET', '/api/keys'),163 mintLinkCode: () => req<{ code: string; expiresAt: string }>('POST', '/api/keys/link-code'),164 removeKey: (publicKey: string) => req<void>('DELETE', '/api/keys', { publicKey }),165};