@linkshell/gateway 0.3.9 → 0.4.0

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 (41) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +13 -14
  3. package/dist/gateway/src/agent-permission-http.d.ts +74 -19
  4. package/dist/gateway/src/agent-permission-http.js +56 -16
  5. package/dist/gateway/src/agent-permission-http.js.map +1 -1
  6. package/dist/gateway/src/embedded.js +61 -153
  7. package/dist/gateway/src/embedded.js.map +1 -1
  8. package/dist/gateway/src/index.js +98 -193
  9. package/dist/gateway/src/index.js.map +1 -1
  10. package/dist/gateway/src/pairings.d.ts +3 -3
  11. package/dist/gateway/src/pairings.js +5 -4
  12. package/dist/gateway/src/pairings.js.map +1 -1
  13. package/dist/gateway/src/relay.d.ts +2 -2
  14. package/dist/gateway/src/relay.js +85 -161
  15. package/dist/gateway/src/relay.js.map +1 -1
  16. package/dist/gateway/src/sessions.d.ts +28 -42
  17. package/dist/gateway/src/sessions.js +145 -200
  18. package/dist/gateway/src/sessions.js.map +1 -1
  19. package/dist/gateway/src/state-store.d.ts +6 -9
  20. package/dist/gateway/src/state-store.js +19 -26
  21. package/dist/gateway/src/state-store.js.map +1 -1
  22. package/dist/gateway/src/tokens.d.ts +7 -27
  23. package/dist/gateway/src/tokens.js +60 -86
  24. package/dist/gateway/src/tokens.js.map +1 -1
  25. package/dist/gateway/src/tunnel.d.ts +13 -11
  26. package/dist/gateway/src/tunnel.js +36 -36
  27. package/dist/gateway/src/tunnel.js.map +1 -1
  28. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  29. package/dist/shared-protocol/src/index.d.ts +11978 -3423
  30. package/dist/shared-protocol/src/index.js +114 -163
  31. package/dist/shared-protocol/src/index.js.map +1 -1
  32. package/package.json +11 -11
  33. package/src/agent-permission-http.ts +63 -20
  34. package/src/embedded.ts +60 -158
  35. package/src/index.ts +98 -199
  36. package/src/pairings.ts +7 -6
  37. package/src/relay.ts +97 -193
  38. package/src/sessions.ts +150 -213
  39. package/src/state-store.ts +25 -41
  40. package/src/tokens.ts +63 -109
  41. package/src/tunnel.ts +43 -49
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import { randomUUID } from "node:crypto";
4
- import { monitorEventLoopDelay } from "node:perf_hooks";
5
4
  import { WebSocketServer } from "ws";
6
5
  import type WebSocket from "ws";
7
6
  import {
@@ -10,7 +9,7 @@ import {
10
9
  PROTOCOL_VERSION,
11
10
  } from "@linkshell/protocol";
12
11
  import { z, ZodError } from "zod";
13
- import { DeviceManager } from "./sessions.js";
12
+ import { SessionManager } from "./sessions.js";
14
13
  import { PairingManager } from "./pairings.js";
15
14
  import { TokenManager } from "./tokens.js";
16
15
  import { createSupabaseStateStore } from "./state-store.js";
@@ -42,16 +41,14 @@ function log(level: "debug" | "info" | "warn" | "error", msg: string): void {
42
41
  }
43
42
 
44
43
  const stateStore = createSupabaseStateStore();
45
- const sessionManager = new DeviceManager();
44
+ const sessionManager = new SessionManager();
46
45
  const pairingManager = new PairingManager(stateStore);
47
46
  const tokenManager = new TokenManager(stateStore);
48
47
  await Promise.all([pairingManager.hydrate(), tokenManager.hydrate()]);
49
48
 
50
49
  const PING_INTERVAL = 20_000;
51
50
  const MAX_BODY_SIZE = 4096;
52
- const MAX_WS_MESSAGE_SIZE = Number(
53
- process.env.MAX_WS_MESSAGE_SIZE ?? 16 * 1024 * 1024,
54
- );
51
+ const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image uploads)
55
52
  const PAIRING_RATE_LIMIT_MAX = Number(process.env.PAIRING_RATE_LIMIT_MAX ?? 30);
