@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/dist/gateway/src/embedded.js +20 -0
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/tokens.d.ts +1 -0
- package/dist/gateway/src/tokens.js +32 -0
- package/dist/gateway/src/tokens.js.map +1 -1
- package/dist/gateway/src/tunnel.js +39 -8
- package/dist/gateway/src/tunnel.js.map +1 -1
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +438 -438
- package/package.json +1 -1
- package/src/embedded.ts +19 -0
- package/src/tokens.ts +28 -0
- package/src/tunnel.ts +31 -8
package/package.json
CHANGED
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.
|
|
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.
|
|
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,
|
|
573
|
+
ws.close(4001, `Unauthorized:${authDiagnostic}`);
|
|
551
574
|
return;
|
|
552
575
|
}
|
|
553
576
|
|