@linkshell/gateway 0.2.25 → 0.2.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/index.js +28 -26
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/pairings.d.ts +5 -1
- package/dist/gateway/src/pairings.js +35 -2
- package/dist/gateway/src/pairings.js.map +1 -1
- package/dist/gateway/src/relay.js +22 -13
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +1 -1
- package/dist/gateway/src/sessions.js +13 -3
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/src/state-store.d.ts +21 -0
- package/dist/gateway/src/state-store.js +93 -0
- package/dist/gateway/src/state-store.js.map +1 -0
- package/dist/gateway/src/tokens.d.ts +5 -1
- package/dist/gateway/src/tokens.js +46 -1
- package/dist/gateway/src/tokens.js.map +1 -1
- package/dist/gateway/src/tunnel.d.ts +1 -1
- package/dist/gateway/src/tunnel.js +74 -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 +46 -40
- package/dist/shared-protocol/src/index.js +4 -1
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +30 -28
- package/src/pairings.ts +34 -2
- package/src/relay.ts +29 -18
- package/src/sessions.ts +16 -3
- package/src/state-store.ts +131 -0
- package/src/tokens.ts +45 -1
- package/src/tunnel.ts +69 -9
package/src/relay.ts
CHANGED
|
@@ -141,29 +141,31 @@ function handleClientMessage(
|
|
|
141
141
|
deviceId: string,
|
|
142
142
|
sessions: SessionManager,
|
|
143
143
|
): void {
|
|
144
|
+
const requireController = (): boolean => {
|
|
145
|
+
if (session.controllerId === deviceId) return true;
|
|
146
|
+
socket.send(
|
|
147
|
+
serializeEnvelope(
|
|
148
|
+
createEnvelope({
|
|
149
|
+
type: "session.error",
|
|
150
|
+
sessionId: session.id,
|
|
151
|
+
payload: {
|
|
152
|
+
code: "control_conflict",
|
|
153
|
+
message: "Not the controller",
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
return false;
|
|
159
|
+
};
|
|
160
|
+
|
|
144
161
|
switch (envelope.type) {
|
|
145
162
|
case "terminal.input": {
|
|
146
|
-
|
|
147
|
-
if (session.controllerId !== deviceId) {
|
|
148
|
-
socket.send(
|
|
149
|
-
serializeEnvelope(
|
|
150
|
-
createEnvelope({
|
|
151
|
-
type: "session.error",
|
|
152
|
-
sessionId: session.id,
|
|
153
|
-
payload: {
|
|
154
|
-
code: "control_conflict",
|
|
155
|
-
message: "Not the controller",
|
|
156
|
-
},
|
|
157
|
-
}),
|
|
158
|
-
),
|
|
159
|
-
);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
163
|
+
if (!requireController()) return;
|
|
162
164
|
sendToHost(session, envelope);
|
|
163
165
|
break;
|
|
164
166
|
}
|
|
165
167
|
case "terminal.resize": {
|
|
166
|
-
if (
|
|
168
|
+
if (!requireController()) return;
|
|
167
169
|
sendToHost(session, envelope);
|
|
168
170
|
break;
|
|
169
171
|
}
|
|
@@ -175,7 +177,11 @@ function handleClientMessage(
|
|
|
175
177
|
case "session.resume": {
|
|
176
178
|
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
177
179
|
// Replay from gateway buffer first
|
|
178
|
-
const replay = sessions.getReplayFrom(
|
|
180
|
+
const replay = sessions.getReplayFrom(
|
|
181
|
+
session.id,
|
|
182
|
+
p.lastAckedSeqByTerminal,
|
|
183
|
+
p.lastAckedSeq,
|
|
184
|
+
);
|
|
179
185
|
for (const msg of replay) {
|
|
180
186
|
const payload = msg.payload as Record<string, unknown>;
|
|
181
187
|
socket.send(
|
|
@@ -183,6 +189,7 @@ function handleClientMessage(
|
|
|
183
189
|
createEnvelope({
|
|
184
190
|
type: "terminal.output",
|
|
185
191
|
sessionId: session.id,
|
|
192
|
+
terminalId: msg.terminalId,
|
|
186
193
|
seq: msg.seq,
|
|
187
194
|
payload: { ...payload, isReplay: true },
|
|
188
195
|
}),
|
|
@@ -234,6 +241,10 @@ function handleClientMessage(
|
|
|
234
241
|
case "terminal.list":
|
|
235
242
|
case "terminal.browse":
|
|
236
243
|
case "terminal.mkdir":
|
|
244
|
+
case "terminal.history.request":
|
|
245
|
+
case "file.upload":
|
|
246
|
+
case "permission.decision":
|
|
247
|
+
if (!requireController()) return;
|
|
237
248
|
sendToHost(session, envelope);
|
|
238
249
|
break;
|
|
239
250
|
default:
|
package/src/sessions.ts
CHANGED
|
@@ -84,6 +84,9 @@ export class SessionManager {
|
|
|
84
84
|
addClient(sessionId: string, device: ConnectedDevice): void {
|
|
85
85
|
const session = this.getOrCreate(sessionId);
|
|
86
86
|
session.clients.set(device.deviceId, device);
|
|
87
|
+
if (!session.controllerId) {
|
|
88
|
+
session.controllerId = device.deviceId;
|
|
89
|
+
}
|
|
87
90
|
session.lastActivity = Date.now();
|
|
88
91
|
}
|
|
89
92
|
|
|
@@ -154,16 +157,26 @@ export class SessionManager {
|
|
|
154
157
|
return [...session.lastStatusByTerminal.values()];
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
getReplayFrom(
|
|
160
|
+
getReplayFrom(
|
|
161
|
+
sessionId: string,
|
|
162
|
+
afterSeqByTerminal: Record<string, number>,
|
|
163
|
+
fallbackAfterSeq = -1,
|
|
164
|
+
): Envelope[] {
|
|
158
165
|
const session = this.sessions.get(sessionId);
|
|
159
166
|
if (!session) return [];
|
|
160
167
|
const result: Envelope[] = [];
|
|
161
|
-
for (const buf of session.outputBuffers
|
|
168
|
+
for (const [terminalId, buf] of session.outputBuffers) {
|
|
169
|
+
const afterSeq = afterSeqByTerminal[terminalId] ?? fallbackAfterSeq;
|
|
162
170
|
for (const e of buf) {
|
|
163
171
|
if (e.seq !== undefined && e.seq > afterSeq) result.push(e);
|
|
164
172
|
}
|
|
165
173
|
}
|
|
166
|
-
return result.sort((a, b) =>
|
|
174
|
+
return result.sort((a, b) => {
|
|
175
|
+
const at = Date.parse(a.timestamp);
|
|
176
|
+
const bt = Date.parse(b.timestamp);
|
|
177
|
+
if (!Number.isNaN(at) && !Number.isNaN(bt) && at !== bt) return at - bt;
|
|
178
|
+
return (a.seq ?? 0) - (b.seq ?? 0);
|
|
179
|
+
});
|
|
167
180
|
}
|
|
168
181
|
|
|
169
182
|
claimControl(sessionId: string, deviceId: string): boolean {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export interface StoredTokenRecord {
|
|
2
|
+
token: string;
|
|
3
|
+
sessionIds: string[];
|
|
4
|
+
createdAt: number;
|
|
5
|
+
lastUsedAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface StoredPairingRecord {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
pairingCode: string;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
claimed: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GatewayStateStore {
|
|
16
|
+
loadTokens(): Promise<StoredTokenRecord[]>;
|
|
17
|
+
saveToken(record: StoredTokenRecord): Promise<void>;
|
|
18
|
+
deleteToken(token: string): Promise<void>;
|
|
19
|
+
loadPairings(): Promise<StoredPairingRecord[]>;
|
|
20
|
+
savePairing(record: StoredPairingRecord): Promise<void>;
|
|
21
|
+
deletePairing(pairingCode: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
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";
|
|
26
|
+
|
|
27
|
+
function msToIso(ms: number): string {
|
|
28
|
+
return new Date(ms).toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isoToMs(value: unknown): number {
|
|
32
|
+
if (typeof value !== "string") return Date.now();
|
|
33
|
+
const parsed = Date.parse(value);
|
|
34
|
+
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createSupabaseStateStore(): GatewayStateStore | undefined {
|
|
38
|
+
const url = process.env.SUPABASE_URL?.replace(/\/+$/, "");
|
|
39
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
|
|
40
|
+
if (!url || !key) return undefined;
|
|
41
|
+
|
|
42
|
+
const headers = {
|
|
43
|
+
apikey: key,
|
|
44
|
+
Authorization: `Bearer ${key}`,
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
49
|
+
const res = await fetch(`${url}/rest/v1/${path}`, {
|
|
50
|
+
...init,
|
|
51
|
+
headers: {
|
|
52
|
+
...headers,
|
|
53
|
+
...(init?.headers ?? {}),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const body = await res.text().catch(() => "");
|
|
58
|
+
throw new Error(`Supabase state store ${res.status}: ${body || res.statusText}`);
|
|
59
|
+
}
|
|
60
|
+
if (res.status === 204) return undefined as T;
|
|
61
|
+
return (await res.json()) as T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async loadTokens() {
|
|
66
|
+
const rows = await request<Array<Record<string, unknown>>>(
|
|
67
|
+
`${TOKEN_TABLE}?select=token,session_ids,created_at,last_used_at`,
|
|
68
|
+
);
|
|
69
|
+
return rows.map((row) => ({
|
|
70
|
+
token: String(row.token ?? ""),
|
|
71
|
+
sessionIds: Array.isArray(row.session_ids)
|
|
72
|
+
? row.session_ids.map(String)
|
|
73
|
+
: [],
|
|
74
|
+
createdAt: isoToMs(row.created_at),
|
|
75
|
+
lastUsedAt: isoToMs(row.last_used_at),
|
|
76
|
+
})).filter((record) => record.token);
|
|
77
|
+
},
|
|
78
|
+
async saveToken(record) {
|
|
79
|
+
await request(
|
|
80
|
+
`${TOKEN_TABLE}?on_conflict=token`,
|
|
81
|
+
{
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { Prefer: "resolution=merge-duplicates" },
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
token: record.token,
|
|
86
|
+
session_ids: record.sessionIds,
|
|
87
|
+
created_at: msToIso(record.createdAt),
|
|
88
|
+
last_used_at: msToIso(record.lastUsedAt),
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
async deleteToken(token) {
|
|
94
|
+
await request(`${TOKEN_TABLE}?token=eq.${encodeURIComponent(token)}`, {
|
|
95
|
+
method: "DELETE",
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
async loadPairings() {
|
|
99
|
+
const rows = await request<Array<Record<string, unknown>>>(
|
|
100
|
+
`${PAIRING_TABLE}?select=pairing_code,session_id,expires_at,claimed`,
|
|
101
|
+
);
|
|
102
|
+
return rows.map((row) => ({
|
|
103
|
+
pairingCode: String(row.pairing_code ?? ""),
|
|
104
|
+
sessionId: String(row.session_id ?? ""),
|
|
105
|
+
expiresAt: isoToMs(row.expires_at),
|
|
106
|
+
claimed: row.claimed === true,
|
|
107
|
+
})).filter((record) => record.pairingCode && record.sessionId);
|
|
108
|
+
},
|
|
109
|
+
async savePairing(record) {
|
|
110
|
+
await request(
|
|
111
|
+
`${PAIRING_TABLE}?on_conflict=pairing_code`,
|
|
112
|
+
{
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { Prefer: "resolution=merge-duplicates" },
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
pairing_code: record.pairingCode,
|
|
117
|
+
session_id: record.sessionId,
|
|
118
|
+
expires_at: msToIso(record.expiresAt),
|
|
119
|
+
claimed: record.claimed,
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
async deletePairing(pairingCode) {
|
|
125
|
+
await request(
|
|
126
|
+
`${PAIRING_TABLE}?pairing_code=eq.${encodeURIComponent(pairingCode)}`,
|
|
127
|
+
{ method: "DELETE" },
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/tokens.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { GatewayStateStore } from "./state-store.js";
|
|
2
3
|
|
|
3
4
|
const CLEANUP_INTERVAL = 5 * 60_000;
|
|
4
5
|
const SESSION_TTL = 7 * 24 * 60 * 60_000; // 7 days — prune stale bindings
|
|
@@ -15,14 +16,40 @@ export class TokenManager {
|
|
|
15
16
|
private sessionToToken = new Map<string, string>();
|
|
16
17
|
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
17
18
|
|
|
18
|
-
constructor() {
|
|
19
|
+
constructor(private readonly store?: GatewayStateStore) {
|
|
19
20
|
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
async hydrate(): Promise<void> {
|
|
24
|
+
if (!this.store) return;
|
|
25
|
+
try {
|
|
26
|
+
const records = await this.store.loadTokens();
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
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),
|
|
36
|
+
createdAt: record.createdAt,
|
|
37
|
+
lastUsedAt: record.lastUsedAt,
|
|
38
|
+
});
|
|
39
|
+
for (const sessionId of record.sessionIds) {
|
|
40
|
+
this.sessionToToken.set(sessionId, record.token);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
process.stderr.write(`[gateway] token store hydrate failed, using memory only: ${err}\n`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
register(deviceToken?: string): string {
|
|
23
49
|
if (deviceToken && this.tokens.has(deviceToken)) {
|
|
24
50
|
const record = this.tokens.get(deviceToken)!;
|
|
25
51
|
record.lastUsedAt = Date.now();
|
|
52
|
+
this.persist(record);
|
|
26
53
|
return deviceToken;
|
|
27
54
|
}
|
|
28
55
|
const token = deviceToken || randomUUID();
|
|
@@ -32,6 +59,7 @@ export class TokenManager {
|
|
|
32
59
|
createdAt: Date.now(),
|
|
33
60
|
lastUsedAt: Date.now(),
|
|
34
61
|
});
|
|
62
|
+
this.persist(this.tokens.get(token)!);
|
|
35
63
|
return token;
|
|
36
64
|
}
|
|
37
65
|
|
|
@@ -41,6 +69,7 @@ export class TokenManager {
|
|
|
41
69
|
record.sessionIds.add(sessionId);
|
|
42
70
|
record.lastUsedAt = Date.now();
|
|
43
71
|
this.sessionToToken.set(sessionId, token);
|
|
72
|
+
this.persist(record);
|
|
44
73
|
return true;
|
|
45
74
|
}
|
|
46
75
|
|
|
@@ -48,6 +77,7 @@ export class TokenManager {
|
|
|
48
77
|
const record = this.tokens.get(token);
|
|
49
78
|
if (!record) return false;
|
|
50
79
|
record.lastUsedAt = Date.now();
|
|
80
|
+
this.persist(record);
|
|
51
81
|
return true;
|
|
52
82
|
}
|
|
53
83
|
|
|
@@ -55,6 +85,7 @@ export class TokenManager {
|
|
|
55
85
|
const record = this.tokens.get(token);
|
|
56
86
|
if (!record) return false;
|
|
57
87
|
record.lastUsedAt = Date.now();
|
|
88
|
+
this.persist(record);
|
|
58
89
|
return record.sessionIds.has(sessionId);
|
|
59
90
|
}
|
|
60
91
|
|
|
@@ -62,6 +93,7 @@ export class TokenManager {
|
|
|
62
93
|
const record = this.tokens.get(token);
|
|
63
94
|
if (!record) return new Set();
|
|
64
95
|
record.lastUsedAt = Date.now();
|
|
96
|
+
this.persist(record);
|
|
65
97
|
return record.sessionIds;
|
|
66
98
|
}
|
|
67
99
|
|
|
@@ -77,10 +109,22 @@ export class TokenManager {
|
|
|
77
109
|
this.sessionToToken.delete(sid);
|
|
78
110
|
}
|
|
79
111
|
this.tokens.delete(token);
|
|
112
|
+
void this.store?.deleteToken(token).catch(() => {});
|
|
80
113
|
}
|
|
81
114
|
}
|
|
82
115
|
}
|
|
83
116
|
|
|
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,
|
|
123
|
+
}).catch((err) => {
|
|
124
|
+
process.stderr.write(`[gateway] token store save failed: ${err}\n`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
84
128
|
destroy(): void {
|
|
85
129
|
clearInterval(this.cleanupTimer);
|
|
86
130
|
}
|
package/src/tunnel.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "@linkshell/protocol";
|
|
8
8
|
import type { SessionManager } from "./sessions.js";
|
|
9
9
|
import type { TokenManager } from "./tokens.js";
|
|
10
|
+
import { AUTH_REQUIRED } from "./auth-middleware.js";
|
|
10
11
|
|
|
11
12
|
const TUNNEL_TIMEOUT = 30_000;
|
|
12
13
|
const MAX_TUNNEL_BODY = 10 * 1024 * 1024; // 10MB
|
|
@@ -111,16 +112,50 @@ export async function handleTunnelRequest(
|
|
|
111
112
|
): Promise<void> {
|
|
112
113
|
const { sessionId, port, path } = parsed;
|
|
113
114
|
|
|
114
|
-
// Auth
|
|
115
|
+
// Auth: device token OR Supabase JWT (userId owns session)
|
|
115
116
|
const token = preAuthToken || extractToken(req, url);
|
|
116
|
-
|
|
117
|
+
const tokenOwns = token && tokens.owns(token, sessionId);
|
|
118
|
+
let authOwns = false;
|
|
119
|
+
let authJwt: string | null = null;
|
|
120
|
+
if (!tokenOwns && AUTH_REQUIRED) {
|
|
121
|
+
// Try preAuthToken as JWT first (from cookie fallback), then from request headers/params
|
|
122
|
+
const jwtCandidate = preAuthToken || url.searchParams.get("auth_token") || (() => {
|
|
123
|
+
const auth = req.headers.authorization;
|
|
124
|
+
if (auth) { const m = auth.match(/^Bearer\s+(.+)$/i); if (m?.[1]) return m[1]; }
|
|
125
|
+
return null;
|
|
126
|
+
})();
|
|
127
|
+
if (jwtCandidate) {
|
|
128
|
+
try {
|
|
129
|
+
const SUPABASE_URL = process.env.SUPABASE_URL ?? "";
|
|
130
|
+
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ?? "";
|
|
131
|
+
if (SUPABASE_URL && SUPABASE_ANON_KEY) {
|
|
132
|
+
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
133
|
+
headers: { Authorization: `Bearer ${jwtCandidate}`, apikey: SUPABASE_ANON_KEY },
|
|
134
|
+
signal: AbortSignal.timeout(5_000),
|
|
135
|
+
});
|
|
136
|
+
if (userRes.ok) {
|
|
137
|
+
const user = (await userRes.json()) as { id: string };
|
|
138
|
+
const session = sessions.get(sessionId);
|
|
139
|
+
if (user.id && session?.userId && user.id === session.userId) {
|
|
140
|
+
authOwns = true;
|
|
141
|
+
authJwt = jwtCandidate;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!tokenOwns && !authOwns) {
|
|
117
149
|
errorResponse(res, 401, "Unauthorized");
|
|
118
150
|
return;
|
|
119
151
|
}
|
|
120
152
|
|
|
121
|
-
// Set auth cookie for subsequent sub-resource requests
|
|
122
|
-
const
|
|
123
|
-
|
|
153
|
+
// Set auth cookie for subsequent sub-resource requests
|
|
154
|
+
const cookieToken = tokenOwns ? token : authJwt;
|
|
155
|
+
if (cookieToken) {
|
|
156
|
+
const cookieVal = encodeURIComponent(`${sessionId}:${port}:${cookieToken}`);
|
|
157
|
+
res.setHeader("Set-Cookie", `lsh_tunnel=${cookieVal}; Path=/; HttpOnly; SameSite=Lax`);
|
|
158
|
+
}
|
|
124
159
|
|
|
125
160
|
// Validate session & host
|
|
126
161
|
const session = sessions.get(sessionId);
|
|
@@ -290,18 +325,43 @@ export function cleanupSessionTunnels(sessionId: string): void {
|
|
|
290
325
|
sessionRequests.delete(sessionId);
|
|
291
326
|
}
|
|
292
327
|
|
|
293
|
-
export function handleTunnelWsUpgrade(
|
|
328
|
+
export async function handleTunnelWsUpgrade(
|
|
294
329
|
ws: WebSocket,
|
|
295
330
|
parsed: { sessionId: string; port: number; path: string },
|
|
296
331
|
url: URL,
|
|
297
332
|
sessions: SessionManager,
|
|
298
333
|
tokens: TokenManager,
|
|
299
|
-
): void {
|
|
334
|
+
): Promise<void> {
|
|
300
335
|
const { sessionId, port, path } = parsed;
|
|
301
336
|
|
|
302
|
-
// Auth
|
|
337
|
+
// Auth: device token OR Supabase JWT (userId owns session)
|
|
303
338
|
const token = url.searchParams.get("token");
|
|
304
|
-
|
|
339
|
+
const tokenOwns = token && tokens.owns(token, sessionId);
|
|
340
|
+
let authOwns = false;
|
|
341
|
+
if (!tokenOwns && AUTH_REQUIRED) {
|
|
342
|
+
// Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
|
|
343
|
+
const authToken = url.searchParams.get("auth_token") || token;
|
|
344
|
+
if (authToken) {
|
|
345
|
+
try {
|
|
346
|
+
const SUPABASE_URL = process.env.SUPABASE_URL ?? "";
|
|
347
|
+
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ?? "";
|
|
348
|
+
if (SUPABASE_URL && SUPABASE_ANON_KEY) {
|
|
349
|
+
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
350
|
+
headers: { Authorization: `Bearer ${authToken}`, apikey: SUPABASE_ANON_KEY },
|
|
351
|
+
signal: AbortSignal.timeout(5_000),
|
|
352
|
+
});
|
|
353
|
+
if (userRes.ok) {
|
|
354
|
+
const user = (await userRes.json()) as { id: string };
|
|
355
|
+
const session = sessions.get(sessionId);
|
|
356
|
+
if (user.id && session?.userId && user.id === session.userId) {
|
|
357
|
+
authOwns = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} catch {}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (!tokenOwns && !authOwns) {
|
|
305
365
|
ws.close(4001, "Unauthorized");
|
|
306
366
|
return;
|
|
307
367
|
}
|