56
53
  const PAIRING_RATE_LIMIT_WINDOW_MS = Number(
57
54
  process.env.PAIRING_RATE_LIMIT_WINDOW_MS ?? 60_000,
@@ -62,9 +59,6 @@ const WS_CONNECT_RATE_LIMIT_MAX = Number(
62
59
  const WS_CONNECT_RATE_LIMIT_WINDOW_MS = Number(
63
60
  process.env.WS_CONNECT_RATE_LIMIT_WINDOW_MS ?? 60_000,
64
61
  );
65
- const DETAILED_HEALTH = process.env.DETAILED_HEALTH === "true";
66
- const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
67
- eventLoopDelay.enable();
68
62
 
69
63
  // ── Rate limiter ────────────────────────────────────────────────────
70
64
 
@@ -133,12 +127,10 @@ function setCors(res: ServerResponse): void {
133
127
 
134
128
  // ── HTTP API ────────────────────────────────────────────────────────
135
129
 
136
- const createPairingBody = z.object({ hostDeviceId: z.string().min(1) });
130
+ const createPairingBody = z.object({ sessionId: z.string().optional() });
137
131
  const claimPairingBody = z.object({
138
132
  pairingCode: z.string().length(6),
139
133
  deviceToken: z.string().min(1).optional(),
140
- clientDeviceId: z.string().min(1).optional(),
141
- clientName: z.string().min(1).optional(),
142
134
  });
143
135
 
144
136
  const server = createServer(async (req, res) => {
@@ -156,7 +148,7 @@ const server = createServer(async (req, res) => {
156
148
  if (err instanceof ZodError) {
157
149
  json(res, 400, {
158
150
  error: "invalid_message",
159
- message: err.issues[0]?.message ?? "Validation failed",
151
+ message: err.errors[0]?.message ?? "Validation failed",
160
152
  });
161
153
  } else if (err instanceof BodyTooLargeError) {
162
154
  json(res, 413, {
@@ -185,54 +177,31 @@ async function handleRequest(
185
177
 
186
178
  // Health check
187
179
  if (method === "GET" && url.pathname === "/healthz") {
188
- const memory = process.memoryUsage();
189
- const detailed = DETAILED_HEALTH || url.searchParams.get("detail") === "1" || url.searchParams.get("detailed") === "true";
190
- json(res, 200, {
191
- ok: true,
192
- uptime: Math.round(process.uptime()),
193
- memory: {
194
- rss: memory.rss,
195
- heapUsed: memory.heapUsed,
196
- heapTotal: memory.heapTotal,
197
- external: memory.external,
198
- },
199
- sessions: sessionManager.getStats(),
200
- ...(detailed ? {
201
- eventLoop: {
202
- delayMeanMs: Number((eventLoopDelay.mean / 1_000_000).toFixed(2)),
203
- delayMaxMs: Number((eventLoopDelay.max / 1_000_000).toFixed(2)),
204
- delayP99Ms: Number((eventLoopDelay.percentile(99) / 1_000_000).toFixed(2)),
205
- },
206
- websocket: {
207
- maxPayloadBytes: MAX_WS_MESSAGE_SIZE,
208
- perMessageDeflate: false,
209
- },
210
- } : {}),
211
- });
180
+ json(res, 200, { ok: true });
212
181
  return;
213
182
  }
214
183
 
215
- // Devices owned by authenticated user (before AUTH_REQUIRED guard — uses its own auth)
216
- if (method === "GET" && url.pathname === "/devices/mine") {
184
+ // Sessions owned by authenticated user (before AUTH_REQUIRED guard — uses its own auth)
185
+ if (method === "GET" && url.pathname === "/sessions/mine") {
217
186
  const authResult = await validateRequest(req);
218
187
  if (!authResult || !authResult.userId) {
219
188
  json(res, 401, { error: "auth_required", message: "Authentication required" });
220
189
  return;
221
190
  }
222
- const devices = sessionManager
191
+ const sessions = sessionManager
223
192
  .listActive()
224
193
  .filter((s) => s.userId === authResult.userId)
225
194
  .map((s) => sessionManager.getSummary(s.id))
226
195
  .filter(Boolean);
227
- json(res, 200, { devices });
196
+ json(res, 200, { sessions });
228
197
  return;
229
198
  }
230
199
 
231
- // Delete a host device owned by authenticated user
232
- if (method === "DELETE" && /^\/devices\/[^/]+$/.test(url.pathname)) {
233
- const hostDeviceId = url.pathname.split("/")[2];
234
- if (!hostDeviceId) {
235
- json(res, 400, { error: "missing_host_device_id" });
200
+ // Delete a session owned by authenticated user
201
+ if (method === "DELETE" && url.pathname.startsWith("/sessions/")) {
202
+ const sessionId = url.pathname.split("/")[2];
203
+ if (!sessionId) {
204
+ json(res, 400, { error: "missing_session_id" });
236
205
  return;
237
206
  }
238
207
  const authResult = await validateRequest(req);
@@ -240,17 +209,17 @@ async function handleRequest(
240
209
  json(res, 401, { error: "auth_required", message: "Authentication required" });
241
210
  return;
242
211
  }
243
- const device = sessionManager.get(hostDeviceId);
244
- if (!device) {
212
+ const session = sessionManager.get(sessionId);
213
+ if (!session) {
245
214
  json(res, 404, { error: "not_found" });
246
215
  return;
247
216
  }
248
- if (device.userId && device.userId === authResult.userId) {
249
- sessionManager.forceDelete(hostDeviceId);
217
+ if (session.userId && session.userId === authResult.userId) {
218
+ sessionManager.forceDelete(sessionId);
250
219
  json(res, 200, { ok: true });
251
220
  return;
252
221
  }
253
- json(res, 403, { error: "forbidden", message: "You do not own this device" });
222
+ json(res, 403, { error: "forbidden", message: "You do not own this session" });
254
223
  return;
255
224
  }
256
225
 
@@ -265,7 +234,7 @@ async function handleRequest(
265
234
  const tunnelCookie = parseTunnelCookie(req);
266
235
  if (tunnelCookie) {
267
236
  const fallbackParsed = {
268
- hostDeviceId: tunnelCookie.hostDeviceId,
237
+ sessionId: tunnelCookie.sessionId,
269
238
  port: tunnelCookie.port,
270
239
  path: url.pathname,
271
240
  };
@@ -280,7 +249,7 @@ async function handleRequest(
280
249
  if (!parsed.success) {
281
250
  json(res, 400, {
282
251
  error: "invalid_payload",
283
- message: parsed.error.issues[0]?.message ?? "Invalid permission response payload",
252
+ message: parsed.error.errors[0]?.message ?? "Invalid permission response payload",
284
253
  });
285
254
  return;
286
255
  }
@@ -295,27 +264,27 @@ async function handleRequest(
295
264
  item.terminalId ? `${item.type}:${item.terminalId}` : item.type,
296
265
  ).join(",") ?? "none";
297
266
  const ack = result.ack ? ` resolved=${result.ack.resolved} delivered=${result.ack.delivered}` : "";
298
- log(result.status === 200 ? "info" : "warn", `agent permission respond protocol=${body.protocol} hostDevice=${body.hostDeviceId} request=${body.requestId} status=${result.status} forwarded=${forwarded}${ack}`);
267
+ log(result.status === 200 ? "info" : "warn", `agent permission respond protocol=${body.protocol} session=${body.sessionId} request=${body.requestId} status=${result.status} forwarded=${forwarded}${ack}`);
299
268
  json(res, result.status, result.body);
300
269
  return;
301
270
  }
302
271
 
303
- // Auth check for premium gateway (skip healthz, device-owned endpoints, tunnel)
272
+ // Auth check for premium gateway (skip healthz, /sessions/mine, tunnel)
304
273
  if (AUTH_REQUIRED) {
305
274
  const authResult = await requireAuth(req, res);
306
275
  if (!authResult) return; // response already sent
307
276
  }
308
277
 
309
- // Create one-time pairing challenge for a host device
278
+ // Create pairing
310
279
  if (method === "POST" && url.pathname === "/pairings") {
311
280
  if (!isRateLimitBypassed(ip) && !pairingLimiter.allow(ip)) {
312
281
  json(res, 429, { error: "rate_limited", message: "Too many requests" });
313
282
  return;
314
283
  }
315
284
  const body = createPairingBody.parse(await readJson(req));
316
- const record = pairingManager.create(body.hostDeviceId);
285
+ const record = pairingManager.create(body.sessionId);
317
286
  json(res, 201, {
318
- hostDeviceId: record.hostDeviceId,
287
+ sessionId: record.sessionId,
319
288
  pairingCode: record.pairingCode,
320
289
  expiresAt: new Date(record.expiresAt).toISOString(),
321
290
  });
@@ -335,62 +304,44 @@ async function handleRequest(
335
304
  return;
336
305
  }
337
306
  const token = tokenManager.register(body.deviceToken);
338
- const authorization = tokenManager.authorize(token, result.hostDeviceId, {
339
- clientDeviceId: body.clientDeviceId,
340
- clientName: body.clientName,
341
- });
342
- json(res, 200, {
343
- hostDeviceId: result.hostDeviceId,
344
- deviceToken: token,
345
- authorizationId: authorization?.authorizationId,
346
- });
307
+ tokenManager.bind(token, result.sessionId);
308
+ json(res, 200, { sessionId: result.sessionId, deviceToken: token });
347
309
  return;
348
310
  }
349
311
 
350
- // Authorized host device list
351
- if (method === "GET" && url.pathname === "/devices") {
312
+ // Session list
313
+ if (method === "GET" && url.pathname === "/sessions") {
352
314
  const token = extractBearerToken(req);
353
- if (!token || !tokenManager.validate(token)) {
354
- json(res, 401, {
355
- error: "unauthorized",
356
- message: "Valid device token required",
357
- });
358
- return;
359
- }
360
- const allowedIds = tokenManager.getHostDeviceIds(token);
361
- const devices = [...allowedIds].map((hostDeviceId) => {
362
- const summary = sessionManager.getSummary(hostDeviceId);
363
- return summary ?? {
364
- id: hostDeviceId,
365
- hostDeviceId,
366
- state: "host_disconnected",
367
- online: false,
368
- hasHost: false,
369
- clientCount: 0,
370
- controllerId: null,
371
- lastActivity: null,
372
- createdAt: null,
373
- bufferSize: 0,
374
- machineId: null,
375
- hostname: null,
376
- platform: null,
377
- cwd: null,
378
- capabilities: [],
379
- authorizationId: tokenManager.getAuthorizationId(token, hostDeviceId) ?? null,
380
- };
381
- }).map((device) => ({
382
- ...device,
383
- authorizationId: tokenManager.getAuthorizationId(token, device.hostDeviceId) ?? null,
384
- }));
385
- json(res, 200, { devices });
315
+ const allowedIds = token && tokenManager.validate(token)
316
+ ? tokenManager.getSessionIds(token)
317
+ : new Set<string>();
318
+ const sessions = sessionManager
319
+ .listActive()
320
+ .filter((s) => allowedIds.has(s.id))
321
+ .map((s) => ({
322
+ id: s.id,
323
+ state: s.state,
324
+ hasHost: !!s.host && s.host.socket.readyState === s.host.socket.OPEN,
325
+ clientCount: s.clients.size,
326
+ controllerId: s.controllerId ?? null,
327
+ lastActivity: s.lastActivity,
328
+ createdAt: s.createdAt,
329
+ provider: s.provider ?? null,
330
+ machineId: s.machineId ?? null,
331
+ hostname: s.hostname ?? null,
332
+ platform: s.platform ?? null,
333
+ cwd: s.cwd ?? null,
334
+ projectName: s.projectName ?? null,
335
+ }));
336
+ json(res, 200, { sessions });
386
337
  return;
387
338
  }
388
339
 
389
- // Device detail
390
- const deviceMatch = url.pathname.match(/^\/devices\/([^/]+)$/);
391
- if (method === "GET" && deviceMatch) {
340
+ // Session detail
341
+ const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
342
+ if (method === "GET" && sessionMatch) {
392
343
  const token = extractBearerToken(req);
393
- const targetId = deviceMatch[1]!;
344
+ const targetId = sessionMatch[1]!;
394
345
  if (!token || !tokenManager.owns(token, targetId)) {
395
346
  json(res, 401, {
396
347
  error: "unauthorized",
@@ -400,51 +351,10 @@ async function handleRequest(
400
351
  }
401
352
  const summary = sessionManager.getSummary(targetId);
402
353
  if (!summary) {
403
- json(res, 200, {
404
- id: targetId,
405
- hostDeviceId: targetId,
406
- state: "host_disconnected",
407
- online: false,
408
- hasHost: false,
409
- clientCount: 0,
410
- controllerId: null,
411
- lastActivity: null,
412
- createdAt: null,
413
- bufferSize: 0,
414
- machineId: null,
415
- hostname: null,
416
- platform: null,
417
- cwd: null,
418
- capabilities: [],
419
- authorizationId: tokenManager.getAuthorizationId(token, targetId) ?? null,
420
- });
354
+ json(res, 404, { error: "session_not_found" });
421
355
  return;
422
356
  }
423
- json(res, 200, {
424
- ...summary,
425
- authorizationId: tokenManager.getAuthorizationId(token, targetId) ?? null,
426
- });
427
- return;
428
- }
429
-
430
- const revokeMatch = url.pathname.match(/^\/devices\/([^/]+)\/authorizations\/([^/]+)$/);
431
- if (method === "DELETE" && revokeMatch) {
432
- const token = extractBearerToken(req);
433
- const hostDeviceId = decodeURIComponent(revokeMatch[1]!);
434
- const authorizationId = decodeURIComponent(revokeMatch[2]!);
435
- if (
436
- !token ||
437
- tokenManager.getAuthorizationId(token, hostDeviceId) !== authorizationId ||
438
- !tokenManager.revoke(token, hostDeviceId, authorizationId)
439
- ) {
440
- json(res, 401, {
441
- error: "unauthorized",
442
- message: "Valid device authorization required",
443
- });
444
- return;
445
- }
446
- sessionManager.disconnectAuthorization(hostDeviceId, authorizationId);
447
- json(res, 200, { ok: true });
357
+ json(res, 200, summary);
448
358
  return;
449
359
  }
450
360
 
@@ -468,7 +378,6 @@ async function handleRequest(
468
378
  const wss = new WebSocketServer({
469
379
  noServer: true,
470
380
  maxPayload: MAX_WS_MESSAGE_SIZE,
471
- perMessageDeflate: false,
472
381
  });
473
382
 
474
383
  server.on("upgrade", (request, socket, head) => {
@@ -496,7 +405,7 @@ server.on("upgrade", (request, socket, head) => {
496
405
  const tunnelCookie = parseTunnelCookie(request);
497
406
  if (tunnelCookie && url.pathname !== "/ws") {
498
407
  const fallbackParsed = {
499
- hostDeviceId: tunnelCookie.hostDeviceId,
408
+ sessionId: tunnelCookie.sessionId,
500
409
  port: tunnelCookie.port,
501
410
  path: url.pathname,
502
411
  };
@@ -550,33 +459,30 @@ server.on("upgrade", (request, socket, head) => {
550
459
  wss.on(
551
460
  "connection",
552
461
  (socket: WebSocket, _request: IncomingMessage, url: URL) => {
553
- const hostDeviceId = url.searchParams.get("hostDeviceId");
462
+ const sessionId = url.searchParams.get("sessionId");
554
463
  const role = url.searchParams.get("role") as "host" | "client" | null;
555
464
 
556
- if (!hostDeviceId || !role || (role !== "host" && role !== "client")) {
557
- socket.close(1008, "missing hostDeviceId or role");
465
+ if (!sessionId || !role || (role !== "host" && role !== "client")) {
466
+ socket.close(1008, "missing sessionId or role");
558
467
  return;
559
468
  }
560
469
 
561
470
  const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
562
471
 
563
- let clientToken: string | undefined;
564
- let clientAuthorizationId: string | undefined;
565
-
566
472
  if (role === "client") {
567
473
  const token = url.searchParams.get("token");
568
474
  const authResult = (_request as any).__authResult as
569
475
  | { userId?: string }
570
476
  | undefined;
571
- const device = sessionManager.get(hostDeviceId);
477
+ const session = sessionManager.get(sessionId);
572
478
 
573
- // Allow if: device token owns host device, OR auth user owns host device
574
- const tokenOwns = Boolean(token && tokenManager.owns(token, hostDeviceId));
479
+ // Allow if: device token owns session, OR auth user owns session
480
+ const tokenOwns = token && tokenManager.owns(token, sessionId);
575
481
  const authOwns =
576
482
  AUTH_REQUIRED &&
577
483
  authResult?.userId &&
578
- device?.userId &&
579
- authResult.userId === device.userId;
484
+ session?.userId &&
485
+ authResult.userId === session.userId;
580
486
 
581
487
  if (!tokenOwns && !authOwns) {
582
488
  socket.close(4001, "unauthorized");
@@ -584,12 +490,8 @@ wss.on(
584
490
  }
585
491
  if (!tokenOwns && authOwns && token) {
586
492
  tokenManager.register(token);
587
- tokenManager.bind(token, hostDeviceId);
588
- log("info", `bound authenticated device token to host device ${hostDeviceId}`);
589
- }
590
- if (token && (tokenOwns || authOwns)) {
591
- clientToken = token;
592
- clientAuthorizationId = tokenManager.getAuthorizationId(token, hostDeviceId);
493
+ tokenManager.bind(token, sessionId);
494
+ log("info", `bound authenticated device token to session ${sessionId}`);
593
495
  }
594
496
  }
595
497
 
@@ -597,33 +499,31 @@ wss.on(
597
499
  socket,
598
500
  role,
599
501
  deviceId,
600
- token: clientToken,
601
- authorizationId: clientAuthorizationId,
602
502
  connectedAt: Date.now(),
603
503
  };
604
504
 
605
505
  if (role === "host") {
606
506
  // Check if this is a reconnect (session already exists with clients)
607
- const existingSession = sessionManager.get(hostDeviceId);
507
+ const existingSession = sessionManager.get(sessionId);
608
508
  const isReconnect =
609
509
  existingSession &&
610
510
  existingSession.clients.size > 0 &&
611
511
  existingSession.state === "host_disconnected";
612
- sessionManager.setHost(hostDeviceId, device);
512
+ sessionManager.setHost(sessionId, device);
613
513
 
614
514
  // Associate userId from auth (for AUTH_REQUIRED gateways)
615
515
  const authResult = (_request as any).__authResult as
616
516
  | { userId?: string }
617
517
  | undefined;
618
518
  if (authResult?.userId) {
619
- const deviceRecord = sessionManager.get(hostDeviceId);
620
- if (deviceRecord) deviceRecord.userId = authResult.userId;
519
+ const session = sessionManager.get(sessionId);
520
+ if (session) session.userId = authResult.userId;
621
521
  }
622
522
  if (isReconnect) {
623
523
  const notification = serializeEnvelope(
624
524
  createEnvelope({
625
- type: "device.host_reconnected",
626
- hostDeviceId,
525
+ type: "session.host_reconnected",
526
+ sessionId,
627
527
  payload: {},
628
528
  }),
629
529
  );
@@ -634,15 +534,15 @@ wss.on(
634
534
  }
635
535
  }
636
536
  } else {
637
- sessionManager.addClient(hostDeviceId, device);
537
+ sessionManager.addClient(sessionId, device);
638
538
  }
639
539
 
640
540
  // Send welcome with protocol version
641
541
  socket.send(
642
542
  serializeEnvelope(
643
543
  createEnvelope({
644
- type: "device.connect",
645
- hostDeviceId,
544
+ type: "session.connect",
545
+ sessionId,
646
546
  payload: {
647
547
  role,
648
548
  clientName: deviceId,
@@ -654,7 +554,7 @@ wss.on(
654
554
 
655
555
  // If client just joined and host is not connected, notify immediately
656
556
  if (role === "client") {
657
- const sessionAfterJoin = sessionManager.get(hostDeviceId);
557
+ const sessionAfterJoin = sessionManager.get(sessionId);
658
558
  if (sessionAfterJoin) {
659
559
  const hostGone =
660
560
  !sessionAfterJoin.host ||
@@ -665,8 +565,8 @@ wss.on(
665
565
  socket.send(
666
566
  serializeEnvelope(
667
567
  createEnvelope({
668
- type: "device.host_disconnected",
669
- hostDeviceId,
568
+ type: "session.host_disconnected",
569
+ sessionId,
670
570
  payload: { reason: "host not connected" },
671
571
  }),
672
572
  ),
@@ -688,18 +588,18 @@ wss.on(
688
588
  socket,
689
589
  data.toString(),
690
590
  role,
691
- hostDeviceId,
591
+ sessionId,
692
592
  deviceId,
693
593
  sessionManager,
694
594
  );
695
595
  } catch (err) {
696
- log("error", `unhandled websocket message error for host device ${hostDeviceId}: ${err instanceof Error ? err.message : String(err)}`);
596
+ log("error", `unhandled websocket message error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
697
597
  if (socket.readyState === socket.OPEN) {
698
598
  socket.send(
699
599
  serializeEnvelope(
700
600
  createEnvelope({
701
- type: "device.error",
702
- hostDeviceId,
601
+ type: "session.error",
602
+ sessionId,
703
603
  payload: {
704
604
  code: "invalid_message",
705
605
  message: "Failed to handle message",
@@ -714,14 +614,13 @@ wss.on(
714
614
  socket.on("close", () => {
715
615
  clearInterval(pingTimer);
716
616
  if (role === "host") {
717
- const result = sessionManager.removeHost(hostDeviceId);
718
- cleanupSessionTunnels(hostDeviceId);
617
+ const result = sessionManager.removeHost(sessionId);
719
618
  // Notify all clients that host disconnected
720
619
  if (result) {
721
620
  const notification = serializeEnvelope(
722
621
  createEnvelope({
723
- type: "device.host_disconnected",
724
- hostDeviceId,
622
+ type: "session.host_disconnected",
623
+ sessionId,
725
624
  payload: { reason: "host connection closed" },
726
625
  }),
727
626
  );
@@ -732,7 +631,7 @@ wss.on(
732
631
  }
733
632
  }
734
633
  } else {
735
- sessionManager.removeClient(hostDeviceId, deviceId);
634
+ sessionManager.removeClient(sessionId, deviceId);
736
635
  }
737
636
  });
738
637
 
@@ -771,18 +670,18 @@ if (AUTH_REQUIRED) {
771
670
  if (!session.userId || !session.host) continue;
772
671
  const subscription = await checkSubscriptionByUserId(session.userId);
773
672
  if (subscription.status === "unknown") {
774
- log("warn", `subscription check unknown for user ${session.userId}, keeping host device ${session.id}${subscription.reason ? ` (${subscription.reason})` : ""}`);
673
+ log("warn", `subscription check unknown for user ${session.userId}, keeping session ${session.id}${subscription.reason ? ` (${subscription.reason})` : ""}`);
775
674
  continue;
776
675
  }
777
676
  if (subscription.status === "inactive") {
778
- log("info", `subscription expired for user ${session.userId}, disconnecting host device ${session.id}`);
677
+ log("info", `subscription expired for user ${session.userId}, disconnecting session ${session.id}`);
779
678
  // Notify host
780
679
  try {
781
680
  session.host.socket.send(
782
681
  serializeEnvelope(
783
682
  createEnvelope({
784
- type: "device.error",
785
- hostDeviceId: session.id,
683
+ type: "session.error",
684
+ sessionId: session.id,
786
685
  payload: {
787
686
  code: "subscription_expired",
788
687
  message: "Your Pro subscription has expired. Renew at https://itool.tech",
@@ -798,12 +697,12 @@ if (AUTH_REQUIRED) {
798
697
  try {
799
698
  client.socket.send(
800
699
  serializeEnvelope(
801
- createEnvelope({
802
- type: "device.error",
803
- hostDeviceId: session.id,
700
+ createEnvelope({
701
+ type: "session.error",
702
+ sessionId: session.id,
804
703
  payload: {
805
704
  code: "subscription_expired",
806
- message: "Host subscription expired. HostDevice ended.",
705
+ message: "Host subscription expired. Session ended.",
807
706
  },
808
707
  }),
809
708
  ),
package/src/pairings.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { randomInt } from "node:crypto";
1
+ import { randomInt, randomUUID } from "node:crypto";
2
2
  import type { GatewayStateStore } from "./state-store.js";
3
3
 
4
4
  export interface PairingRecord {
5
- hostDeviceId: string;
5
+ sessionId: string;
6
6
  pairingCode: string;
7
7
  expiresAt: number; // unix ms
8
8
  claimed: boolean;
@@ -36,10 +36,11 @@ export class PairingManager {
36
36
  }
37
37
  }
38
38
 
39
- create(hostDeviceId: string): PairingRecord {
39
+ create(sessionId?: string): PairingRecord {
40
+ const id = sessionId ?? randomUUID();
40
41
  const code = String(randomInt(100000, 999999));
41
42
  const record: PairingRecord = {
42
- hostDeviceId,
43
+ sessionId: id,
43
44
  pairingCode: code,
44
45
  expiresAt: Date.now() + PAIRING_TTL,
45
46
  claimed: false,
@@ -67,7 +68,7 @@ export class PairingManager {
67
68
  return record;
68
69
  }
69
70
 
70
- getStatus(pairingCode: string): { status: string; expiresAt: number; hostDeviceId: string } | { error: string; httpStatus: number } {
71
+ getStatus(pairingCode: string): { status: string; expiresAt: number; sessionId: string } | { error: string; httpStatus: number } {
71
72
  const record = this.pairings.get(pairingCode);
72
73
  if (!record) {
73
74
  return { error: "pairing_not_found", httpStatus: 404 };
@@ -80,7 +81,7 @@ export class PairingManager {
80
81
  return {
81
82
  status: record.claimed ? "claimed" : "waiting",
82
83
  expiresAt: record.expiresAt,
83
- hostDeviceId: record.hostDeviceId,
84
+ sessionId: record.sessionId,
84
85
  };
85
86
  }
86
87