// The watch's REST API, as described in docs/api.md and typed in contract.ts. import type { BunRequest } from "bun"; import type { ErrorResponse, NewThreadRequest, NewThreadResponse, PairRequest, PairResponse, ProjectsResponse, PushRegistration, RespondRequest, PushResult, SendMessageRequest, StatusResponse, } from "./contract.ts"; import type { Db, DeviceRow } from "./db.ts"; import type { Hub } from "./hub.ts"; import type { Pusher } from "./push.ts"; import { HostError, type T3Host } from "./t3/host.ts"; import { RpcError } from "./t3/rpc.ts"; import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts"; import { buildFeed, buildSummary, buildThreadDetail, hostStatusDetail, hostSummary, type SummaryCache } from "./views.ts"; export interface ApiDeps { db: Db; hub: Hub; cache: SummaryCache; pusher: Pusher; /** When the gateway started, for the status screen. */ startedAt: string; log?: (message: string) => void; } /** Wrong pairing codes allowed before every open code is voided. */ const PAIR_ATTEMPTS = 10; const PAIR_WINDOW_MS = 15 * 60_000; const DECISIONS: ReadonlySet = new Set([ "accept", "acceptForSession", "acceptAlways", "decline", "cancel", ]); class HttpError extends Error { constructor( readonly status: number, message: string, ) { super(message); } } const json = (body: unknown, status = 200) => Response.json(body, { status }); const accepted = () => new Response(null, { status: 202 }); export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} }: ApiDeps) { let pairFailures: number[] = []; /** Wraps a handler with device auth and error mapping. */ const authed =

