talon-agent 3.33.0 → 3.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/backend/kilo/factory.ts +39 -119
  3. package/src/backend/kilo/handler/index.ts +1 -11
  4. package/src/backend/kilo/handler/message.ts +20 -317
  5. package/src/backend/kilo/server.ts +55 -272
  6. package/src/backend/kilo/sessions.ts +5 -83
  7. package/src/backend/opencode/factory.ts +41 -118
  8. package/src/backend/opencode/handler/index.ts +1 -11
  9. package/src/backend/opencode/handler/message.ts +20 -321
  10. package/src/backend/opencode/server.ts +57 -217
  11. package/src/backend/opencode/sessions.ts +5 -83
  12. package/src/backend/remote-server/chat-turn.ts +359 -0
  13. package/src/backend/remote-server/factory.ts +150 -0
  14. package/src/backend/remote-server/index.ts +14 -19
  15. package/src/backend/remote-server/server-bindings.ts +232 -0
  16. package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
  17. package/src/core/mesh/bridge-links.ts +319 -0
  18. package/src/core/mesh/common.ts +30 -0
  19. package/src/core/mesh/device-files.ts +710 -0
  20. package/src/core/mesh/service.ts +72 -894
  21. package/src/frontend/discord/handlers/access.ts +7 -56
  22. package/src/frontend/discord/handlers/state.ts +15 -8
  23. package/src/frontend/native/server.ts +391 -323
  24. package/src/frontend/shared/access.ts +152 -0
  25. package/src/frontend/telegram/handlers/access.ts +8 -25
  26. package/src/frontend/telegram/handlers/queue.ts +3 -35
  27. package/src/frontend/telegram/handlers/state.ts +15 -8
  28. package/src/backend/kilo/events.ts +0 -60
  29. package/src/backend/kilo/handler/state.ts +0 -9
  30. package/src/backend/opencode/handler/state.ts +0 -9
  31. package/src/backend/opencode/handler/turn.ts +0 -314
@@ -185,6 +185,87 @@ const AUTH_LOCKOUT_MAX_TRACKED = 10_000;
185
185
  */
186
186
  type AuthState = "ok" | "anonymous" | "bad";
187
187
 
