329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
// 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<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, pusher, startedAt, 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/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<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, 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<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 && 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<string, string | string[]> =>
|
|
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<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;
|
|
}
|