Gateway: watch API, pairing, notifications, APNs sender, CLI, tests
This commit is contained in:
1 parent
972f6e4be5
commit
6ca098b701
12 files changed
+1297
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user