@linkshell/gateway 0.2.21 → 0.2.23

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/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  handleTunnelRequest,
20
20
  cleanupSessionTunnels,
21
21
  } from "./tunnel.js";
22
+ import { AUTH_REQUIRED, requireAuth, checkWsAuth, validateRequest, checkSubscriptionByUserId } from "./auth-middleware.js";
22
23
 
23
24
  const port = Number(process.env.PORT ?? 8787);
24
25
  const logLevel = (process.env.LOG_LEVEL ?? "info") as
@@ -173,6 +174,28 @@ async function handleRequest(
173
174
  return;
174
175
  }
175
176
 
177
+ // Sessions owned by authenticated user (before AUTH_REQUIRED guard — uses its own auth)
178
+ if (method === "GET" && url.pathname === "/sessions/mine") {
179
+ const authResult = await validateRequest(req);
180
+ if (!authResult || !authResult.userId) {
181
+ json(res, 401, { error: "auth_required", message: "Authentication required" });
182
+ return;
183
+ }
184
+ const sessions = sessionManager
185
+ .listActive()
186
+ .filter((s) => s.userId === authResult.userId)
187
+ .map((s) => sessionManager.getSummary(s.id))
188
+ .filter(Boolean);
189
+ json(res, 200, { sessions });
190
+ return;
191
+ }
192
+
193
+ // Auth check for premium gateway (skip healthz and /sessions/mine)
194
+ if (AUTH_REQUIRED) {
195
+ const authResult = await requireAuth(req, res);
196
+ if (!authResult) return; // response already sent
197
+ }
198
+
176
199
  // Create pairing
