@linkshell/gateway 0.4.24 → 0.4.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linkshell/gateway",
3
- "version": "0.4.24",
3
+ "version": "0.4.27",
4
4
  "type": "module",
5
5
  "main": "dist/gateway/src/index.js",
6
6
  "exports": {
package/src/embedded.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  handleTunnelWsUpgrade,
26
26
  cleanupSessionTunnels,
27
27
  } from "./tunnel.js";
28
+ import { serveWeb, serveWebAsset, webEnabled, webDistPath } from "./static-web.js";
28
29
 
29
30
  export interface EmbeddedGatewayOptions {
30
31
  port?: number;
@@ -252,6 +253,11 @@ export function startEmbeddedGateway(
252
253
  return;
253
254
  }
254
255
 
256
+ // Serve the gateway's OWN built web assets (no SPA fallback) before the
257
+ // tunnel cookie fallback, so a real asset like /assets/index-xxx.js is
258
+ // served locally instead of being proxied via a stale tunnel cookie.
259
+ if (await serveWebAsset(req, res)) return;
260
+
255
261
  // Tunnel fallback: cookie-based routing for sub-resources (e.g. /_next/static/...)
256
262
  const tunnelCookie = parseTunnelCookie(req);
257
263
  if (tunnelCookie && shouldUseTunnelCookieFallback(req, url.pathname, (pathname) =>
@@ -272,6 +278,11 @@ export function startEmbeddedGateway(
272
278
  return;
273
279
  }
274
280
 
281
+ // Web SPA: last GET fallback, after every API route, so it never shadows
282
+ // them. Serves index.html for "/" and client-side routes; a no-op (false)
283
+ // when no web dist is bundled (e.g. an API-only embedded gateway).
284
+ if (await serveWeb(req, res)) return;
285
+
275
286
  json(res, 404, { error: "not_found" });
276
287
  } catch (err) {
277
288
  if (err instanceof ZodError) {
@@ -479,6 +490,14 @@ export function startEmbeddedGateway(
479
490
  const actualPort =
480
491
  typeof addr === "object" && addr ? addr.port : targetPort;
481
492
  log("info", `embedded gateway on port ${actualPort}`);
493
+ // Surface whether the bundled web SPA was found — the in-app WebView loads
494
+ // this gateway's "/", so a missing dist means a blank screen. Makes the
495
+ // LAN/self-hosted "WebView is blank" failure mode diagnosable from logs.
496
+ if (webEnabled()) {
497
+ log("info", `web UI served from ${webDistPath()}`);
498
+ } else {
499
+ log("warn", `web UI not bundled (WEB_DIST=${webDistPath()} has no index.html) — in-app agent console will be blank`);
500
+ }
482
501
  resolve({
483
502
  port: actualPort,
484
503
  httpUrl: `http://127.0.0.1:${actualPort}`,
package/src/tokens.ts CHANGED
@@ -89,6 +89,34 @@ export class TokenManager {
89
89
  return record.sessionIds.has(sessionId);
90
90
  }
91
91
 
92
+ async ownsFresh(token: string, sessionId: string): Promise<boolean> {
93
+ if (this.owns(token, sessionId)) return true;
94
+ if (!this.store) return false;
95
+ try {
96
+ const records = await this.store.loadTokens();
97
+ const now = Date.now();
98
+ for (const record of records) {
99
+ if (now - record.lastUsedAt > SESSION_TTL) continue;
100
+ if (record.token !== token) continue;
101
+ const next = {
102
+ token: record.token,
103
+ sessionIds: new Set(record.sessionIds),
104
+ createdAt: record.createdAt,
105
+ lastUsedAt: Date.now(),
106
+ };
107
+ this.tokens.set(record.token, next);
108
+ for (const sid of record.sessionIds) {
109
+ this.sessionToToken.set(sid, record.token);
110
+ }
111
+ this.persist(next);
112
+ return next.sessionIds.has(sessionId);
113
+ }
114
+ } catch (err) {
115
+ process.stderr.write(`[gateway] token store refresh failed: ${err}\n`);
116
+ }
117
+ return false;
118
+ }
119
+
92
120
  getSessionIds(token: string): Set<string> {
93
121
  const record = this.tokens.get(token);
94
122
  if (!record) return new Set();
package/src/tunnel.ts CHANGED
@@ -171,7 +171,7 @@ export function parseTunnelCookie(req: IncomingMessage): { sessionId: string; po
171
171
  return { sessionId, port, token };
172
172
  }
173
173
 
174
- function errorResponse(res: ServerResponse, status: number, message: string): void {
174
+ function errorResponse(res: ServerResponse, status: number, message: string, diagnostic?: string): void {
175
175
  if (res.headersSent) return;
176
176
  res.writeHead(status, {
177
177
  "content-type": "text/plain",
@@ -180,6 +180,7 @@ function errorResponse(res: ServerResponse, status: number, message: string): vo
180
180
  "surrogate-control": "no-store",
181
181
  "vary": "authorization, cookie",
182
182
  "access-control-allow-origin": "*",
183
+ ...(diagnostic ? { "x-linkshell-tunnel-error": diagnostic } : {}),
183
184
  });
184
185
  res.end(message);
185
186
  }
@@ -267,9 +268,10 @@ export async function handleTunnelRequest(
267
268
 
268
269
  // Auth: device token OR Supabase JWT (userId owns session)
269
270
  let token = preAuthToken || extractToken(req, url) || tokenFromTunnelCookie(req, sessionId, port);
270
- let tokenOwns = Boolean(token && tokens.owns(token, sessionId));
271
+ let tokenOwns = Boolean(token && await tokens.ownsFresh(token, sessionId));
271
272
  let authOwns = false;
272
273
  let authJwt: string | null = null;
274
+ let authDiagnostic = token ? "token_not_bound" : "missing_token";
273
275
  if (!tokenOwns && AUTH_REQUIRED) {
274
276
  // Try preAuthToken as JWT first (from cookie fallback), then from request headers/params
275
277
  const jwtCandidate = preAuthToken || url.searchParams.get("auth_token") || (() => {
@@ -292,10 +294,20 @@ export async function handleTunnelRequest(
292
294
  if (user.id && session?.userId && user.id === session.userId) {
293
295
  authOwns = true;
294
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";
295
303
  }
304
+ } else {
305
+ authDiagnostic = "invalid_jwt";
296
306
  }
297
307
  }
298
- } catch {}
308
+ } catch {
309
+ authDiagnostic = "jwt_check_failed";
310
+ }
299
311
  }
300
312
  }
301
313
  if (!tokenOwns && authOwns) {
@@ -306,7 +318,7 @@ export async function handleTunnelRequest(
306
318
  }
307
319
  }
308
320
  if (!tokenOwns && !authOwns) {
309
- errorResponse(res, 401, "Unauthorized");
321
+ errorResponse(res, 401, "Unauthorized", authDiagnostic);
310
322
  return;
311
323
  }
312
324
 
@@ -322,7 +334,7 @@ export async function handleTunnelRequest(
322
334
  // Validate session & host
323
335
  const session = sessions.get(sessionId);
324
336
  if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
325
- errorResponse(res, 502, "Host not connected");
337
+ errorResponse(res, 502, "Host not connected", !session ? "session_not_local" : "host_not_connected");
326
338
  return;
327
339
  }
328
340
 
@@ -514,8 +526,9 @@ export async function handleTunnelWsUpgrade(
514
526
 
515
527
  // Auth: device token OR Supabase JWT (userId owns session)
516
528
  let token = preAuthToken || url.searchParams.get("token");
517
- let tokenOwns = Boolean(token && tokens.owns(token, sessionId));
529
+ let tokenOwns = Boolean(token && await tokens.ownsFresh(token, sessionId));
518
530
  let authOwns = false;
531
+ let authDiagnostic = token ? "token_not_bound" : "missing_token";
519
532
  if (!tokenOwns && AUTH_REQUIRED) {
520
533
  // Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
521
534
  const authToken = url.searchParams.get("auth_token") || token;
@@ -533,10 +546,20 @@ export async function handleTunnelWsUpgrade(
533
546
  const session = sessions.get(sessionId);
534
547
  if (user.id && session?.userId && user.id === session.userId) {
535
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";
536
555
  }
556
+ } else {
557
+ authDiagnostic = "invalid_jwt";
537
558
  }
538
559
  }
539
- } catch {}
560
+ } catch {
561
+ authDiagnostic = "jwt_check_failed";
562
+ }
540
563
  }
541
564
  }
542
565
  if (!tokenOwns && authOwns) {
@@ -547,7 +570,7 @@ export async function handleTunnelWsUpgrade(
547
570
  }
548
571
  }
549
572
  if (!tokenOwns && !authOwns) {
550
- ws.close(4001, "Unauthorized");
573
+ ws.close(4001, `Unauthorized:${authDiagnostic}`);
551
574
  return;
552
575
  }
553
576