@linkshell/gateway 0.2.46 → 0.2.48

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 (39) hide show
  1. package/dist/gateway/src/agent-permission-http.d.ts +18 -9
  2. package/dist/gateway/src/agent-permission-http.js +18 -10
  3. package/dist/gateway/src/agent-permission-http.js.map +1 -1
  4. package/dist/gateway/src/embedded.js +119 -55
  5. package/dist/gateway/src/embedded.js.map +1 -1
  6. package/dist/gateway/src/index.js +158 -91
  7. package/dist/gateway/src/index.js.map +1 -1
  8. package/dist/gateway/src/pairings.d.ts +3 -3
  9. package/dist/gateway/src/pairings.js +4 -5
  10. package/dist/gateway/src/pairings.js.map +1 -1
  11. package/dist/gateway/src/relay.d.ts +1 -1
  12. package/dist/gateway/src/relay.js +25 -18
  13. package/dist/gateway/src/relay.js.map +1 -1
  14. package/dist/gateway/src/sessions.d.ts +35 -28
  15. package/dist/gateway/src/sessions.js +165 -145
  16. package/dist/gateway/src/sessions.js.map +1 -1
  17. package/dist/gateway/src/state-store.d.ts +9 -6
  18. package/dist/gateway/src/state-store.js +26 -19
  19. package/dist/gateway/src/state-store.js.map +1 -1
  20. package/dist/gateway/src/tokens.d.ts +27 -7
  21. package/dist/gateway/src/tokens.js +86 -60
  22. package/dist/gateway/src/tokens.js.map +1 -1
  23. package/dist/gateway/src/tunnel.d.ts +11 -10
  24. package/dist/gateway/src/tunnel.js +46 -35
  25. package/dist/gateway/src/tunnel.js.map +1 -1
  26. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  27. package/dist/shared-protocol/src/index.d.ts +662 -494
  28. package/dist/shared-protocol/src/index.js +52 -15
  29. package/dist/shared-protocol/src/index.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/agent-permission-http.ts +18 -10
  32. package/src/embedded.ts +122 -54
  33. package/src/index.ts +162 -91
  34. package/src/pairings.ts +6 -7
  35. package/src/relay.ts +28 -20
  36. package/src/sessions.ts +179 -150
  37. package/src/state-store.ts +41 -25
  38. package/src/tokens.ts +109 -63
  39. package/src/tunnel.ts +57 -39