(handler: (req: BunRequest

, device: DeviceRow) => Promise | Response) => (req: BunRequest

) => handle(async () => { const token = req.headers.get("authorization")?.match(/^Bearer (.+)$/)?.[1]; const device = token ? db.devices.authenticate(token) : null; if (!device) throw new HttpError(401, "This watch is not paired."); return handler(req, device); }); async function handle(run: () => Promise | Response): Promise { try { return await run(); } catch (error) { if (error instanceof HttpError) return json({ error: error.message } satisfies ErrorResponse, error.status); if (error instanceof HostError) { return json({ error: error.message } satisfies ErrorResponse, error.status === 503 ? 503 : 502); } if (error instanceof RpcError) return json({ error: error.message } satisfies ErrorResponse, 502); log(`unhandled: ${error instanceof Error ? error.stack : String(error)}`); return json({ error: "Something went wrong in the gateway." } satisfies ErrorResponse, 500); } } const findHost = (hostId: string): T3Host => { const host = hub.get(hostId); if (!host) throw new HttpError(404, "Unknown host."); return host; }; const findThread = (hostId: string, threadId: string): { host: T3Host; thread: ThreadShell } => { const host = findHost(hostId); const thread = host.threads.get(threadId); if (!thread) throw new HttpError(404, "Unknown thread."); return { host, thread }; }; const resolveModel = (host: T3Host, projectId: string): ModelSelection | null => host.projects.get(projectId)?.defaultModelSelection ?? host.serverConfig?.settings?.defaultModelSelection ?? null; return { "/v1/health": { GET: () => json({ ok: true, hosts: hub.list().map(hostSummary) }), }, "/v1/pair": { POST: (req: BunRequest<"/v1/pair">) => handle(async () => { const body = await readBody(req); const code = String(body.code ?? "").trim(); const name = String(body.deviceName ?? "").trim() || "Apple Watch"; pairFailures = pairFailures.filter((at) => Date.now() - at < PAIR_WINDOW_MS); if (pairFailures.length >= PAIR_ATTEMPTS) throw new HttpError(429, "Too many attempts. Make a new code."); if (!/^\d{6}$/.test(code) || !db.pairing.redeem(code)) { pairFailures.push(Date.now()); if (pairFailures.length >= PAIR_ATTEMPTS) db.pairing.voidAll(); throw new HttpError(400, "That code is wrong or has expired."); } const deviceToken = `wc_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`; const deviceId = db.devices.create(name, deviceToken); log(`paired device ${name} (${deviceId})`); return json({ deviceId, deviceToken } satisfies PairResponse); }), }, "/v1/device/push": { PUT: authed<"/v1/device/push">(async (req, device) => { const body = await readBody(req); if (!/^[0-9a-fA-F]{32,200}$/.test(body.apnsToken ?? "")) throw new HttpError(400, "apnsToken must be hex."); if (body.apnsEnvironment !== "sandbox" && body.apnsEnvironment !== "production") { throw new HttpError(400, "apnsEnvironment must be sandbox or production."); } db.devices.setPush(device.id, body.apnsToken.toLowerCase(), body.apnsEnvironment); return new Response(null, { status: 204 }); }), }, "/v1/device": { /** Unpairs the watch asking. */ DELETE: authed<"/v1/device">((_req, device) => { db.devices.revoke(device.id); log(`unpaired device ${device.name} (${device.id})`); return new Response(null, { status: 204 }); }), }, "/v1/device/test-push": { POST: authed<"/v1/device/test-push">(async (_req, device) => json( (await pusher.sendTo(device, { title: "WristCode", body: "Test notification. Pushes reach this watch.", category: "test", data: {}, collapseId: "test", })) satisfies PushResult, ), ), }, "/v1/status": { GET: authed((_req, device) => { const expiries = new Map(hub.rows().map((row) => [row.id, row.token_expires_at])); return json({ hosts: hub.list().map((host) => hostStatusDetail(host, expiries.get(host.id) ?? null)), gateway: { startedAt, pushConfigured: pusher.configured, lastPush: pusher.last }, device: { name: device.name, pairedAt: device.created_at, pushRegistered: device.apns_token !== null, apnsEnvironment: device.apns_env, }, } satisfies StatusResponse); }), }, "/v1/hosts/:hostId/reconnect": { POST: authed<"/v1/hosts/:hostId/reconnect">((req) => { findHost(req.params.hostId).reconnect(); return accepted(); }), }, "/v1/feed": { GET: authed(async () => json(await buildFeed(hub.list(), cache))), }, "/v1/summary": { GET: authed(() => json(buildSummary(hub.list()))), }, "/v1/projects": { GET: authed(() => json({ hosts: hub.list().map((host) => ({ ...hostSummary(host), projects: [...host.projects.values()] .sort((a, b) => a.title.localeCompare(b.title)) .map((project) => ({ id: project.id, title: project.title, model: resolveModel(host, project.id)?.model ?? null })), })), } satisfies ProjectsResponse), ), }, "/v1/threads": { POST: authed<"/v1/threads">(async (req) => { const body = await readBody(req); const text = String(body.text ?? "").trim(); if (!text) throw new HttpError(400, "Say what the thread should do."); const host = findHost(String(body.hostId)); if (!host.projects.has(String(body.projectId))) throw new HttpError(404, "Unknown project."); const modelSelection = resolveModel(host, body.projectId); if (!modelSelection) throw new HttpError(400, "That project has no default model. Set one in T3 Code."); const result = await host.launchThread({ commandId: crypto.randomUUID(), creationSource: "mobile", projectId: body.projectId, title: titleFrom(text), generateTitle: true, modelSelection, runtimeMode: host.serverConfig?.settings?.defaultRuntimeMode ?? "full-access", interactionMode: "default", workspaceStrategy: { type: "root" }, initialMessage: { messageId: crypto.randomUUID(), text, attachments: [] }, }); return json({ hostId: host.id, threadId: result.threadId } satisfies NewThreadResponse, 201); }), }, "/v1/threads/:hostId/:threadId": { GET: authed<"/v1/threads/:hostId/:threadId">(async (req) => { const { host, thread } = findThread(req.params.hostId, req.params.threadId); return json(await buildThreadDetail(host, thread, cache, hub.list())); }), }, "/v1/threads/:hostId/:threadId/messages": { POST: authed<"/v1/threads/:hostId/:threadId/messages">(async (req) => { const { host, thread } = findThread(req.params.hostId, req.params.threadId); const text = String((await readBody(req)).text ?? "").trim(); if (!text) throw new HttpError(400, "The message is empty."); await host.dispatch({ type: "message.dispatch", commandId: crypto.randomUUID(), threadId: thread.id, messageId: crypto.randomUUID(), text, attachments: [], createdBy: "user", creationSource: "mobile", dispatchMode: { type: "queue_after_active" }, }); return accepted(); }), }, "/v1/threads/:hostId/:threadId/respond": { POST: authed<"/v1/threads/:hostId/:threadId/respond">(async (req) => { const { host, thread } = findThread(req.params.hostId, req.params.threadId); const body = await readBody(req); if (thread.pendingRuntimeRequest?.id !== body.requestId) { throw new HttpError(409, "That request was already answered."); } const base = { type: "runtime-request.respond", commandId: crypto.randomUUID(), threadId: thread.id, requestId: body.requestId } as const; if ("decision" in body) { if (!DECISIONS.has(body.decision)) throw new HttpError(400, "Unknown decision."); await host.dispatch({ ...base, decision: body.decision }); } else if ("answers" in body && isAnswers(body.answers)) { await host.dispatch({ ...base, answers: body.answers }); } else { throw new HttpError(400, "Send a decision or answers."); } cache.forget(host, thread.id); return accepted(); }), }, "/v1/threads/:hostId/:threadId/settle": { POST: authed<"/v1/threads/:hostId/:threadId/settle">(async (req) => { const { host, thread } = findThread(req.params.hostId, req.params.threadId); await host.dispatch({ type: "thread.settle", commandId: crypto.randomUUID(), threadId: thread.id }); return accepted(); }), }, "/v1/threads/:hostId/:threadId/stop": { POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => { const { host, thread } = findThread(req.params.hostId, req.params.threadId); if (host.supports("threadStop")) { await host.dispatch({ type: "thread.stop", commandId: crypto.randomUUID(), threadId: thread.id }); } else if (thread.activeRunId) { await host.dispatch({ type: "run.interrupt", commandId: crypto.randomUUID(), threadId: thread.id, runId: thread.activeRunId, }); } else { throw new HttpError(409, "Nothing is running."); } return accepted(); }), }, }; } const isAnswers = (value: unknown): value is Record => typeof value === "object" && value !== null && Object.values(value).every( (answer) => typeof answer === "string" || (Array.isArray(answer) && answer.every((item) => typeof item === "string")), ); async function readBody(req: Request): Promise & T> { try { return (await req.json()) as Partial & T; } catch { throw new HttpError(400, "The body must be JSON."); } } /** A thread title from the first line of the prompt. T3 replaces it with a generated one. */ function titleFrom(text: string): string { const line = text.split("\n")[0]?.trim() ?? text; return line.length > 80 ? `${line.slice(0, 79)}…` : line; }