Gateway: watch API, pairing, notifications, APNs sender, CLI, tests

This commit is contained in:
gib committed 2026-11-01 02:09:18 -05:00
1 parent 972f6e4be5
commit 6ca098b701
12 files changed
+1297 -1

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
// 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,
SendMessageRequest,
} from "./contract.ts";
import type { Db, DeviceRow } from "./db.ts";
import type { Hub } from "./hub.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, buildThreadDetail, hostSummary, type SummaryCache } from "./views.ts";
export interface ApiDeps {
db: Db;
hub: Hub;
cache: SummaryCache;
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<string> = new Set<ApprovalDecision>([
"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, log = () => {} }: ApiDeps) {
let pairFailures: number[] = [];
/** Wraps a handler with device auth and error mapping. */
const authed =
<P extends string>(handler: (req: BunRequest<P>, device: DeviceRow) => Promise<Response> | Response) =>
(req: BunRequest<P>) =>
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> | Response): Promise<Response> {
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<PairRequest>(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<PushRegistration>(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/feed": {
GET: authed(async () => json(await buildFeed(hub.list(), cache))),
},
"/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<NewThreadRequest>(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));
}),
},
"/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<SendMessageRequest>(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<RespondRequest>(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 && body.answers && typeof body.answers === "object") {
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/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();
}),
},
};
}
async function readBody<T>(req: Request): Promise<Partial<T> & T> {
try {
return (await req.json()) as Partial<T> & 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;
}