@linkshell/gateway 0.5.3 → 0.6.1

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 (109) hide show
  1. package/Dockerfile +8 -22
  2. package/README.md +102 -44
  3. package/dist/gateway/src/accounts.js.map +1 -0
  4. package/dist/gateway/src/index.d.ts +4 -1
  5. package/dist/gateway/src/index.js +4 -863
  6. package/dist/gateway/src/index.js.map +1 -1
  7. package/dist/gateway/src/main.js +57 -0
  8. package/dist/gateway/src/main.js.map +1 -0
  9. package/dist/gateway/src/rate-limit.d.ts +32 -0
  10. package/dist/gateway/src/rate-limit.js +106 -0
  11. package/dist/gateway/src/rate-limit.js.map +1 -0
  12. package/dist/gateway/src/relay.d.ts +84 -5
  13. package/dist/gateway/src/relay.js +402 -529
  14. package/dist/gateway/src/relay.js.map +1 -1
  15. package/dist/gateway/src/serve.d.ts +34 -0
  16. package/dist/gateway/src/serve.js +115 -0
  17. package/dist/gateway/src/serve.js.map +1 -0
  18. package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
  19. package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
  20. package/dist/gateway/src/store.js.map +1 -0
  21. package/dist/gateway/src/subscription.d.ts +18 -0
  22. package/dist/gateway/src/subscription.js +46 -0
  23. package/dist/gateway/src/subscription.js.map +1 -0
  24. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  25. package/dist/wire/src/direct.d.ts +22 -0
  26. package/dist/wire/src/direct.js +37 -0
  27. package/dist/wire/src/direct.js.map +1 -0
  28. package/dist/wire/src/index.d.ts +1 -0
  29. package/dist/wire/src/index.js +1 -0
  30. package/dist/wire/src/index.js.map +1 -1
  31. package/dist/wire/src/model.d.ts +287 -6
  32. package/dist/wire/src/model.js +33 -0
  33. package/dist/wire/src/model.js.map +1 -1
  34. package/dist/wire/src/relay.d.ts +8 -8
  35. package/dist/wire/src/rpc.d.ts +1780 -44
  36. package/dist/wire/src/rpc.js +48 -3
  37. package/dist/wire/src/rpc.js.map +1 -1
  38. package/dist/wire/src/updates.d.ts +356 -0
  39. package/dist/wire/src/updates.js +11 -1
  40. package/dist/wire/src/updates.js.map +1 -1
  41. package/package.json +13 -13
  42. package/src/accounts.ts +32 -0
  43. package/src/index.ts +4 -997
  44. package/src/main.ts +65 -0
  45. package/src/rate-limit.ts +114 -0
  46. package/src/relay.ts +435 -620
  47. package/src/serve.ts +142 -0
  48. package/src/store.ts +135 -0
  49. package/src/subscription.ts +51 -0
  50. package/dist/gateway/src/auth-middleware.d.ts +0 -44
  51. package/dist/gateway/src/auth-middleware.js +0 -204
  52. package/dist/gateway/src/auth-middleware.js.map +0 -1
  53. package/dist/gateway/src/cors.d.ts +0 -2
  54. package/dist/gateway/src/cors.js +0 -7
  55. package/dist/gateway/src/cors.js.map +0 -1
  56. package/dist/gateway/src/embedded.d.ts +0 -21
  57. package/dist/gateway/src/embedded.js +0 -438
  58. package/dist/gateway/src/embedded.js.map +0 -1
  59. package/dist/gateway/src/host-auth.d.ts +0 -32
  60. package/dist/gateway/src/host-auth.js +0 -78
  61. package/dist/gateway/src/host-auth.js.map +0 -1
  62. package/dist/gateway/src/pairings.d.ts +0 -38
  63. package/dist/gateway/src/pairings.js +0 -119
  64. package/dist/gateway/src/pairings.js.map +0 -1
  65. package/dist/gateway/src/presence.d.ts +0 -28
  66. package/dist/gateway/src/presence.js +0 -92
  67. package/dist/gateway/src/presence.js.map +0 -1
  68. package/dist/gateway/src/sessions.d.ts +0 -113
  69. package/dist/gateway/src/sessions.js +0 -352
  70. package/dist/gateway/src/sessions.js.map +0 -1
  71. package/dist/gateway/src/state-store.d.ts +0 -21
  72. package/dist/gateway/src/state-store.js +0 -103
  73. package/dist/gateway/src/state-store.js.map +0 -1
  74. package/dist/gateway/src/static-web.d.ts +0 -15
  75. package/dist/gateway/src/static-web.js +0 -129
  76. package/dist/gateway/src/static-web.js.map +0 -1
  77. package/dist/gateway/src/tokens.d.ts +0 -19
  78. package/dist/gateway/src/tokens.js +0 -151
  79. package/dist/gateway/src/tokens.js.map +0 -1
  80. package/dist/gateway/src/tunnel.d.ts +0 -61
  81. package/dist/gateway/src/tunnel.js +0 -576
  82. package/dist/gateway/src/tunnel.js.map +0 -1
  83. package/dist/gateway-v2/src/accounts.js.map +0 -1
  84. package/dist/gateway-v2/src/index.d.ts +0 -3
  85. package/dist/gateway-v2/src/index.js +0 -4
  86. package/dist/gateway-v2/src/index.js.map +0 -1
  87. package/dist/gateway-v2/src/main.js +0 -17
  88. package/dist/gateway-v2/src/main.js.map +0 -1
  89. package/dist/gateway-v2/src/server.d.ts +0 -84
  90. package/dist/gateway-v2/src/server.js +0 -426
  91. package/dist/gateway-v2/src/server.js.map +0 -1
  92. package/dist/gateway-v2/src/store.js.map +0 -1
  93. package/dist/shared-protocol/src/index.d.ts +0 -17029
  94. package/dist/shared-protocol/src/index.js +0 -1064
  95. package/dist/shared-protocol/src/index.js.map +0 -1
  96. package/src/auth-middleware.ts +0 -266
  97. package/src/cors.ts +0 -8
  98. package/src/embedded.ts +0 -526
  99. package/src/host-auth.ts +0 -88
  100. package/src/pairings.ts +0 -133
  101. package/src/presence.ts +0 -118
  102. package/src/sessions.ts +0 -432
  103. package/src/state-store.ts +0 -140
  104. package/src/static-web.ts +0 -133
  105. package/src/tokens.ts +0 -159
  106. package/src/tunnel.ts +0 -644
  107. /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
  108. /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
  109. /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