177
200
  if (method === "POST" && url.pathname === "/pairings") {
178
201
  if (!isRateLimitBypassed(ip) && !pairingLimiter.allow(ip)) {
@@ -345,6 +368,27 @@ server.on("upgrade", (request, socket, head) => {
345
368
  return;
346
369
  }
347
370
 
371
+ // Auth check for premium gateway WebSocket connections
372
+ if (AUTH_REQUIRED) {
373
+ checkWsAuth(request).then((authResult) => {
374
+ if (!authResult || !authResult.authenticated) {
375
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
376
+ socket.destroy();
377
+ return;
378
+ }
379
+ if (!authResult.subscribed) {
380
+ socket.write("HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nPro subscription required. Subscribe at https://itool.tech\r\n");
381
+ socket.destroy();
382
+ return;
383
+ }
384
+ (request as any).__authResult = authResult;
385
+ wss.handleUpgrade(request, socket, head, (ws) => {
386
+ wss.emit("connection", ws, request, url);
387
+ });
388
+ });
389
+ return;
390
+ }
391
+
348
392
  wss.handleUpgrade(request, socket, head, (ws) => {
349
393
  wss.emit("connection", ws, request, url);
350
394
  });
@@ -386,6 +430,15 @@ wss.on(
386
430
  existingSession.clients.size > 0 &&
387
431
  existingSession.state === "host_disconnected";
388
432
  sessionManager.setHost(sessionId, device);
433
+
434
+ // Associate userId from auth (for AUTH_REQUIRED gateways)
435
+ const authResult = (_request as any).__authResult as
436
+ | { userId?: string }
437
+ | undefined;
438
+ if (authResult?.userId) {
439
+ const session = sessionManager.get(sessionId);
440
+ if (session) session.userId = authResult.userId;
441
+ }
389
442
  if (isReconnect) {
390
443
  const notification = serializeEnvelope(
391
444
  createEnvelope({
@@ -509,6 +562,57 @@ function shutdown() {
509
562
  process.on("SIGINT", shutdown);
510
563
  process.on("SIGTERM", shutdown);
511
564
 
565
+ // ── Subscription expiry checker (AUTH_REQUIRED gateways only) ──────
566
+
567
+ const SUB_CHECK_INTERVAL = 5 * 60_000; // 5 minutes
568
+
569
+ if (AUTH_REQUIRED) {
570
+ setInterval(async () => {
571
+ for (const session of sessionManager.listActive()) {
572
+ if (!session.userId || !session.host) continue;
573
+ const stillSubscribed = await checkSubscriptionByUserId(session.userId);
574
+ if (!stillSubscribed) {
575
+ log("info", `subscription expired for user ${session.userId}, disconnecting session ${session.id}`);
576
+ // Notify host
577
+ try {
578
+ session.host.socket.send(
579
+ serializeEnvelope(
580
+ createEnvelope({
581
+ type: "session.error",
582
+ sessionId: session.id,
583
+ payload: {
584
+ code: "subscription_expired",
585
+ message: "Your Pro subscription has expired. Renew at https://itool.tech",
586
+ },
587
+ }),
588
+ ),
589
+ );
590
+ } catch {}
591
+ // Close host connection
592
+ session.host.socket.close(4003, "subscription_expired");
593
+ // Notify clients
594
+ for (const [, client] of session.clients) {
595
+ try {
596
+ client.socket.send(
597
+ serializeEnvelope(
598
+ createEnvelope({
599
+ type: "session.error",
600
+ sessionId: session.id,
601
+ payload: {
602
+ code: "subscription_expired",
603
+ message: "Host subscription expired. Session ended.",
604
+ },
605
+ }),
606
+ ),
607
+ );
608
+ client.socket.close(4003, "subscription_expired");
609
+ } catch {}
610
+ }
611
+ }
612
+ }
613
+ }, SUB_CHECK_INTERVAL);
614
+ }
615
+
512
616
  // ── Start ───────────────────────────────────────────────────────────
513
617
 
514
618
  server.listen(port, () => {
package/src/sessions.ts CHANGED
@@ -27,6 +27,8 @@ export interface Session {
27
27
  platform: string | undefined;
28
28
  cwd: string | undefined;
29
29
  projectName: string | undefined;
30
+ // Auth: user who owns this session (set on AUTH_REQUIRED gateways)
31
+ userId: string | undefined;
30
32
  }
31
33
 
32
34
  const OUTPUT_BUFFER_CAPACITY = 200;
@@ -60,6 +62,7 @@ export class SessionManager {
60
62
  platform: undefined,
61
63
  cwd: undefined,
62
64
  projectName: undefined,
65
+ userId: undefined,
63
66
  };
64
67
  this.sessions.set(sessionId, session);
65
68
  }
@@ -194,6 +197,7 @@ export class SessionManager {
194
197
  platform: session.platform ?? null,
195
198
  cwd: session.cwd ?? null,
196
199
  projectName: session.projectName ?? null,
200
+ userId: session.userId ?? null,
197
201
  };
198
202
  }
199
203
 
package/src/tunnel.ts CHANGED
@@ -209,29 +209,29 @@ export function handleTunnelResponse(payload: {
209
209
  body: string;
210
210
  isFinal: boolean;
211
211
  }): void {
212
- const pending = pendingRequests.get(payload.requestId);
213
- if (!pending) return;
214
-
215
- if (!pending.headersSent) {
216
- // Merge CORS headers
217
- const responseHeaders: Record<string, string> = {
218
- ...payload.headers,
219
- "access-control-allow-origin": "*",
220
- };
221
- pending.res.writeHead(payload.statusCode, responseHeaders);
222
- pending.headersSent = true;
223
- }
212
+ try {
213
+ const pending = pendingRequests.get(payload.requestId);
214
+ if (!pending) return;
215
+
216
+ if (!pending.headersSent) {
217
+ const responseHeaders: Record<string, string> = {
218
+ ...payload.headers,
219
+ "access-control-allow-origin": "*",
220
+ };
221
+ pending.res.writeHead(payload.statusCode, responseHeaders);
222
+ pending.headersSent = true;
223
+ }
224
224
 
225
- // Write body chunk
226
- if (payload.body) {
227
- pending.res.write(Buffer.from(payload.body, "base64"));
228
- }
225
+ if (payload.body) {
226
+ pending.res.write(Buffer.from(payload.body, "base64"));
227
+ }
229
228
 
230
- if (payload.isFinal) {
231
- clearTimeout(pending.timeout);
232
- pendingRequests.delete(payload.requestId);
233
- pending.res.end();
234
- }
229
+ if (payload.isFinal) {
230
+ clearTimeout(pending.timeout);
231
+ pendingRequests.delete(payload.requestId);
232
+ pending.res.end();
233
+ }
234
+ } catch {}
235
235
  }
236
236
 
237
237
  export function handleTunnelWsData(payload: {
@@ -239,10 +239,12 @@ export function handleTunnelWsData(payload: {
239
239
  data: string;
240
240
  isBinary: boolean;
241
241
  }): void {
242
- const pending = pendingWsSockets.get(payload.requestId);
243
- if (!pending) return;
244
- const buf = Buffer.from(payload.data, "base64");
245
- pending.ws.send(payload.isBinary ? buf : buf.toString("utf8"));
242
+ try {
243
+ const pending = pendingWsSockets.get(payload.requestId);
244
+ if (!pending || pending.ws.readyState !== 1) return;
245
+ const buf = Buffer.from(payload.data, "base64");
246
+ pending.ws.send(payload.isBinary ? buf : buf.toString("utf8"));
247
+ } catch {}
246
248
  }
247
249
 
248
250
  export function handleTunnelWsClose(payload: {
@@ -250,11 +252,15 @@ export function handleTunnelWsClose(payload: {
250
252
  code?: number;
251
253
  reason?: string;
252
254
  }): void {
253
- const pending = pendingWsSockets.get(payload.requestId);
254
- if (!pending) return;
255
- const code = payload.code && payload.code >= 1000 && payload.code <= 4999 ? payload.code : 1000;
256
- pending.ws.close(code, payload.reason ?? "");
257
- pendingWsSockets.delete(payload.requestId);
255
+ try {
256
+ const pending = pendingWsSockets.get(payload.requestId);
257
+ if (!pending) return;
258
+ pendingWsSockets.delete(payload.requestId);
259
+ if (pending.ws.readyState === 1) {
260
+ const code = typeof payload.code === "number" && payload.code >= 1000 && payload.code <= 4999 ? payload.code : 1000;
261
+ pending.ws.close(code, payload.reason ?? "");
262
+ }
263
+ } catch {}
258
264
  }
259
265
 
260
266
  export function registerTunnelWs(requestId: string, ws: WebSocket): void {
@@ -330,36 +336,41 @@ export function handleTunnelWsUpgrade(
330
336
 
331
337
  // Forward data from browser WS to host
332
338
  ws.on("message", (data: Buffer | string) => {
333
- const s = sessions.get(sessionId);
334
- if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
335
- const isBinary = typeof data !== "string";
336
- const buf = typeof data === "string" ? Buffer.from(data) : data;
337
- const fwd = createEnvelope({
338
- type: "tunnel.ws.data",
339
- sessionId,
340
- payload: {
341
- requestId,
342
- data: buf.toString("base64"),
343
- isBinary,
344
- },
345
- });
346
- s.host.socket.send(serializeEnvelope(fwd));
339
+ try {
340
+ const s = sessions.get(sessionId);
341
+ if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
342
+ const isBinary = typeof data !== "string";
343
+ const buf = typeof data === "string" ? Buffer.from(data) : data;
344
+ const fwd = createEnvelope({
345
+ type: "tunnel.ws.data",
346
+ sessionId,
347
+ payload: {
348
+ requestId,
349
+ data: buf.toString("base64"),
350
+ isBinary,
351
+ },
352
+ });
353
+ s.host.socket.send(serializeEnvelope(fwd));
354
+ } catch {}
347
355
  });
348
356
 
349
357
  ws.on("close", (code, reason) => {
350
- removeTunnelWs(requestId);
351
- untrackRequest(sessionId, requestId);
352
- const s = sessions.get(sessionId);
353
- if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
354
- const fwd = createEnvelope({
355
- type: "tunnel.ws.close",
356
- sessionId,
357
- payload: {
358
- requestId,
359
- code,
360
- reason: reason?.toString() || "",
361
- },
362
- });
363
- s.host.socket.send(serializeEnvelope(fwd));
358
+ try {
359
+ removeTunnelWs(requestId);
360
+ untrackRequest(sessionId, requestId);
361
+ const s = sessions.get(sessionId);
362
+ if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
363
+ const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
364
+ const fwd = createEnvelope({
365
+ type: "tunnel.ws.close",
366
+ sessionId,
367
+ payload: {
368
+ requestId,
369
+ code: safeCode,
370
+ reason: reason?.toString() || "",
371
+ },
372
+ });
373
+ s.host.socket.send(serializeEnvelope(fwd));
374
+ } catch {}
364
375
  });
365
376
  }