@linkshell/gateway 0.3.8 → 0.3.10
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 +63 -76
- 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 -196
- 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 +11940 -3451
- package/dist/shared-protocol/src/index.js +98 -172
- 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 +70 -92
- package/src/sessions.ts +150 -210
- 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,151 +58,119 @@ 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) => {
|
|
@@ -218,124 +181,101 @@ export class DeviceManager {
|
|
|
218
181
|
});
|
|
219
182
|
}
|
|
220
183
|
|
|
221
|
-
claimControl(
|
|
222
|
-
const
|
|
223
|
-
if (!
|
|
224
|
-
|
|
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;
|
|
225
189
|
return true;
|
|
226
190
|
}
|
|
227
191
|
|
|
228
|
-
releaseControl(
|
|
229
|
-
const
|
|
230
|
-
if (!
|
|
231
|
-
if (
|
|
232
|
-
|
|
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;
|
|
233
197
|
return true;
|
|
234
198
|
}
|
|
235
199
|
|
|
236
|
-
terminate(
|
|
237
|
-
const
|
|
238
|
-
if (!
|
|
239
|
-
|
|
200
|
+
terminate(sessionId: string): void {
|
|
201
|
+
const session = this.sessions.get(sessionId);
|
|
202
|
+
if (!session) return;
|
|
203
|
+
session.state = "terminated";
|
|
240
204
|
}
|
|
241
205
|
|
|
242
|
-
listActive():
|
|
243
|
-
return [...this.
|
|
206
|
+
listActive(): Session[] {
|
|
207
|
+
return [...this.sessions.values()].filter((s) => s.state !== "terminated");
|
|
244
208
|
}
|
|
245
209
|
|
|
246
|
-
getSummary(
|
|
247
|
-
const
|
|
248
|
-
if (!
|
|
210
|
+
getSummary(sessionId: string) {
|
|
211
|
+
const session = this.sessions.get(sessionId);
|
|
212
|
+
if (!session) return undefined;
|
|
249
213
|
return {
|
|
250
|
-
id:
|
|
251
|
-
|
|
252
|
-
state: device.state,
|
|
253
|
-
online:
|
|
254
|
-
!!device.host &&
|
|
255
|
-
device.host.socket.readyState === device.host.socket.OPEN,
|
|
214
|
+
id: session.id,
|
|
215
|
+
state: session.state,
|
|
256
216
|
hasHost:
|
|
257
|
-
!!
|
|
258
|
-
|
|
259
|
-
clientCount:
|
|
260
|
-
controllerId:
|
|
261
|
-
lastActivity:
|
|
262
|
-
createdAt:
|
|
263
|
-
bufferSize: [...
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
getStats() {
|
|
274
|
-
let clientCount = 0;
|
|
275
|
-
let bufferedTerminalFrames = 0;
|
|
276
|
-
let hostAbsentDevices = 0;
|
|
277
|
-
let terminalCount = 0;
|
|
278
|
-
for (const device of this.devices.values()) {
|
|
279
|
-
clientCount += device.clients.size;
|
|
280
|
-
terminalCount += device.outputBuffers.size;
|
|
281
|
-
bufferedTerminalFrames += [...device.outputBuffers.values()].reduce((sum, buf) => sum + buf.length, 0);
|
|
282
|
-
if (!device.host || device.host.socket.readyState !== device.host.socket.OPEN) hostAbsentDevices++;
|
|
283
|
-
}
|
|
284
|
-
return {
|
|
285
|
-
devices: this.devices.size,
|
|
286
|
-
activeDevices: this.listActive().length,
|
|
287
|
-
clients: clientCount,
|
|
288
|
-
droppedClients: this.droppedClients,
|
|
289
|
-
hostAbsentDevices,
|
|
290
|
-
terminalsWithReplay: terminalCount,
|
|
291
|
-
bufferedTerminalFrames,
|
|
292
|
-
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,
|
|
293
231
|
};
|
|
294
232
|
}
|
|
295
233
|
|
|
296
234
|
setMetadata(
|
|
297
|
-
|
|
298
|
-
|
|
235
|
+
sessionId: string,
|
|
236
|
+
provider?: string,
|
|
299
237
|
machineId?: string,
|
|
300
238
|
hostname?: string,
|
|
301
239
|
platform?: string,
|
|
302
240
|
cwd?: string,
|
|
303
|
-
|
|
304
|
-
capabilities?: string[],
|
|
241
|
+
projectName?: string,
|
|
305
242
|
): void {
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
308
|
-
if (
|
|
309
|
-
if (
|
|
310
|
-
if (
|
|
311
|
-
if (
|
|
312
|
-
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;
|
|
313
251
|
}
|
|
314
252
|
|
|
315
|
-
private maybeDelete(
|
|
316
|
-
const
|
|
317
|
-
if (!
|
|
318
|
-
if (!
|
|
319
|
-
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);
|
|
320
258
|
}
|
|
321
259
|
}
|
|
322
260
|
|
|
323
261
|
private cleanup(): void {
|
|
324
262
|
const now = Date.now();
|
|
325
|
-
for (const [
|
|
263
|
+
for (const [id, session] of this.sessions) {
|
|
264
|
+
// Remove sessions where host disconnected and window expired
|
|
326
265
|
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
now -
|
|
266
|
+
session.state === "host_disconnected" &&
|
|
267
|
+
session.hostDisconnectedAt &&
|
|
268
|
+
now - session.hostDisconnectedAt > HOST_RECONNECT_WINDOW
|
|
330
269
|
) {
|
|
331
|
-
|
|
270
|
+
session.state = "terminated";
|
|
332
271
|
}
|
|
272
|
+
// Clean up terminated sessions with no connections
|
|
333
273
|
if (
|
|
334
|
-
|
|
335
|
-
!
|
|
336
|
-
|
|
274
|
+
session.state === "terminated" &&
|
|
275
|
+
!session.host &&
|
|
276
|
+
session.clients.size === 0
|
|
337
277
|
) {
|
|
338
|
-
this.
|
|
278
|
+
this.sessions.delete(id);
|
|
339
279
|
}
|
|
340
280
|
}
|
|
341
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
|
}),
|