@@ -1,28 +1,35 @@
1
- export interface StoredTokenRecord {
1
+ export interface StoredAuthorizationRecord {
2
+ authorizationId: string;
2
3
  token: string;
3
- sessionIds: string[];
4
+ hostDeviceId: string;
5
+ clientDeviceId?: string;
6
+ clientName?: string;
4
7
  createdAt: number;
5
8
  lastUsedAt: number;
6
9
  }
7
10
 
8
11
  export interface StoredPairingRecord {
9
- sessionId: string;
12
+ hostDeviceId: string;
10
13
  pairingCode: string;
11
14
  expiresAt: number;
12
15
  claimed: boolean;
13
16
  }
14
17
 
15
18
  export interface GatewayStateStore {
16
- loadTokens(): Promise<StoredTokenRecord[]>;
17
- saveToken(record: StoredTokenRecord): Promise<void>;
18
- deleteToken(token: string): Promise<void>;
19
+ loadAuthorizations(): Promise<StoredAuthorizationRecord[]>;
20
+ saveAuthorization(record: StoredAuthorizationRecord): Promise<void>;
21
+ deleteAuthorization(authorizationId: string): Promise<void>;
19
22
  loadPairings(): Promise<StoredPairingRecord[]>;
20
23
  savePairing(record: StoredPairingRecord): Promise<void>;
21
24
  deletePairing(pairingCode: string): Promise<void>;
22
25
  }
23
26
 
24
- const TOKEN_TABLE = process.env.SUPABASE_GATEWAY_TOKEN_TABLE ?? "linkshell_gateway_tokens";
25
- const PAIRING_TABLE = process.env.SUPABASE_GATEWAY_PAIRING_TABLE ?? "linkshell_gateway_pairings";
27
+ const AUTHORIZATION_TABLE =
28
+ process.env.SUPABASE_GATEWAY_AUTHORIZATION_TABLE ??
29
+ "linkshell_gateway_device_authorizations";
30
+ const PAIRING_TABLE =
31
+ process.env.SUPABASE_GATEWAY_PAIRING_TABLE ??
32
+ "linkshell_gateway_pairing_challenges";
26
33
  const STORE_TIMEOUT_MS = Number(process.env.SUPABASE_STATE_TIMEOUT_MS ?? 3_000);
27
34
 
28
35
  function msToIso(ms: number): string {
@@ -35,6 +42,10 @@ function isoToMs(value: unknown): number {
35
42
  return Number.isNaN(parsed) ? Date.now() : parsed;
36
43
  }
37
44
 
45
+ function maybeString(value: unknown): string | undefined {
46
+ return typeof value === "string" && value.length > 0 ? value : undefined;
47
+ }
48
+
38
49
  export function createSupabaseStateStore(): GatewayStateStore | undefined {
39
50
  const url = process.env.SUPABASE_URL?.replace(/\/+$/, "");
40
51
  const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
@@ -64,49 +75,54 @@ export function createSupabaseStateStore(): GatewayStateStore | undefined {
64
75
  }
65
76
 
66
77
  return {
67
- async loadTokens() {
78
+ async loadAuthorizations() {
68
79
  const rows = await request<Array<Record<string, unknown>>>(
69
- `${TOKEN_TABLE}?select=token,session_ids,created_at,last_used_at`,
80
+ `${AUTHORIZATION_TABLE}?select=authorization_id,token,host_device_id,client_device_id,client_name,created_at,last_used_at`,
70
81
  );
71
82
  return rows.map((row) => ({
83
+ authorizationId: String(row.authorization_id ?? ""),
72
84
  token: String(row.token ?? ""),
73
- sessionIds: Array.isArray(row.session_ids)
74
- ? row.session_ids.map(String)
75
- : [],
85
+ hostDeviceId: String(row.host_device_id ?? ""),
86
+ clientDeviceId: maybeString(row.client_device_id),
87
+ clientName: maybeString(row.client_name),
76
88
  createdAt: isoToMs(row.created_at),
77
89
  lastUsedAt: isoToMs(row.last_used_at),
78
- })).filter((record) => record.token);
90
+ })).filter((record) => record.authorizationId && record.token && record.hostDeviceId);
79
91
  },
80
- async saveToken(record) {
92
+ async saveAuthorization(record) {
81
93
  await request(
82
- `${TOKEN_TABLE}?on_conflict=token`,
94
+ `${AUTHORIZATION_TABLE}?on_conflict=authorization_id`,
83
95
  {
84
96
  method: "POST",
85
97
  headers: { Prefer: "resolution=merge-duplicates" },
86
98
  body: JSON.stringify({
99
+ authorization_id: record.authorizationId,
87
100
  token: record.token,
88
- session_ids: record.sessionIds,
101
+ host_device_id: record.hostDeviceId,
102
+ client_device_id: record.clientDeviceId ?? null,
103
+ client_name: record.clientName ?? null,
89
104
  created_at: msToIso(record.createdAt),
90
105
  last_used_at: msToIso(record.lastUsedAt),
91
106
  }),
92
107
  },
93
108
  );
94
109
  },
95
- async deleteToken(token) {
96
- await request(`${TOKEN_TABLE}?token=eq.${encodeURIComponent(token)}`, {
97
- method: "DELETE",
98
- });
110
+ async deleteAuthorization(authorizationId) {
111
+ await request(
112
+ `${AUTHORIZATION_TABLE}?authorization_id=eq.${encodeURIComponent(authorizationId)}`,
113
+ { method: "DELETE" },
114
+ );
99
115
  },
100
116
  async loadPairings() {
101
117
  const rows = await request<Array<Record<string, unknown>>>(
102
- `${PAIRING_TABLE}?select=pairing_code,session_id,expires_at,claimed`,
118
+ `${PAIRING_TABLE}?select=pairing_code,host_device_id,expires_at,claimed`,
103
119
  );
104
120
  return rows.map((row) => ({
105
121
  pairingCode: String(row.pairing_code ?? ""),
106
- sessionId: String(row.session_id ?? ""),
122
+ hostDeviceId: String(row.host_device_id ?? ""),
107
123
  expiresAt: isoToMs(row.expires_at),
108
124
  claimed: row.claimed === true,
109
- })).filter((record) => record.pairingCode && record.sessionId);
125
+ })).filter((record) => record.pairingCode && record.hostDeviceId);
110
126
  },
111
127
  async savePairing(record) {
112
128
  await request(
@@ -116,7 +132,7 @@ export function createSupabaseStateStore(): GatewayStateStore | undefined {
116
132
  headers: { Prefer: "resolution=merge-duplicates" },
117
133
  body: JSON.stringify({
118
134
  pairing_code: record.pairingCode,
119
- session_id: record.sessionId,
135
+ host_device_id: record.hostDeviceId,
120
136
  expires_at: msToIso(record.expiresAt),
121
137
  claimed: record.claimed,
122
138
  }),
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
- const CLEANUP_INTERVAL = 5 * 60_000;
5
- const SESSION_TTL = 7 * 24 * 60 * 60_000; // 7 days — prune stale bindings
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
- sessionIds: Set<string>;
15
+ authorizations: Map<string, DeviceAuthorization>;
10
16
  createdAt: number;
11
17
  lastUsedAt: number;
12
18
  }
13
19
 
14
- export class TokenManager {
20
+ export class AuthorizationManager {
15
21
  private tokens = new Map<string, TokenRecord>();
16
- private sessionToToken = new Map<string, string>();
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.loadTokens();
27
- const now = Date.now();
29
+ const records = await this.store.loadAuthorizations();
28
30
  for (const record of records) {
29
- if (now - record.lastUsedAt > SESSION_TTL) {
30
- void this.store.deleteToken(record.token).catch(() => {});
31
- continue;
32
- }
33
- this.tokens.set(record.token, {
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] token store hydrate failed, using memory only: ${err}\n`);
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
- sessionIds: new Set(),
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
- bind(token: string, sessionId: string): boolean {
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 false;
69
- record.sessionIds.add(sessionId);
70
- record.lastUsedAt = Date.now();
71
- this.sessionToToken.set(sessionId, token);
72
- this.persist(record);
73
- return true;
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, sessionId: string): boolean {
107
+ owns(token: string, hostDeviceId: string): boolean {
85
108
  const record = this.tokens.get(token);
86
109
  if (!record) return false;
87
- record.lastUsedAt = Date.now();
88
- this.persist(record);
89
- return record.sessionIds.has(sessionId);
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
- getSessionIds(token: string): Set<string> {
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
- this.persist(record);
97
- return record.sessionIds;
141
+ return new Set(record.authorizations.keys());
98
142
  }
99
143
 
100
- getTokenForSession(sessionId: string): string | undefined {
101
- return this.sessionToToken.get(sessionId);
144
+ getSessionIds(token: string): Set<string> {
145
+ return this.getHostDeviceIds(token);
102
146
  }
103
147
 
104
- private cleanup(): void {
105
- const now = Date.now();
106
- for (const [token, record] of this.tokens) {
107
- if (now - record.lastUsedAt > SESSION_TTL) {
108
- for (const sid of record.sessionIds) {
109
- this.sessionToToken.delete(sid);
110
- }
111
- this.tokens.delete(token);
112
- void this.store?.deleteToken(token).catch(() => {});
113
- }
114
- }
148
+ getAuthorizationId(token: string, hostDeviceId: string): string | undefined {
149
+ return this.tokens.get(token)?.authorizations.get(hostDeviceId)?.authorizationId;
150
+ }
151
+
152
+ getTokenForSession(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(record: TokenRecord): void {
118
- void this.store?.saveToken({
119
- token: record.token,
120
- sessionIds: [...record.sessionIds],
121
- createdAt: record.createdAt,
122
- lastUsedAt: record.lastUsedAt,
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] token store save failed: ${err}\n`);
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
@@ -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 session for cleanup on host disconnect
30
- const sessionRequests = new Map<string, Set<string>>();
29
+ // Track requestIds per host device for cleanup on host disconnect
30
+ const deviceRequests = new Map<string, Set<string>>();
31
31
 
32
- function trackRequest(sessionId: string, requestId: string): void {
33
- let set = sessionRequests.get(sessionId);
32
+ function trackRequest(hostDeviceId: string, requestId: string): void {
33
+ let set = deviceRequests.get(hostDeviceId);
34
34
  if (!set) {
35
35
  set = new Set();
36
- sessionRequests.set(sessionId, set);
36
+ deviceRequests.set(hostDeviceId, set);
37
37
  }
38
38
  set.add(requestId);
39
39
  }
40
40
 
41
- function untrackRequest(sessionId: string, requestId: string): void {
42
- const set = sessionRequests.get(sessionId);
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) sessionRequests.delete(sessionId);
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: "sessionId:port:token" */
69
- export function parseTunnelCookie(req: IncomingMessage): { sessionId: string; port: number; token: string } | null {
68
+ /** Parse lsh_tunnel cookie: "hostDeviceId:port:token" */
69
+ export function parseTunnelCookie(req: IncomingMessage): { hostDeviceId: string; sessionId: 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 sessionId = parts[0]!;
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 (!sessionId || isNaN(port) || port < 1 || port > 65535 || !token) return null;
80
- return { sessionId, port, token };
79
+ if (!hostDeviceId || isNaN(port) || port < 1 || port > 65535 || !token) return null;
80
+ return { hostDeviceId, sessionId: hostDeviceId, port, token };
81
81
  }
82
82
 
83
83
  function errorResponse(res: ServerResponse, status: number, message: string): void {
@@ -89,32 +89,45 @@ function errorResponse(res: ServerResponse, status: number, message: string): vo
89
89
  res.end(message);
90
90
  }
91
91
 
92
- export function parseTunnelPath(pathname: string): { sessionId: string; port: number; path: string } | null {
92
+ export function parseTunnelPath(pathname: string): { hostDeviceId: string; sessionId: 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
+ hostDeviceId: match[1]!,
98
99
  sessionId: match[1]!,
99
100
  port,
100
101
  path: match[3] || "/",
101
102
  };
102
103
  }
103
104
 
105
+ type ParsedTunnelTarget = {
106
+ hostDeviceId?: string;
107
+ sessionId?: string;
108
+ port: number;
109
+ path: string;
110
+ };
111
+
104
112
  export async function handleTunnelRequest(
105
113
  req: IncomingMessage,
106
114
  res: ServerResponse,
107
115
  sessions: SessionManager,
108
116
  tokens: TokenManager,
109
- parsed: { sessionId: string; port: number; path: string },
117
+ parsed: ParsedTunnelTarget,
110
118
  url: URL,
111
119
  preAuthToken?: string,
112
120
  ): Promise<void> {
113
- const { sessionId, port, path } = parsed;
121
+ const hostDeviceId = parsed.hostDeviceId ?? parsed.sessionId;
122
+ const { port, path } = parsed;
123
+ if (!hostDeviceId) {
124
+ errorResponse(res, 400, "Missing host device id");
125
+ return;
126
+ }
114
127
 
115
128
  // Auth: device token OR Supabase JWT (userId owns session)
116
129
  const token = preAuthToken || extractToken(req, url);
117
- const tokenOwns = token && tokens.owns(token, sessionId);
130
+ const tokenOwns = token && tokens.owns(token, hostDeviceId);
118
131
  let authOwns = false;
119
132
  let authJwt: string | null = null;
120
133
  if (!tokenOwns && AUTH_REQUIRED) {
@@ -135,7 +148,7 @@ export async function handleTunnelRequest(
135
148
  });
136
149
  if (userRes.ok) {
137
150
  const user = (await userRes.json()) as { id: string };
138
- const session = sessions.get(sessionId);
151
+ const session = sessions.get(hostDeviceId);
139
152
  if (user.id && session?.userId && user.id === session.userId) {
140
153
  authOwns = true;
141
154
  authJwt = jwtCandidate;
@@ -153,12 +166,12 @@ export async function handleTunnelRequest(
153
166
  // Set auth cookie for subsequent sub-resource requests
154
167
  const cookieToken = tokenOwns ? token : authJwt;
155
168
  if (cookieToken) {
156
- const cookieVal = encodeURIComponent(`${sessionId}:${port}:${cookieToken}`);
169
+ const cookieVal = encodeURIComponent(`${hostDeviceId}:${port}:${cookieToken}`);
157
170
  res.setHeader("Set-Cookie", `lsh_tunnel=${cookieVal}; Path=/; HttpOnly; SameSite=Lax`);
158
171
  }
159
172
 
160
173
  // Validate session & host
161
- const session = sessions.get(sessionId);
174
+ const session = sessions.get(hostDeviceId);
162
175
  if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
163
176
  errorResponse(res, 502, "Host not connected");
164
177
  return;
@@ -204,17 +217,17 @@ export async function handleTunnelRequest(
204
217
  headersSent: false,
205
218
  timeout: setTimeout(() => {
206
219
  pendingRequests.delete(requestId);
207
- untrackRequest(sessionId, requestId);
220
+ untrackRequest(hostDeviceId, requestId);
208
221
  errorResponse(res, 504, "Tunnel request timed out");
209
222
  }, TUNNEL_TIMEOUT),
210
223
  };
211
224
  pendingRequests.set(requestId, pending);
212
- trackRequest(sessionId, requestId);
225
+ trackRequest(hostDeviceId, requestId);
213
226
 
214
227
  // Send tunnel.request to host
215
228
  const envelope = createEnvelope({
216
229
  type: "tunnel.request",
217
- sessionId,
230
+ hostDeviceId,
218
231
  payload: {
219
232
  requestId,
220
233
  method,
@@ -232,7 +245,7 @@ export async function handleTunnelRequest(
232
245
  if (p) {
233
246
  clearTimeout(p.timeout);
234
247
  pendingRequests.delete(requestId);
235
- untrackRequest(sessionId, requestId);
248
+ untrackRequest(hostDeviceId, requestId);
236
249
  }
237
250
  });
238
251
  }
@@ -306,8 +319,8 @@ export function removeTunnelWs(requestId: string): void {
306
319
  pendingWsSockets.delete(requestId);
307
320
  }
308
321
 
309
- export function cleanupSessionTunnels(sessionId: string): void {
310
- const requestIds = sessionRequests.get(sessionId);
322
+ export function cleanupSessionTunnels(hostDeviceId: string): void {
323
+ const requestIds = deviceRequests.get(hostDeviceId);
311
324
  if (!requestIds) return;
312
325
  for (const rid of requestIds) {
313
326
  const pending = pendingRequests.get(rid);
@@ -322,21 +335,26 @@ export function cleanupSessionTunnels(sessionId: string): void {
322
335
  pendingWsSockets.delete(rid);
323
336
  }
324
337
  }
325
- sessionRequests.delete(sessionId);
338
+ deviceRequests.delete(hostDeviceId);
326
339
  }
327
340
 
328
341
  export async function handleTunnelWsUpgrade(
329
342
  ws: WebSocket,
330
- parsed: { sessionId: string; port: number; path: string },
343
+ parsed: ParsedTunnelTarget,
331
344
  url: URL,
332
345
  sessions: SessionManager,
333
346
  tokens: TokenManager,
334
347
  ): Promise<void> {
335
- const { sessionId, port, path } = parsed;
348
+ const hostDeviceId = parsed.hostDeviceId ?? parsed.sessionId;
349
+ const { port, path } = parsed;
350
+ if (!hostDeviceId) {
351
+ ws.close(1008, "Missing host device id");
352
+ return;
353
+ }
336
354
 
337
355
  // Auth: device token OR Supabase JWT (userId owns session)
338
356
  const token = url.searchParams.get("token");
339
- const tokenOwns = token && tokens.owns(token, sessionId);
357
+ const tokenOwns = token && tokens.owns(token, hostDeviceId);
340
358
  let authOwns = false;
341
359
  if (!tokenOwns && AUTH_REQUIRED) {
342
360
  // Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
@@ -352,7 +370,7 @@ export async function handleTunnelWsUpgrade(
352
370
  });
353
371
  if (userRes.ok) {
354
372
  const user = (await userRes.json()) as { id: string };
355
- const session = sessions.get(sessionId);
373
+ const session = sessions.get(hostDeviceId);
356
374
  if (user.id && session?.userId && user.id === session.userId) {
357
375
  authOwns = true;
358
376
  }
@@ -366,7 +384,7 @@ export async function handleTunnelWsUpgrade(
366
384
  return;
367
385
  }
368
386
 
369
- const session = sessions.get(sessionId);
387
+ const session = sessions.get(hostDeviceId);
370
388
  if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
371
389
  ws.close(4002, "Host not connected");
372
390
  return;
@@ -377,12 +395,12 @@ export async function handleTunnelWsUpgrade(
377
395
 
378
396
  // Register this WS so host responses route here
379
397
  registerTunnelWs(requestId, ws);
380
- trackRequest(sessionId, requestId);
398
+ trackRequest(hostDeviceId, requestId);
381
399
 
382
400
  // Send tunnel.request with upgrade header to host
383
401
  const envelope = createEnvelope({
384
402
  type: "tunnel.request",
385
- sessionId,
403
+ hostDeviceId,
386
404
  payload: {
387
405
  requestId,
388
406
  method: "GET",
@@ -397,13 +415,13 @@ export async function handleTunnelWsUpgrade(
397
415
  // Forward data from browser WS to host
398
416
  ws.on("message", (data: Buffer | string) => {
399
417
  try {
400
- const s = sessions.get(sessionId);
418
+ const s = sessions.get(hostDeviceId);
401
419
  if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
402
420
  const isBinary = typeof data !== "string";
403
421
  const buf = typeof data === "string" ? Buffer.from(data) : data;
404
422
  const fwd = createEnvelope({
405
423
  type: "tunnel.ws.data",
406
- sessionId,
424
+ hostDeviceId,
407
425
  payload: {
408
426
  requestId,
409
427
  data: buf.toString("base64"),
@@ -417,13 +435,13 @@ export async function handleTunnelWsUpgrade(
417
435
  ws.on("close", (code, reason) => {
418
436
  try {
419
437
  removeTunnelWs(requestId);
420
- untrackRequest(sessionId, requestId);
421
- const s = sessions.get(sessionId);
438
+ untrackRequest(hostDeviceId, requestId);
439
+ const s = sessions.get(hostDeviceId);
422
440
  if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
423
441
  const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
424
442
  const fwd = createEnvelope({
425
443
  type: "tunnel.ws.close",
426
- sessionId,
444
+ hostDeviceId,
427
445
  payload: {
428
446
  requestId,
429
447
  code: safeCode,