Something went wrong. Try again.
llm chat done the right way™
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581import { randomUUID } from "node:crypto";import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib";import { ConversationActor, type Subscriber, type WireEvent } from "./actor";import { authEnabled, type Capability, can, declaredRoles, gateApi, getSession, policyFor, type Role, requestRole, roleCan, roleFor, roleMayUse, sessionUser,} from "./auth";import type { BlobStore } from "./blobs";import { ACTOR_IDLE_TTL_MS, SSE_RETRY_MS, SUBSCRIBER_HEARTBEAT_MS } from "./config";import { credentialsReady, findConnector, isService } from "./connectors";import { byokAllowed, connectableProviders, disconnect, listConnections, oauthFlow, saveApiKey, saveOAuthGrant, saveTokenBundle,} from "./credentials";import { Event, type EventData, type EventName, makeId, parseEventId } from "./events";import { getExecutor } from "./executor";import { getRegistry } from "./inference";import { getContext, LOCAL_SUB, lardConnected, lardDisconnect, lardEnabled, memoryDelete, memoryList, memoryProjects, memoryRead, memoryWrite,} from "./lard";import { flowFor } from "./oauthflows";import { offArtifactHost } from "./sandbox";import { CredentialBody, MyModelBody, PrefsPatchBody, ProjectAssignBody, ProjectCreateBody, ProjectPatchBody, PromptBody, PublishBody, RenameBody, SignOutBody, SteerBody,} from "./schemas";import { getConfig } from "./settings";import { sseBlock } from "./sse";import type { Store } from "./store";import { budgetStatus, DAY_MS } from "./usage";import { describeRef, forgetUserModels, userModelRefs } from "./usermodels";import { withBody } from "./validate";
/** * The web layer as a plain data structure: `apiRoutes(deps)` returns a Bun * `routes` object, and the entrypoint (server.ts) merges it with the HTML page * routes and hands it to `Bun.serve`. Keeping this framework-free (Bun's native * per-method routes + a Standard-Schema `withBody`, no Elysia) means it's the * same shape production runs and tests exercise against a real ephemeral server. */
/** * The seq to replay after when a stream opens. On a live reconnect the browser * sends the full event id (`<conversationId>:<seq>`) as `Last-Event-ID`; on the * INITIAL connect the client hands off from a batch history load via `?after=` * (native EventSource can't set a header on the first request). The header wins * when present (it's the freshest cursor). Returns 0 (replay all) on any parse * failure, or when the id belongs to a different conversation — an id is scoped * to its own conversation, so a stale or foreign cursor must never skip events. */function afterSeqFor(req: Request, conversationId: string): number { const raw = req.headers.get("last-event-id") ?? new URL(req.url).searchParams.get("after"); if (raw === null) return 0; try { const parsed = parseEventId(raw); return parsed.conversationId === conversationId ? parsed.seq : 0; } catch { return 0; }}
// Context-file gating: keep uploads to text. A denylist of common binary// extensions gives a clear early "no", and a content sniff catches the rest —// binary decoded as UTF-8 carries NUL and replacement (U+FFFD) chars.const BINARY_EXT = new Set([ "xls", "xlsx", "xlsm", "ods", "doc", "docx", "ppt", "pptx", "pdf", "png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "ico", "svg", "heic", "zip", "gz", "tar", "rar", "7z", "bz2", "xz", "mp3", "wav", "flac", "ogg", "mp4", "mov", "avi", "mkv", "webm", "exe", "dll", "so", "dylib", "bin", "wasm", "class", "o", "a", "ttf", "otf", "woff", "woff2", "sqlite", "db",]);function isBinaryName(name: string): boolean { const i = name.lastIndexOf("."); return i >= 0 && BINARY_EXT.has(name.slice(i + 1).toLowerCase());}function looksBinary(s: string): boolean { if (s.indexOf("\u0000") !== -1) return true; // NUL never appears in real text const n = Math.min(s.length, 8192); let bad = 0; for (let i = 0; i < n; i++) { const c = s.charCodeAt(i); if (c === 0xfffd || c < 9 || (c > 13 && c < 32)) bad++; // U+FFFD or a control char } return n > 0 && bad / n > 0.02;}
/** Sentinel enqueued into an idle stream so proxies keep the connection open. */const KEEPALIVE = Symbol("keepalive");/** An out-of-band frame: a named event with no log seq, so it can't be resumed. */interface Note { event: string; data: unknown;}type StreamItem = WireEvent | Note | typeof KEEPALIVE;
/** * Subscriber backed by a ReadableStream: Bun's stream layer pulls strictly * serially, so a double-delivery is structurally impossible (unlike an async * iterator whose concurrent pulls race on a shared waiter). The actor `push`es * each event straight into the controller; backpressure is the platform's job. */class StreamSubscriber implements Subscriber { closed = false; readonly stream: ReadableStream<StreamItem>; private lastSeq: number; private controller!: ReadableStreamDefaultController<StreamItem>; private readonly onClose: () => void;
constructor(afterSeq: number, onClose: () => void) { this.onClose = onClose; this.lastSeq = afterSeq; this.stream = new ReadableStream<StreamItem>({ start: (controller) => { this.controller = controller; }, cancel: () => { this.close(); }, }); }
push(e: WireEvent): void { if (this.closed) return; try { const seq = parseEventId(e.id).seq; if (seq <= this.lastSeq) return; this.lastSeq = seq; } catch { return; } this.controller.enqueue(e); }
/** Last event put on this stream, for cross-process log fan-out. */ get afterSeq(): number { return this.lastSeq; }
/** * Sends something that isn't in the log — today, the conversation's live * state. It carries no id, so it never moves the resume cursor. */ note(event: string, data: unknown): void { if (this.closed) return; this.controller.enqueue({ event, data }); }
keepalive(): void { if (this.closed) return; this.controller.enqueue(KEEPALIVE); }
close(): void { if (this.closed) return; this.closed = true; this.onClose(); try { this.controller.close(); } catch { // already closed by the consumer } }}
/** * Per-conversation actors with idle eviction. Actors with no subscribers and * no recent activity are removed so the map doesn't grow without bound. * Subscribed actors are never evicted: an SSE stream is pinned to its actor * instance, and evicting it would orphan the stream from every future run. */const actors = new Map<string, ConversationActor>();export function getActor(id: string, store: Store): ConversationActor { let a = actors.get(id); if (!a) { a = new ConversationActor(id, store); actors.set(id, a); } return a;}
export function evictIdleActors(): void { const cutoff = Date.now() - ACTOR_IDLE_TTL_MS; for (const [id, actor] of actors) { if (!actor.hasSubscribers() && actor.lastActivity < cutoff) { actors.delete(id); } }}
/** * A body compressed per the client's Accept-Encoding — brotli (quality 5, a fast * ratio/speed balance) preferred over gzip, both better than nothing over a * tunnel (Bun.serve does no compression of its own). The browser delivers the * response as a decompressing stream, so the client can render it incrementally. */function compressed(req: Request, body: string, contentType: string): Response { const accept = req.headers.get("accept-encoding") ?? ""; if (/\bbr\b/.test(accept)) { const out = brotliCompressSync(body, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } }); return new Response(out, { headers: { "Content-Type": contentType, "Content-Encoding": "br" }, }); } if (/\bgzip\b/.test(accept)) { return new Response(gzipSync(body), { headers: { "Content-Type": contentType, "Content-Encoding": "gzip" }, }); } return new Response(body, { headers: { "Content-Type": contentType } });}
/** Refs of every model this deployment can run (enabled providers + echo). */function knownModelRefs(): Set<string> { return new Set( getRegistry() .listModels() .map((m) => m.ref), );}
/** 422 for unknown model refs, null when known. The gate every model-taking endpoint starts with. */function requireKnownModel(ref: string): Response | null { if (knownModelRefs().has(ref)) return null; return Response.json({ error: `unknown model "${ref}"` }, { status: 422 });}
/** * The same gate, narrowed by role: a guest may only run what curation offers * guests. The picker already hides the rest, but the picker is a suggestion — * this is the boundary, and it has to sit on every path that names a model, * because a run costs someone real credits. */function requireUsableModel( ref: string, store: Store, role: Role, sub: string | undefined,): Response | null { // Paying for it is permission enough. Someone who connected their own account // is spending their own credits, and that account decides what it will run — // so the ref need not be one this deployment knows, and the provider's own // error is the right answer for one it doesn't. if (sub && store.getCredentialRow(sub, "inference", ref.split("/")[0] ?? "")) return null; const unknown = requireKnownModel(ref); if (unknown) return unknown; // Otherwise it is the instance paying, and the role says which of its models // this person may reach. What they picked for their own picker is their // business, not a permission. if (!roleMayUse(role, ref)) { return Response.json({ error: `model "${ref}" is not available to you` }, { status: 403 }); } // And how much of it they may spend. Checked as a run starts, so a turn // already in flight is never cut off part-way — the budget bounds what gets // started, not what finishes. const budget = budgetStatus(store, sub ?? LOCAL_SUB, role); if (!budget.ok) { return Response.json( { error: `${budget.reason}. Connect your own account to keep going.`, resetsAt: budget.resetsAt, }, { status: 429 }, ); } return null;}
/** * Whose request this is. With auth off there is one user and `LOCAL_SUB` is * them; every path must agree, or a credential gets stored under one name and * looked up under another — which is how a connected account turned into * "unknown model" the first time, and how a hidden model came back the time * before that. */function whoami(req: Request, store: Store): string { return getSession(req, store)?.sub ?? LOCAL_SUB;}
/** 403 unless the caller may do `capability`. */function require(req: Request, store: Store, capability: Capability): Response | null { if (can(req, store, capability)) return null; return Response.json({ error: `not permitted: ${capability}` }, { status: 403 });}const requireOwner = (req: Request, store: Store): Response | null => require(req, store, "admin");
/** * Everything this person could put in their picker: the instance's models their * role may pick from, plus whatever their own connected accounts reach. */async function availableModels(store: Store, role: Role, sub: string) { const instance = getRegistry() .listModels() .filter((m) => roleMayUse(role, m.ref)); const mine = new Set(await userModelRefs(store, sub)); const seen = new Set(instance.map((m) => m.ref)); const extra = [...mine].filter((ref) => !seen.has(ref)).map((ref) => describeRef(ref)); return [ ...instance.map((m) => ({ ...m, yours: mine.has(m.ref) })), ...extra.map((m) => ({ ...m, yours: true })), ];}
/** * One person's picker: what they chose, named and ordered as they set it, and * narrowed to what they may still reach — a role's bound can change, and a * model they can no longer use should not sit in the list waiting to 403. */async function chatModelsFor(store: Store, role: Role, sub: string) { const available = new Map((await availableModels(store, role, sub)).map((m) => [m.ref, m])); return store .listUserModels(sub) .flatMap((chosen) => { const m = available.get(chosen.ref); if (!m) return []; return [ { ref: m.ref, name: chosen.displayName ?? m.name, contextWindow: m.contextWindow, reasoningLevels: m.reasoningLevels, supportsImages: m.supportsImages, sortOrder: chosen.sortOrder, yours: m.yours, }, ]; }) .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));}
/** * A conversation's history as NDJSON, one event per line. * * Two liberties, both taken because history is READ, not watched: * * Rows go out as the JSON text they already are in SQLite. Decoding every event * only to re-encode it costs a parse and a stringify of the whole conversation. * * And a run of consecutive deltas of one kind becomes one line. They are one * thing said in pieces — the client renders them into a single block either way * — but handed over as pieces they cost a line, a parse and a parser write * each. In a real session 11,364 delta rows are 354 runs. The merged line is * anchored at the run's LAST seq so the cursor it leaves behind is the newest * event actually delivered; runs never straddle a request boundary, because * both boundaries are turn cuts and a run belongs to one turn. */function historyNdjson( store: Store, id: string, range: { from?: number; before?: number; exclude?: string },): string { const line = (seq: number, event: string, data: string) => `{"id":${JSON.stringify(makeId(id, seq))},"event":${JSON.stringify(event)},"data":${data}}`; const lines: string[] = []; let run: { event: string; seq: number; data: Record<string, unknown>; text: string } | null = null; const flushRun = () => { if (!run) return; lines.push(line(run.seq, run.event, JSON.stringify({ ...run.data, delta: run.text }))); run = null; }; for (const e of store.rawEvents(id, range)) { if (e.event === Event.TextDelta || e.event === Event.ReasoningDelta) { if (run && run.event !== e.event) flushRun(); const d = JSON.parse(e.data) as { delta?: string }; if (run) { run.seq = e.seq; run.text += d.delta ?? ""; } else { run = { event: e.event, seq: e.seq, data: d, text: d.delta ?? "" }; } continue; } flushRun(); // anything else ends the run, and belongs after it lines.push(line(e.seq, e.event, e.data)); } flushRun(); return lines.join("\n");}
/** The long-lived SSE stream for a conversation (down channel). */function openStream(conversationId: string, req: Request, store: Store): Response { const actor = getActor(conversationId, store); const after = afterSeqFor(req, conversationId);
let unsub: () => void = () => {}; let poll: ReturnType<typeof setInterval> | null = null; const sub = new StreamSubscriber(after, () => { unsub(); if (poll) clearInterval(poll); }); unsub = actor.subscribe(sub, after);
// Whether a run is actually in flight is the server's to know: the log can // end on a `message-start` whose worker died, and a client replaying that // would sit there showing a Stop button for a run nobody is running. Send the // state on connect, then whenever it changes. let state = store.conversationState(conversationId); sub.note("state", { state }); // "Idle" has to be corroborated before it goes out. One exchange can be two // jobs (a run, then the flush of what was steered into it), and the gap // between them is a real moment with no job row — reported straight away it // reads as "finished", and the composer flips to Send and back a second later. // Starting is not ambiguous in the same way, so it goes out at once. let quiet = 0;
// A standalone worker persists through its own actor, so it cannot directly // notify this server process's subscribers. The log is authoritative: poll // only the cursor gap and let StreamSubscriber dedupe local actor fan-out. let tick = 0; poll = setInterval(() => { for (const e of store.replay(conversationId, sub.afterSeq)) { sub.push({ id: `${conversationId}:${e.seq}`, event: e.event as EventName, data: e.data as EventData, }); } // The state follows the jobs table, which moves far slower than the event // stream — once a second is plenty, and cheap. if (++tick % 4 === 0) { const now = store.conversationState(conversationId); quiet = now === "idle" ? quiet + 1 : 0; if (now !== state && (now !== "idle" || quiet >= 2)) { state = now; sub.note("state", { state }); } } }, 250);
// NOTE: this stream is intentionally long-lived. It does not close when a run // ends; the next run's events arrive on the same connection. A dropped // connection is what triggers replay via Last-Event-ID. Keepalive comments // keep proxies (and Bun's idle timeout) from closing the idle connection. const sseTransform = new TransformStream<StreamItem, string>({ // The reconnect delay is ours to set, not the browser's to guess. start: (controller) => controller.enqueue(`retry: ${SSE_RETRY_MS}\n\n`), transform: (item, controller) => { if (item === KEEPALIVE) controller.enqueue(": keepalive\n\n"); else controller.enqueue(sseBlock(item)); }, }); const body = sub.stream.pipeThrough(sseTransform);
const keepAlive = setInterval(() => { if (sub.closed) { clearInterval(keepAlive); if (poll) clearInterval(poll); return; } sub.keepalive(); }, SUBSCRIBER_HEARTBEAT_MS);
return new Response(body, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }, });}
/** * Starts a generation run: appends the user message through the actor and * enqueues a job. Rejects unknown models up front (422): otherwise the job is * accepted and fails silently in the drive loop at resolve time. The user * message and the run share `runId` so the client can correlate the * optimistic echo. *//** * 422 if any attachment references a blob that was never uploaded, so the log * never carries a dangling reference (and blob_refs never points at nothing). * Shared by the prompt and steer paths. */function requireKnownBlobs(attachments: PromptBody["attachments"], store: Store): Response | null { for (const a of attachments ?? []) { if (!store.getBlob(a.sha256)) { return Response.json({ error: `unknown blob "${a.sha256}"` }, { status: 422 }); } } return null;}
function startRun( conversationId: string, data: PromptBody, store: Store, role: Role, owner?: string,): Response { const rejected = requireUsableModel(data.model, store, role, owner); if (rejected) return rejected; const badBlob = requireKnownBlobs(data.attachments, store); if (badBlob) return badBlob; // Claim a new conversation before appending its first event. This is the // ownership boundary: two users racing a chosen id cannot both enqueue work. if (owner && !store.claimConversationOwner(conversationId, owner)) { return Response.json({ error: "not found" }, { status: 404 }); }
const actor = getActor(conversationId, store); const runId = data.runId ?? randomUUID(); const messageId = randomUUID(); actor.appendUser(data.content, runId, data.attachments, data.ask); // Record the owner on the freshly-created conversation (first writer wins), so // per-user lard identity can resolve back to whose token to use. store.setConversationOwner(conversationId, owner);
const jobId = `${conversationId}:${randomUUID()}`; store.enqueue(jobId, conversationId, { conversationId, runId, messageId, prompt: data.content, model: data.model, ...(data.effort ? { effort: data.effort } : {}), });
// Enqueue decouples the response from the run: the client opens the stream // separately and receives message-start/text-deltas/message-end as the drive // loop (possibly in another process) executes the job. return Response.json({ jobId, runId, messageId }, { status: 202 });}
/** * Steers mid-run WITHOUT interrupting: parks the message in the steer queue * (a durable `queued-message` event, so every device sees it immediately) and * enqueues one flush job if none is already queued for the conversation. When * the current run finishes, the drive loop promotes the WHOLE pending queue * and runs it as a single batched generation. */function startSteer( conversationId: string, data: SteerBody, store: Store, role: Role, sub: string | undefined,): Response { const rejected = requireUsableModel(data.model, store, role, sub); if (rejected) return rejected; const badBlob = requireKnownBlobs(data.attachments, store); if (badBlob) return badBlob;
const actor = getActor(conversationId, store); const runId = data.runId ?? randomUUID(); actor.queueSteer(data.content, data.model, runId, data.attachments, data.effort);
// One flush job is enough to drain the whole queue; skip it when one is // already waiting (it drains everything queued by the time it runs). if (!store.hasPendingFlush(conversationId)) { store.enqueue(`${conversationId}:${randomUUID()}`, conversationId, { kind: "flush", conversationId, }); } return Response.json({ ok: true, runId }, { status: 202 });}
/** A 64-char lowercase hex sha256 — the shape a blob id must have. */function isSha256(s: string): boolean { return /^[0-9a-f]{64}$/.test(s);}
/** Marks a body that ran past the size cap while streaming. */class PayloadTooLarge extends Error {}
/** * Wraps an upload stream so it errors past `max` bytes instead of buffering the * whole thing to find out — Content-Length is only a hint (and spoofable), so * the cap is enforced on the actual byte flow. */function limitBody(body: ReadableStream<Uint8Array>, max: number): ReadableStream<Uint8Array> { let total = 0; return body.pipeThrough( new TransformStream<Uint8Array, Uint8Array>({ transform(chunk, controller) { total += chunk.byteLength; if (total > max) controller.error(new PayloadTooLarge()); else controller.enqueue(chunk); }, }), );}
/** * Content-addressed upload: streams the body into the blob store (hashing as it * goes), records its metadata, and returns the sha256. The raw body IS the blob * (Content-Type is its mime), so this bypasses `withBody`'s JSON path. Dedup and * the content address both fall out of the store; the 413 is enforced mid-stream. */async function uploadBlob(req: Request, store: Store, blobs: BlobStore): Promise<Response> { const max = getConfig().blobs.maxBytes; const declared = Number(req.headers.get("content-length")); if (Number.isFinite(declared) && declared > max) { return Response.json({ error: `blob exceeds ${max} bytes` }, { status: 413 }); } if (!req.body) return Response.json({ error: "empty body" }, { status: 400 }); const mime = req.headers.get("content-type") || "application/octet-stream"; try { const ref = await blobs.put(limitBody(req.body, max)); store.recordBlob(ref.sha256, mime, ref.size); return Response.json({ sha256: ref.sha256, size: ref.size, mime }, { status: 201 }); } catch (err) { if (err instanceof PayloadTooLarge) { return Response.json({ error: `blob exceeds ${max} bytes` }, { status: 413 }); } throw err; }}
/** * Serves blob bytes by sha256. Immutable (content-addressed) so it's cached * hard; the mime comes from the metadata row, not the file. With the S3 backend * the returned `S3File` makes this a redirect to a presigned URL (serving * offload) rather than a proxy of the bytes. */async function serveBlob( sha256: string, store: Store, blobs: BlobStore, filename?: string,): Promise<Response> { if (!isSha256(sha256)) return new Response("not found", { status: 404 }); const meta = store.getBlob(sha256); const blob = meta ? await blobs.get(sha256) : null; if (!meta || !blob) return new Response("not found", { status: 404 }); // The original filename is per-reference (not stored on the content-addressed // blob), so the caller passes it via `?name=`. Sanitized to a single safe // path segment so it can't inject header bytes or a Content-Disposition break. const safe = filename ? sanitizeFilename(filename) : ""; const disposition = safe ? `inline; filename="${safe}"` : "inline"; const headers: Record<string, string> = { "Content-Type": meta.mime, "Cache-Control": "public, max-age=31536000, immutable", "Content-Disposition": disposition, // Never let a sniffed type outrank the declared one: a .txt blob whose // bytes start with `<html>` must not become a document. "X-Content-Type-Options": "nosniff", }; // Blobs are model- and upload-authored bytes served from the app's own origin, // so anything the browser would run script from has to be defanged. `sandbox` // with no tokens drops the response into an opaque origin with scripting off, // which makes a direct visit to an HTML artifact inert — it cannot read the // session cookie's requests, call /api on the user's behalf, or touch the app. // // The header is ignored for `<img>`/`<embed>` of the same bytes (no document // is created), so inline images keep working. Rendering an artifact as a live // page is a separate, deliberate path: a sandboxed iframe in the client. if (ACTIVE_MIME.test(meta.mime)) headers["Content-Security-Policy"] = "sandbox"; return new Response(blob, { headers });}
/** * What a file is, without its bytes. * * The pane asks this before offering a download for something it cannot show: * a filename alone is thin grounds for deciding whether to press the button, * and "4.1 MB" is most of the rest of the answer. The record holds the type and * the size, so this never touches the byte store — which is the point, given * that the caller has just decided not to fetch the file. */function headBlob(sha256: string, store: Store): Response { if (!isSha256(sha256)) return new Response(null, { status: 404 }); const meta = store.getBlob(sha256); if (!meta) return new Response(null, { status: 404 }); return new Response(null, { headers: { "Content-Type": meta.mime, "Content-Length": String(meta.size), "Cache-Control": "public, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff", }, });}
/** * Preferences the settings page may write. * * A closed set, because the run loop reads this table: an endpoint that stored * any key anyone sent would make the table a place to put things, and the two * that matter here decide which model spends the tokens. */const KNOWN_PREFS = new Set(["research.leadModel", "research.workerModel"]);
/** * Preferences, and what they are overriding. * * Two sources decide these: a line in kloe.json and a click in the settings * page, the click winning. A UI shown only its own half cannot tell "nobody * chose" from "the file chose" — it would offer "same as the chat model" while * the run used the configured model, which is a straightforward lie about what * the deployment does. So the config value ships alongside, and clearing a * preference visibly reverts to it rather than to nothing. */function prefsPayload(store: Store) { const cfg = getConfig().research; return { prefs: store.listPrefs(), config: { "research.leadModel": cfg.leadModel ?? null, "research.workerModel": cfg.workerModel ?? null, }, };}
/** Mimes a browser will execute script from when it renders them as a document. */const ACTIVE_MIME = /^(text\/html|application\/xhtml\+xml|image\/svg\+xml|(?:text|application)\/xml)\b/i;
/** A filename reduced to one safe segment: no path separators, quotes, or control bytes. */function sanitizeFilename(name: string): string { return name.replace(/[^\w.\- ]+/g, "_").slice(0, 128);}
/** * The API routes as a Bun `routes` object. Per-method handlers give free method * dispatch + `req.params`; `withBody` validates + types JSON bodies before the * handler runs. Everything dynamic lives under `/api/` so a future service * worker can bypass it cleanly and cache only the static shell. */export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => void }) { const { store, blobs } = deps; // Nudge the drive loop to claim a freshly enqueued job NOW instead of waiting // for the next poll tick (that poll delay is up to ~1s of pure queue-wait on // every message). No-op in tests, which drive jobs manually. const kick = deps.kick ?? (() => {});
// Authorization for a specific conversation. gateApi only proves you're signed // in; this proves the conversation is YOURS. When auth is on you may only touch // conversations you own — an unowned (legacy/new) row is claimed by the first // authenticated user to reach it, and someone else's conversation 404s (not // 403, so we don't confirm it exists). Returns a Response to short-circuit, or // null to proceed. const guardConv = (req: Request, id: string): Response | null => { if (!authEnabled()) return null; const sub = getSession(req, store)?.sub; if (!sub) return Response.json({ error: "unauthorized" }, { status: 401 }); if (store.isConversationDeleted(id)) return Response.json({ error: "not found" }, { status: 404 }); const owner = store.getConversationOwner(id); if (owner === undefined) { store.setConversationOwner(id, sub); return null; } if (owner !== sub) return Response.json({ error: "not found" }, { status: 404 }); return null; };
// Same, for a project: you may only touch a project you own (404 hides others'). const guardProject = (req: Request, id: string): Response | null => { if (!authEnabled()) return null; const owner = store.getProjectOwner(id); const sub = getSession(req, store)?.sub; if (owner !== undefined && owner !== sub) return Response.json({ error: "not found" }, { status: 404 }); return null; };
const routes = { "/health": { GET: () => Response.json({ ok: true }) },
// Who's signed in — the SPA renders the avatar from this and, on a 401 // (auth on, no session), redirects to /auth/login. When auth is OFF the gate // is a no-op, so this returns {authenticated:false} and the SPA stays open. "/api/me": { GET: (req: Bun.BunRequest<"/api/me">) => { const s = getSession(req, store); // The role rides along so the UI can hide what it may not touch; the // API gates on its own reading of the session either way. So does the // artifact origin: it is a fact about the deployment the client needs // at boot (it decides how a page-shaped artifact is framed), and this // is the call the client already makes before anything renders. const deployment = { artifactOrigin: getConfig().server.artifactOrigin || null }; return s ? Response.json({ ...sessionUser(s, store.getUserRole(s.sub)), ...deployment }) : Response.json({ authenticated: false, role: roleFor(undefined), ...deployment }); }, },
// lard link status for the settings page: whether the integration is enabled // for this deployment and whether THIS user has connected their account. "/api/lard": { GET: (req: Bun.BunRequest<"/api/lard">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; return Response.json({ enabled: lardEnabled(), connected: lardEnabled() && lardConnected(store, sub), }); }, DELETE: async (req: Bun.BunRequest<"/api/lard">) => { await lardDisconnect(store, getSession(req, store)?.sub ?? LOCAL_SUB); return Response.json({ ok: true }); }, },
// Inspect this user's lard memory — the settings page proxies through kloe // because the token is server-side and per-user. List of subjects, and a // single subject's markdown (read + overwrite). Subject path is a query param // so multi-segment paths (areas/<name>) need no wildcard route. "/api/lard/memory": { GET: async (req: Bun.BunRequest<"/api/lard/memory">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; if (!lardEnabled() || !lardConnected(store, sub)) return Response.json({ error: "not connected" }, { status: 409 }); try { return Response.json({ listing: await memoryList(store, sub) }); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } }, }, // The lard project registry, for the project page's "pin memory" picker. "/api/lard/projects": { GET: async (req: Bun.BunRequest<"/api/lard/projects">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; if (!lardEnabled() || !lardConnected(store, sub)) return Response.json({ error: "not connected" }, { status: 409 }); try { return Response.json({ projects: await memoryProjects(store, sub) }); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } }, }, // The context bundle for a project (profile + that project's area + listing), // for the project page's Memory preview. "/api/lard/context": { GET: async (req: Bun.BunRequest<"/api/lard/context">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; if (!lardEnabled() || !lardConnected(store, sub)) return Response.json({ error: "not connected" }, { status: 409 }); const project = new URL(req.url).searchParams.get("project") || undefined; try { return Response.json(await getContext(store, sub, project)); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } }, }, "/api/lard/subject": { GET: async (req: Bun.BunRequest<"/api/lard/subject">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; const path = new URL(req.url).searchParams.get("path"); if (!path) return Response.json({ error: "missing path" }, { status: 400 }); if (!lardEnabled() || !lardConnected(store, sub)) return Response.json({ error: "not connected" }, { status: 409 }); try { return Response.json({ path, body: await memoryRead(store, sub, path) }); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } }, PUT: async (req: Bun.BunRequest<"/api/lard/subject">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; const path = new URL(req.url).searchParams.get("path"); if (!path) return Response.json({ error: "missing path" }, { status: 400 }); if (!lardEnabled() || !lardConnected(store, sub)) return Response.json({ error: "not connected" }, { status: 409 }); try { await memoryWrite(store, sub, path, await req.text()); return Response.json({ ok: true }); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } }, DELETE: async (req: Bun.BunRequest<"/api/lard/subject">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; const path = new URL(req.url).searchParams.get("path"); if (!path) return Response.json({ error: "missing path" }, { status: 400 }); if (!lardEnabled() || !lardConnected(store, sub)) return Response.json({ error: "not connected" }, { status: 409 }); try { await memoryDelete(store, sub, path); return Response.json({ ok: true }); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } }, },
// Documents this conversation's tools produced — newest version of each, for // the header list and the artifact pane. Bytes come from /api/blobs/:sha256. // // `?name=` narrows to one document and answers with its history instead: // same resource, one level in. A path segment would have been the other // shape, but names are filenames and filenames carry slashes. "/api/conversations/:id/artifacts": { GET: (req: Bun.BunRequest<"/api/conversations/:id/artifacts">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; const name = new URL(req.url).searchParams.get("name"); if (name) { // The link belongs to the DOCUMENT, so it is reported once beside the // history rather than smeared across the version rows. const pub = store.publicationFor(req.params.id, name); return Response.json({ versions: store.artifactVersions(req.params.id, name), publication: pub && { token: pub.token, mode: pub.mode, version: pub.version }, }); } return Response.json({ artifacts: store.listArtifacts(req.params.id) }); }, },
// Public links for this conversation's documents. Creating one is // idempotent per version, so the owner can press Publish twice and still // have exactly one link to revoke. The public side of these lives in // src/share.ts, outside the auth gate — nothing here serves a reader. "/api/conversations/:id/publications": { POST: (req: Bun.BunRequest<"/api/conversations/:id/publications">) => { // A public link is minted on this instance's domain, so it is the // operator's to give away rather than any signed-in user's to take. const denied = guardConv(req, req.params.id) ?? require(req, store, "publish"); if (denied) return Promise.resolve(denied); return withBody(PublishBody, (data) => { const pub = store.publish(req.params.id, data.name, data.version, data.mode); if (!pub) return Response.json({ error: "not found" }, { status: 404 }); return Response.json({ token: pub.token, url: `/s/${pub.token}`, mode: pub.mode, version: pub.version, }); })(req); }, }, "/api/conversations/:id/publications/:token": { DELETE: (req: Bun.BunRequest<"/api/conversations/:id/publications/:token">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; // Idempotent: a link that is already gone is in the state the caller // asked for, so unpublishing twice is not an error. store.unpublish(req.params.id, req.params.token); return Response.json({ ok: true }); }, },
// Preferences the settings page owns. Deliberately a flat map: these are // choices made by clicking, and each one should not cost an endpoint. "/api/prefs": { GET: () => Response.json(prefsPayload(store)), PATCH: withBody(PrefsPatchBody, (data, req: Bun.BunRequest<"/api/prefs">) => { const denied = requireOwner(req, store); if (denied) return denied; for (const [key, value] of Object.entries(data)) { // Only keys we know: an open key/value endpoint is an invitation to // store whatever, and this table is read by the run loop. if (!KNOWN_PREFS.has(key)) { return Response.json({ error: `unknown preference "${key}"` }, { status: 422 }); } if (value && key.endsWith("Model")) { const rejected = requireKnownModel(value); if (rejected) return rejected; } } for (const [key, value] of Object.entries(data)) store.setPref(key, value); return Response.json(prefsPayload(store)); }), },
// Content-addressed blobs: upload (raw body) → sha256; fetch by sha256. "/api/blobs": { POST: (req: Bun.BunRequest<"/api/blobs">) => uploadBlob(req, store, blobs), }, "/api/blobs/:sha256": { GET: (req: Bun.BunRequest<"/api/blobs/:sha256">) => serveBlob( req.params.sha256, store, blobs, new URL(req.url).searchParams.get("name") ?? undefined, ), HEAD: (req: Bun.BunRequest<"/api/blobs/:sha256">) => headBlob(req.params.sha256, store), },
"/api/conversations": { // `?q=` searches titles + message contents; no query lists all, newest first. GET: (req: Bun.BunRequest<"/api/conversations">) => { const q = new URL(req.url).searchParams.get("q")?.trim(); // Only list the caller's own conversations (undefined owner → no filter // when auth is off). const owner = authEnabled() ? getSession(req, store)?.sub : undefined; const conversations = q ? store.searchConversations(q, owner) : store.listConversations(owner); return Response.json({ conversations }); }, },
// Settings/admin view: every available model + its curation state.
/** * A user's own accounts: what they've connected, a key they paste, and the * two halves of a device flow. * * Everything here is scoped to the caller's own sub — there is no path that * reads or writes somebody else's credential, which is why none of these * take a user parameter. The service is in the path because "exa" the * search engine and "exa" the inference endpoint are not the same account. */ "/api/credentials": { GET: (req: Bun.BunRequest<"/api/credentials">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; return Response.json({ // Not ready means the deployment has no key to encrypt with, so // every Connect button here would fail. The page says so rather than // offering them. ready: credentialsReady(), providers: connectableProviders(), connections: listConnections(store, sub), }); }, POST: withBody(CredentialBody, (data, req: Bun.BunRequest<"/api/credentials">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; if (!credentialsReady()) { // Storing it would throw on the encryption; say what is missing // rather than returning a stack trace's worth of nothing. return Response.json( { error: "this instance has no security.credentialKey, so it cannot store a key" }, { status: 503 }, ); } if (!isService(data.service)) { return Response.json({ error: "unknown service" }, { status: 422 }); } const service = data.service; const connector = findConnector(service, data.providerId); if (!connector || !(connector.byok || connector.paste)) { return Response.json( { error: `"${data.providerId}" does not take a user credential here` }, { status: 422 }, ); } try { // A whole credential file goes through its provider's parser; a key // is just a key. if (connector.paste) { saveTokenBundle(store, sub, service, data.providerId, data.apiKey); } else { saveApiKey(store, sub, service, data.providerId, data.apiKey); } } catch (e) { // The parser's message is written for whoever pasted it. return Response.json({ error: (e as Error).message }, { status: 422 }); } forgetUserModels(sub, data.providerId); return Response.json({ connections: listConnections(store, sub) }); }), },
"/api/credentials/:service/:providerId": { DELETE: (req: Bun.BunRequest<"/api/credentials/:service/:providerId">) => { const sub = getSession(req, store)?.sub ?? LOCAL_SUB; if (!isService(req.params.service)) { return Response.json({ error: "unknown service" }, { status: 422 }); } disconnect(store, sub, req.params.service, req.params.providerId); forgetUserModels(sub, req.params.providerId); return Response.json({ connections: listConnections(store, sub) }); }, },
// Start a device authorization: returns the code the user types and where // to type it. The device_code comes back to the client because only the // browser knows when the user has finished approving. "/api/credentials/:service/:providerId/device": { POST: async (req: Bun.BunRequest<"/api/credentials/:service/:providerId/device">) => { const service = req.params.service; const ref = isService(service) ? oauthFlow(service, req.params.providerId) : undefined; const flow = ref && flowFor(ref.flow); if (!ref || !flow?.start) { return Response.json({ error: "no device flow for this provider" }, { status: 422 }); } try { return Response.json(await flow.start(ref.baseUrl, "kloe")); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } }, },
// Poll it. On approval the grant is exchanged immediately: the refresh // token a provider hands over is one-time and short-lived, so it becomes a // stored pair here rather than travelling any further. "/api/credentials/:service/:providerId/device/:deviceCode": { GET: async ( req: Bun.BunRequest<"/api/credentials/:service/:providerId/device/:deviceCode">, ) => { const service = req.params.service; const providerId = req.params.providerId; const ref = isService(service) ? oauthFlow(service, providerId) : undefined; const flow = ref && flowFor(ref.flow); if (!ref || !flow?.poll || !isService(service)) { return Response.json({ error: "no device flow for this provider" }, { status: 422 }); } const sub = getSession(req, store)?.sub ?? LOCAL_SUB; try { const poll = await flow.poll(ref.baseUrl, req.params.deviceCode); if (poll.status !== "granted") return Response.json({ status: poll.status }); // A flow whose poll already finished the exchange hands the pair // over; one that hands back a one-time refresh token has it traded // here, before anything else can spend it. const pair = poll.grant.tokens ?? (await flow.exchange(ref.baseUrl, poll.grant.refreshToken)); saveOAuthGrant( store, sub, service, providerId, pair, poll.grant.teamName, poll.grant.meta, ); forgetUserModels(sub, providerId); return Response.json({ status: "connected", connections: listConnections(store, sub), }); } catch (e) { return Response.json({ error: (e as Error).message }, { status: 502 }); } }, },
/** * Who holds which role, and what each role may do. Read-only: roles are * declared in kloe.json, so this reports the config rather than editing it. */ "/api/roles": { GET: (req: Bun.BunRequest<"/api/roles">) => requireOwner(req, store) ?? Response.json({ // The issuer's host, so the UI can name it rather than saying "the // identity provider" at somebody who calls it something else. provider: (() => { try { return new URL(getConfig().auth.issuer).hostname; } catch { return ""; } })(), roles: declaredRoles().map((name) => ({ name, ...policyFor(name) })), users: store.listUserRoles().map((u) => ({ ...u, // What is in force. The provider's answer sits beside it, since the // two differ whenever a role is held by name. effective: roleFor(u.sub, u.role), })), }), },
/** * End someone's sessions. * * Not a config change, which is why it survived the config becoming the * only place roles live: a role mapped from the identity provider only * reaches kloe at a fresh login, so this is what makes a change there * apply without waiting out a session. */ "/api/roles/signout": { POST: withBody(SignOutBody, (data, req: Bun.BunRequest<"/api/roles/signout">) => { const denied = requireOwner(req, store); if (denied) return denied; if (data.sub === getSession(req, store)?.sub) { return Response.json({ error: "use log out for your own session" }, { status: 422 }); } return Response.json({ endedSessions: store.deleteSessionsFor(data.sub) }); }), },
/** * Your picker: everything you could put in it, and what you have. * * There is no instance-wide equivalent any more. What a role may pick from * is `auth.roles[].models` in kloe.json, where it is reviewable; everything * downstream of that is a person's own arrangement of their own list. */ "/api/models/mine": { GET: async (req: Bun.BunRequest<"/api/models/mine">) => { const sub = whoami(req, store); const available = await availableModels(store, requestRole(req, store), sub); const chosen = new Map(store.listUserModels(sub).map((m) => [m.ref, m])); return Response.json({ models: available.map((m) => ({ ...m, enabled: chosen.has(m.ref), displayName: chosen.get(m.ref)?.displayName ?? null, sortOrder: chosen.get(m.ref)?.sortOrder ?? 0, })), }); }, PATCH: withBody(MyModelBody, async (data, req: Bun.BunRequest<"/api/models/mine">) => { const sub = whoami(req, store); // You may only arrange what you could otherwise use: a row for a model // out of your reach is a row that means nothing. const available = await availableModels(store, requestRole(req, store), sub); if (!available.some((m) => m.ref === data.ref)) { return Response.json( { error: `model "${data.ref}" is not available to you` }, { status: 403 }, ); } store.setUserModel(sub, data.ref, data); return Response.json({ ok: true }); }), },
// Chat view: this person's picker, in their order. "/api/models/chat": { GET: async (req: Bun.BunRequest<"/api/models/chat">) => Response.json({ models: await chatModelsFor(store, requestRole(req, store), whoami(req, store)), }), },
/** * What has been spent, and what is left to spend. * * Split by who paid, because the two numbers answer different questions. * The instance's side is what the operator is buying and what a budget * bounds; the connected side is what someone's own account covered, which * nobody rations and which is the whole reward for connecting one. * * Yours by default. `?scope=everyone` is the instance's ledger across * people, and needs admin. */ "/api/usage": { GET: (req: Bun.BunRequest<"/api/usage">) => { const url = new URL(req.url); const sub = whoami(req, store); const role = requestRole(req, store); const everyone = url.searchParams.get("scope") === "everyone"; if (everyone) { const denied = requireOwner(req, store); if (denied) return denied; } const days = Math.min(Math.max(Number(url.searchParams.get("days")) || 30, 1), 365); const since = Date.now() - days * DAY_MS; const whose = everyone ? {} : { sub }; const side = (payer: "instance" | "user") => { const byModel = store.usageTotals({ since, groupBy: "model", payer, ...whose }); return { byModel, calls: byModel.reduce((n, r) => n + r.calls, 0), tokens: byModel.reduce((n, r) => n + r.inputTokens + r.outputTokens, 0), costUsd: byModel.reduce((n, r) => n + r.costUsd, 0), }; }; return Response.json({ days, role, ...budgetStatus(store, sub, role), instance: side("instance"), connected: side("user"), byDay: store.usageTotals({ since, groupBy: "day", ...whose }), // Only what the instance paid for. What someone's own account covered // is between them and their provider, and an admin reading this is // asking about the bill they get. ...(everyone ? { byUser: store.usageTotals({ since, groupBy: "sub", payer: "instance" }) } : {}), }); }, },
"/api/conversations/:id/stream": { GET: (req: Bun.BunRequest<"/api/conversations/:id/stream">) => guardConv(req, req.params.id) ?? openStream(req.params.id, req, store), }, "/api/conversations/:id/prompt": { POST: withBody(PromptBody, (data, req: Bun.BunRequest<"/api/conversations/:id/prompt">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; const res = startRun( req.params.id, data, store, requestRole(req, store), whoami(req, store), ); kick(); return res; }), }, "/api/conversations/:id/cancel": { POST: (req: Bun.BunRequest<"/api/conversations/:id/cancel">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; getActor(req.params.id, store).requestCancel(); return Response.json({ ok: true }); }, }, "/api/conversations/:id/steer": { POST: withBody(SteerBody, (data, req: Bun.BunRequest<"/api/conversations/:id/steer">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; const res = startSteer( req.params.id, data, store, requestRole(req, store), whoami(req, store), ); kick(); return res; }), GET: (req: Bun.BunRequest<"/api/conversations/:id/steer">) => guardConv(req, req.params.id) ?? Response.json({ queued: store.pendingQueue(req.params.id) }), }, // Remove a still-pending steer from the queue (before it's promoted). "/api/conversations/:id/steer/:runId": { DELETE: (req: Bun.BunRequest<"/api/conversations/:id/steer/:runId">) => { const { id, runId } = req.params; const denied = guardConv(req, id); if (denied) return denied; // Only tombstone something actually queued, so a bad runId can't spam // the log; already-promoted or already-cancelled steers 404. if (!store.pendingQueue(id).some((m) => m.runId === runId)) { return Response.json({ error: "no such queued message" }, { status: 404 }); } getActor(id, store).cancelSteer(runId); return Response.json({ ok: true }); }, }, "/api/conversations/:id/events": { // History as NDJSON (one event per line) so the client parses + renders it // incrementally as the compressed stream arrives. Brotli'd; the browser // decompresses on the fly. For bottom-first loading the client fetches the // last few turns (`?tailTurns=N`) to fill the viewport instantly, then // backfills everything older (`?before=<seq>`) above. No params → full log. GET: (req: Bun.BunRequest<"/api/conversations/:id/events">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; const id = req.params.id; const url = new URL(req.url); const beforeParam = url.searchParams.get("before"); const tailParam = url.searchParams.get("tailTurns"); const range: { from?: number; before?: number; exclude?: string } = { // The argument fragments belong to the moment they were written: the // client watches a call being typed, and the `tool-call` event that // follows carries what they built. Replaying them costs a repaint per // fragment for nothing anyone can see. A call still being typed when // this lands is not lost — the live stream resumes from the last event // sent here, so its fragments arrive as fragments should. exclude: Event.ToolInput, }; if (beforeParam !== null) { const before = Number(beforeParam); if (Number.isFinite(before)) range.before = before; } else if (tailParam !== null) { const tailTurns = Number(tailParam); if (tailTurns > 0) range.from = store.turnCutSeq(id, tailTurns); } return compressed(req, historyNdjson(store, id, range), "application/x-ndjson"); }, }, "/api/conversations/:id": { DELETE: async (req: Bun.BunRequest<"/api/conversations/:id">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; getActor(req.params.id, store).requestCancel(); const orphaned = store.deleteConversation(req.params.id); actors.delete(req.params.id); // drop the in-memory actor so it can't resurrect the log getExecutor()?.disposeSession(req.params.id); // tear down this chat's persistent sandbox // Free blobs this conversation was the last to reference: bytes first, // then the metadata row (a crash between leaves it for the GC sweep). for (const sha256 of orphaned) { await blobs.delete(sha256); store.deleteBlob(sha256); } return Response.json({ ok: true }); }, PATCH: withBody(RenameBody, (data, req: Bun.BunRequest<"/api/conversations/:id">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; store.renameConversation(req.params.id, data.title); return Response.json({ ok: true }); }), },
// File (or unfile) a conversation into a project. "/api/conversations/:id/project": { PUT: withBody( ProjectAssignBody, (data, req: Bun.BunRequest<"/api/conversations/:id/project">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; if (data.projectId) { const pd = guardProject(req, data.projectId); if (pd) return pd; } store.setConversationProject(req.params.id, data.projectId); if (data.projectId) store.touchProject(data.projectId); return Response.json({ ok: true }); }, ), },
// ---- projects ---- "/api/projects": { GET: (req: Bun.BunRequest<"/api/projects">) => { const owner = authEnabled() ? getSession(req, store)?.sub : undefined; return Response.json({ projects: store.listProjects(owner) }); }, POST: withBody(ProjectCreateBody, (data, req: Bun.BunRequest<"/api/projects">) => { const id = randomUUID(); store.createProject(id, data.name, data.description, getSession(req, store)?.sub); return Response.json({ id }, { status: 201 }); }), }, "/api/projects/:id": { GET: (req: Bun.BunRequest<"/api/projects/:id">) => { const denied = guardProject(req, req.params.id); if (denied) return denied; const project = store.getProject(req.params.id); if (!project) return Response.json({ error: "not found" }, { status: 404 }); const owner = authEnabled() ? getSession(req, store)?.sub : undefined; return Response.json({ project, conversations: store.listConversations(owner, req.params.id), }); }, PATCH: withBody(ProjectPatchBody, (data, req: Bun.BunRequest<"/api/projects/:id">) => { const denied = guardProject(req, req.params.id); if (denied) return denied; if (!store.getProject(req.params.id)) return Response.json({ error: "not found" }, { status: 404 }); // An empty lardProject string clears the pin. store.updateProject(req.params.id, { name: data.name, description: data.description, lardProject: data.lardProject === undefined ? undefined : data.lardProject || null, }); return Response.json({ ok: true }); }), DELETE: (req: Bun.BunRequest<"/api/projects/:id">) => { const denied = guardProject(req, req.params.id); if (denied) return denied; store.deleteProject(req.params.id); return Response.json({ ok: true }); }, },
// Project context files — hand-authored text injected into the project's chats. "/api/projects/:id/context": { GET: (req: Bun.BunRequest<"/api/projects/:id/context">) => { const denied = guardProject(req, req.params.id); if (denied) return denied; return Response.json({ files: store.listProjectContext(req.params.id) }); }, POST: async (req: Bun.BunRequest<"/api/projects/:id/context">) => { const denied = guardProject(req, req.params.id); if (denied) return denied; if (!store.getProject(req.params.id)) return Response.json({ error: "not found" }, { status: 404 }); const name = (new URL(req.url).searchParams.get("name") || "file.txt").slice(0, 200); const body = await req.text(); if (body.length > 512 * 1024) return Response.json({ error: "file too large (max 512KB text)" }, { status: 413 }); // Context files are injected verbatim into the prompt, so only text is // useful (and safe). A spreadsheet/image/PDF decodes to a UTF-8 string // riddled with NUL and replacement chars — reject those, and known // binary extensions, with a clear message. if (isBinaryName(name) || looksBinary(body)) { return Response.json( { error: "only text files can be added as context (no spreadsheets, images, or other binary files)", }, { status: 415 }, ); } const cid = randomUUID(); store.addProjectContext(cid, req.params.id, name, body); return Response.json({ id: cid }, { status: 201 }); }, }, "/api/projects/:id/context/:cid": { GET: (req: Bun.BunRequest<"/api/projects/:id/context/:cid">) => { const denied = guardProject(req, req.params.id); if (denied) return denied; const f = store.projectContextFiles(req.params.id).find((x) => x.id === req.params.cid); if (!f) return Response.json({ error: "not found" }, { status: 404 }); return Response.json(f); }, DELETE: (req: Bun.BunRequest<"/api/projects/:id/context/:cid">) => { const denied = guardProject(req, req.params.id); if (denied) return denied; store.deleteProjectContext(req.params.id, req.params.cid); return Response.json({ ok: true }); }, }, }; // Two gates, both no-ops until something turns them on. When auth is enabled, // every /api/* route requires a session (401 otherwise) and /health stays // open. And when a separate artifact origin is configured, none of this // answers there: that host serves artifacts and nothing else. return offArtifactHost(gateApi(routes as never, store)) as typeof routes;}