@linkshell/gateway 0.3.9 → 0.4.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/Dockerfile +1 -3
- package/README.md +13 -14
- package/dist/gateway/src/agent-permission-http.d.ts +74 -19
- package/dist/gateway/src/agent-permission-http.js +56 -16
- package/dist/gateway/src/agent-permission-http.js.map +1 -1
- package/dist/gateway/src/embedded.js +61 -153
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/index.js +98 -193
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/pairings.d.ts +3 -3
- package/dist/gateway/src/pairings.js +5 -4
- package/dist/gateway/src/pairings.js.map +1 -1
- package/dist/gateway/src/relay.d.ts +2 -2
- package/dist/gateway/src/relay.js +85 -161
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +28 -42
- package/dist/gateway/src/sessions.js +145 -200
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/src/state-store.d.ts +6 -9
- package/dist/gateway/src/state-store.js +19 -26
- package/dist/gateway/src/state-store.js.map +1 -1
- package/dist/gateway/src/tokens.d.ts +7 -27
- package/dist/gateway/src/tokens.js +60 -86
- package/dist/gateway/src/tokens.js.map +1 -1
- package/dist/gateway/src/tunnel.d.ts +13 -11
- 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 +11978 -3423
- package/dist/shared-protocol/src/index.js +114 -163
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +11 -11
- package/src/agent-permission-http.ts +63 -20
- package/src/embedded.ts +60 -158
- package/src/index.ts +98 -199
- package/src/pairings.ts +7 -6
- package/src/relay.ts +97 -193
- package/src/sessions.ts +150 -213
- package/src/state-store.ts +25 -41
- package/src/tokens.ts +63 -109
- package/src/tunnel.ts +43 -49
package/src/sessions.ts
CHANGED
|
@@ -1,59 +1,54 @@
|
|
|
1
1
|
import type WebSocket from "ws";
|
|
2
2
|
import type { Envelope } from "@linkshell/protocol";
|
|
3
3
|
|
|
4
|
-
export type
|
|
4
|
+
export type SessionState = "active" | "host_disconnected" | "terminated";
|
|
5
5
|
|
|
6
6
|
export interface ConnectedDevice {
|
|
7
7
|
socket: WebSocket;
|
|
8
8
|
role: "host" | "client";
|
|
9
9
|
deviceId: string;
|
|
10
|
-
token?: string;
|
|
11
|
-
authorizationId?: string;
|
|
12
10
|
connectedAt: number;
|
|
13
11
|
}
|
|
14
12
|
|
|
15
|
-
export interface
|
|
13
|
+
export interface Session {
|
|
16
14
|
id: string;
|
|
17
|
-
|
|
18
|
-
state: DeviceState;
|
|
15
|
+
state: SessionState;
|
|
19
16
|
host: ConnectedDevice | undefined;
|
|
20
17
|
clients: Map<string, ConnectedDevice>;
|
|
21
18
|
controllerId: string | undefined;
|
|
22
19
|
lastActivity: number;
|
|
23
20
|
createdAt: number;
|
|
24
|
-
outputBuffers: Map<string, Envelope[]>;
|
|
25
|
-
lastStatusByTerminal: Map<string, Envelope>;
|
|
21
|
+
outputBuffers: Map<string, Envelope[]>; // keyed by terminalId
|
|
22
|
+
lastStatusByTerminal: Map<string, Envelope>; // last terminal.status per terminal
|
|
26
23
|
hostDisconnectedAt: number | undefined;
|
|
24
|
+
// Metadata from host's session.connect
|
|
25
|
+
provider: string | undefined;
|
|
27
26
|
machineId: string | undefined;
|
|
28
27
|
hostname: string | undefined;
|
|
29
28
|
platform: string | undefined;
|
|
30
29
|
cwd: string | undefined;
|
|
31
|
-
|
|
30
|
+
projectName: string | undefined;
|
|
31
|
+
// Auth: user who owns this session (set on AUTH_REQUIRED gateways)
|
|
32
32
|
userId: string | undefined;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const OUTPUT_BUFFER_CAPACITY = 200;
|
|
36
|
-
const
|
|
37
|
-
process.env.OUTPUT_BUFFER_MAX_PAYLOAD_BYTES ?? 64 * 1024,
|
|
38
|
-
);
|
|
39
|
-
const HOST_RECONNECT_WINDOW = 60_000;
|
|
36
|
+
const HOST_RECONNECT_WINDOW = 60_000; // 60s
|
|
40
37
|
const CLEANUP_INTERVAL = 30_000;
|
|
41
38
|
|
|
42
|
-
export class
|
|
43
|
-
private
|
|
39
|
+
export class SessionManager {
|
|
40
|
+
private sessions = new Map<string, Session>();
|
|
44
41
|
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
45
|
-
private droppedClients = 0;
|
|
46
42
|
|
|
47
43
|
constructor() {
|
|
48
44
|
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
49
45
|
}
|
|
50
46
|
|
|
51
|
-
getOrCreate(
|
|
52
|
-
let
|
|
53
|
-
if (!
|
|
54
|
-
|
|
55
|
-
id:
|
|
56
|
-
hostDeviceId,
|
|
47
|
+
getOrCreate(sessionId: string): Session {
|
|
48
|
+
let session = this.sessions.get(sessionId);
|
|
49
|
+
if (!session) {
|
|
50
|
+
session = {
|
|
51
|
+
id: sessionId,
|
|
57
52
|
state: "active",
|
|
58
53
|
host: undefined,
|
|
59
54
|
clients: new Map(),
|
|
@@ -63,155 +58,122 @@ export class DeviceManager {
|
|
|
63
58
|
outputBuffers: new Map(),
|
|
64
59
|
lastStatusByTerminal: new Map(),
|
|
65
60
|
hostDisconnectedAt: undefined,
|
|
61
|
+
provider: undefined,
|
|
66
62
|
machineId: undefined,
|
|
67
63
|
hostname: undefined,
|
|
68
64
|
platform: undefined,
|
|
69
65
|
cwd: undefined,
|
|
70
|
-
|
|
66
|
+
projectName: undefined,
|
|
71
67
|
userId: undefined,
|
|
72
68
|
};
|
|
73
|
-
this.
|
|
69
|
+
this.sessions.set(sessionId, session);
|
|
74
70
|
}
|
|
75
|
-
return
|
|
71
|
+
return session;
|
|
76
72
|
}
|
|
77
73
|
|
|
78
|
-
get(
|
|
79
|
-
return this.
|
|
74
|
+
get(sessionId: string): Session | undefined {
|
|
75
|
+
return this.sessions.get(sessionId);
|
|
80
76
|
}
|
|
81
77
|
|
|
82
|
-
setHost(
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
setHost(sessionId: string, device: ConnectedDevice): void {
|
|
79
|
+
const session = this.getOrCreate(sessionId);
|
|
80
|
+
session.host = device;
|
|
81
|
+
session.state = "active";
|
|
82
|
+
session.hostDisconnectedAt = undefined;
|
|
83
|
+
session.lastActivity = Date.now();
|
|
88
84
|
}
|
|
89
85
|
|
|
90
|
-
addClient(
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
if (!
|
|
94
|
-
|
|
86
|
+
addClient(sessionId: string, device: ConnectedDevice): void {
|
|
87
|
+
const session = this.getOrCreate(sessionId);
|
|
88
|
+
session.clients.set(device.deviceId, device);
|
|
89
|
+
if (!session.controllerId) {
|
|
90
|
+
session.controllerId = device.deviceId;
|
|
95
91
|
}
|
|
96
|
-
|
|
92
|
+
session.lastActivity = Date.now();
|
|
97
93
|
}
|
|
98
94
|
|
|
99
95
|
removeHost(
|
|
100
|
-
|
|
96
|
+
sessionId: string,
|
|
101
97
|
): { clients: Map<string, ConnectedDevice> } | undefined {
|
|
102
|
-
const
|
|
103
|
-
if (!
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return { clients:
|
|
98
|
+
const session = this.sessions.get(sessionId);
|
|
99
|
+
if (!session) return undefined;
|
|
100
|
+
session.host = undefined;
|
|
101
|
+
session.state = "host_disconnected";
|
|
102
|
+
session.hostDisconnectedAt = Date.now();
|
|
103
|
+
return { clients: session.clients };
|
|
108
104
|
}
|
|
109
105
|
|
|
110
|
-
removeClient(
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
106
|
+
removeClient(sessionId: string, deviceId: string): void {
|
|
107
|
+
const session = this.sessions.get(sessionId);
|
|
108
|
+
if (!session) return;
|
|
109
|
+
session.clients.delete(deviceId);
|
|
110
|
+
if (session.controllerId === deviceId) {
|
|
111
|
+
// Transfer control to next client or clear
|
|
112
|
+
const next = session.clients.keys().next();
|
|
113
|
+
session.controllerId = next.done ? undefined : next.value;
|
|
117
114
|
}
|
|
118
|
-
this.maybeDelete(
|
|
115
|
+
this.maybeDelete(sessionId);
|
|
119
116
|
}
|
|
120
117
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
client.socket.close(4001, "authorization revoked");
|
|
129
|
-
} catch {}
|
|
130
|
-
device.clients.delete(deviceId);
|
|
131
|
-
closed++;
|
|
118
|
+
/** Force-delete a session: disconnect host + all clients, remove from map. */
|
|
119
|
+
forceDelete(sessionId: string): boolean {
|
|
120
|
+
const session = this.sessions.get(sessionId);
|
|
121
|
+
if (!session) return false;
|
|
122
|
+
if (session.host) {
|
|
123
|
+
try { session.host.socket.close(1000, "session deleted"); } catch {}
|
|
132
124
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const next = device.clients.keys().next();
|
|
136
|
-
device.controllerId = next.done ? undefined : next.value;
|
|
125
|
+
for (const [, client] of session.clients) {
|
|
126
|
+
try { client.socket.close(1000, "session deleted"); } catch {}
|
|
137
127
|
}
|
|
138
|
-
this.
|
|
139
|
-
return closed;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
forceDelete(hostDeviceId: string): boolean {
|
|
143
|
-
const device = this.devices.get(hostDeviceId);
|
|
144
|
-
if (!device) return false;
|
|
145
|
-
if (device.host) {
|
|
146
|
-
try {
|
|
147
|
-
device.host.socket.close(1000, "device deleted");
|
|
148
|
-
} catch {}
|
|
149
|
-
}
|
|
150
|
-
for (const [, client] of device.clients) {
|
|
151
|
-
try {
|
|
152
|
-
client.socket.close(1000, "device deleted");
|
|
153
|
-
} catch {}
|
|
154
|
-
}
|
|
155
|
-
this.devices.delete(hostDeviceId);
|
|
128
|
+
this.sessions.delete(sessionId);
|
|
156
129
|
return true;
|
|
157
130
|
}
|
|
158
131
|
|
|
159
|
-
bufferOutput(
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
device.lastActivity = Date.now();
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
const terminalId = envelope.terminalId ?? "default";
|
|
171
|
-
let buffer = device.outputBuffers.get(terminalId);
|
|
172
|
-
if (!buffer) {
|
|
173
|
-
buffer = [];
|
|
174
|
-
device.outputBuffers.set(terminalId, buffer);
|
|
132
|
+
bufferOutput(sessionId: string, envelope: Envelope): void {
|
|
133
|
+
const session = this.sessions.get(sessionId);
|
|
134
|
+
if (!session) return;
|
|
135
|
+
const tid = (envelope as any).terminalId ?? "default";
|
|
136
|
+
let buf = session.outputBuffers.get(tid);
|
|
137
|
+
if (!buf) {
|
|
138
|
+
buf = [];
|
|
139
|
+
session.outputBuffers.set(tid, buf);
|
|
175
140
|
}
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
141
|
+
buf.push(envelope);
|
|
142
|
+
if (buf.length > OUTPUT_BUFFER_CAPACITY) {
|
|
143
|
+
buf.shift();
|
|
179
144
|
}
|
|
180
|
-
|
|
145
|
+
session.lastActivity = Date.now();
|
|
181
146
|
}
|
|
182
147
|
|
|
183
|
-
cacheStatus(
|
|
184
|
-
const
|
|
185
|
-
if (!
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
148
|
+
cacheStatus(sessionId: string, envelope: Envelope): void {
|
|
149
|
+
const session = this.sessions.get(sessionId);
|
|
150
|
+
if (!session) return;
|
|
151
|
+
const tid = (envelope as any).terminalId ?? "default";
|
|
152
|
+
session.lastStatusByTerminal.set(tid, envelope);
|
|
153
|
+
session.lastActivity = Date.now();
|
|
189
154
|
}
|
|
190
155
|
|
|
191
|
-
getStatusReplay(
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
194
|
-
return [...
|
|
156
|
+
getStatusReplay(sessionId: string): Envelope[] {
|
|
157
|
+
const session = this.sessions.get(sessionId);
|
|
158
|
+
if (!session) return [];
|
|
159
|
+
return [...session.lastStatusByTerminal.values()];
|
|
195
160
|
}
|
|
196
161
|
|
|
197
162
|
getReplayFrom(
|
|
198
|
-
|
|
163
|
+
sessionId: string,
|
|
199
164
|
afterSeqByTerminal: Record<string, number>,
|
|
200
165
|
fallbackAfterSeq = -1,
|
|
201
166
|
): Envelope[] {
|
|
202
|
-
const
|
|
203
|
-
if (!
|
|
167
|
+
const session = this.sessions.get(sessionId);
|
|
168
|
+
if (!session) return [];
|
|
204
169
|
const result: Envelope[] = [];
|
|
205
|
-
for (const [terminalId,
|
|
170
|
+
for (const [terminalId, buf] of session.outputBuffers) {
|
|
206
171
|
const afterSeq = afterSeqByTerminal[terminalId] ?? fallbackAfterSeq;
|
|
207
|
-
for (const
|
|
208
|
-
if (
|
|
209
|
-
result.push(envelope);
|
|
210
|
-
}
|
|
172
|
+
for (const e of buf) {
|
|
173
|
+
if (e.seq !== undefined && e.seq > afterSeq) result.push(e);
|
|
211
174
|
}
|
|
212
175
|
}
|
|
213
176
|
return result.sort((a, b) => {
|
|
214
|
-
if (a.seq !== undefined && b.seq !== undefined && a.seq !== b.seq) return a.seq - b.seq;
|
|
215
177
|
const at = Date.parse(a.timestamp);
|
|
216
178
|
const bt = Date.parse(b.timestamp);
|
|
217
179
|
if (!Number.isNaN(at) && !Number.isNaN(bt) && at !== bt) return at - bt;
|
|
@@ -219,126 +181,101 @@ export class DeviceManager {
|
|
|
219
181
|
});
|
|
220
182
|
}
|
|
221
183
|
|
|
222
|
-
claimControl(
|
|
223
|
-
const
|
|
224
|
-
if (!
|
|
225
|
-
|
|
184
|
+
claimControl(sessionId: string, deviceId: string): boolean {
|
|
185
|
+
const session = this.sessions.get(sessionId);
|
|
186
|
+
if (!session) return false;
|
|
187
|
+
// Always allow takeover – last claimer wins
|
|
188
|
+
session.controllerId = deviceId;
|
|
226
189
|
return true;
|
|
227
190
|
}
|
|
228
191
|
|
|
229
|
-
releaseControl(
|
|
230
|
-
const
|
|
231
|
-
if (!
|
|
232
|
-
if (
|
|
233
|
-
|
|
192
|
+
releaseControl(sessionId: string, deviceId: string): boolean {
|
|
193
|
+
const session = this.sessions.get(sessionId);
|
|
194
|
+
if (!session) return false;
|
|
195
|
+
if (session.controllerId !== deviceId) return false;
|
|
196
|
+
session.controllerId = undefined;
|
|
234
197
|
return true;
|
|
235
198
|
}
|
|
236
199
|
|
|
237
|
-
terminate(
|
|
238
|
-
const
|
|
239
|
-
if (!
|
|
240
|
-
|
|
200
|
+
terminate(sessionId: string): void {
|
|
201
|
+
const session = this.sessions.get(sessionId);
|
|
202
|
+
if (!session) return;
|
|
203
|
+
session.state = "terminated";
|
|
241
204
|
}
|
|
242
205
|
|
|
243
|
-
listActive():
|
|
244
|
-
return [...this.
|
|
206
|
+
listActive(): Session[] {
|
|
207
|
+
return [...this.sessions.values()].filter((s) => s.state !== "terminated");
|
|
245
208
|
}
|
|
246
209
|
|
|
247
|
-
getSummary(
|
|
248
|
-
const
|
|
249
|
-
if (!
|
|
210
|
+
getSummary(sessionId: string) {
|
|
211
|
+
const session = this.sessions.get(sessionId);
|
|
212
|
+
if (!session) return undefined;
|
|
250
213
|
return {
|
|
251
|
-
id:
|
|
252
|
-
|
|
253
|
-
state: device.state,
|
|
254
|
-
online:
|
|
255
|
-
!!device.host &&
|
|
256
|
-
device.host.socket.readyState === device.host.socket.OPEN,
|
|
214
|
+
id: session.id,
|
|
215
|
+
state: session.state,
|
|
257
216
|
hasHost:
|
|
258
|
-
!!
|
|
259
|
-
|
|
260
|
-
clientCount:
|
|
261
|
-
controllerId:
|
|
262
|
-
lastActivity:
|
|
263
|
-
createdAt:
|
|
264
|
-
bufferSize: [...
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
getStats() {
|
|
275
|
-
let clientCount = 0;
|
|
276
|
-
let bufferedTerminalFrames = 0;
|
|
277
|
-
let hostAbsentDevices = 0;
|
|
278
|
-
let terminalCount = 0;
|
|
279
|
-
for (const device of this.devices.values()) {
|
|
280
|
-
clientCount += device.clients.size;
|
|
281
|
-
terminalCount += device.outputBuffers.size;
|
|
282
|
-
bufferedTerminalFrames += [...device.outputBuffers.values()].reduce((sum, buf) => sum + buf.length, 0);
|
|
283
|
-
if (!device.host || device.host.socket.readyState !== device.host.socket.OPEN) hostAbsentDevices++;
|
|
284
|
-
}
|
|
285
|
-
return {
|
|
286
|
-
devices: this.devices.size,
|
|
287
|
-
activeDevices: this.listActive().length,
|
|
288
|
-
clients: clientCount,
|
|
289
|
-
droppedClients: this.droppedClients,
|
|
290
|
-
hostAbsentDevices,
|
|
291
|
-
terminalsWithReplay: terminalCount,
|
|
292
|
-
bufferedTerminalFrames,
|
|
293
|
-
bufferedAgentFrames: 0,
|
|
217
|
+
!!session.host &&
|
|
218
|
+
session.host.socket.readyState === session.host.socket.OPEN,
|
|
219
|
+
clientCount: session.clients.size,
|
|
220
|
+
controllerId: session.controllerId ?? null,
|
|
221
|
+
lastActivity: session.lastActivity,
|
|
222
|
+
createdAt: session.createdAt,
|
|
223
|
+
bufferSize: [...session.outputBuffers.values()].reduce((sum, buf) => sum + buf.length, 0),
|
|
224
|
+
provider: session.provider ?? null,
|
|
225
|
+
machineId: session.machineId ?? null,
|
|
226
|
+
hostname: session.hostname ?? null,
|
|
227
|
+
platform: session.platform ?? null,
|
|
228
|
+
cwd: session.cwd ?? null,
|
|
229
|
+
projectName: session.projectName ?? null,
|
|
230
|
+
userId: session.userId ?? null,
|
|
294
231
|
};
|
|
295
232
|
}
|
|
296
233
|
|
|
297
234
|
setMetadata(
|
|
298
|
-
|
|
299
|
-
|
|
235
|
+
sessionId: string,
|
|
236
|
+
provider?: string,
|
|
300
237
|
machineId?: string,
|
|
301
238
|
hostname?: string,
|
|
302
239
|
platform?: string,
|
|
303
240
|
cwd?: string,
|
|
304
|
-
|
|
305
|
-
capabilities?: string[],
|
|
241
|
+
projectName?: string,
|
|
306
242
|
): void {
|
|
307
|
-
const
|
|
308
|
-
if (!
|
|
309
|
-
if (
|
|
310
|
-
if (
|
|
311
|
-
if (
|
|
312
|
-
if (
|
|
313
|
-
if (
|
|
243
|
+
const session = this.sessions.get(sessionId);
|
|
244
|
+
if (!session) return;
|
|
245
|
+
if (provider) session.provider = provider;
|
|
246
|
+
if (machineId) session.machineId = machineId;
|
|
247
|
+
if (hostname) session.hostname = hostname;
|
|
248
|
+
if (platform) session.platform = platform;
|
|
249
|
+
if (cwd) session.cwd = cwd;
|
|
250
|
+
if (projectName) session.projectName = projectName;
|
|
314
251
|
}
|
|
315
252
|
|
|
316
|
-
private maybeDelete(
|
|
317
|
-
const
|
|
318
|
-
if (!
|
|
319
|
-
if (!
|
|
320
|
-
this.
|
|
253
|
+
private maybeDelete(sessionId: string): void {
|
|
254
|
+
const session = this.sessions.get(sessionId);
|
|
255
|
+
if (!session) return;
|
|
256
|
+
if (!session.host && session.clients.size === 0) {
|
|
257
|
+
this.sessions.delete(sessionId);
|
|
321
258
|
}
|
|
322
259
|
}
|
|
323
260
|
|
|
324
261
|
private cleanup(): void {
|
|
325
262
|
const now = Date.now();
|
|
326
|
-
for (const [
|
|
263
|
+
for (const [id, session] of this.sessions) {
|
|
264
|
+
// Remove sessions where host disconnected and window expired
|
|
327
265
|
if (
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
now -
|
|
266
|
+
session.state === "host_disconnected" &&
|
|
267
|
+
session.hostDisconnectedAt &&
|
|
268
|
+
now - session.hostDisconnectedAt > HOST_RECONNECT_WINDOW
|
|
331
269
|
) {
|
|
332
|
-
|
|
333
|
-
device.outputBuffers.clear();
|
|
334
|
-
device.lastStatusByTerminal.clear();
|
|
270
|
+
session.state = "terminated";
|
|
335
271
|
}
|
|
272
|
+
// Clean up terminated sessions with no connections
|
|
336
273
|
if (
|
|
337
|
-
|
|
338
|
-
!
|
|
339
|
-
|
|
274
|
+
session.state === "terminated" &&
|
|
275
|
+
!session.host &&
|
|
276
|
+
session.clients.size === 0
|
|
340
277
|
) {
|
|
341
|
-
this.
|
|
278
|
+
this.sessions.delete(id);
|
|
342
279
|
}
|
|
343
280
|
}
|
|
344
281
|
}
|
package/src/state-store.ts
CHANGED
|
@@ -1,35 +1,28 @@
|
|
|
1
|
-
export interface
|
|
2
|
-
authorizationId: string;
|
|
1
|
+
export interface StoredTokenRecord {
|
|
3
2
|
token: string;
|
|
4
|
-
|
|
5
|
-
clientDeviceId?: string;
|
|
6
|
-
clientName?: string;
|
|
3
|
+
sessionIds: string[];
|
|
7
4
|
createdAt: number;
|
|
8
5
|
lastUsedAt: number;
|
|
9
6
|
}
|
|
10
7
|
|
|
11
8
|
export interface StoredPairingRecord {
|
|
12
|
-
|
|
9
|
+
sessionId: string;
|
|
13
10
|
pairingCode: string;
|
|
14
11
|
expiresAt: number;
|
|
15
12
|
claimed: boolean;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
export interface GatewayStateStore {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
loadTokens(): Promise<StoredTokenRecord[]>;
|
|
17
|
+
saveToken(record: StoredTokenRecord): Promise<void>;
|
|
18
|
+
deleteToken(token: string): Promise<void>;
|
|
22
19
|
loadPairings(): Promise<StoredPairingRecord[]>;
|
|
23
20
|
savePairing(record: StoredPairingRecord): Promise<void>;
|
|
24
21
|
deletePairing(pairingCode: string): Promise<void>;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
"linkshell_gateway_device_authorizations";
|
|
30
|
-
const PAIRING_TABLE =
|
|
31
|
-
process.env.SUPABASE_GATEWAY_PAIRING_TABLE ??
|
|
32
|
-
"linkshell_gateway_pairing_challenges";
|
|
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";
|
|
33
26
|
const STORE_TIMEOUT_MS = Number(process.env.SUPABASE_STATE_TIMEOUT_MS ?? 3_000);
|
|
34
27
|
|
|
35
28
|
function msToIso(ms: number): string {
|
|
@@ -42,10 +35,6 @@ function isoToMs(value: unknown): number {
|
|
|
42
35
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
43
36
|
}
|
|
44
37
|
|
|
45
|
-
function maybeString(value: unknown): string | undefined {
|
|
46
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
38
|
export function createSupabaseStateStore(): GatewayStateStore | undefined {
|
|
50
39
|
const url = process.env.SUPABASE_URL?.replace(/\/+$/, "");
|
|
51
40
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
|
|
@@ -75,54 +64,49 @@ export function createSupabaseStateStore(): GatewayStateStore | undefined {
|
|
|
75
64
|
}
|
|
76
65
|
|
|
77
66
|
return {
|
|
78
|
-
async
|
|
67
|
+
async loadTokens() {
|
|
79
68
|
const rows = await request<Array<Record<string, unknown>>>(
|
|
80
|
-
`${
|
|
69
|
+
`${TOKEN_TABLE}?select=token,session_ids,created_at,last_used_at`,
|
|
81
70
|
);
|
|
82
71
|
return rows.map((row) => ({
|
|
83
|
-
authorizationId: String(row.authorization_id ?? ""),
|
|
84
72
|
token: String(row.token ?? ""),
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
73
|
+
sessionIds: Array.isArray(row.session_ids)
|
|
74
|
+
? row.session_ids.map(String)
|
|
75
|
+
: [],
|
|
88
76
|
createdAt: isoToMs(row.created_at),
|
|
89
77
|
lastUsedAt: isoToMs(row.last_used_at),
|
|
90
|
-
})).filter((record) => record.
|
|
78
|
+
})).filter((record) => record.token);
|
|
91
79
|
},
|
|
92
|
-
async
|
|
80
|
+
async saveToken(record) {
|
|
93
81
|
await request(
|
|
94
|
-
`${
|
|
82
|
+
`${TOKEN_TABLE}?on_conflict=token`,
|
|
95
83
|
{
|
|
96
84
|
method: "POST",
|
|
97
85
|
headers: { Prefer: "resolution=merge-duplicates" },
|
|
98
86
|
body: JSON.stringify({
|
|
99
|
-
authorization_id: record.authorizationId,
|
|
100
87
|
token: record.token,
|
|
101
|
-
|
|
102
|
-
client_device_id: record.clientDeviceId ?? null,
|
|
103
|
-
client_name: record.clientName ?? null,
|
|
88
|
+
session_ids: record.sessionIds,
|
|
104
89
|
created_at: msToIso(record.createdAt),
|
|
105
90
|
last_used_at: msToIso(record.lastUsedAt),
|
|
106
91
|
}),
|
|
107
92
|
},
|
|
108
93
|
);
|
|
109
94
|
},
|
|
110
|
-
async
|
|
111
|
-
await request(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
95
|
+
async deleteToken(token) {
|
|
96
|
+
await request(`${TOKEN_TABLE}?token=eq.${encodeURIComponent(token)}`, {
|
|
97
|
+
method: "DELETE",
|
|
98
|
+
});
|
|
115
99
|
},
|
|
116
100
|
async loadPairings() {
|
|
117
101
|
const rows = await request<Array<Record<string, unknown>>>(
|
|
118
|
-
`${PAIRING_TABLE}?select=pairing_code,
|
|
102
|
+
`${PAIRING_TABLE}?select=pairing_code,session_id,expires_at,claimed`,
|
|
119
103
|
);
|
|
120
104
|
return rows.map((row) => ({
|
|
121
105
|
pairingCode: String(row.pairing_code ?? ""),
|
|
122
|
-
|
|
106
|
+
sessionId: String(row.session_id ?? ""),
|
|
123
107
|
expiresAt: isoToMs(row.expires_at),
|
|
124
108
|
claimed: row.claimed === true,
|
|
125
|
-
})).filter((record) => record.pairingCode && record.
|
|
109
|
+
})).filter((record) => record.pairingCode && record.sessionId);
|
|
126
110
|
},
|
|
127
111
|
async savePairing(record) {
|
|
128
112
|
await request(
|
|
@@ -132,7 +116,7 @@ export function createSupabaseStateStore(): GatewayStateStore | undefined {
|
|
|
132
116
|
headers: { Prefer: "resolution=merge-duplicates" },
|
|
133
117
|
body: JSON.stringify({
|
|
134
118
|
pairing_code: record.pairingCode,
|
|
135
|
-
|
|
119
|
+
session_id: record.sessionId,
|
|
136
120
|
expires_at: msToIso(record.expiresAt),
|
|
137
121
|
claimed: record.claimed,
|
|
138
122
|
}),
|