@linkshell/gateway 0.2.47 → 0.3.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/README.md +14 -13
- package/dist/gateway/src/agent-permission-http.d.ts +10 -37
- package/dist/gateway/src/agent-permission-http.js +16 -56
- package/dist/gateway/src/agent-permission-http.js.map +1 -1
- package/dist/gateway/src/embedded.js +121 -57
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/index.js +161 -94
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/pairings.d.ts +3 -3
- package/dist/gateway/src/pairings.js +4 -5
- package/dist/gateway/src/pairings.js.map +1 -1
- package/dist/gateway/src/relay.d.ts +2 -2
- package/dist/gateway/src/relay.js +27 -38
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +31 -28
- package/dist/gateway/src/sessions.js +163 -145
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/src/state-store.d.ts +9 -6
- package/dist/gateway/src/state-store.js +26 -19
- package/dist/gateway/src/state-store.js.map +1 -1
- package/dist/gateway/src/tokens.d.ts +27 -7
- package/dist/gateway/src/tokens.js +86 -60
- package/dist/gateway/src/tokens.js.map +1 -1
- package/dist/gateway/src/tunnel.d.ts +11 -13
- 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 +3961 -5788
- package/dist/shared-protocol/src/index.js +19 -84
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +10 -10
- package/src/agent-permission-http.ts +20 -63
- package/src/embedded.ts +124 -56
- package/src/index.ts +165 -94
- package/src/pairings.ts +6 -7
- package/src/relay.ts +38 -48
- package/src/sessions.ts +174 -150
- package/src/state-store.ts +41 -25
- package/src/tokens.ts +109 -63
- package/src/tunnel.ts +49 -43
package/src/tokens.ts
CHANGED
|
@@ -1,47 +1,45 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { GatewayStateStore } from "./state-store.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
interface DeviceAuthorization {
|
|
5
|
+
authorizationId: string;
|
|
6
|
+
hostDeviceId: string;
|
|
7
|
+
clientDeviceId: string | undefined;
|
|
8
|
+
clientName: string | undefined;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
lastUsedAt: number;
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
interface TokenRecord {
|
|
8
14
|
token: string;
|
|
9
|
-
|
|
15
|
+
authorizations: Map<string, DeviceAuthorization>;
|
|
10
16
|
createdAt: number;
|
|
11
17
|
lastUsedAt: number;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export class
|
|
20
|
+
export class AuthorizationManager {
|
|
15
21
|
private tokens = new Map<string, TokenRecord>();
|
|
16
|
-
private
|
|
17
|
-
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
22
|
+
private hostDeviceToTokens = new Map<string, Set<string>>();
|
|
18
23
|
|
|
19
|
-
constructor(private readonly store?: GatewayStateStore) {
|
|
20
|
-
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
21
|
-
}
|
|
24
|
+
constructor(private readonly store?: GatewayStateStore) {}
|
|
22
25
|
|
|
23
26
|
async hydrate(): Promise<void> {
|
|
24
27
|
if (!this.store) return;
|
|
25
28
|
try {
|
|
26
|
-
const records = await this.store.
|
|
27
|
-
const now = Date.now();
|
|
29
|
+
const records = await this.store.loadAuthorizations();
|
|
28
30
|
for (const record of records) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
token: record.token,
|
|
35
|
-
sessionIds: new Set(record.sessionIds),
|
|
31
|
+
const token = this.register(record.token);
|
|
32
|
+
this.authorize(token, record.hostDeviceId, {
|
|
33
|
+
authorizationId: record.authorizationId,
|
|
34
|
+
clientDeviceId: record.clientDeviceId,
|
|
35
|
+
clientName: record.clientName,
|
|
36
36
|
createdAt: record.createdAt,
|
|
37
37
|
lastUsedAt: record.lastUsedAt,
|
|
38
|
+
persist: false,
|
|
38
39
|
});
|
|
39
|
-
for (const sessionId of record.sessionIds) {
|
|
40
|
-
this.sessionToToken.set(sessionId, record.token);
|
|
41
|
-
}
|
|
42
40
|
}
|
|
43
41
|
} catch (err) {
|
|
44
|
-
process.stderr.write(`[gateway]
|
|
42
|
+
process.stderr.write(`[gateway] authorization store hydrate failed, using memory only: ${err}\n`);
|
|
45
43
|
}
|
|
46
44
|
}
|
|
47
45
|
|
|
@@ -49,83 +47,131 @@ export class TokenManager {
|
|
|
49
47
|
if (deviceToken && this.tokens.has(deviceToken)) {
|
|
50
48
|
const record = this.tokens.get(deviceToken)!;
|
|
51
49
|
record.lastUsedAt = Date.now();
|
|
52
|
-
this.persist(record);
|
|
53
50
|
return deviceToken;
|
|
54
51
|
}
|
|
55
52
|
const token = deviceToken || randomUUID();
|
|
56
53
|
this.tokens.set(token, {
|
|
57
54
|
token,
|
|
58
|
-
|
|
55
|
+
authorizations: new Map(),
|
|
59
56
|
createdAt: Date.now(),
|
|
60
57
|
lastUsedAt: Date.now(),
|
|
61
58
|
});
|
|
62
|
-
this.persist(this.tokens.get(token)!);
|
|
63
59
|
return token;
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
|
|
62
|
+
authorize(
|
|
63
|
+
token: string,
|
|
64
|
+
hostDeviceId: string,
|
|
65
|
+
input: {
|
|
66
|
+
authorizationId?: string;
|
|
67
|
+
clientDeviceId?: string;
|
|
68
|
+
clientName?: string;
|
|
69
|
+
createdAt?: number;
|
|
70
|
+
lastUsedAt?: number;
|
|
71
|
+
persist?: boolean;
|
|
72
|
+
} = {},
|
|
73
|
+
): DeviceAuthorization | undefined {
|
|
67
74
|
const record = this.tokens.get(token);
|
|
68
|
-
if (!record) return
|
|
69
|
-
record.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
if (!record) return undefined;
|
|
76
|
+
const existing = record.authorizations.get(hostDeviceId);
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const authorization: DeviceAuthorization = {
|
|
79
|
+
authorizationId: input.authorizationId ?? existing?.authorizationId ?? randomUUID(),
|
|
80
|
+
hostDeviceId,
|
|
81
|
+
clientDeviceId: input.clientDeviceId ?? existing?.clientDeviceId,
|
|
82
|
+
clientName: input.clientName ?? existing?.clientName,
|
|
83
|
+
createdAt: input.createdAt ?? existing?.createdAt ?? now,
|
|
84
|
+
lastUsedAt: input.lastUsedAt ?? now,
|
|
85
|
+
};
|
|
86
|
+
record.authorizations.set(hostDeviceId, authorization);
|
|
87
|
+
record.lastUsedAt = now;
|
|
88
|
+
let tokens = this.hostDeviceToTokens.get(hostDeviceId);
|
|
89
|
+
if (!tokens) {
|
|
90
|
+
tokens = new Set();
|
|
91
|
+
this.hostDeviceToTokens.set(hostDeviceId, tokens);
|
|
92
|
+
}
|
|
93
|
+
tokens.add(token);
|
|
94
|
+
if (input.persist !== false) {
|
|
95
|
+
this.persist(token, authorization);
|
|
96
|
+
}
|
|
97
|
+
return authorization;
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
validate(token: string): boolean {
|
|
77
101
|
const record = this.tokens.get(token);
|
|
78
102
|
if (!record) return false;
|
|
79
103
|
record.lastUsedAt = Date.now();
|
|
80
|
-
this.persist(record);
|
|
81
104
|
return true;
|
|
82
105
|
}
|
|
83
106
|
|
|
84
|
-
owns(token: string,
|
|
107
|
+
owns(token: string, hostDeviceId: string): boolean {
|
|
85
108
|
const record = this.tokens.get(token);
|
|
86
109
|
if (!record) return false;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
const authorization = record.authorizations.get(hostDeviceId);
|
|
111
|
+
if (!authorization) return false;
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
record.lastUsedAt = now;
|
|
114
|
+
authorization.lastUsedAt = now;
|
|
115
|
+
this.persist(token, authorization);
|
|
116
|
+
return true;
|
|
90
117
|
}
|
|
91
118
|
|
|
92
|
-
|
|
119
|
+
revoke(token: string, hostDeviceId: string, authorizationId: string): boolean {
|
|
120
|
+
const record = this.tokens.get(token);
|
|
121
|
+
const authorization = record?.authorizations.get(hostDeviceId);
|
|
122
|
+
if (!record || !authorization || authorization.authorizationId !== authorizationId) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
record.authorizations.delete(hostDeviceId);
|
|
126
|
+
const tokens = this.hostDeviceToTokens.get(hostDeviceId);
|
|
127
|
+
tokens?.delete(token);
|
|
128
|
+
if (tokens && tokens.size === 0) {
|
|
129
|
+
this.hostDeviceToTokens.delete(hostDeviceId);
|
|
130
|
+
}
|
|
131
|
+
void this.store?.deleteAuthorization(authorizationId).catch((err) => {
|
|
132
|
+
process.stderr.write(`[gateway] authorization store delete failed: ${err}\n`);
|
|
133
|
+
});
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getHostDeviceIds(token: string): Set<string> {
|
|
93
138
|
const record = this.tokens.get(token);
|
|
94
139
|
if (!record) return new Set();
|
|
95
140
|
record.lastUsedAt = Date.now();
|
|
96
|
-
|
|
97
|
-
return record.sessionIds;
|
|
141
|
+
return new Set(record.authorizations.keys());
|
|
98
142
|
}
|
|
99
143
|
|
|
100
|
-
|
|
101
|
-
return this.
|
|
144
|
+
getSessionIds(token: string): Set<string> {
|
|
145
|
+
return this.getHostDeviceIds(token);
|
|
102
146
|
}
|
|
103
147
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
148
|
+
getAuthorizationId(token: string, hostDeviceId: string): string | undefined {
|
|
149
|
+
return this.tokens.get(token)?.authorizations.get(hostDeviceId)?.authorizationId;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getTokenForHostDevice(hostDeviceId: string): string | undefined {
|
|
153
|
+
return this.hostDeviceToTokens.get(hostDeviceId)?.values().next().value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
bind(token: string, hostDeviceId: string): boolean {
|
|
157
|
+
return !!this.authorize(token, hostDeviceId);
|
|
115
158
|
}
|
|
116
159
|
|
|
117
|
-
private persist(
|
|
118
|
-
void this.store?.
|
|
119
|
-
token
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
160
|
+
private persist(token: string, authorization: DeviceAuthorization): void {
|
|
161
|
+
void this.store?.saveAuthorization({
|
|
162
|
+
token,
|
|
163
|
+
authorizationId: authorization.authorizationId,
|
|
164
|
+
hostDeviceId: authorization.hostDeviceId,
|
|
165
|
+
clientDeviceId: authorization.clientDeviceId,
|
|
166
|
+
clientName: authorization.clientName,
|
|
167
|
+
createdAt: authorization.createdAt,
|
|
168
|
+
lastUsedAt: authorization.lastUsedAt,
|
|
123
169
|
}).catch((err) => {
|
|
124
|
-
process.stderr.write(`[gateway]
|
|
170
|
+
process.stderr.write(`[gateway] authorization store save failed: ${err}\n`);
|
|
125
171
|
});
|
|
126
172
|
}
|
|
127
173
|
|
|
128
|
-
destroy(): void {
|
|
129
|
-
clearInterval(this.cleanupTimer);
|
|
130
|
-
}
|
|
174
|
+
destroy(): void {}
|
|
131
175
|
}
|
|
176
|
+
|
|
177
|
+
export class TokenManager extends AuthorizationManager {}
|
package/src/tunnel.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
createEnvelope,
|
|
6
6
|
serializeEnvelope,
|
|
7
7
|
} from "@linkshell/protocol";
|
|
8
|
-
import type {
|
|
8
|
+
import type { DeviceManager } from "./sessions.js";
|
|
9
9
|
import type { TokenManager } from "./tokens.js";
|
|
10
10
|
import { AUTH_REQUIRED } from "./auth-middleware.js";
|
|
11
11
|
|
|
@@ -26,23 +26,23 @@ export interface PendingTunnelWs {
|
|
|
26
26
|
const pendingRequests = new Map<string, PendingTunnelRequest>();
|
|
27
27
|
const pendingWsSockets = new Map<string, PendingTunnelWs>();
|
|
28
28
|
|
|
29
|
-
// Track requestIds per
|
|
30
|
-
const
|
|
29
|
+
// Track requestIds per host device for cleanup on host disconnect
|
|
30
|
+
const deviceRequests = new Map<string, Set<string>>();
|
|
31
31
|
|
|
32
|
-
function trackRequest(
|
|
33
|
-
let set =
|
|
32
|
+
function trackRequest(hostDeviceId: string, requestId: string): void {
|
|
33
|
+
let set = deviceRequests.get(hostDeviceId);
|
|
34
34
|
if (!set) {
|
|
35
35
|
set = new Set();
|
|
36
|
-
|
|
36
|
+
deviceRequests.set(hostDeviceId, set);
|
|
37
37
|
}
|
|
38
38
|
set.add(requestId);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function untrackRequest(
|
|
42
|
-
const set =
|
|
41
|
+
function untrackRequest(hostDeviceId: string, requestId: string): void {
|
|
42
|
+
const set = deviceRequests.get(hostDeviceId);
|
|
43
43
|
if (set) {
|
|
44
44
|
set.delete(requestId);
|
|
45
|
-
if (set.size === 0)
|
|
45
|
+
if (set.size === 0) deviceRequests.delete(hostDeviceId);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -65,19 +65,19 @@ function extractToken(req: IncomingMessage, url: URL): string | null {
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/** Parse lsh_tunnel cookie: "
|
|
69
|
-
export function parseTunnelCookie(req: IncomingMessage): {
|
|
68
|
+
/** Parse lsh_tunnel cookie: "hostDeviceId:port:token" */
|
|
69
|
+
export function parseTunnelCookie(req: IncomingMessage): { hostDeviceId: string; port: number; token: string } | null {
|
|
70
70
|
const cookie = req.headers.cookie;
|
|
71
71
|
if (!cookie) return null;
|
|
72
72
|
const match = cookie.match(/lsh_tunnel=([^;]+)/);
|
|
73
73
|
if (!match?.[1]) return null;
|
|
74
74
|
const parts = decodeURIComponent(match[1]).split(":");
|
|
75
75
|
if (parts.length < 3) return null;
|
|
76
|
-
const
|
|
76
|
+
const hostDeviceId = parts[0]!;
|
|
77
77
|
const port = Number(parts[1]);
|
|
78
78
|
const token = parts.slice(2).join(":"); // token may contain colons
|
|
79
|
-
if (!
|
|
80
|
-
return {
|
|
79
|
+
if (!hostDeviceId || isNaN(port) || port < 1 || port > 65535 || !token) return null;
|
|
80
|
+
return { hostDeviceId, port, token };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function errorResponse(res: ServerResponse, status: number, message: string): void {
|
|
@@ -89,32 +89,38 @@ function errorResponse(res: ServerResponse, status: number, message: string): vo
|
|
|
89
89
|
res.end(message);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export function parseTunnelPath(pathname: string): {
|
|
92
|
+
export function parseTunnelPath(pathname: string): { hostDeviceId: string; port: number; path: string } | null {
|
|
93
93
|
const match = pathname.match(/^\/tunnel\/([^/]+)\/(\d+)(\/.*)?$/);
|
|
94
94
|
if (!match) return null;
|
|
95
95
|
const port = Number(match[2]);
|
|
96
96
|
if (port < 1 || port > 65535) return null;
|
|
97
97
|
return {
|
|
98
|
-
|
|
98
|
+
hostDeviceId: match[1]!,
|
|
99
99
|
port,
|
|
100
100
|
path: match[3] || "/",
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
type ParsedTunnelTarget = {
|
|
105
|
+
hostDeviceId: string;
|
|
106
|
+
port: number;
|
|
107
|
+
path: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
104
110
|
export async function handleTunnelRequest(
|
|
105
111
|
req: IncomingMessage,
|
|
106
112
|
res: ServerResponse,
|
|
107
|
-
sessions:
|
|
113
|
+
sessions: DeviceManager,
|
|
108
114
|
tokens: TokenManager,
|
|
109
|
-
parsed:
|
|
115
|
+
parsed: ParsedTunnelTarget,
|
|
110
116
|
url: URL,
|
|
111
117
|
preAuthToken?: string,
|
|
112
118
|
): Promise<void> {
|
|
113
|
-
const {
|
|
119
|
+
const { hostDeviceId, port, path } = parsed;
|
|
114
120
|
|
|
115
121
|
// Auth: device token OR Supabase JWT (userId owns session)
|
|
116
122
|
const token = preAuthToken || extractToken(req, url);
|
|
117
|
-
const tokenOwns = token && tokens.owns(token,
|
|
123
|
+
const tokenOwns = token && tokens.owns(token, hostDeviceId);
|
|
118
124
|
let authOwns = false;
|
|
119
125
|
let authJwt: string | null = null;
|
|
120
126
|
if (!tokenOwns && AUTH_REQUIRED) {
|
|
@@ -135,7 +141,7 @@ export async function handleTunnelRequest(
|
|
|
135
141
|
});
|
|
136
142
|
if (userRes.ok) {
|
|
137
143
|
const user = (await userRes.json()) as { id: string };
|
|
138
|
-
const session = sessions.get(
|
|
144
|
+
const session = sessions.get(hostDeviceId);
|
|
139
145
|
if (user.id && session?.userId && user.id === session.userId) {
|
|
140
146
|
authOwns = true;
|
|
141
147
|
authJwt = jwtCandidate;
|
|
@@ -153,12 +159,12 @@ export async function handleTunnelRequest(
|
|
|
153
159
|
// Set auth cookie for subsequent sub-resource requests
|
|
154
160
|
const cookieToken = tokenOwns ? token : authJwt;
|
|
155
161
|
if (cookieToken) {
|
|
156
|
-
const cookieVal = encodeURIComponent(`${
|
|
162
|
+
const cookieVal = encodeURIComponent(`${hostDeviceId}:${port}:${cookieToken}`);
|
|
157
163
|
res.setHeader("Set-Cookie", `lsh_tunnel=${cookieVal}; Path=/; HttpOnly; SameSite=Lax`);
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
// Validate session & host
|
|
161
|
-
const session = sessions.get(
|
|
167
|
+
const session = sessions.get(hostDeviceId);
|
|
162
168
|
if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
|
|
163
169
|
errorResponse(res, 502, "Host not connected");
|
|
164
170
|
return;
|
|
@@ -204,17 +210,17 @@ export async function handleTunnelRequest(
|
|
|
204
210
|
headersSent: false,
|
|
205
211
|
timeout: setTimeout(() => {
|
|
206
212
|
pendingRequests.delete(requestId);
|
|
207
|
-
untrackRequest(
|
|
213
|
+
untrackRequest(hostDeviceId, requestId);
|
|
208
214
|
errorResponse(res, 504, "Tunnel request timed out");
|
|
209
215
|
}, TUNNEL_TIMEOUT),
|
|
210
216
|
};
|
|
211
217
|
pendingRequests.set(requestId, pending);
|
|
212
|
-
trackRequest(
|
|
218
|
+
trackRequest(hostDeviceId, requestId);
|
|
213
219
|
|
|
214
220
|
// Send tunnel.request to host
|
|
215
221
|
const envelope = createEnvelope({
|
|
216
222
|
type: "tunnel.request",
|
|
217
|
-
|
|
223
|
+
hostDeviceId,
|
|
218
224
|
payload: {
|
|
219
225
|
requestId,
|
|
220
226
|
method,
|
|
@@ -232,7 +238,7 @@ export async function handleTunnelRequest(
|
|
|
232
238
|
if (p) {
|
|
233
239
|
clearTimeout(p.timeout);
|
|
234
240
|
pendingRequests.delete(requestId);
|
|
235
|
-
untrackRequest(
|
|
241
|
+
untrackRequest(hostDeviceId, requestId);
|
|
236
242
|
}
|
|
237
243
|
});
|
|
238
244
|
}
|
|
@@ -306,8 +312,8 @@ export function removeTunnelWs(requestId: string): void {
|
|
|
306
312
|
pendingWsSockets.delete(requestId);
|
|
307
313
|
}
|
|
308
314
|
|
|
309
|
-
export function cleanupSessionTunnels(
|
|
310
|
-
const requestIds =
|
|
315
|
+
export function cleanupSessionTunnels(hostDeviceId: string): void {
|
|
316
|
+
const requestIds = deviceRequests.get(hostDeviceId);
|
|
311
317
|
if (!requestIds) return;
|
|
312
318
|
for (const rid of requestIds) {
|
|
313
319
|
const pending = pendingRequests.get(rid);
|
|
@@ -322,21 +328,21 @@ export function cleanupSessionTunnels(sessionId: string): void {
|
|
|
322
328
|
pendingWsSockets.delete(rid);
|
|
323
329
|
}
|
|
324
330
|
}
|
|
325
|
-
|
|
331
|
+
deviceRequests.delete(hostDeviceId);
|
|
326
332
|
}
|
|
327
333
|
|
|
328
334
|
export async function handleTunnelWsUpgrade(
|
|
329
335
|
ws: WebSocket,
|
|
330
|
-
parsed:
|
|
336
|
+
parsed: ParsedTunnelTarget,
|
|
331
337
|
url: URL,
|
|
332
|
-
sessions:
|
|
338
|
+
sessions: DeviceManager,
|
|
333
339
|
tokens: TokenManager,
|
|
334
340
|
): Promise<void> {
|
|
335
|
-
const {
|
|
341
|
+
const { hostDeviceId, port, path } = parsed;
|
|
336
342
|
|
|
337
343
|
// Auth: device token OR Supabase JWT (userId owns session)
|
|
338
344
|
const token = url.searchParams.get("token");
|
|
339
|
-
const tokenOwns = token && tokens.owns(token,
|
|
345
|
+
const tokenOwns = token && tokens.owns(token, hostDeviceId);
|
|
340
346
|
let authOwns = false;
|
|
341
347
|
if (!tokenOwns && AUTH_REQUIRED) {
|
|
342
348
|
// Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
|
|
@@ -352,7 +358,7 @@ export async function handleTunnelWsUpgrade(
|
|
|
352
358
|
});
|
|
353
359
|
if (userRes.ok) {
|
|
354
360
|
const user = (await userRes.json()) as { id: string };
|
|
355
|
-
const session = sessions.get(
|
|
361
|
+
const session = sessions.get(hostDeviceId);
|
|
356
362
|
if (user.id && session?.userId && user.id === session.userId) {
|
|
357
363
|
authOwns = true;
|
|
358
364
|
}
|
|
@@ -366,7 +372,7 @@ export async function handleTunnelWsUpgrade(
|
|
|
366
372
|
return;
|
|
367
373
|
}
|
|
368
374
|
|
|
369
|
-
const session = sessions.get(
|
|
375
|
+
const session = sessions.get(hostDeviceId);
|
|
370
376
|
if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
|
|
371
377
|
ws.close(4002, "Host not connected");
|
|
372
378
|
return;
|
|
@@ -377,12 +383,12 @@ export async function handleTunnelWsUpgrade(
|
|
|
377
383
|
|
|
378
384
|
// Register this WS so host responses route here
|
|
379
385
|
registerTunnelWs(requestId, ws);
|
|
380
|
-
trackRequest(
|
|
386
|
+
trackRequest(hostDeviceId, requestId);
|
|
381
387
|
|
|
382
388
|
// Send tunnel.request with upgrade header to host
|
|
383
389
|
const envelope = createEnvelope({
|
|
384
390
|
type: "tunnel.request",
|
|
385
|
-
|
|
391
|
+
hostDeviceId,
|
|
386
392
|
payload: {
|
|
387
393
|
requestId,
|
|
388
394
|
method: "GET",
|
|
@@ -397,13 +403,13 @@ export async function handleTunnelWsUpgrade(
|
|
|
397
403
|
// Forward data from browser WS to host
|
|
398
404
|
ws.on("message", (data: Buffer | string) => {
|
|
399
405
|
try {
|
|
400
|
-
const s = sessions.get(
|
|
406
|
+
const s = sessions.get(hostDeviceId);
|
|
401
407
|
if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
|
|
402
408
|
const isBinary = typeof data !== "string";
|
|
403
409
|
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
404
410
|
const fwd = createEnvelope({
|
|
405
411
|
type: "tunnel.ws.data",
|
|
406
|
-
|
|
412
|
+
hostDeviceId,
|
|
407
413
|
payload: {
|
|
408
414
|
requestId,
|
|
409
415
|
data: buf.toString("base64"),
|
|
@@ -417,13 +423,13 @@ export async function handleTunnelWsUpgrade(
|
|
|
417
423
|
ws.on("close", (code, reason) => {
|
|
418
424
|
try {
|
|
419
425
|
removeTunnelWs(requestId);
|
|
420
|
-
untrackRequest(
|
|
421
|
-
const s = sessions.get(
|
|
426
|
+
untrackRequest(hostDeviceId, requestId);
|
|
427
|
+
const s = sessions.get(hostDeviceId);
|
|
422
428
|
if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
|
|
423
429
|
const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
424
430
|
const fwd = createEnvelope({
|
|
425
431
|
type: "tunnel.ws.close",
|
|
426
|
-
|
|
432
|
+
hostDeviceId,
|
|
427
433
|
payload: {
|
|
428
434
|
requestId,
|
|
429
435
|
code: safeCode,
|