188
+ /**
189
+ * "public": served without a bearer token. Every entry is gated some other
190
+ * way — a single-use grant minted by the daemon, or (for /health) by
191
+ * answering only what pairing needs until a token is presented.
192
+ * "bearer": the request must carry the bridge token.
193
+ */
194
+ export type BridgeRouteAuth = "public" | "bearer";
195
+
196
+ /**
197
+ * The bridge's routes and the auth tier of each — declared once, here,
198
+ * rather than implied by where an `if` sits relative to the auth check.
199
+ * The security posture of the transport is this table: a route is
200
+ * pre-auth only by appearing in it as "public", and the route test walks
201
+ * every entry and proves the tier holds on the wire. Adding a route
202
+ * without an entry is a type error (`buildRoutes` is exhaustive over
203
+ * these keys); adding one as "public" is a diff a reviewer sees.
204
+ */
205
+ export const BRIDGE_ROUTE_AUTH = {
206
+ // Pre-auth by design. /health serves pairing data (identity, protocol,
207
+ // fingerprint) to anyone and the operational view only to a token
208
+ // holder. /pair, /node/install and /node/binary hand over a credential
209
+ // to a device that holds none yet; the single-use grant in the query is
210
+ // the entire authorization.
211
+ "GET /health": "public",
212
+ "GET /pair": "public",
213
+ "GET /node/install": "public",
214
+ "GET /node/binary": "public",
215
+
216
+ // Everything a client can do once paired.
217
+ "GET /events": "bearer",
218
+ "GET /chats": "bearer",
219
+ "POST /chats": "bearer",
220
+ "POST /chats/rename": "bearer",
221
+ "POST /chats/delete": "bearer",
222
+ "POST /chats/reset": "bearer",
223
+ "POST /chats/interrupt": "bearer",
224
+ "POST /chats/pulse": "bearer",
225
+ "POST /queue": "bearer",
226
+ "GET /history": "bearer",
227
+ "GET /search": "bearer",
228
+ "POST /send": "bearer",
229
+ "POST /upload": "bearer",
230
+ "GET /media": "bearer",
231
+ "GET /models": "bearer",
232
+ "POST /model": "bearer",
233
+ "GET /backends": "bearer",
234
+ "POST /backend": "bearer",
235
+ "GET /effort": "bearer",
236
+ "POST /effort": "bearer",
237
+ "GET /logs": "bearer",
238
+ "GET /plugins": "bearer",
239
+ "POST /plugins/toggle": "bearer",
240
+ "GET /skills": "bearer",
241
+ "POST /skills/toggle": "bearer",
242
+ "GET /config": "bearer",
243
+ "POST /config": "bearer",
244
+ "POST /control": "bearer",
245
+
246
+ // Mesh. The one-time `transfer` token on /devices/file authorizes one
247
+ // direction+path, but the route still sits behind the bearer like every
248
+ // device route — the token is a scope, not a credential.
249
+ "POST /devices/register": "bearer",
250
+ "POST /location": "bearer",
251
+ "GET /devices": "bearer",
252
+ "POST /devices/command-result": "bearer",
253
+ "POST /devices/file": "bearer",
254
+ "GET /devices/file": "bearer",
255
+ } as const satisfies Record<string, BridgeRouteAuth>;
256
+
257
+ export type BridgeRouteKey = keyof typeof BRIDGE_ROUTE_AUTH;
258
+
259
+ /** What a route handler receives; `auth` is already evaluated. */
260
+ type RouteContext = {
261
+ req: IncomingMessage;
262
+ res: ServerResponse;
263
+ url: URL;
264
+ auth: AuthState;
265
+ };
266
+
267
+ type RouteHandler = (ctx: RouteContext) => void | Promise<void>;
268
+
188
269
  export class BridgeServer {
189
270
  private server: Server | null = null;
190
271
  /**
@@ -199,6 +280,8 @@ export class BridgeServer {
199
280
  private tlsIdentity: BridgeTlsIdentity | null = null;
200
281
  /** Wrong-token counts per remote address (behind a proxy: per proxy). */
201
282
  private authFailures = new Map<string, { count: number; resetAt: number }>();
283
+ /** `METHOD /path` → handler; a Map so lookups only ever hit own entries. */
284
+ private readonly routes: ReadonlyMap<BridgeRouteKey, RouteHandler>;
202
285
 
203
286
  constructor(
204
287
  private readonly opts: {
@@ -217,7 +300,11 @@ export class BridgeServer {
217
300
  tls?: () => Promise<BridgeTlsIdentity>;
218
301
  },
219
302
  private readonly handlers: BridgeServerHandlers,
220
- ) {}
303
+ ) {
304
+ this.routes = new Map(
305
+ Object.entries(this.buildRoutes()) as [BridgeRouteKey, RouteHandler][],
306
+ );
307
+ }
221
308
 
222
309
  getPort(): number {
223
310
  return this.port;
@@ -410,8 +497,8 @@ export class BridgeServer {
410
497
  }
411
498
  // Set once here rather than in corsHeaders(): setHeader values survive
412
499
  // every later writeHead(code, {...}) that does not name the same key,
413
- // so each of the ~8 response sites keeps the grant without threading
414
- // the origin through all of them.
500
+ // so each of the response sites keeps the grant without threading the
501
+ // origin through all of them.
415
502
  if (origin !== undefined && this.isAllowedOrigin(origin)) {
416
503
  res.setHeader("Access-Control-Allow-Origin", origin);
417
504
  res.setHeader("Vary", "Origin");
@@ -439,384 +526,365 @@ export class BridgeServer {
439
526
  if (auth === "bad") this.recordAuthFailure(remote);
440
527
  else if (auth === "ok") this.authFailures.delete(remote);
441
528
 
442
- // /health is unauthenticated so clients can discover/ping the bridge
443
- // before they hold a token. Pre-auth it serves only what pairing needs
444
- // (identity, protocol, fingerprint) — operational details like bot name,
445
- // backend, and chat count are not for internet scanners to enumerate.
446
- if (method === "GET" && path === "/health") {
447
- const base = {
448
- app: "talon-bridge",
449
- ok: true,
450
- protocol: BRIDGE_PROTOCOL_VERSION,
451
- port: this.port,
452
- scheme: this.getScheme(),
453
- // The certificate's own hash — public by definition (any TLS client
454
- // sees the certificate), surfaced so pairing UIs can display it.
455
- fingerprint: this.getFingerprint(),
456
- authRequired: Boolean(this.opts.token),
457
- };
458
- if (auth !== "ok") return this.json(res, 200, base);
459
- const s = this.handlers.status();
460
- return this.json(res, 200, {
461
- ...base,
462
- host: this.opts.host,
463
- startedAt: this.opts.startedAt,
464
- botName: s.botName,
465
- backend: s.backend,
466
- model: s.model,
467
- activeChats: s.activeChats,
468
- capabilities: ["mesh", "mesh-commands", "mesh-file-stream"],
469
- });
470
- }
529
+ const key = `${method} ${path}` as BridgeRouteKey;
530
+ const route = this.routes.get(key);
531
+ const ctx: RouteContext = { req, res, url, auth };
471
532
 
472
- // Companion pairing is PRE-AUTH for the same reason as node
473
- // provisioning below: the phone holds no bridge credential yet, and the
474
- // single-use grant is what it comes to collect. Serving the page IS the
475
- // handover, so the grant is spent whichever leg is hit.
476
- if (method === "GET" && path === "/pair") {
477
- const token = url.searchParams.get("grant") ?? "";
478
- const wantsJson =
479
- url.searchParams.get("format") === "json" ||
480
- (req.headers.accept ?? "").includes("application/json");
481
- const served = token
482
- ? this.handlers.openCompanionPair(token, wantsJson ? "json" : "html")
483
- : null;
484
- if (!served) {
485
- return this.json(res, 404, {
486
- ok: false,
487
- error: "Unknown, expired, or already-used pairing link",
488
- });
489
- }
490
- res.writeHead(200, {
491
- "Content-Type": served.contentType,
492
- // A pairing payload is a credential; nothing may keep a copy.
493
- "Cache-Control": "no-store",
494
- ...this.corsHeaders(),
495
- });
496
- res.end(served.body);
533
+ if (route && BRIDGE_ROUTE_AUTH[key] === "public") {
534
+ await route(ctx);
497
535
  return;
498
536
  }
537
+ // Unknown routes are 401 before they are 404: an unauthenticated caller
538
+ // learns nothing about the route map.
539
+ if (auth !== "ok") {
540
+ return this.json(res, 401, { ok: false, error: "Unauthorized" });
541
+ }
542
+ if (!route) return this.json(res, 404, { ok: false, error: "Not found" });
543
+
544
+ try {
545
+ await route(ctx);
546
+ } catch (err) {
547
+ const msg = err instanceof Error ? err.message : String(err);
548
+ return this.json(res, 400, { ok: false, error: msg });
549
+ }
550
+ }
499
551
 
500
- // Node provisioning runs PRE-AUTH by design: the target host holds no
501
- // bridge credential yet — the single-use grant token (minted by
502
- // make_node_install_link, expiring, one serve per leg) is the entire
503
- // authorization, the same trust model as streamed-transfer tokens.
504
- if (
505
- method === "GET" &&
506
- (path === "/node/install" || path === "/node/binary")
507
- ) {
508
- const token = url.searchParams.get("provision") ?? "";
509
- const unknown = () =>
510
- this.json(res, 404, {
511
- ok: false,
512
- error: "Unknown, expired, or already-used provisioning token",
552
+ /**
553
+ * One handler per BRIDGE_ROUTE_AUTH key. The return type makes the
554
+ * object exhaustive: a route declared in the table without a handler
555
+ * here, or vice versa, does not compile.
556
+ */
557
+ private buildRoutes(): Record<BridgeRouteKey, RouteHandler> {
558
+ const h = this.handlers;
559
+ const json = (res: ServerResponse, code: number, body: unknown) =>
560
+ this.json(res, code, body);
561
+ const readJson = (req: IncomingMessage) => this.readJson(req);
562
+
563
+ // Streamed device file transfers (see core/mesh/transfers.ts). The
564
+ // one-time `transfer` token authorizes exactly one direction+path; the
565
+ // caller names itself so the token's device binding can be checked.
566
+ const transferToken = (url: URL, res: ServerResponse): string | null => {
567
+ const token = url.searchParams.get("transfer") ?? "";
568
+ if (!token) {
569
+ json(res, 400, { ok: false, error: "transfer required" });
570
+ return null;
571
+ }
572
+ return token;
573
+ };
574
+
575
+ return {
576
+ // ── Pre-auth ───────────────────────────────────────────────────────
577
+
578
+ // Unauthenticated so clients can discover/ping the bridge before they
579
+ // hold a token. Pre-auth it serves only what pairing needs (identity,
580
+ // protocol, fingerprint) — operational details like bot name,
581
+ // backend, and chat count are not for internet scanners to enumerate.
582
+ "GET /health": ({ res, auth }) => {
583
+ const base = {
584
+ app: "talon-bridge",
585
+ ok: true,
586
+ protocol: BRIDGE_PROTOCOL_VERSION,
587
+ port: this.port,
588
+ scheme: this.getScheme(),
589
+ // The certificate's own hash — public by definition (any TLS
590
+ // client sees the certificate), surfaced so pairing UIs can
591
+ // display it.
592
+ fingerprint: this.getFingerprint(),
593
+ authRequired: Boolean(this.opts.token),
594
+ };
595
+ if (auth !== "ok") return json(res, 200, base);
596
+ const s = h.status();
597
+ return json(res, 200, {
598
+ ...base,
599
+ host: this.opts.host,
600
+ startedAt: this.opts.startedAt,
601
+ botName: s.botName,
602
+ backend: s.backend,
603
+ model: s.model,
604
+ activeChats: s.activeChats,
605
+ capabilities: ["mesh", "mesh-commands", "mesh-file-stream"],
606
+ });
607
+ },
608
+
609
+ // Companion pairing: the phone holds no bridge credential yet, and
610
+ // the single-use grant is what it comes to collect. Serving the page
611
+ // IS the handover, so the grant is spent whichever leg is hit.
612
+ "GET /pair": ({ req, res, url }) => {
613
+ const token = url.searchParams.get("grant") ?? "";
614
+ const wantsJson =
615
+ url.searchParams.get("format") === "json" ||
616
+ (req.headers.accept ?? "").includes("application/json");
617
+ const served = token
618
+ ? h.openCompanionPair(token, wantsJson ? "json" : "html")
619
+ : null;
620
+ if (!served) {
621
+ return json(res, 404, {
622
+ ok: false,
623
+ error: "Unknown, expired, or already-used pairing link",
624
+ });
625
+ }
626
+ res.writeHead(200, {
627
+ "Content-Type": served.contentType,
628
+ // A pairing payload is a credential; nothing may keep a copy.
629
+ "Cache-Control": "no-store",
630
+ ...this.corsHeaders(),
513
631
  });
514
- if (!token) return unknown();
515
- if (path === "/node/install") {
516
- const install = this.handlers.openNodeInstall(token);
517
- if (!install) return unknown();
632
+ res.end(served.body);
633
+ },
634
+
635
+ // Node provisioning: the target host holds no bridge credential yet —
636
+ // the single-use grant token (minted by make_node_install_link,
637
+ // expiring, one serve per leg) is the entire authorization, the same
638
+ // trust model as streamed-transfer tokens.
639
+ "GET /node/install": ({ res, url }) => {
640
+ const token = url.searchParams.get("provision") ?? "";
641
+ const install = token ? h.openNodeInstall(token) : null;
642
+ if (!install) return this.unknownProvision(res);
518
643
  res.writeHead(200, {
519
644
  "Content-Type": "text/plain; charset=utf-8",
520
645
  "Content-Disposition": `attachment; filename="${install.filename}"`,
521
646
  ...this.corsHeaders(),
522
647
  });
523
648
  res.end(install.script);
524
- return;
525
- }
526
- const binary = this.handlers.openNodeBinary(token);
527
- if (!binary) return unknown();
528
- res.writeHead(200, {
529
- "Content-Type": "application/octet-stream",
530
- "Content-Length": String(binary.size),
531
- ...this.corsHeaders(),
532
- });
533
- const stream = createReadStream(binary.path);
534
- stream.on("error", () => res.destroy());
535
- stream.pipe(res);
536
- return;
537
- }
538
-
539
- if (auth !== "ok") {
540
- return this.json(res, 401, { ok: false, error: "Unauthorized" });
541
- }
542
-
543
- try {
544
- // A mesh client names itself here so device-addressed events reach it
545
- // alone (see sendToDevice); UI clients simply omit it.
546
- if (method === "GET" && path === "/events")
547
- return this.openStream(res, deviceIdParam(url));
548
-
549
- if (method === "GET" && path === "/chats")
550
- return this.json(res, 200, { chats: this.handlers.listChats() });
551
-
552
- if (method === "POST" && path === "/chats") {
553
- const body = await this.readJson(req);
554
- const chat = this.handlers.createChat(asString(body.title));
555
- return this.json(res, 200, { chat });
556
- }
557
-
558
- if (method === "POST" && path === "/chats/rename") {
559
- const body = await this.readJson(req);
560
- const chat = this.handlers.renameChat(
649
+ },
650
+ "GET /node/binary": ({ res, url }) => {
651
+ const token = url.searchParams.get("provision") ?? "";
652
+ const binary = token ? h.openNodeBinary(token) : null;
653
+ if (!binary) return this.unknownProvision(res);
654
+ this.streamFile(res, binary);
655
+ },
656
+
657
+ // ── Chats ──────────────────────────────────────────────────────────
658
+
659
+ // A mesh client names itself here so device-addressed events reach
660
+ // it alone (see sendToDevice); UI clients simply omit it.
661
+ "GET /events": ({ res, url }) => this.openStream(res, deviceIdParam(url)),
662
+ "GET /chats": ({ res }) => json(res, 200, { chats: h.listChats() }),
663
+ "POST /chats": async ({ req, res }) => {
664
+ const body = await readJson(req);
665
+ json(res, 200, { chat: h.createChat(asString(body.title)) });
666
+ },
667
+ "POST /chats/rename": async ({ req, res }) => {
668
+ const body = await readJson(req);
669
+ const chat = h.renameChat(
561
670
  asString(body.chatId) ?? "",
562
671
  asString(body.title) ?? "",
563
672
  );
564
673
  return chat
565
- ? this.json(res, 200, { chat })
566
- : this.json(res, 404, { ok: false, error: "No such chat" });
567
- }
568
-
569
- if (method === "POST" && path === "/chats/delete") {
570
- const body = await this.readJson(req);
571
- const ok = this.handlers.deleteChat(asString(body.chatId) ?? "");
572
- return this.json(res, 200, { ok });
573
- }
574
-
575
- if (method === "POST" && path === "/chats/reset") {
576
- const body = await this.readJson(req);
577
- const ok = this.handlers.resetChat(asString(body.chatId) ?? "");
578
- return this.json(res, 200, { ok });
579
- }
580
-
581
- if (method === "POST" && path === "/chats/interrupt") {
582
- const body = await this.readJson(req);
583
- const ok = await this.handlers.interruptTurn(
584
- asString(body.chatId) ?? "",
585
- );
586
- return this.json(res, 200, { ok });
587
- }
588
-
589
- if (method === "POST" && path === "/chats/pulse") {
590
- const body = await this.readJson(req);
591
- this.handlers.setPulse(asString(body.chatId) ?? "", body.on === true);
592
- return this.json(res, 200, { ok: true });
593
- }
594
-
595
- if (method === "POST" && path === "/queue") {
596
- const body = await this.readJson(req);
597
- this.handlers.queueMessage(
598
- asString(body.chatId) ?? "",
599
- asString(body.text) ?? "",
600
- );
601
- return this.json(res, 200, { ok: true });
602
- }
603
-
604
- if (method === "GET" && path === "/history") {
674
+ ? json(res, 200, { chat })
675
+ : json(res, 404, { ok: false, error: "No such chat" });
676
+ },
677
+ "POST /chats/delete": async ({ req, res }) => {
678
+ const body = await readJson(req);
679
+ json(res, 200, { ok: h.deleteChat(asString(body.chatId) ?? "") });
680
+ },
681
+ "POST /chats/reset": async ({ req, res }) => {
682
+ const body = await readJson(req);
683
+ json(res, 200, { ok: h.resetChat(asString(body.chatId) ?? "") });
684
+ },
685
+ "POST /chats/interrupt": async ({ req, res }) => {
686
+ const body = await readJson(req);
687
+ const ok = await h.interruptTurn(asString(body.chatId) ?? "");
688
+ json(res, 200, { ok });
689
+ },
690
+ "POST /chats/pulse": async ({ req, res }) => {
691
+ const body = await readJson(req);
692
+ h.setPulse(asString(body.chatId) ?? "", body.on === true);
693
+ json(res, 200, { ok: true });
694
+ },
695
+ "POST /queue": async ({ req, res }) => {
696
+ const body = await readJson(req);
697
+ h.queueMessage(asString(body.chatId) ?? "", asString(body.text) ?? "");
698
+ json(res, 200, { ok: true });
699
+ },
700
+ "GET /history": ({ res, url }) => {
605
701
  const id = url.searchParams.get("chatId") ?? "";
606
702
  const before = asPositiveInt(url.searchParams.get("before"));
607
703
  const limit = asPositiveInt(url.searchParams.get("limit"));
608
- return this.json(res, 200, {
704
+ json(res, 200, {
609
705
  chatId: id,
610
- messages: this.handlers.history(id, { before, limit }),
706
+ messages: h.history(id, { before, limit }),
611
707
  });
612
- }
613
-
614
- if (method === "GET" && path === "/search") {
708
+ },
709
+ "GET /search": ({ res, url }) => {
615
710
  const q = (url.searchParams.get("q") ?? "").trim();
616
- if (!q) return this.json(res, 400, { ok: false, error: "q required" });
711
+ if (!q) return json(res, 400, { ok: false, error: "q required" });
617
712
  const chatId = url.searchParams.get("chatId") ?? undefined;
618
- return this.json(res, 200, {
619
- results: this.handlers.search(q, chatId),
620
- });
621
- }
622
-
623
- if (method === "POST" && path === "/send") {
624
- const body = await this.readJson(req);
713
+ json(res, 200, { results: h.search(q, chatId) });
714
+ },
715
+ "POST /send": async ({ req, res }) => {
716
+ const body = await readJson(req);
625
717
  const id = asString(body.chatId) ?? "";
626
718
  const text = asString(body.text) ?? "";
627
719
  const imagePath = asString(body.imagePath);
628
720
  const attachmentPath = asString(body.attachmentPath);
629
- // Text may be empty when an image is attached; require one or the other.
721
+ // Text may be empty when an image is attached; require one or the
722
+ // other.
630
723
  if (!id || (!text.trim() && !attachmentPath))
631
- return this.json(res, 400, {
724
+ return json(res, 400, {
632
725
  ok: false,
633
726
  error: "chatId and text (or an attachment) required",
634
727
  });
635
- this.handlers.send(id, text, { imagePath, attachmentPath });
636
- return this.json(res, 202, { ok: true });
637
- }
638
-
639
- if (method === "POST" && path === "/devices/register") {
640
- const body = await this.readJson(req);
641
- const device = await this.handlers.registerDevice(body);
642
- return this.json(res, 200, { ok: true, deviceId: device.id });
643
- }
644
-
645
- if (method === "POST" && path === "/location") {
646
- const body = await this.readJson(req);
647
- await this.handlers.storeLocation(body);
648
- return this.json(res, 200, { ok: true });
649
- }
650
-
651
- if (method === "GET" && path === "/devices") {
652
- return this.json(res, 200, await this.handlers.listDevices());
653
- }
654
-
655
- if (method === "POST" && path === "/devices/command-result") {
656
- const body = await this.readJson(req);
657
- // ok:false for a late/unknown correlation id — not an HTTP error,
658
- // the device's POST was well-formed; nothing was waiting anymore.
659
- return this.json(res, 200, {
660
- ok: this.handlers.completeCommand(body),
661
- });
662
- }
663
-
664
- // Streamed device file transfers (see core/mesh/transfers.ts). The
665
- // one-time `transfer` token authorizes exactly one direction+path;
666
- // these sit behind the bridge bearer auth like every device route.
667
- if (path === "/devices/file") {
668
- const token = url.searchParams.get("transfer") ?? "";
669
- if (!token)
670
- return this.json(res, 400, { ok: false, error: "transfer required" });
671
- // The caller names itself so the token's device binding can be
672
- // checked (see core/mesh/transfers.ts take()).
673
- const from = deviceIdParam(url);
674
- if (method === "POST") {
675
- const result = await this.handlers.acceptFileUpload(token, req, from);
676
- return this.json(res, result.ok ? 200 : 409, result);
677
- }
678
- if (method === "GET") {
679
- const file = await this.handlers.openFileDownload(token, from);
680
- if (!file)
681
- return this.json(res, 404, {
682
- ok: false,
683
- error: "Unknown or already-used transfer token",
684
- });
685
- res.writeHead(200, {
686
- "Content-Type": "application/octet-stream",
687
- "Content-Length": String(file.size),
688
- ...this.corsHeaders(),
689
- });
690
- const stream = createReadStream(file.path);
691
- stream.on("error", () => res.destroy());
692
- stream.pipe(res);
693
- return;
694
- }
695
- }
696
-
697
- if (method === "POST" && path === "/upload") {
728
+ h.send(id, text, { imagePath, attachmentPath });
729
+ json(res, 202, { ok: true });
730
+ },
731
+ "POST /upload": async ({ req, res, url }) => {
698
732
  const filename = url.searchParams.get("filename") ?? "upload";
699
733
  const contentType =
700
734
  req.headers["content-type"] ?? "application/octet-stream";
701
735
  const bytes = await this.readRaw(req, MAX_UPLOAD_BYTES);
702
736
  if (!bytes.length)
703
- return this.json(res, 400, { ok: false, error: "Empty upload" });
704
- const result = await this.handlers.upload(filename, contentType, bytes);
705
- return this.json(res, 200, { ok: true, ...result });
706
- }
707
-
708
- if (method === "GET" && path === "/models") {
709
- const id = url.searchParams.get("chatId") ?? undefined;
710
- return this.json(res, 200, await this.handlers.listModels(id));
711
- }
712
-
713
- if (method === "POST" && path === "/model") {
714
- const body = await this.readJson(req);
715
- this.handlers.setModel(
716
- asString(body.chatId) ?? "",
717
- asString(body.model) ?? "",
718
- );
719
- return this.json(res, 200, { ok: true });
720
- }
737
+ return json(res, 400, { ok: false, error: "Empty upload" });
738
+ const result = await h.upload(filename, contentType, bytes);
739
+ json(res, 200, { ok: true, ...result });
740
+ },
741
+ "GET /media": ({ res, url }) =>
742
+ this.serveMedia(res, url.searchParams.get("id") ?? ""),
721
743
 
722
- if (method === "GET" && path === "/backends") {
723
- const id = url.searchParams.get("chatId") ?? "";
724
- return this.json(res, 200, this.handlers.listBackends(id));
725
- }
744
+ // ── Models / backends / effort ─────────────────────────────────────
726
745
 
727
- if (method === "POST" && path === "/backend") {
728
- const body = await this.readJson(req);
746
+ "GET /models": async ({ res, url }) => {
747
+ const id = url.searchParams.get("chatId") ?? undefined;
748
+ json(res, 200, await h.listModels(id));
749
+ },
750
+ "POST /model": async ({ req, res }) => {
751
+ const body = await readJson(req);
752
+ h.setModel(asString(body.chatId) ?? "", asString(body.model) ?? "");
753
+ json(res, 200, { ok: true });
754
+ },
755
+ "GET /backends": ({ res, url }) =>
756
+ json(res, 200, h.listBackends(url.searchParams.get("chatId") ?? "")),
757
+ "POST /backend": async ({ req, res }) => {
758
+ const body = await readJson(req);
729
759
  // Always 200: ok/error is an application result the client renders,
730
- // not an HTTP-level failure (the client's decoder drops >=400 bodies).
731
- const result = await this.handlers.setBackend(
760
+ // not an HTTP-level failure (the client's decoder drops >=400
761
+ // bodies).
762
+ const result = await h.setBackend(
732
763
  asString(body.chatId) ?? "",
733
764
  asString(body.backend) ?? "",
734
765
  );
735
- return this.json(res, 200, result);
736
- }
737
-
738
- if (method === "GET" && path === "/effort") {
739
- const id = url.searchParams.get("chatId") ?? "";
740
- return this.json(res, 200, await this.handlers.effortLevels(id));
741
- }
742
-
743
- if (method === "POST" && path === "/effort") {
744
- const body = await this.readJson(req);
745
- this.handlers.setEffort(
746
- asString(body.chatId) ?? "",
747
- asString(body.effort) ?? "",
748
- );
749
- return this.json(res, 200, { ok: true });
750
- }
751
-
752
- if (method === "GET" && path === "/media") {
753
- const id = url.searchParams.get("id") ?? "";
754
- return await this.serveMedia(res, id);
755
- }
756
-
757
- if (method === "GET" && path === "/logs") {
766
+ json(res, 200, result);
767
+ },
768
+ "GET /effort": async ({ res, url }) =>
769
+ json(
770
+ res,
771
+ 200,
772
+ await h.effortLevels(url.searchParams.get("chatId") ?? ""),
773
+ ),
774
+ "POST /effort": async ({ req, res }) => {
775
+ const body = await readJson(req);
776
+ h.setEffort(asString(body.chatId) ?? "", asString(body.effort) ?? "");
777
+ json(res, 200, { ok: true });
778
+ },
779
+
780
+ // ── Daemon ─────────────────────────────────────────────────────────
781
+
782
+ "GET /logs": ({ res, url }) => {
758
783
  const lines = Math.min(
759
784
  asPositiveInt(url.searchParams.get("lines")) ?? 200,
760
785
  1000,
761
786
  );
762
787
  const level = url.searchParams.get("level") ?? "";
763
788
  const component = url.searchParams.get("component") ?? undefined;
764
- return this.json(res, 200, {
765
- entries: this.handlers.logs({
789
+ json(res, 200, {
790
+ entries: h.logs({
766
791
  lines,
767
792
  minLevel: isLogLevel(level) ? level : undefined,
768
793
  component,
769
794
  }),
770
795
  });
771
- }
772
-
773
- if (method === "GET" && path === "/plugins")
774
- return this.json(res, 200, { plugins: this.handlers.listPlugins() });
775
-
776
- if (method === "POST" && path === "/plugins/toggle") {
777
- const body = await this.readJson(req);
778
- // Always 200: ok/error is an application result the client renders,
779
- // not an HTTP-level failure (mirrors /backend).
780
- const result = await this.handlers.setPluginEnabled(
796
+ },
797
+ "GET /plugins": ({ res }) => json(res, 200, { plugins: h.listPlugins() }),
798
+ "POST /plugins/toggle": async ({ req, res }) => {
799
+ const body = await readJson(req);
800
+ // Always 200: ok/error is an application result the client renders
801
+ // (mirrors /backend).
802
+ const result = await h.setPluginEnabled(
781
803
  asString(body.name) ?? "",
782
804
  body.enabled === true,
783
805
  );
784
- return this.json(res, 200, result);
785
- }
786
-
787
- if (method === "GET" && path === "/skills")
788
- return this.json(res, 200, { skills: this.handlers.listSkills() });
789
-
790
- if (method === "POST" && path === "/skills/toggle") {
791
- const body = await this.readJson(req);
792
- const result = this.handlers.setSkillEnabled(
793
- asString(body.name) ?? "",
794
- body.enabled === true,
806
+ json(res, 200, result);
807
+ },
808
+ "GET /skills": ({ res }) => json(res, 200, { skills: h.listSkills() }),
809
+ "POST /skills/toggle": async ({ req, res }) => {
810
+ const body = await readJson(req);
811
+ json(
812
+ res,
813
+ 200,
814
+ h.setSkillEnabled(asString(body.name) ?? "", body.enabled === true),
795
815
  );
796
- return this.json(res, 200, result);
797
- }
798
-
799
- if (method === "GET" && path === "/config")
800
- return this.json(res, 200, this.handlers.getConfig());
801
-
802
- if (method === "POST" && path === "/config") {
803
- const body = await this.readJson(req);
804
- return this.json(res, 200, this.handlers.setConfig(body));
805
- }
816
+ },
817
+ "GET /config": ({ res }) => json(res, 200, h.getConfig()),
818
+ "POST /config": async ({ req, res }) => {
819
+ const body = await readJson(req);
820
+ json(res, 200, h.setConfig(body));
821
+ },
822
+ "POST /control": async ({ req, res }) => {
823
+ const body = await readJson(req);
824
+ // Always 200: ok/message is an application result the client
825
+ // renders (mirrors /backend).
826
+ json(res, 200, await h.control(asString(body.action) ?? ""));
827
+ },
828
+
829
+ // ── Mesh ───────────────────────────────────────────────────────────
830
+
831
+ "POST /devices/register": async ({ req, res }) => {
832
+ const body = await readJson(req);
833
+ const device = await h.registerDevice(body);
834
+ json(res, 200, { ok: true, deviceId: device.id });
835
+ },
836
+ "POST /location": async ({ req, res }) => {
837
+ const body = await readJson(req);
838
+ await h.storeLocation(body);
839
+ json(res, 200, { ok: true });
840
+ },
841
+ "GET /devices": async ({ res }) => json(res, 200, await h.listDevices()),
842
+ "POST /devices/command-result": async ({ req, res }) => {
843
+ const body = await readJson(req);
844
+ // ok:false for a late/unknown correlation id — not an HTTP error,
845
+ // the device's POST was well-formed; nothing was waiting anymore.
846
+ json(res, 200, { ok: h.completeCommand(body) });
847
+ },
848
+ "POST /devices/file": async ({ req, res, url }) => {
849
+ const token = transferToken(url, res);
850
+ if (token === null) return;
851
+ const result = await h.acceptFileUpload(token, req, deviceIdParam(url));
852
+ json(res, result.ok ? 200 : 409, result);
853
+ },
854
+ "GET /devices/file": async ({ res, url }) => {
855
+ const token = transferToken(url, res);
856
+ if (token === null) return;
857
+ const file = await h.openFileDownload(token, deviceIdParam(url));
858
+ if (!file)
859
+ return json(res, 404, {
860
+ ok: false,
861
+ error: "Unknown or already-used transfer token",
862
+ });
863
+ this.streamFile(res, file);
864
+ },
865
+ };
866
+ }
806
867
 
807
- if (method === "POST" && path === "/control") {
808
- const body = await this.readJson(req);
809
- // Always 200: ok/message is an application result the client renders,
810
- // not an HTTP-level failure (mirrors /backend).
811
- const result = await this.handlers.control(asString(body.action) ?? "");
812
- return this.json(res, 200, result);
813
- }
868
+ private unknownProvision(res: ServerResponse): void {
869
+ this.json(res, 404, {
870
+ ok: false,
871
+ error: "Unknown, expired, or already-used provisioning token",
872
+ });
873
+ }
814
874
 
815
- return this.json(res, 404, { ok: false, error: "Not found" });
816
- } catch (err) {
817
- const msg = err instanceof Error ? err.message : String(err);
818
- return this.json(res, 400, { ok: false, error: msg });
819
- }
875
+ /** Stream a file whose size is already known as an octet-stream body. */
876
+ private streamFile(
877
+ res: ServerResponse,
878
+ file: { path: string; size: number },
879
+ ): void {
880
+ res.writeHead(200, {
881
+ "Content-Type": "application/octet-stream",
882
+ "Content-Length": String(file.size),
883
+ ...this.corsHeaders(),
884
+ });
885
+ const stream = createReadStream(file.path);
886
+ stream.on("error", () => res.destroy());
887
+ stream.pipe(res);
820
888
  }
821
889
 
822
890
  /** Stream an attached image by id. Auth is already enforced by `handle`. */