@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.
- 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 +85 -161
- 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 -200
- 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 +11978 -3423
- package/dist/shared-protocol/src/index.js +114 -163
- 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 +97 -193
- package/src/sessions.ts +150 -213
- package/src/state-store.ts +25 -41
- package/src/tokens.ts +63 -109
- package/src/tunnel.ts +43 -49
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
4
3
|
import { WebSocketServer } from "ws";
|
|
5
4
|
import { createEnvelope, serializeEnvelope, PROTOCOL_VERSION, } from "@linkshell/protocol";
|
|
6
5
|
import { z, ZodError } from "zod";
|
|
7
|
-
import {
|
|
6
|
+
import { SessionManager } from "./sessions.js";
|
|
8
7
|
import { PairingManager } from "./pairings.js";
|
|
9
8
|
import { TokenManager } from "./tokens.js";
|
|
10
9
|
import { createSupabaseStateStore } from "./state-store.js";
|
|
11
10
|
import { handleSocketMessage } from "./relay.js";
|
|
12
11
|
import { agentPermissionHttpBodySchema, forwardAgentPermissionHttp, } from "./agent-permission-http.js";
|
|
13
|
-
import { parseTunnelPath, parseTunnelCookie, handleTunnelRequest,
|
|
12
|
+
import { parseTunnelPath, parseTunnelCookie, handleTunnelRequest, } from "./tunnel.js";
|
|
14
13
|
import { AUTH_REQUIRED, requireAuth, checkWsAuth, validateRequest, checkSubscriptionByUserId } from "./auth-middleware.js";
|
|
15
14
|
const port = Number(process.env.PORT ?? 8787);
|
|
16
15
|
const logLevel = (process.env.LOG_LEVEL ?? "info");
|
|
@@ -21,20 +20,17 @@ function log(level, msg) {
|
|
|
21
20
|
}
|
|
22
21
|
}
|
|
23
22
|
const stateStore = createSupabaseStateStore();
|
|
24
|
-
const sessionManager = new
|
|
23
|
+
const sessionManager = new SessionManager();
|
|
25
24
|
const pairingManager = new PairingManager(stateStore);
|
|
26
25
|
const tokenManager = new TokenManager(stateStore);
|
|
27
26
|
await Promise.all([pairingManager.hydrate(), tokenManager.hydrate()]);
|
|
28
27
|
const PING_INTERVAL = 20_000;
|
|
29
28
|
const MAX_BODY_SIZE = 4096;
|
|
30
|
-
const MAX_WS_MESSAGE_SIZE =
|
|
29
|
+
const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image uploads)
|
|
31
30
|
const PAIRING_RATE_LIMIT_MAX = Number(process.env.PAIRING_RATE_LIMIT_MAX ?? 30);
|
|
32
31
|
const PAIRING_RATE_LIMIT_WINDOW_MS = Number(process.env.PAIRING_RATE_LIMIT_WINDOW_MS ?? 60_000);
|
|
33
32
|
const WS_CONNECT_RATE_LIMIT_MAX = Number(process.env.WS_CONNECT_RATE_LIMIT_MAX ?? 20);
|
|
34
33
|
const WS_CONNECT_RATE_LIMIT_WINDOW_MS = Number(process.env.WS_CONNECT_RATE_LIMIT_WINDOW_MS ?? 60_000);
|
|
35
|
-
const DETAILED_HEALTH = process.env.DETAILED_HEALTH === "true";
|
|
36
|
-
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
37
|
-
eventLoopDelay.enable();
|
|
38
34
|
// ── Rate limiter ────────────────────────────────────────────────────
|
|
39
35
|
class RateLimiter {
|
|
40
36
|
maxHits;
|
|
@@ -87,12 +83,10 @@ function setCors(res) {
|
|
|
87
83
|
res.setHeader("Access-Control-Max-Age", "86400");
|
|
88
84
|
}
|
|
89
85
|
// ── HTTP API ────────────────────────────────────────────────────────
|
|
90
|
-
const createPairingBody = z.object({
|
|
86
|
+
const createPairingBody = z.object({ sessionId: z.string().optional() });
|
|
91
87
|
const claimPairingBody = z.object({
|
|
92
88
|
pairingCode: z.string().length(6),
|
|
93
89
|
deviceToken: z.string().min(1).optional(),
|
|
94
|
-
clientDeviceId: z.string().min(1).optional(),
|
|
95
|
-
clientName: z.string().min(1).optional(),
|
|
96
90
|
});
|
|
97
91
|
const server = createServer(async (req, res) => {
|
|
98
92
|
setCors(res);
|
|
@@ -108,7 +102,7 @@ const server = createServer(async (req, res) => {
|
|
|
108
102
|
if (err instanceof ZodError) {
|
|
109
103
|
json(res, 400, {
|
|
110
104
|
error: "invalid_message",
|
|
111
|
-
message: err.
|
|
105
|
+
message: err.errors[0]?.message ?? "Validation failed",
|
|
112
106
|
});
|
|
113
107
|
}
|
|
114
108
|
else if (err instanceof BodyTooLargeError) {
|
|
@@ -135,52 +129,29 @@ async function handleRequest(req, res) {
|
|
|
135
129
|
const ip = getClientIp(req);
|
|
136
130
|
// Health check
|
|
137
131
|
if (method === "GET" && url.pathname === "/healthz") {
|
|
138
|
-
|
|
139
|
-
const detailed = DETAILED_HEALTH || url.searchParams.get("detail") === "1" || url.searchParams.get("detailed") === "true";
|
|
140
|
-
json(res, 200, {
|
|
141
|
-
ok: true,
|
|
142
|
-
uptime: Math.round(process.uptime()),
|
|
143
|
-
memory: {
|
|
144
|
-
rss: memory.rss,
|
|
145
|
-
heapUsed: memory.heapUsed,
|
|
146
|
-
heapTotal: memory.heapTotal,
|
|
147
|
-
external: memory.external,
|
|
148
|
-
},
|
|
149
|
-
sessions: sessionManager.getStats(),
|
|
150
|
-
...(detailed ? {
|
|
151
|
-
eventLoop: {
|
|
152
|
-
delayMeanMs: Number((eventLoopDelay.mean / 1_000_000).toFixed(2)),
|
|
153
|
-
delayMaxMs: Number((eventLoopDelay.max / 1_000_000).toFixed(2)),
|
|
154
|
-
delayP99Ms: Number((eventLoopDelay.percentile(99) / 1_000_000).toFixed(2)),
|
|
155
|
-
},
|
|
156
|
-
websocket: {
|
|
157
|
-
maxPayloadBytes: MAX_WS_MESSAGE_SIZE,
|
|
158
|
-
perMessageDeflate: false,
|
|
159
|
-
},
|
|
160
|
-
} : {}),
|
|
161
|
-
});
|
|
132
|
+
json(res, 200, { ok: true });
|
|
162
133
|
return;
|
|
163
134
|
}
|
|
164
|
-
//
|
|
165
|
-
if (method === "GET" && url.pathname === "/
|
|
135
|
+
// Sessions owned by authenticated user (before AUTH_REQUIRED guard — uses its own auth)
|
|
136
|
+
if (method === "GET" && url.pathname === "/sessions/mine") {
|
|
166
137
|
const authResult = await validateRequest(req);
|
|
167
138
|
if (!authResult || !authResult.userId) {
|
|
168
139
|
json(res, 401, { error: "auth_required", message: "Authentication required" });
|
|
169
140
|
return;
|
|
170
141
|
}
|
|
171
|
-
const
|
|
142
|
+
const sessions = sessionManager
|
|
172
143
|
.listActive()
|
|
173
144
|
.filter((s) => s.userId === authResult.userId)
|
|
174
145
|
.map((s) => sessionManager.getSummary(s.id))
|
|
175
146
|
.filter(Boolean);
|
|
176
|
-
json(res, 200, {
|
|
147
|
+
json(res, 200, { sessions });
|
|
177
148
|
return;
|
|
178
149
|
}
|
|
179
|
-
// Delete a
|
|
180
|
-
if (method === "DELETE" &&
|
|
181
|
-
const
|
|
182
|
-
if (!
|
|
183
|
-
json(res, 400, { error: "
|
|
150
|
+
// Delete a session owned by authenticated user
|
|
151
|
+
if (method === "DELETE" && url.pathname.startsWith("/sessions/")) {
|
|
152
|
+
const sessionId = url.pathname.split("/")[2];
|
|
153
|
+
if (!sessionId) {
|
|
154
|
+
json(res, 400, { error: "missing_session_id" });
|
|
184
155
|
return;
|
|
185
156
|
}
|
|
186
157
|
const authResult = await validateRequest(req);
|
|
@@ -188,17 +159,17 @@ async function handleRequest(req, res) {
|
|
|
188
159
|
json(res, 401, { error: "auth_required", message: "Authentication required" });
|
|
189
160
|
return;
|
|
190
161
|
}
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
162
|
+
const session = sessionManager.get(sessionId);
|
|
163
|
+
if (!session) {
|
|
193
164
|
json(res, 404, { error: "not_found" });
|
|
194
165
|
return;
|
|
195
166
|
}
|
|
196
|
-
if (
|
|
197
|
-
sessionManager.forceDelete(
|
|
167
|
+
if (session.userId && session.userId === authResult.userId) {
|
|
168
|
+
sessionManager.forceDelete(sessionId);
|
|
198
169
|
json(res, 200, { ok: true });
|
|
199
170
|
return;
|
|
200
171
|
}
|
|
201
|
-
json(res, 403, { error: "forbidden", message: "You do not own this
|
|
172
|
+
json(res, 403, { error: "forbidden", message: "You do not own this session" });
|
|
202
173
|
return;
|
|
203
174
|
}
|
|
204
175
|
// Tunnel HTTP proxy (explicit path) — before AUTH_REQUIRED, tunnel has its own auth
|
|
@@ -211,7 +182,7 @@ async function handleRequest(req, res) {
|
|
|
211
182
|
const tunnelCookie = parseTunnelCookie(req);
|
|
212
183
|
if (tunnelCookie) {
|
|
213
184
|
const fallbackParsed = {
|
|
214
|
-
|
|
185
|
+
sessionId: tunnelCookie.sessionId,
|
|
215
186
|
port: tunnelCookie.port,
|
|
216
187
|
path: url.pathname,
|
|
217
188
|
};
|
|
@@ -225,7 +196,7 @@ async function handleRequest(req, res) {
|
|
|
225
196
|
if (!parsed.success) {
|
|
226
197
|
json(res, 400, {
|
|
227
198
|
error: "invalid_payload",
|
|
228
|
-
message: parsed.error.
|
|
199
|
+
message: parsed.error.errors[0]?.message ?? "Invalid permission response payload",
|
|
229
200
|
});
|
|
230
201
|
return;
|
|
231
202
|
}
|
|
@@ -238,26 +209,26 @@ async function handleRequest(req, res) {
|
|
|
238
209
|
});
|
|
239
210
|
const forwarded = result.forwarded?.map((item) => item.terminalId ? `${item.type}:${item.terminalId}` : item.type).join(",") ?? "none";
|
|
240
211
|
const ack = result.ack ? ` resolved=${result.ack.resolved} delivered=${result.ack.delivered}` : "";
|
|
241
|
-
log(result.status === 200 ? "info" : "warn", `agent permission respond protocol=${body.protocol}
|
|
212
|
+
log(result.status === 200 ? "info" : "warn", `agent permission respond protocol=${body.protocol} session=${body.sessionId} request=${body.requestId} status=${result.status} forwarded=${forwarded}${ack}`);
|
|
242
213
|
json(res, result.status, result.body);
|
|
243
214
|
return;
|
|
244
215
|
}
|
|
245
|
-
// Auth check for premium gateway (skip healthz,
|
|
216
|
+
// Auth check for premium gateway (skip healthz, /sessions/mine, tunnel)
|
|
246
217
|
if (AUTH_REQUIRED) {
|
|
247
218
|
const authResult = await requireAuth(req, res);
|
|
248
219
|
if (!authResult)
|
|
249
220
|
return; // response already sent
|
|
250
221
|
}
|
|
251
|
-
// Create
|
|
222
|
+
// Create pairing
|
|
252
223
|
if (method === "POST" && url.pathname === "/pairings") {
|
|
253
224
|
if (!isRateLimitBypassed(ip) && !pairingLimiter.allow(ip)) {
|
|
254
225
|
json(res, 429, { error: "rate_limited", message: "Too many requests" });
|
|
255
226
|
return;
|
|
256
227
|
}
|
|
257
228
|
const body = createPairingBody.parse(await readJson(req));
|
|
258
|
-
const record = pairingManager.create(body.
|
|
229
|
+
const record = pairingManager.create(body.sessionId);
|
|
259
230
|
json(res, 201, {
|
|
260
|
-
|
|
231
|
+
sessionId: record.sessionId,
|
|
261
232
|
pairingCode: record.pairingCode,
|
|
262
233
|
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
263
234
|
});
|
|
@@ -276,60 +247,42 @@ async function handleRequest(req, res) {
|
|
|
276
247
|
return;
|
|
277
248
|
}
|
|
278
249
|
const token = tokenManager.register(body.deviceToken);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
clientName: body.clientName,
|
|
282
|
-
});
|
|
283
|
-
json(res, 200, {
|
|
284
|
-
hostDeviceId: result.hostDeviceId,
|
|
285
|
-
deviceToken: token,
|
|
286
|
-
authorizationId: authorization?.authorizationId,
|
|
287
|
-
});
|
|
250
|
+
tokenManager.bind(token, result.sessionId);
|
|
251
|
+
json(res, 200, { sessionId: result.sessionId, deviceToken: token });
|
|
288
252
|
return;
|
|
289
253
|
}
|
|
290
|
-
//
|
|
291
|
-
if (method === "GET" && url.pathname === "/
|
|
254
|
+
// Session list
|
|
255
|
+
if (method === "GET" && url.pathname === "/sessions") {
|
|
292
256
|
const token = extractBearerToken(req);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
bufferSize: 0,
|
|
314
|
-
machineId: null,
|
|
315
|
-
hostname: null,
|
|
316
|
-
platform: null,
|
|
317
|
-
cwd: null,
|
|
318
|
-
capabilities: [],
|
|
319
|
-
authorizationId: tokenManager.getAuthorizationId(token, hostDeviceId) ?? null,
|
|
320
|
-
};
|
|
321
|
-
}).map((device) => ({
|
|
322
|
-
...device,
|
|
323
|
-
authorizationId: tokenManager.getAuthorizationId(token, device.hostDeviceId) ?? null,
|
|
257
|
+
const allowedIds = token && tokenManager.validate(token)
|
|
258
|
+
? tokenManager.getSessionIds(token)
|
|
259
|
+
: new Set();
|
|
260
|
+
const sessions = sessionManager
|
|
261
|
+
.listActive()
|
|
262
|
+
.filter((s) => allowedIds.has(s.id))
|
|
263
|
+
.map((s) => ({
|
|
264
|
+
id: s.id,
|
|
265
|
+
state: s.state,
|
|
266
|
+
hasHost: !!s.host && s.host.socket.readyState === s.host.socket.OPEN,
|
|
267
|
+
clientCount: s.clients.size,
|
|
268
|
+
controllerId: s.controllerId ?? null,
|
|
269
|
+
lastActivity: s.lastActivity,
|
|
270
|
+
createdAt: s.createdAt,
|
|
271
|
+
provider: s.provider ?? null,
|
|
272
|
+
machineId: s.machineId ?? null,
|
|
273
|
+
hostname: s.hostname ?? null,
|
|
274
|
+
platform: s.platform ?? null,
|
|
275
|
+
cwd: s.cwd ?? null,
|
|
276
|
+
projectName: s.projectName ?? null,
|
|
324
277
|
}));
|
|
325
|
-
json(res, 200, {
|
|
278
|
+
json(res, 200, { sessions });
|
|
326
279
|
return;
|
|
327
280
|
}
|
|
328
|
-
//
|
|
329
|
-
const
|
|
330
|
-
if (method === "GET" &&
|
|
281
|
+
// Session detail
|
|
282
|
+
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
283
|
+
if (method === "GET" && sessionMatch) {
|
|
331
284
|
const token = extractBearerToken(req);
|
|
332
|
-
const targetId =
|
|
285
|
+
const targetId = sessionMatch[1];
|
|
333
286
|
if (!token || !tokenManager.owns(token, targetId)) {
|
|
334
287
|
json(res, 401, {
|
|
335
288
|
error: "unauthorized",
|
|
@@ -339,48 +292,10 @@ async function handleRequest(req, res) {
|
|
|
339
292
|
}
|
|
340
293
|
const summary = sessionManager.getSummary(targetId);
|
|
341
294
|
if (!summary) {
|
|
342
|
-
json(res,
|
|
343
|
-
id: targetId,
|
|
344
|
-
hostDeviceId: targetId,
|
|
345
|
-
state: "host_disconnected",
|
|
346
|
-
online: false,
|
|
347
|
-
hasHost: false,
|
|
348
|
-
clientCount: 0,
|
|
349
|
-
controllerId: null,
|
|
350
|
-
lastActivity: null,
|
|
351
|
-
createdAt: null,
|
|
352
|
-
bufferSize: 0,
|
|
353
|
-
machineId: null,
|
|
354
|
-
hostname: null,
|
|
355
|
-
platform: null,
|
|
356
|
-
cwd: null,
|
|
357
|
-
capabilities: [],
|
|
358
|
-
authorizationId: tokenManager.getAuthorizationId(token, targetId) ?? null,
|
|
359
|
-
});
|
|
295
|
+
json(res, 404, { error: "session_not_found" });
|
|
360
296
|
return;
|
|
361
297
|
}
|
|
362
|
-
json(res, 200,
|
|
363
|
-
...summary,
|
|
364
|
-
authorizationId: tokenManager.getAuthorizationId(token, targetId) ?? null,
|
|
365
|
-
});
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
const revokeMatch = url.pathname.match(/^\/devices\/([^/]+)\/authorizations\/([^/]+)$/);
|
|
369
|
-
if (method === "DELETE" && revokeMatch) {
|
|
370
|
-
const token = extractBearerToken(req);
|
|
371
|
-
const hostDeviceId = decodeURIComponent(revokeMatch[1]);
|
|
372
|
-
const authorizationId = decodeURIComponent(revokeMatch[2]);
|
|
373
|
-
if (!token ||
|
|
374
|
-
tokenManager.getAuthorizationId(token, hostDeviceId) !== authorizationId ||
|
|
375
|
-
!tokenManager.revoke(token, hostDeviceId, authorizationId)) {
|
|
376
|
-
json(res, 401, {
|
|
377
|
-
error: "unauthorized",
|
|
378
|
-
message: "Valid device authorization required",
|
|
379
|
-
});
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
sessionManager.disconnectAuthorization(hostDeviceId, authorizationId);
|
|
383
|
-
json(res, 200, { ok: true });
|
|
298
|
+
json(res, 200, summary);
|
|
384
299
|
return;
|
|
385
300
|
}
|
|
386
301
|
// Pairing status (for CLI polling)
|
|
@@ -400,7 +315,6 @@ async function handleRequest(req, res) {
|
|
|
400
315
|
const wss = new WebSocketServer({
|
|
401
316
|
noServer: true,
|
|
402
317
|
maxPayload: MAX_WS_MESSAGE_SIZE,
|
|
403
|
-
perMessageDeflate: false,
|
|
404
318
|
});
|
|
405
319
|
server.on("upgrade", (request, socket, head) => {
|
|
406
320
|
const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
|
|
@@ -425,7 +339,7 @@ server.on("upgrade", (request, socket, head) => {
|
|
|
425
339
|
const tunnelCookie = parseTunnelCookie(request);
|
|
426
340
|
if (tunnelCookie && url.pathname !== "/ws") {
|
|
427
341
|
const fallbackParsed = {
|
|
428
|
-
|
|
342
|
+
sessionId: tunnelCookie.sessionId,
|
|
429
343
|
port: tunnelCookie.port,
|
|
430
344
|
path: url.pathname,
|
|
431
345
|
};
|
|
@@ -472,65 +386,57 @@ server.on("upgrade", (request, socket, head) => {
|
|
|
472
386
|
});
|
|
473
387
|
});
|
|
474
388
|
wss.on("connection", (socket, _request, url) => {
|
|
475
|
-
const
|
|
389
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
476
390
|
const role = url.searchParams.get("role");
|
|
477
|
-
if (!
|
|
478
|
-
socket.close(1008, "missing
|
|
391
|
+
if (!sessionId || !role || (role !== "host" && role !== "client")) {
|
|
392
|
+
socket.close(1008, "missing sessionId or role");
|
|
479
393
|
return;
|
|
480
394
|
}
|
|
481
395
|
const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
|
|
482
|
-
let clientToken;
|
|
483
|
-
let clientAuthorizationId;
|
|
484
396
|
if (role === "client") {
|
|
485
397
|
const token = url.searchParams.get("token");
|
|
486
398
|
const authResult = _request.__authResult;
|
|
487
|
-
const
|
|
488
|
-
// Allow if: device token owns
|
|
489
|
-
const tokenOwns =
|
|
399
|
+
const session = sessionManager.get(sessionId);
|
|
400
|
+
// Allow if: device token owns session, OR auth user owns session
|
|
401
|
+
const tokenOwns = token && tokenManager.owns(token, sessionId);
|
|
490
402
|
const authOwns = AUTH_REQUIRED &&
|
|
491
403
|
authResult?.userId &&
|
|
492
|
-
|
|
493
|
-
authResult.userId ===
|
|
404
|
+
session?.userId &&
|
|
405
|
+
authResult.userId === session.userId;
|
|
494
406
|
if (!tokenOwns && !authOwns) {
|
|
495
407
|
socket.close(4001, "unauthorized");
|
|
496
408
|
return;
|
|
497
409
|
}
|
|
498
410
|
if (!tokenOwns && authOwns && token) {
|
|
499
411
|
tokenManager.register(token);
|
|
500
|
-
tokenManager.bind(token,
|
|
501
|
-
log("info", `bound authenticated device token to
|
|
502
|
-
}
|
|
503
|
-
if (token && (tokenOwns || authOwns)) {
|
|
504
|
-
clientToken = token;
|
|
505
|
-
clientAuthorizationId = tokenManager.getAuthorizationId(token, hostDeviceId);
|
|
412
|
+
tokenManager.bind(token, sessionId);
|
|
413
|
+
log("info", `bound authenticated device token to session ${sessionId}`);
|
|
506
414
|
}
|
|
507
415
|
}
|
|
508
416
|
const device = {
|
|
509
417
|
socket,
|
|
510
418
|
role,
|
|
511
419
|
deviceId,
|
|
512
|
-
token: clientToken,
|
|
513
|
-
authorizationId: clientAuthorizationId,
|
|
514
420
|
connectedAt: Date.now(),
|
|
515
421
|
};
|
|
516
422
|
if (role === "host") {
|
|
517
423
|
// Check if this is a reconnect (session already exists with clients)
|
|
518
|
-
const existingSession = sessionManager.get(
|
|
424
|
+
const existingSession = sessionManager.get(sessionId);
|
|
519
425
|
const isReconnect = existingSession &&
|
|
520
426
|
existingSession.clients.size > 0 &&
|
|
521
427
|
existingSession.state === "host_disconnected";
|
|
522
|
-
sessionManager.setHost(
|
|
428
|
+
sessionManager.setHost(sessionId, device);
|
|
523
429
|
// Associate userId from auth (for AUTH_REQUIRED gateways)
|
|
524
430
|
const authResult = _request.__authResult;
|
|
525
431
|
if (authResult?.userId) {
|
|
526
|
-
const
|
|
527
|
-
if (
|
|
528
|
-
|
|
432
|
+
const session = sessionManager.get(sessionId);
|
|
433
|
+
if (session)
|
|
434
|
+
session.userId = authResult.userId;
|
|
529
435
|
}
|
|
530
436
|
if (isReconnect) {
|
|
531
437
|
const notification = serializeEnvelope(createEnvelope({
|
|
532
|
-
type: "
|
|
533
|
-
|
|
438
|
+
type: "session.host_reconnected",
|
|
439
|
+
sessionId,
|
|
534
440
|
payload: {},
|
|
535
441
|
}));
|
|
536
442
|
for (const [, client] of existingSession.clients) {
|
|
@@ -541,12 +447,12 @@ wss.on("connection", (socket, _request, url) => {
|
|
|
541
447
|
}
|
|
542
448
|
}
|
|
543
449
|
else {
|
|
544
|
-
sessionManager.addClient(
|
|
450
|
+
sessionManager.addClient(sessionId, device);
|
|
545
451
|
}
|
|
546
452
|
// Send welcome with protocol version
|
|
547
453
|
socket.send(serializeEnvelope(createEnvelope({
|
|
548
|
-
type: "
|
|
549
|
-
|
|
454
|
+
type: "session.connect",
|
|
455
|
+
sessionId,
|
|
550
456
|
payload: {
|
|
551
457
|
role,
|
|
552
458
|
clientName: deviceId,
|
|
@@ -555,7 +461,7 @@ wss.on("connection", (socket, _request, url) => {
|
|
|
555
461
|
})));
|
|
556
462
|
// If client just joined and host is not connected, notify immediately
|
|
557
463
|
if (role === "client") {
|
|
558
|
-
const sessionAfterJoin = sessionManager.get(
|
|
464
|
+
const sessionAfterJoin = sessionManager.get(sessionId);
|
|
559
465
|
if (sessionAfterJoin) {
|
|
560
466
|
const hostGone = !sessionAfterJoin.host ||
|
|
561
467
|
sessionAfterJoin.state === "host_disconnected" ||
|
|
@@ -563,8 +469,8 @@ wss.on("connection", (socket, _request, url) => {
|
|
|
563
469
|
sessionAfterJoin.host.socket.OPEN;
|
|
564
470
|
if (hostGone) {
|
|
565
471
|
socket.send(serializeEnvelope(createEnvelope({
|
|
566
|
-
type: "
|
|
567
|
-
|
|
472
|
+
type: "session.host_disconnected",
|
|
473
|
+
sessionId,
|
|
568
474
|
payload: { reason: "host not connected" },
|
|
569
475
|
})));
|
|
570
476
|
}
|
|
@@ -578,14 +484,14 @@ wss.on("connection", (socket, _request, url) => {
|
|
|
578
484
|
}, PING_INTERVAL);
|
|
579
485
|
socket.on("message", (data) => {
|
|
580
486
|
try {
|
|
581
|
-
handleSocketMessage(socket, data.toString(), role,
|
|
487
|
+
handleSocketMessage(socket, data.toString(), role, sessionId, deviceId, sessionManager);
|
|
582
488
|
}
|
|
583
489
|
catch (err) {
|
|
584
|
-
log("error", `unhandled websocket message error for
|
|
490
|
+
log("error", `unhandled websocket message error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
585
491
|
if (socket.readyState === socket.OPEN) {
|
|
586
492
|
socket.send(serializeEnvelope(createEnvelope({
|
|
587
|
-
type: "
|
|
588
|
-
|
|
493
|
+
type: "session.error",
|
|
494
|
+
sessionId,
|
|
589
495
|
payload: {
|
|
590
496
|
code: "invalid_message",
|
|
591
497
|
message: "Failed to handle message",
|
|
@@ -597,13 +503,12 @@ wss.on("connection", (socket, _request, url) => {
|
|
|
597
503
|
socket.on("close", () => {
|
|
598
504
|
clearInterval(pingTimer);
|
|
599
505
|
if (role === "host") {
|
|
600
|
-
const result = sessionManager.removeHost(
|
|
601
|
-
cleanupSessionTunnels(hostDeviceId);
|
|
506
|
+
const result = sessionManager.removeHost(sessionId);
|
|
602
507
|
// Notify all clients that host disconnected
|
|
603
508
|
if (result) {
|
|
604
509
|
const notification = serializeEnvelope(createEnvelope({
|
|
605
|
-
type: "
|
|
606
|
-
|
|
510
|
+
type: "session.host_disconnected",
|
|
511
|
+
sessionId,
|
|
607
512
|
payload: { reason: "host connection closed" },
|
|
608
513
|
}));
|
|
609
514
|
for (const [, client] of result.clients) {
|
|
@@ -614,7 +519,7 @@ wss.on("connection", (socket, _request, url) => {
|
|
|
614
519
|
}
|
|
615
520
|
}
|
|
616
521
|
else {
|
|
617
|
-
sessionManager.removeClient(
|
|
522
|
+
sessionManager.removeClient(sessionId, deviceId);
|
|
618
523
|
}
|
|
619
524
|
});
|
|
620
525
|
socket.on("error", () => {
|
|
@@ -646,16 +551,16 @@ if (AUTH_REQUIRED) {
|
|
|
646
551
|
continue;
|
|
647
552
|
const subscription = await checkSubscriptionByUserId(session.userId);
|
|
648
553
|
if (subscription.status === "unknown") {
|
|
649
|
-
log("warn", `subscription check unknown for user ${session.userId}, keeping
|
|
554
|
+
log("warn", `subscription check unknown for user ${session.userId}, keeping session ${session.id}${subscription.reason ? ` (${subscription.reason})` : ""}`);
|
|
650
555
|
continue;
|
|
651
556
|
}
|
|
652
557
|
if (subscription.status === "inactive") {
|
|
653
|
-
log("info", `subscription expired for user ${session.userId}, disconnecting
|
|
558
|
+
log("info", `subscription expired for user ${session.userId}, disconnecting session ${session.id}`);
|
|
654
559
|
// Notify host
|
|
655
560
|
try {
|
|
656
561
|
session.host.socket.send(serializeEnvelope(createEnvelope({
|
|
657
|
-
type: "
|
|
658
|
-
|
|
562
|
+
type: "session.error",
|
|
563
|
+
sessionId: session.id,
|
|
659
564
|
payload: {
|
|
660
565
|
code: "subscription_expired",
|
|
661
566
|
message: "Your Pro subscription has expired. Renew at https://itool.tech",
|
|
@@ -669,11 +574,11 @@ if (AUTH_REQUIRED) {
|
|
|
669
574
|
for (const [, client] of session.clients) {
|
|
670
575
|
try {
|
|
671
576
|
client.socket.send(serializeEnvelope(createEnvelope({
|
|
672
|
-
type: "
|
|
673
|
-
|
|
577
|
+
type: "session.error",
|
|
578
|
+
sessionId: session.id,
|
|
674
579
|
payload: {
|
|
675
580
|
code: "subscription_expired",
|
|
676
|
-
message: "Host subscription expired.
|
|
581
|
+
message: "Host subscription expired. Session ended.",
|
|
677
582
|
},
|
|
678
583
|
})));
|
|
679
584
|
client.socket.close(4003, "subscription_expired");
|