@linkshell/gateway 0.5.3 → 0.6.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 (109) hide show
  1. package/Dockerfile +8 -22
  2. package/README.md +99 -44
  3. package/dist/gateway/src/accounts.js.map +1 -0
  4. package/dist/gateway/src/index.d.ts +4 -1
  5. package/dist/gateway/src/index.js +4 -863
  6. package/dist/gateway/src/index.js.map +1 -1
  7. package/dist/gateway/src/main.js +57 -0
  8. package/dist/gateway/src/main.js.map +1 -0
  9. package/dist/gateway/src/rate-limit.d.ts +22 -0
  10. package/dist/gateway/src/rate-limit.js +66 -0
  11. package/dist/gateway/src/rate-limit.js.map +1 -0
  12. package/dist/gateway/src/relay.d.ts +84 -5
  13. package/dist/gateway/src/relay.js +402 -529
  14. package/dist/gateway/src/relay.js.map +1 -1
  15. package/dist/gateway/src/serve.d.ts +34 -0
  16. package/dist/gateway/src/serve.js +102 -0
  17. package/dist/gateway/src/serve.js.map +1 -0
  18. package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
  19. package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
  20. package/dist/gateway/src/store.js.map +1 -0
  21. package/dist/gateway/src/subscription.d.ts +18 -0
  22. package/dist/gateway/src/subscription.js +46 -0
  23. package/dist/gateway/src/subscription.js.map +1 -0
  24. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  25. package/dist/wire/src/direct.d.ts +22 -0
  26. package/dist/wire/src/direct.js +37 -0
  27. package/dist/wire/src/direct.js.map +1 -0
  28. package/dist/wire/src/index.d.ts +1 -0
  29. package/dist/wire/src/index.js +1 -0
  30. package/dist/wire/src/index.js.map +1 -1
  31. package/dist/wire/src/model.d.ts +287 -6
  32. package/dist/wire/src/model.js +33 -0
  33. package/dist/wire/src/model.js.map +1 -1
  34. package/dist/wire/src/relay.d.ts +8 -8
  35. package/dist/wire/src/rpc.d.ts +1780 -44
  36. package/dist/wire/src/rpc.js +48 -3
  37. package/dist/wire/src/rpc.js.map +1 -1
  38. package/dist/wire/src/updates.d.ts +356 -0
  39. package/dist/wire/src/updates.js +11 -1
  40. package/dist/wire/src/updates.js.map +1 -1
  41. package/package.json +12 -12
  42. package/src/accounts.ts +32 -0
  43. package/src/index.ts +4 -997
  44. package/src/main.ts +65 -0
  45. package/src/rate-limit.ts +72 -0
  46. package/src/relay.ts +435 -620
  47. package/src/serve.ts +130 -0
  48. package/src/store.ts +135 -0
  49. package/src/subscription.ts +51 -0
  50. package/dist/gateway/src/auth-middleware.d.ts +0 -44
  51. package/dist/gateway/src/auth-middleware.js +0 -204
  52. package/dist/gateway/src/auth-middleware.js.map +0 -1
  53. package/dist/gateway/src/cors.d.ts +0 -2
  54. package/dist/gateway/src/cors.js +0 -7
  55. package/dist/gateway/src/cors.js.map +0 -1
  56. package/dist/gateway/src/embedded.d.ts +0 -21
  57. package/dist/gateway/src/embedded.js +0 -438
  58. package/dist/gateway/src/embedded.js.map +0 -1
  59. package/dist/gateway/src/host-auth.d.ts +0 -32
  60. package/dist/gateway/src/host-auth.js +0 -78
  61. package/dist/gateway/src/host-auth.js.map +0 -1
  62. package/dist/gateway/src/pairings.d.ts +0 -38
  63. package/dist/gateway/src/pairings.js +0 -119
  64. package/dist/gateway/src/pairings.js.map +0 -1
  65. package/dist/gateway/src/presence.d.ts +0 -28
  66. package/dist/gateway/src/presence.js +0 -92
  67. package/dist/gateway/src/presence.js.map +0 -1
  68. package/dist/gateway/src/sessions.d.ts +0 -113
  69. package/dist/gateway/src/sessions.js +0 -352
  70. package/dist/gateway/src/sessions.js.map +0 -1
  71. package/dist/gateway/src/state-store.d.ts +0 -21
  72. package/dist/gateway/src/state-store.js +0 -103
  73. package/dist/gateway/src/state-store.js.map +0 -1
  74. package/dist/gateway/src/static-web.d.ts +0 -15
  75. package/dist/gateway/src/static-web.js +0 -129
  76. package/dist/gateway/src/static-web.js.map +0 -1
  77. package/dist/gateway/src/tokens.d.ts +0 -19
  78. package/dist/gateway/src/tokens.js +0 -151
  79. package/dist/gateway/src/tokens.js.map +0 -1
  80. package/dist/gateway/src/tunnel.d.ts +0 -61
  81. package/dist/gateway/src/tunnel.js +0 -576
  82. package/dist/gateway/src/tunnel.js.map +0 -1
  83. package/dist/gateway-v2/src/accounts.js.map +0 -1
  84. package/dist/gateway-v2/src/index.d.ts +0 -3
  85. package/dist/gateway-v2/src/index.js +0 -4
  86. package/dist/gateway-v2/src/index.js.map +0 -1
  87. package/dist/gateway-v2/src/main.js +0 -17
  88. package/dist/gateway-v2/src/main.js.map +0 -1
  89. package/dist/gateway-v2/src/server.d.ts +0 -84
  90. package/dist/gateway-v2/src/server.js +0 -426
  91. package/dist/gateway-v2/src/server.js.map +0 -1
  92. package/dist/gateway-v2/src/store.js.map +0 -1
  93. package/dist/shared-protocol/src/index.d.ts +0 -17029
  94. package/dist/shared-protocol/src/index.js +0 -1064
  95. package/dist/shared-protocol/src/index.js.map +0 -1
  96. package/src/auth-middleware.ts +0 -266
  97. package/src/cors.ts +0 -8
  98. package/src/embedded.ts +0 -526
  99. package/src/host-auth.ts +0 -88
  100. package/src/pairings.ts +0 -133
  101. package/src/presence.ts +0 -118
  102. package/src/sessions.ts +0 -432
  103. package/src/state-store.ts +0 -140
  104. package/src/static-web.ts +0 -133
  105. package/src/tokens.ts +0 -159
  106. package/src/tunnel.ts +0 -644
  107. /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
  108. /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
  109. /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