package/src/embedded.ts DELETED
@@ -1,526 +0,0 @@
1
- import { createServer } from "node:http";
2
- import type { IncomingMessage, ServerResponse, Server } from "node:http";
3
- import { randomUUID } from "node:crypto";
4
- import { WebSocketServer } from "ws";
5
- import type WebSocket from "ws";
6
- import {
7
- createEnvelope,
8
- serializeEnvelope,
9
- PROTOCOL_VERSION,
10
- } from "@linkshell/protocol";
11
- import { z, ZodError } from "zod";
12
- import { SessionManager } from "./sessions.js";
13
- import { PairingManager } from "./pairings.js";
14
- import { TokenManager } from "./tokens.js";
15
- import { handleSocketMessage, replayAgentToSocket } from "./relay.js";
16
- import { PresenceHub, startPresenceWatcher } from "./presence.js";
17
- import {
18
- parseTunnelPath,
19
- parseTunnelCookie,
20
- shouldUseTunnelCookieFallback,
21
- handleTunnelRequest,
22
- handleTunnelWsUpgrade,
23
- cleanupSessionTunnels,
24
- } from "./tunnel.js";
25
- import { serveWeb, serveWebAsset, webEnabled, webDistPath } from "./static-web.js";
26
-
27
- export interface EmbeddedGatewayOptions {
28
- port?: number;
29
- logLevel?: "debug" | "info" | "warn" | "error";
30
- silent?: boolean;
31
- /**
32
- * Also serve the v2 relay (end-to-end encrypted, at /v2/connect), keeping its
33
- * pairings in SQLite here. Needs Node.js 22.13+ (node:sqlite).
34
- */
35
- relayDataPath?: string;
36
- }
37
-
38
- export interface EmbeddedGateway {
39
- port: number;
40
- httpUrl: string;
41
- wsUrl: string;
42
- close: () => Promise<void>;
43
- }
44
-
45
- const PING_INTERVAL = 20_000;
46
- const MAX_BODY_SIZE = 4096;
47
- const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image uploads)
48
-
49
- const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
50
-
51
- const createPairingBody = z.object({ sessionId: z.string().optional() });
52
- const claimPairingBody = z.object({
53
- pairingCode: z.string().length(6),
54
- deviceToken: z.string().min(1).optional(),
55
- });
56
-
57
- class BodyTooLargeError extends Error {}
58
-
59
- function json(res: ServerResponse, status: number, body: unknown): void {
60
- res.writeHead(status, {
61
- "content-type": "application/json",
62
- "access-control-allow-origin": "*",
63
- "access-control-allow-methods": "GET, POST, OPTIONS",
64
- "access-control-allow-headers": "Content-Type, Authorization",
65
- });
66
- res.end(JSON.stringify(body));
67
- }
68
-
69
- async function readJson(req: IncomingMessage): Promise<unknown> {
70
- const chunks: Buffer[] = [];
71
- let size = 0;
72
- for await (const chunk of req) {
73
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
74
- size += buf.length;
75
- if (size > MAX_BODY_SIZE) throw new BodyTooLargeError();
76
- chunks.push(buf);
77
- }
78
- if (chunks.length === 0) return {};
79
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
80
- }
81
-
82
- function getClientIp(req: IncomingMessage): string {
83
- const forwarded = req.headers["x-forwarded-for"];
84
- if (typeof forwarded === "string") return forwarded.split(",")[0]!.trim();
85
- return req.socket.remoteAddress ?? "unknown";
86
- }
87
-
88
- function extractBearerToken(req: IncomingMessage): string | null {
89
- const auth = req.headers.authorization;
90
- if (!auth) return null;
91
- const match = auth.match(/^Bearer\s+(.+)$/i);
92
- return match?.[1] ?? null;
93
- }
94
-
95
- /**
96
- * Start an embedded gateway. Returns a handle to get URLs and close it.
97
- * Used by CLI when no external --gateway is provided.
98
- */
99
- export async function startEmbeddedGateway(
100
- options: EmbeddedGatewayOptions = {},
101
- ): Promise<EmbeddedGateway> {
102
- const targetPort = options.port ?? 0; // 0 = random available port
103
- const logLevel = options.logLevel ?? "warn";
104
- const silent = options.silent ?? false;
105
-
106
- function log(level: "debug" | "info" | "warn" | "error", msg: string): void {
107
- if (silent) return;
108
- if (LOG_LEVELS[level] >= LOG_LEVELS[logLevel]) {
109
- process.stderr.write(`[gateway:${level}] ${msg}\n`);
110
- }
111
- }
112
-
113
- const sessionManager = new SessionManager();
114
- const pairingManager = new PairingManager();
115
- const tokenManager = new TokenManager();
116
- const presenceHub = new PresenceHub();
117
- sessionManager.onPresenceChange = (session) => presenceHub.notify(session);
118
-
119
- const server = createServer(async (req, res) => {
120
- if (req.method === "OPTIONS") {
121
- res.writeHead(204, {
122
- "access-control-allow-origin": "*",
123
- "access-control-allow-methods": "GET, POST, OPTIONS",
124
- "access-control-allow-headers": "Content-Type, Authorization",
125
- "access-control-max-age": "86400",
126
- });
127
- res.end();
128
- return;
129
- }
130
-
131
- try {
132
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
133
- const method = req.method ?? "GET";
134
-
135
- if (method === "GET" && url.pathname === "/healthz") {
136
- json(res, 200, { ok: true });
137
- return;
138
- }
139
-
140
- if (method === "POST" && url.pathname === "/pairings") {
141
- const body = createPairingBody.parse(await readJson(req));
142
- const record = pairingManager.create(body.sessionId);
143
- json(res, 201, {
144
- sessionId: record.sessionId,
145
- pairingCode: record.pairingCode,
146
- expiresAt: new Date(record.expiresAt).toISOString(),
147
- });
148
- return;
149
- }
150
-
151
- if (method === "POST" && url.pathname === "/pairings/claim") {
152
- const body = claimPairingBody.parse(await readJson(req));
153
- const result = pairingManager.claim(body.pairingCode);
154
- if ("error" in result) {
155
- json(res, result.status, { error: result.error });
156
- return;
157
- }
158
- const token = tokenManager.register(body.deviceToken);
159
- tokenManager.bind(token, result.sessionId);
160
- json(res, 200, { sessionId: result.sessionId, deviceToken: token });
161
- return;
162
- }
163
-
164
- if (method === "GET" && url.pathname === "/sessions") {
165
- const token = extractBearerToken(req);
166
- if (!token || !tokenManager.validate(token)) {
167
- json(res, 401, {
168
- error: "unauthorized",
169
- message: "Valid device token required",
170
- });
171
- return;
172
- }
173
- const allowedIds = tokenManager.getSessionIds(token);
174
- const sessions = sessionManager
175
- .listActive()
176
- .filter((s) => allowedIds.has(s.id))
177
- .map((s) => sessionManager.getSummary(s.id))
178
- .filter(Boolean);
179
- json(res, 200, { sessions });
180
- return;
181
- }
182
-
183
- const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
184
- if (method === "GET" && sessionMatch) {
185
- const token = extractBearerToken(req);
186
- const targetId = sessionMatch[1]!;
187
- if (!token || !tokenManager.owns(token, targetId)) {
188
- json(res, 401, {
189
- error: "unauthorized",
190
- message: "Valid device token required",
191
- });
192
- return;
193
- }
194
- const summary = sessionManager.getSummary(targetId);
195
- if (!summary) {
196
- json(res, 404, { error: "session_not_found" });
197
- return;
198
- }
199
- json(res, 200, summary);
200
- return;
201
- }
202
-
203
- const pairingMatch = url.pathname.match(/^\/pairings\/(\d{6})\/status$/);
204
- if (method === "GET" && pairingMatch) {
205
- const result = pairingManager.getStatus(pairingMatch[1]!);
206
- if ("error" in result) {
207
- json(res, result.httpStatus, { error: result.error });
208
- return;
209
- }
210
- json(res, 200, result);
211
- return;
212
- }
213
-
214
- // Tunnel HTTP proxy (explicit path)
215
- const tunnelParsed = parseTunnelPath(url.pathname);
216
- if (tunnelParsed) {
217
- await handleTunnelRequest(req, res, sessionManager, tokenManager, tunnelParsed, url);
218
- return;
219
- }
220
-
221
- // Serve the gateway's OWN built web assets (no SPA fallback) before the
222
- // tunnel cookie fallback, so a real asset like /assets/index-xxx.js is
223
- // served locally instead of being proxied via a stale tunnel cookie.
224
- if (await serveWebAsset(req, res)) return;
225
-
226
- // Tunnel fallback: cookie-based routing for sub-resources (e.g. /_next/static/...)
227
- const tunnelCookie = parseTunnelCookie(req);
228
- if (tunnelCookie && shouldUseTunnelCookieFallback(req, url.pathname, (pathname) =>
229
- pathname === "/healthz" ||
230
- pathname === "/sessions" ||
231
- pathname.startsWith("/sessions/") ||
232
- pathname === "/pairings" ||
233
- pathname.startsWith("/pairings/") ||
234
- pathname.startsWith("/agent/") ||
235
- pathname.startsWith("/tunnel/")
236
- )) {
237
- const fallbackParsed = {
238
- sessionId: tunnelCookie.sessionId,
239
- port: tunnelCookie.port,
240
- path: url.pathname,
241
- };
242
- await handleTunnelRequest(req, res, sessionManager, tokenManager, fallbackParsed, url, tunnelCookie.token);
243
- return;
244
- }
245
-
246
- // Web SPA: last GET fallback, after every API route, so it never shadows
247
- // them. Serves index.html for "/" and client-side routes; a no-op (false)
248
- // when no web dist is bundled (e.g. an API-only embedded gateway).
249
- if (await serveWeb(req, res)) return;
250
-
251
- json(res, 404, { error: "not_found" });
252
- } catch (err) {
253
- if (err instanceof ZodError) {
254
- json(res, 400, {
255
- error: "invalid_message",
256
- message: err.errors[0]?.message ?? "Validation failed",
257
- });
258
- } else if (err instanceof BodyTooLargeError) {
259
- json(res, 413, {
260
- error: "body_too_large",
261
- message: "Request body exceeds limit",
262
- });
263
- } else if (err instanceof SyntaxError) {
264
- json(res, 400, { error: "invalid_json", message: "Malformed JSON" });
265
- } else {
266
- log("error", `unhandled: ${err}`);
267
- json(res, 500, {
268
- error: "internal_error",
269
- message: "Internal server error",
270
- });
271
- }
272
- }
273
- });
274
-
275
- const wss = new WebSocketServer({
276
- noServer: true,
277
- maxPayload: MAX_WS_MESSAGE_SIZE,
278
- });
279
-
280
- // Loaded on demand: v1 users on older Node never touch node:sqlite.
281
- const relay = options.relayDataPath ? await startRelay(options.relayDataPath, log) : undefined;
282
-
283
- server.on("upgrade", (request, socket, head) => {
284
- const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
285
-
286
- if (relay && url.pathname === relay.path) {
287
- relay.gateway.handleUpgrade(request, socket, head);
288
- return;
289
- }
290
-
291
- // Tunnel WebSocket upgrade (for HMR etc.)
292
- const tunnelParsed = parseTunnelPath(url.pathname);
293
- if (tunnelParsed) {
294
- const tunnelCookie = parseTunnelCookie(request);
295
- const cookieToken = tunnelCookie?.sessionId === tunnelParsed.sessionId && tunnelCookie.port === tunnelParsed.port
296
- ? tunnelCookie.token
297
- : undefined;
298
- wss.handleUpgrade(request, socket, head, (ws) => {
299
- handleTunnelWsUpgrade(ws, tunnelParsed, url, sessionManager, tokenManager, cookieToken);
300
- });
301
- return;
302
- }
303
-
304
- // Tunnel WS fallback via cookie (for HMR paths like /_next/webpack-hmr)
305
- const tunnelCookie = parseTunnelCookie(request);
306
- if (tunnelCookie && shouldUseTunnelCookieFallback(request, url.pathname, (pathname) => pathname === "/ws")) {
307
- const fallbackParsed = {
308
- sessionId: tunnelCookie.sessionId,
309
- port: tunnelCookie.port,
310
- path: url.pathname,
311
- };
312
- wss.handleUpgrade(request, socket, head, (ws) => {
313
- handleTunnelWsUpgrade(ws, fallbackParsed, url, sessionManager, tokenManager, tunnelCookie.token);
314
- });
315
- return;
316
- }
317
-
318
- if (url.pathname !== "/ws") {
319
- socket.destroy();
320
- return;
321
- }
322
- wss.handleUpgrade(request, socket, head, (ws) => {
323
- wss.emit("connection", ws, request, url);
324
- });
325
- });
326
-
327
- wss.on(
328
- "connection",
329
- (socket: WebSocket, _request: IncomingMessage, url: URL) => {
330
- const sessionId = url.searchParams.get("sessionId");
331
- const role = url.searchParams.get("role") as "host" | "client" | "watcher" | null;
332
-
333
- if (role === "watcher") {
334
- const token = url.searchParams.get("token");
335
- if (!token || !tokenManager.validate(token)) {
336
- socket.close(4001, "unauthorized");
337
- return;
338
- }
339
- startPresenceWatcher({
340
- socket,
341
- sessions: sessionManager,
342
- hub: presenceHub,
343
- sessionIds: () => tokenManager.getSessionIds(token),
344
- pingIntervalMs: PING_INTERVAL,
345
- });
346
- return;
347
- }
348
-
349
- if (!sessionId || !role || (role !== "host" && role !== "client")) {
350
- socket.close(1008, "missing sessionId or role");
351
- return;
352
- }
353
-
354
- const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
355
-
356
- if (role === "client") {
357
- const token = url.searchParams.get("token");
358
- if (!token || !tokenManager.owns(token, sessionId)) {
359
- socket.close(4001, "unauthorized");
360
- return;
361
- }
362
- }
363
-
364
- const device = { socket, role, deviceId, connectedAt: Date.now() };
365
-
366
- if (role === "host") {
367
- const existingSession = sessionManager.get(sessionId);
368
- const isReconnect =
369
- existingSession &&
370
- existingSession.clients.size > 0 &&
371
- existingSession.state === "host_disconnected";
372
- sessionManager.setHost(sessionId, device);
373
- if (isReconnect) {
374
- const notification = serializeEnvelope(
375
- createEnvelope({
376
- type: "session.host_reconnected",
377
- sessionId,
378
- payload: {},
379
- }),
380
- );
381
- for (const [, client] of existingSession.clients) {
382
- if (client.socket.readyState === client.socket.OPEN)
383
- client.socket.send(notification);
384
- }
385
- }
386
- } else {
387
- sessionManager.addClient(sessionId, device);
388
- }
389
-
390
- socket.send(
391
- serializeEnvelope(
392
- createEnvelope({
393
- type: "session.connect",
394
- sessionId,
395
- payload: {
396
- role,
397
- clientName: deviceId,
398
- protocolVersion: PROTOCOL_VERSION,
399
- },
400
- }),
401
- ),
402
- );
403
-
404
- if (role === "client") {
405
- replayAgentToSocket(socket, sessionId, sessionManager);
406
- }
407
-
408
- // Ping/pong for liveness — terminate sockets that stop answering pings.
409
- const liveSocket = socket as WebSocket & { isAlive?: boolean };
410
- liveSocket.isAlive = true;
411
- socket.on("pong", () => {
412
- liveSocket.isAlive = true;
413
- });
414
- const pingTimer = setInterval(() => {
415
- if (socket.readyState !== socket.OPEN) return;
416
- if (liveSocket.isAlive === false) {
417
- socket.terminate();
418
- return;
419
- }
420
- liveSocket.isAlive = false;
421
- socket.ping();
422
- }, PING_INTERVAL);
423
-
424
- socket.on("message", (data: WebSocket.RawData) => {
425
- try {
426
- handleSocketMessage(
427
- socket,
428
- data.toString(),
429
- role,
430
- sessionId,
431
- deviceId,
432
- sessionManager,
433
- );
434
- } catch (err) {
435
- log("error", `unhandled websocket message error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
436
- if (socket.readyState === socket.OPEN) {
437
- socket.send(
438
- serializeEnvelope(
439
- createEnvelope({
440
- type: "session.error",
441
- sessionId,
442
- payload: {
443
- code: "invalid_message",
444
- message: "Failed to handle message",
445
- },
446
- }),
447
- ),
448
- );
449
- }
450
- }
451
- });
452
-
453
- socket.on("close", () => {
454
- clearInterval(pingTimer);
455
- if (role === "host") {
456
- const result = sessionManager.removeHost(sessionId);
457
- if (result) {
458
- const notification = serializeEnvelope(
459
- createEnvelope({
460
- type: "session.host_disconnected",
461
- sessionId,
462
- payload: { reason: "host connection closed" },
463
- }),
464
- );
465
- for (const [, client] of result.clients) {
466
- if (client.socket.readyState === client.socket.OPEN)
467
- client.socket.send(notification);
468
- }
469
- }
470
- } else {
471
- sessionManager.removeClient(sessionId, deviceId);
472
- }
473
- });
474
-
475
- socket.on("error", () => {});
476
- },
477
- );
478
-
479
- return new Promise<EmbeddedGateway>((resolve, reject) => {
480
- server.on("error", reject);
481
- server.listen(targetPort, () => {
482
- const addr = server.address();
483
- const actualPort =
484
- typeof addr === "object" && addr ? addr.port : targetPort;
485
- log("info", `embedded gateway on port ${actualPort}`);
486
- // Surface whether the bundled web SPA was found — the in-app WebView loads
487
- // this gateway's "/", so a missing dist means a blank screen. Makes the
488
- // LAN/self-hosted "WebView is blank" failure mode diagnosable from logs.
489
- if (webEnabled()) {
490
- log("info", `web UI served from ${webDistPath()}`);
491
- } else {
492
- log("warn", `web UI not bundled (WEB_DIST=${webDistPath()} has no index.html) — in-app agent console will be blank`);
493
- }
494
- resolve({
495
- port: actualPort,
496
- httpUrl: `http://127.0.0.1:${actualPort}`,
497
- wsUrl: `ws://127.0.0.1:${actualPort}/ws`,
498
- close: () =>
499
- new Promise<void>((res) => {
500
- wss.clients.forEach((ws) => ws.close(1001, "shutting down"));
501
- sessionManager.destroy();
502
- pairingManager.destroy();
503
- tokenManager.destroy();
504
- void relay?.gateway.stop();
505
- server.close(() => res());
506
- }),
507
- });
508
- });
509
- });
510
- }
511
-
512
- async function startRelay(databasePath: string, log: (level: "debug" | "info" | "warn" | "error", message: string) => void) {
513
- const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
514
- if (major < 22 || (major === 22 && minor < 13)) {
515
- log("warn", `v2 relay needs Node.js 22.13+ (this is ${process.version}); serving v1 only`);
516
- return undefined;
517
- }
518
- const { mkdirSync } = await import("node:fs");
519
- const { dirname } = await import("node:path");
520
- const [{ Gateway }, { RELAY_PATH }] = await Promise.all([import("@linkshell/gateway-v2"), import("@linkshell/wire")]);
521
- mkdirSync(dirname(databasePath), { recursive: true });
522
- const gateway = new Gateway({ port: 0, databasePath, log: (message) => log("info", `[relay] ${message}`) });
523
- gateway.attach();
524
- log("info", `v2 relay on ${RELAY_PATH}`);
525
- return { gateway, path: RELAY_PATH };
526
- }
package/src/host-auth.ts DELETED
@@ -1,88 +0,0 @@
1
- import { randomUUID, timingSafeEqual } from "node:crypto";
2
-
3
- const CLEANUP_INTERVAL = 5 * 60_000;
4
- const HOST_TOKEN_TTL = 7 * 24 * 60 * 60_000; // 7 days
5
-
6
- interface HostBinding {
7
- sessionId: string;
8
- hostToken: string;
9
- lastUsedAt: number;
10
- }
11
-
12
- function safeEqual(a: string, b: string): boolean {
13
- const ab = Buffer.from(a);
14
- const bb = Buffer.from(b);
15
- if (ab.length !== bb.length) return false;
16
- return timingSafeEqual(ab, bb);
17
- }
18
-
19
- /**
20
- * Binds a session to the secret host token issued when its pairing was created,
21
- * so only the original host (the CLI that created the pairing) can connect as
22
- * `role=host`. Without this, anyone who learns a sessionId could connect as host
23
- * and capture every controller keystroke or inject terminal output.
24
- *
25
- * Kept separate from PairingManager because a host connection long outlives the
26
- * pairing code's 10-minute TTL (and reconnects days later), so the binding needs
27
- * its own longer lifetime.
28
- */
29
- export class HostAuthManager {
30
- private bindings = new Map<string, HostBinding>();
31
- private cleanupTimer: ReturnType<typeof setInterval>;
32
-
33
- constructor() {
34
- this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
35
- this.cleanupTimer.unref?.();
36
- }
37
-
38
- /** Mint a fresh host token to hand to the CLI at pairing creation.
39
- *
40
- * Deliberately does NOT create a binding yet: the binding is established
41
- * trust-on-first-use when the host first connects WITH this token (see
42
- * `adopt`). This keeps the gateway backward-compatible with older CLIs that
43
- * don't send a host token at all — they simply never create a binding and
44
- * are allowed as legacy hosts, instead of being locked out by an eager
45
- * binding they can't satisfy. The legitimate host connects milliseconds
46
- * after pairing, so the trust-on-first-use window is negligible. */
47
- issue(_sessionId: string): string {
48
- return randomUUID();
49
- }
50
-
51
- /** Trust-on-first-use: register a host-provided token when no binding exists
52
- * yet (e.g. after a gateway restart lost the in-memory binding). */
53
- adopt(sessionId: string, hostToken: string): void {
54
- this.bindings.set(sessionId, { sessionId, hostToken, lastUsedAt: Date.now() });
55
- }
56
-
57
- has(sessionId: string): boolean {
58
- const binding = this.bindings.get(sessionId);
59
- if (!binding) return false;
60
- if (Date.now() - binding.lastUsedAt > HOST_TOKEN_TTL) {
61
- this.bindings.delete(sessionId);
62
- return false;
63
- }
64
- return true;
65
- }
66
-
67
- verify(sessionId: string, hostToken: string | undefined): boolean {
68
- if (!hostToken) return false;
69
- const binding = this.bindings.get(sessionId);
70
- if (!binding) return false;
71
- if (!safeEqual(binding.hostToken, hostToken)) return false;
72
- binding.lastUsedAt = Date.now();
73
- return true;
74
- }
75
-
76
- private cleanup(): void {
77
- const now = Date.now();
78
- for (const [sessionId, binding] of this.bindings) {
79
- if (now - binding.lastUsedAt > HOST_TOKEN_TTL) {
80
- this.bindings.delete(sessionId);
81
- }
82
- }
83
- }
84
-
85
- destroy(): void {
86
- clearInterval(this.cleanupTimer);
87
- }
88
- }