@linkshell/gateway 0.3.8 → 0.3.10
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.
- package/Dockerfile +1 -3
- package/README.md +13 -14
- package/dist/gateway/src/agent-permission-http.d.ts +74 -19
- package/dist/gateway/src/agent-permission-http.js +56 -16
- package/dist/gateway/src/agent-permission-http.js.map +1 -1
- package/dist/gateway/src/embedded.js +61 -153
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/index.js +98 -193
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/pairings.d.ts +3 -3
- package/dist/gateway/src/pairings.js +5 -4
- package/dist/gateway/src/pairings.js.map +1 -1
- package/dist/gateway/src/relay.d.ts +2 -2
- package/dist/gateway/src/relay.js +63 -76
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +28 -42
- package/dist/gateway/src/sessions.js +145 -196
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/src/state-store.d.ts +6 -9
- package/dist/gateway/src/state-store.js +19 -26
- package/dist/gateway/src/state-store.js.map +1 -1
- package/dist/gateway/src/tokens.d.ts +7 -27
- package/dist/gateway/src/tokens.js +60 -86
- package/dist/gateway/src/tokens.js.map +1 -1
- package/dist/gateway/src/tunnel.d.ts +13 -11
- package/dist/gateway/src/tunnel.js +36 -36
- package/dist/gateway/src/tunnel.js.map +1 -1
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +11940 -3451
- package/dist/shared-protocol/src/index.js +98 -172
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +11 -11
- package/src/agent-permission-http.ts +63 -20
- package/src/embedded.ts +60 -158
- package/src/index.ts +98 -199
- package/src/pairings.ts +7 -6
- package/src/relay.ts +70 -92
- package/src/sessions.ts +150 -210
- package/src/state-store.ts +25 -41
- package/src/tokens.ts +63 -109
- 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 {
|
|
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
|
|
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 =
|
|
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({
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
216
|
-
if (method === "GET" && url.pathname === "/
|
|
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
|
|
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, {
|
|
196
|
+
json(res, 200, { sessions });
|
|
228
197
|
return;
|
|
229
198
|
}
|
|
230
199
|
|
|
231
|
-
// Delete a
|
|
232
|
-
if (method === "DELETE" &&
|
|
233
|
-
const
|
|
234
|
-
if (!
|
|
235
|
-
json(res, 400, { error: "
|
|
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
|
|
244
|
-
if (!
|
|
212
|
+
const session = sessionManager.get(sessionId);
|
|
213
|
+
if (!session) {
|
|
245
214
|
json(res, 404, { error: "not_found" });
|
|
246
215
|
return;
|
|
247
216
|
}
|
|
248
|
-
if (
|
|
249
|
-
sessionManager.forceDelete(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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}
|
|
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,
|
|
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
|
|
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.
|
|
285
|
+
const record = pairingManager.create(body.sessionId);
|
|
317
286
|
json(res, 201, {
|
|
318
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
//
|
|
351
|
-
if (method === "GET" && url.pathname === "/
|
|
312
|
+
// Session list
|
|
313
|
+
if (method === "GET" && url.pathname === "/sessions") {
|
|
352
314
|
const token = extractBearerToken(req);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
//
|
|
390
|
-
const
|
|
391
|
-
if (method === "GET" &&
|
|
340
|
+
// Session detail
|
|
341
|
+
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
342
|
+
if (method === "GET" && sessionMatch) {
|
|
392
343
|
const token = extractBearerToken(req);
|
|
393
|
-
const targetId =
|
|
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,
|
|
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
|
-
|
|
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
|
|
462
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
554
463
|
const role = url.searchParams.get("role") as "host" | "client" | null;
|
|
555
464
|
|
|
556
|
-
if (!
|
|
557
|
-
socket.close(1008, "missing
|
|
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
|
|
477
|
+
const session = sessionManager.get(sessionId);
|
|
572
478
|
|
|
573
|
-
// Allow if: device token owns
|
|
574
|
-
const tokenOwns =
|
|
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
|
-
|
|
579
|
-
authResult.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,
|
|
588
|
-
log("info", `bound authenticated device token to
|
|
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(
|
|
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(
|
|
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
|
|
620
|
-
if (
|
|
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: "
|
|
626
|
-
|
|
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(
|
|
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: "
|
|
645
|
-
|
|
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(
|
|
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: "
|
|
669
|
-
|
|
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
|
-
|
|
591
|
+
sessionId,
|
|
692
592
|
deviceId,
|
|
693
593
|
sessionManager,
|
|
694
594
|
);
|
|
695
595
|
} catch (err) {
|
|
696
|
-
log("error", `unhandled websocket message error for
|
|
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: "
|
|
702
|
-
|
|
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(
|
|
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: "
|
|
724
|
-
|
|
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(
|
|
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
|
|
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
|
|
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: "
|
|
785
|
-
|
|
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
|
-
|
|
802
|
-
type: "
|
|
803
|
-
|
|
700
|
+
createEnvelope({
|
|
701
|
+
type: "session.error",
|
|
702
|
+
sessionId: session.id,
|
|
804
703
|
payload: {
|
|
805
704
|
code: "subscription_expired",
|
|
806
|
-
message: "Host subscription expired.
|
|
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
|
-
|
|
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(
|
|
39
|
+
create(sessionId?: string): PairingRecord {
|
|
40
|
+
const id = sessionId ?? randomUUID();
|
|
40
41
|
const code = String(randomInt(100000, 999999));
|
|
41
42
|
const record: PairingRecord = {
|
|
42
|
-
|
|
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;
|
|
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
|
-
|
|
84
|
+
sessionId: record.sessionId,
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
|