package/src/tunnel.ts DELETED
@@ -1,644 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { randomUUID } from "node:crypto";
3
- import type WebSocket from "ws";
4
- import {
5
- createEnvelope,
6
- serializeEnvelope,
7
- } from "@linkshell/protocol";
8
- import type { SessionManager } from "./sessions.js";
9
- import type { TokenManager } from "./tokens.js";
10
- import { AUTH_REQUIRED } from "./auth-middleware.js";
11
-
12
- const TUNNEL_TIMEOUT = 30_000;
13
- const MAX_TUNNEL_BODY = 10 * 1024 * 1024; // 10MB
14
-
15
- export interface PendingTunnelRequest {
16
- res: ServerResponse;
17
- headersSent: boolean;
18
- timeout: ReturnType<typeof setTimeout>;
19
- tunnelCookie?: string;
20
- sessionId: string;
21
- port: number;
22
- requestPath: string;
23
- }
24
-
25
- export interface PendingTunnelWs {
26
- ws: WebSocket;
27
- }
28
-
29
- // Module-level maps keyed by requestId
30
- const pendingRequests = new Map<string, PendingTunnelRequest>();
31
- const pendingWsSockets = new Map<string, PendingTunnelWs>();
32
-
33
- // Track requestIds per session for cleanup on host disconnect
34
- const sessionRequests = new Map<string, Set<string>>();
35
-
36
- function trackRequest(sessionId: string, requestId: string): void {
37
- let set = sessionRequests.get(sessionId);
38
- if (!set) {
39
- set = new Set();
40
- sessionRequests.set(sessionId, set);
41
- }
42
- set.add(requestId);
43
- }
44
-
45
- function untrackRequest(sessionId: string, requestId: string): void {
46
- const set = sessionRequests.get(sessionId);
47
- if (set) {
48
- set.delete(requestId);
49
- if (set.size === 0) sessionRequests.delete(sessionId);
50
- }
51
- }
52
-
53
- function extractToken(req: IncomingMessage, url: URL): string | null {
54
- // Check Authorization header
55
- const auth = req.headers.authorization;
56
- if (auth) {
57
- const match = auth.match(/^Bearer\s+(.+)$/i);
58
- if (match?.[1]) return match[1];
59
- }
60
- // Check query param
61
- const qToken = url.searchParams.get("token");
62
- if (qToken) return qToken;
63
- // Check cookie (token-only cookie)
64
- const cookie = req.headers.cookie;
65
- if (cookie) {
66
- const match = cookie.match(/lsh_token=([^;]+)/);
67
- if (match?.[1]) return match[1];
68
- }
69
- return null;
70
- }
71
-
72
- function stripGatewayCookies(cookie: string): string | null {
73
- const kept = cookie
74
- .split(";")
75
- .map((part) => part.trim())
76
- .filter((part) => {
77
- const name = part.split("=", 1)[0]?.trim();
78
- return name !== "lsh_tunnel" && name !== "lsh_token";
79
- });
80
- return kept.length > 0 ? kept.join("; ") : null;
81
- }
82
-
83
- function bindExplicitDeviceToken(
84
- tokens: TokenManager,
85
- sessionId: string,
86
- url: URL,
87
- preAuthToken?: string,
88
- ): string | null {
89
- // Cookie fallback may pass a JWT as preAuthToken. Only bind the explicit
90
- // browser/device token from the original tunnel URL after JWT ownership has
91
- // been proven.
92
- if (preAuthToken) return null;
93
- const deviceToken = url.searchParams.get("token");
94
- if (!deviceToken) return null;
95
- const token = tokens.register(deviceToken);
96
- return tokens.bind(token, sessionId) ? token : null;
97
- }
98
-
99
- function forwardedUrl(path: string, url: URL): string {
100
- const params = new URLSearchParams(url.searchParams);
101
- params.delete("token");
102
- params.delete("auth_token");
103
- const query = params.toString();
104
- return query ? `${path}?${query}` : path;
105
- }
106
-
107
- function stripGatewayAuthParams(pathname: string, searchParams: URLSearchParams, hash = ""): string {
108
- const params = new URLSearchParams(searchParams);
109
- params.delete("token");
110
- params.delete("auth_token");
111
- const query = params.toString();
112
- return `${pathname}${query ? `?${query}` : ""}${hash}`;
113
- }
114
-
115
- function rewriteTunnelLocation(
116
- location: string,
117
- input: { sessionId: string; port: number; requestPath: string },
118
- ): string {
119
- try {
120
- const parsed = new URL(location, `http://127.0.0.1:${input.port}${input.requestPath}`);
121
- const isLocal =
122
- parsed.hostname === "127.0.0.1" ||
123
- parsed.hostname === "localhost" ||
124
- parsed.hostname === "::1";
125
- if (!isLocal || parsed.port !== String(input.port)) return location;
126
- const target = stripGatewayAuthParams(parsed.pathname, parsed.searchParams, parsed.hash);
127
- return `/tunnel/${encodeURIComponent(input.sessionId)}/${input.port}${target}`;
128
- } catch {
129
- return location;
130
- }
131
- }
132
-
133
- function rewriteTunnelResponseHeaders(
134
- headers: Record<string, string | string[]>,
135
- input: { sessionId: string; port: number; requestPath: string },
136
- ): Record<string, string | string[]> {
137
- const rewritten = { ...headers };
138
- const locationKey = Object.keys(rewritten).find((key) => key.toLowerCase() === "location");
139
- if (!locationKey) return rewritten;
140
-
141
- const location = rewritten[locationKey];
142
- if (typeof location === "string") {
143
- rewritten[locationKey] = rewriteTunnelLocation(location, input);
144
- }
145
- return rewritten;
146
- }
147
-
148
- export function tokenFromTunnelCookie(
149
- req: IncomingMessage,
150
- sessionId: string,
151
- port: number,
152
- ): string | null {
153
- const tunnelCookie = parseTunnelCookie(req);
154
- if (!tunnelCookie) return null;
155
- if (tunnelCookie.sessionId !== sessionId || tunnelCookie.port !== port) return null;
156
- return tunnelCookie.token;
157
- }
158
-
159
- /** Parse lsh_tunnel cookie: "sessionId:port:token" */
160
- export function parseTunnelCookie(req: IncomingMessage): { sessionId: string; port: number; token: string } | null {
161
- const cookie = req.headers.cookie;
162
- if (!cookie) return null;
163
- const match = cookie.match(/lsh_tunnel=([^;]+)/);
164
- if (!match?.[1]) return null;
165
- const parts = decodeURIComponent(match[1]).split(":");
166
- if (parts.length < 3) return null;
167
- const sessionId = parts[0]!;
168
- const port = Number(parts[1]);
169
- const token = parts.slice(2).join(":"); // token may contain colons
170
- if (!sessionId || isNaN(port) || port < 1 || port > 65535 || !token) return null;
171
- return { sessionId, port, token };
172
- }
173
-
174
- function errorResponse(res: ServerResponse, status: number, message: string, diagnostic?: string): void {
175
- if (res.headersSent) return;
176
- res.writeHead(status, {
177
- "content-type": "text/plain",
178
- "cache-control": "no-store, private",
179
- "cdn-cache-control": "no-store",
180
- "surrogate-control": "no-store",
181
- "vary": "authorization, cookie",
182
- "access-control-allow-origin": "*",
183
- ...(diagnostic ? { "x-linkshell-tunnel-error": diagnostic } : {}),
184
- });
185
- res.end(message);
186
- }
187
-
188
- function isSecureRequest(req: IncomingMessage): boolean {
189
- if ((req.socket as { encrypted?: boolean }).encrypted) return true;
190
- const proto = req.headers["x-forwarded-proto"];
191
- if (typeof proto === "string") {
192
- return proto.split(",")[0]?.trim().toLowerCase() === "https";
193
- }
194
- return false;
195
- }
196
-
197
- export function parseTunnelPath(pathname: string): { sessionId: string; port: number; path: string } | null {
198
- const match = pathname.match(/^\/tunnel\/([^/]+)\/(\d+)(\/.*)?$/);
199
- if (!match) return null;
200
- const port = Number(match[2]);
201
- if (port < 1 || port > 65535) return null;
202
- return {
203
- sessionId: match[1]!,
204
- port,
205
- path: match[3] || "/",
206
- };
207
- }
208
-
209
- export function hasTunnelReferer(req: IncomingMessage): boolean {
210
- const referer = req.headers.referer;
211
- if (typeof referer !== "string") return false;
212
- try {
213
- const ref = new URL(referer);
214
- return ref.pathname.startsWith("/tunnel/");
215
- } catch {
216
- return false;
217
- }
218
- }
219
-
220
- export function shouldUseTunnelCookieFallback(
221
- req: IncomingMessage,
222
- pathname: string,
223
- isReservedPath: (pathname: string) => boolean = () => false,
224
- ): boolean {
225
- if (pathname === "/" || isReservedPath(pathname)) return false;
226
-
227
- // A stale Path=/ tunnel cookie must not hijack normal top-level document
228
- // visits to the LinkShell web app. Subresources are allowed through the
229
- // cookie fallback even when Referrer-Policy strips the /tunnel/... path; the
230
- // real LinkShell shell clears this HttpOnly cookie on index.html responses,
231
- // and concrete LinkShell assets are served before fallback.
232
- const dest = req.headers["sec-fetch-dest"];
233
- if (hasTunnelReferer(req)) return true;
234
- if (!dest) return true;
235
- if (dest === "iframe") return true;
236
- if (dest !== "document") return true;
237
-
238
- return false;
239
- }
240
-
241
- export function mergeTunnelSetCookie(
242
- headers: Record<string, string | string[]>,
243
- tunnelCookie?: string,
244
- ): Record<string, string | string[]> {
245
- if (!tunnelCookie) return headers;
246
- const merged = { ...headers };
247
- const setCookieKey = Object.keys(merged).find((key) => key.toLowerCase() === "set-cookie");
248
- const upstreamSetCookie = setCookieKey ? merged[setCookieKey] : undefined;
249
- if (setCookieKey) delete merged[setCookieKey];
250
- merged["set-cookie"] = upstreamSetCookie
251
- ? Array.isArray(upstreamSetCookie)
252
- ? [...upstreamSetCookie, tunnelCookie]
253
- : [upstreamSetCookie, tunnelCookie]
254
- : tunnelCookie;
255
- return merged;
256
- }
257
-
258
- export async function handleTunnelRequest(
259
- req: IncomingMessage,
260
- res: ServerResponse,
261
- sessions: SessionManager,
262
- tokens: TokenManager,
263
- parsed: { sessionId: string; port: number; path: string },
264
- url: URL,
265
- preAuthToken?: string,
266
- ): Promise<void> {
267
- const { sessionId, port, path } = parsed;
268
-
269
- // Auth: device token OR Supabase JWT (userId owns session)
270
- let token = preAuthToken || extractToken(req, url) || tokenFromTunnelCookie(req, sessionId, port);
271
- let tokenOwns = Boolean(token && await tokens.ownsFresh(token, sessionId));
272
- let authOwns = false;
273
- let authJwt: string | null = null;
274
- let authDiagnostic = token ? "token_not_bound" : "missing_token";
275
- if (!tokenOwns && AUTH_REQUIRED) {
276
- // Try preAuthToken as JWT first (from cookie fallback), then from request headers/params
277
- const jwtCandidate = preAuthToken || url.searchParams.get("auth_token") || (() => {
278
- const auth = req.headers.authorization;
279
- if (auth) { const m = auth.match(/^Bearer\s+(.+)$/i); if (m?.[1]) return m[1]; }
280
- return null;
281
- })();
282
- if (jwtCandidate) {
283
- try {
284
- const SUPABASE_URL = process.env.SUPABASE_URL ?? "";
285
- const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ?? "";
286
- if (SUPABASE_URL && SUPABASE_ANON_KEY) {
287
- const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
288
- headers: { Authorization: `Bearer ${jwtCandidate}`, apikey: SUPABASE_ANON_KEY },
289
- signal: AbortSignal.timeout(5_000),
290
- });
291
- if (userRes.ok) {
292
- const user = (await userRes.json()) as { id: string };
293
- const session = sessions.get(sessionId);
294
- if (user.id && session?.userId && user.id === session.userId) {
295
- authOwns = true;
296
- authJwt = jwtCandidate;
297
- } else if (!session) {
298
- authDiagnostic = "session_not_local";
299
- } else if (!session.userId) {
300
- authDiagnostic = "session_user_missing";
301
- } else {
302
- authDiagnostic = "session_user_mismatch";
303
- }
304
- } else {
305
- authDiagnostic = "invalid_jwt";
306
- }
307
- }
308
- } catch {
309
- authDiagnostic = "jwt_check_failed";
310
- }
311
- }
312
- }
313
- if (!tokenOwns && authOwns) {
314
- const boundToken = bindExplicitDeviceToken(tokens, sessionId, url, preAuthToken);
315
- if (boundToken) {
316
- token = boundToken;
317
- tokenOwns = true;
318
- }
319
- }
320
- if (!tokenOwns && !authOwns) {
321
- errorResponse(res, 401, "Unauthorized", authDiagnostic);
322
- return;
323
- }
324
-
325
- // Set auth cookie for subsequent sub-resource requests
326
- let tunnelCookie: string | undefined;
327
- const cookieToken = tokenOwns ? token : authJwt;
328
- if (cookieToken) {
329
- const cookieVal = encodeURIComponent(`${sessionId}:${port}:${cookieToken}`);
330
- const secure = isSecureRequest(req) ? "; Secure" : "";
331
- tunnelCookie = `lsh_tunnel=${cookieVal}; Path=/; Max-Age=600; HttpOnly${secure}; SameSite=Lax`;
332
- }
333
-
334
- // Validate session & host
335
- const session = sessions.get(sessionId);
336
- if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
337
- errorResponse(res, 502, "Host not connected", !session ? "session_not_local" : "host_not_connected");
338
- return;
339
- }
340
-
341
- const requestId = randomUUID();
342
- const method = req.method ?? "GET";
343
-
344
- // Read request body
345
- let body: string | null = null;
346
- if (method !== "GET" && method !== "HEAD") {
347
- const chunks: Buffer[] = [];
348
- let size = 0;
349
- for await (const chunk of req) {
350
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
351
- size += buf.length;
352
- if (size > MAX_TUNNEL_BODY) {
353
- errorResponse(res, 413, "Request body too large");
354
- return;
355
- }
356
- chunks.push(buf);
357
- }
358
- if (chunks.length > 0) {
359
- body = Buffer.concat(chunks).toString("base64");
360
- }
361
- }
362
-
363
- // Build forwarded headers (strip hop-by-hop)
364
- const headers: Record<string, string> = {};
365
- const skipHeaders = new Set(["host", "connection", "upgrade", "transfer-encoding", "keep-alive"]);
366
- for (const [key, val] of Object.entries(req.headers)) {
367
- if (!skipHeaders.has(key) && typeof val === "string") {
368
- if (key === "cookie") {
369
- const cookie = stripGatewayCookies(val);
370
- if (cookie) headers[key] = cookie;
371
- } else {
372
- headers[key] = val;
373
- }
374
- }
375
- }
376
-
377
- // Reconstruct URL with query string. The gateway authenticates fallback
378
- // subresources with lsh_tunnel; it must not mutate host-facing URLs with
379
- // auth query params because that leaks credentials and poisons cache keys.
380
- const fullUrl = forwardedUrl(path, url);
381
-
382
- // Register pending request
383
- const pending: PendingTunnelRequest = {
384
- res,
385
- headersSent: false,
386
- timeout: setTimeout(() => {
387
- pendingRequests.delete(requestId);
388
- untrackRequest(sessionId, requestId);
389
- errorResponse(res, 504, "Tunnel request timed out");
390
- }, TUNNEL_TIMEOUT),
391
- tunnelCookie,
392
- sessionId,
393
- port,
394
- requestPath: path,
395
- };
396
- pendingRequests.set(requestId, pending);
397
- trackRequest(sessionId, requestId);
398
-
399
- // Send tunnel.request to host
400
- const envelope = createEnvelope({
401
- type: "tunnel.request",
402
- sessionId,
403
- payload: {
404
- requestId,
405
- method,
406
- url: fullUrl,
407
- headers,
408
- body,
409
- port,
410
- },
411
- });
412
- session.host.socket.send(serializeEnvelope(envelope));
413
-
414
- // Handle client disconnect
415
- req.on("close", () => {
416
- const p = pendingRequests.get(requestId);
417
- if (p) {
418
- clearTimeout(p.timeout);
419
- pendingRequests.delete(requestId);
420
- untrackRequest(sessionId, requestId);
421
- }
422
- });
423
- }
424
-
425
- export function handleTunnelResponse(payload: {
426
- requestId: string;
427
- statusCode: number;
428
- headers: Record<string, string>;
429
- body: string;
430
- isFinal: boolean;
431
- }): void {
432
- try {
433
- const pending = pendingRequests.get(payload.requestId);
434
- if (!pending) return;
435
-
436
- if (!pending.headersSent) {
437
- const responseHeaders: Record<string, string | string[]> = {
438
- ...rewriteTunnelResponseHeaders(payload.headers, pending),
439
- "cache-control": "no-store, private",
440
- "cdn-cache-control": "no-store",
441
- "surrogate-control": "no-store",
442
- "vary": "authorization, cookie",
443
- "access-control-allow-origin": "*",
444
- };
445
- pending.res.writeHead(payload.statusCode, mergeTunnelSetCookie(responseHeaders, pending.tunnelCookie));
446
- pending.headersSent = true;
447
- }
448
-
449
- if (payload.body) {
450
- pending.res.write(Buffer.from(payload.body, "base64"));
451
- }
452
-
453
- if (payload.isFinal) {
454
- clearTimeout(pending.timeout);
455
- pendingRequests.delete(payload.requestId);
456
- pending.res.end();
457
- }
458
- } catch {}
459
- }
460
-
461
- export function handleTunnelWsData(payload: {
462
- requestId: string;
463
- data: string;
464
- isBinary: boolean;
465
- }): void {
466
- try {
467
- const pending = pendingWsSockets.get(payload.requestId);
468
- if (!pending || pending.ws.readyState !== 1) return;
469
- const buf = Buffer.from(payload.data, "base64");
470
- pending.ws.send(payload.isBinary ? buf : buf.toString("utf8"));
471
- } catch {}
472
- }
473
-
474
- export function handleTunnelWsClose(payload: {
475
- requestId: string;
476
- code?: number;
477
- reason?: string;
478
- }): void {
479
- try {
480
- const pending = pendingWsSockets.get(payload.requestId);
481
- if (!pending) return;
482
- pendingWsSockets.delete(payload.requestId);
483
- if (pending.ws.readyState === 1) {
484
- const code = typeof payload.code === "number" && payload.code >= 1000 && payload.code <= 4999 ? payload.code : 1000;
485
- pending.ws.close(code, payload.reason ?? "");
486
- }
487
- } catch {}
488
- }
489
-
490
- export function registerTunnelWs(requestId: string, ws: WebSocket): void {
491
- pendingWsSockets.set(requestId, { ws });
492
- }
493
-
494
- export function removeTunnelWs(requestId: string): void {
495
- pendingWsSockets.delete(requestId);
496
- }
497
-
498
- export function cleanupSessionTunnels(sessionId: string): void {
499
- const requestIds = sessionRequests.get(sessionId);
500
- if (!requestIds) return;
501
- for (const rid of requestIds) {
502
- const pending = pendingRequests.get(rid);
503
- if (pending) {
504
- clearTimeout(pending.timeout);
505
- errorResponse(pending.res, 502, "Host disconnected");
506
- pendingRequests.delete(rid);
507
- }
508
- const ws = pendingWsSockets.get(rid);
509
- if (ws) {
510
- ws.ws.close(1001, "Host disconnected");
511
- pendingWsSockets.delete(rid);
512
- }
513
- }
514
- sessionRequests.delete(sessionId);
515
- }
516
-
517
- export async function handleTunnelWsUpgrade(
518
- ws: WebSocket,
519
- parsed: { sessionId: string; port: number; path: string },
520
- url: URL,
521
- sessions: SessionManager,
522
- tokens: TokenManager,
523
- preAuthToken?: string,
524
- ): Promise<void> {
525
- const { sessionId, port, path } = parsed;
526
-
527
- // Auth: device token OR Supabase JWT (userId owns session)
528
- let token = preAuthToken || url.searchParams.get("token");
529
- let tokenOwns = Boolean(token && await tokens.ownsFresh(token, sessionId));
530
- let authOwns = false;
531
- let authDiagnostic = token ? "token_not_bound" : "missing_token";
532
- if (!tokenOwns && AUTH_REQUIRED) {
533
- // Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
534
- const authToken = url.searchParams.get("auth_token") || token;
535
- if (authToken) {
536
- try {
537
- const SUPABASE_URL = process.env.SUPABASE_URL ?? "";
538
- const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ?? "";
539
- if (SUPABASE_URL && SUPABASE_ANON_KEY) {
540
- const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
541
- headers: { Authorization: `Bearer ${authToken}`, apikey: SUPABASE_ANON_KEY },
542
- signal: AbortSignal.timeout(5_000),
543
- });
544
- if (userRes.ok) {
545
- const user = (await userRes.json()) as { id: string };
546
- const session = sessions.get(sessionId);
547
- if (user.id && session?.userId && user.id === session.userId) {
548
- authOwns = true;
549
- } else if (!session) {
550
- authDiagnostic = "session_not_local";
551
- } else if (!session.userId) {
552
- authDiagnostic = "session_user_missing";
553
- } else {
554
- authDiagnostic = "session_user_mismatch";
555
- }
556
- } else {
557
- authDiagnostic = "invalid_jwt";
558
- }
559
- }
560
- } catch {
561
- authDiagnostic = "jwt_check_failed";
562
- }
563
- }
564
- }
565
- if (!tokenOwns && authOwns) {
566
- const boundToken = bindExplicitDeviceToken(tokens, sessionId, url, preAuthToken);
567
- if (boundToken) {
568
- token = boundToken;
569
- tokenOwns = true;
570
- }
571
- }
572
- if (!tokenOwns && !authOwns) {
573
- ws.close(4001, `Unauthorized:${authDiagnostic}`);
574
- return;
575
- }
576
-
577
- const session = sessions.get(sessionId);
578
- if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
579
- ws.close(4002, "Host not connected");
580
- return;
581
- }
582
-
583
- const requestId = randomUUID();
584
- const fullUrl = forwardedUrl(path, url);
585
-
586
- // Register this WS so host responses route here
587
- registerTunnelWs(requestId, ws);
588
- trackRequest(sessionId, requestId);
589
-
590
- // Send tunnel.request with upgrade header to host
591
- const envelope = createEnvelope({
592
- type: "tunnel.request",
593
- sessionId,
594
- payload: {
595
- requestId,
596
- method: "GET",
597
- url: fullUrl,
598
- headers: { "upgrade": "websocket" },
599
- body: null,
600
- port,
601
- },
602
- });
603
- session.host.socket.send(serializeEnvelope(envelope));
604
-
605
- // Forward data from browser WS to host
606
- ws.on("message", (data: Buffer | string) => {
607
- try {
608
- const s = sessions.get(sessionId);
609
- if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
610
- const isBinary = typeof data !== "string";
611
- const buf = typeof data === "string" ? Buffer.from(data) : data;
612
- const fwd = createEnvelope({
613
- type: "tunnel.ws.data",
614
- sessionId,
615
- payload: {
616
- requestId,
617
- data: buf.toString("base64"),
618
- isBinary,
619
- },
620
- });
621
- s.host.socket.send(serializeEnvelope(fwd));
622
- } catch {}
623
- });
624
-
625
- ws.on("close", (code, reason) => {
626
- try {
627
- removeTunnelWs(requestId);
628
- untrackRequest(sessionId, requestId);
629
- const s = sessions.get(sessionId);
630
- if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
631
- const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
632
- const fwd = createEnvelope({
633
- type: "tunnel.ws.close",
634
- sessionId,
635
- payload: {
636
- requestId,
637
- code: safeCode,
638
- reason: reason?.toString() || "",
639
- },
640
- });
641
- s.host.socket.send(serializeEnvelope(fwd));
642
- } catch {}
643
- });
644
- }
File without changes
File without changes
File without changes