@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.
Files changed (41) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +13 -14
  3. package/dist/gateway/src/agent-permission-http.d.ts +74 -19
  4. package/dist/gateway/src/agent-permission-http.js +56 -16
  5. package/dist/gateway/src/agent-permission-http.js.map +1 -1
  6. package/dist/gateway/src/embedded.js +61 -153
  7. package/dist/gateway/src/embedded.js.map +1 -1
  8. package/dist/gateway/src/index.js +98 -193
  9. package/dist/gateway/src/index.js.map +1 -1
  10. package/dist/gateway/src/pairings.d.ts +3 -3
  11. package/dist/gateway/src/pairings.js +5 -4
  12. package/dist/gateway/src/pairings.js.map +1 -1
  13. package/dist/gateway/src/relay.d.ts +2 -2
  14. package/dist/gateway/src/relay.js +85 -161
  15. package/dist/gateway/src/relay.js.map +1 -1
  16. package/dist/gateway/src/sessions.d.ts +28 -42
  17. package/dist/gateway/src/sessions.js +145 -200
  18. package/dist/gateway/src/sessions.js.map +1 -1
  19. package/dist/gateway/src/state-store.d.ts +6 -9
  20. package/dist/gateway/src/state-store.js +19 -26
  21. package/dist/gateway/src/state-store.js.map +1 -1
  22. package/dist/gateway/src/tokens.d.ts +7 -27
  23. package/dist/gateway/src/tokens.js +60 -86
  24. package/dist/gateway/src/tokens.js.map +1 -1
  25. package/dist/gateway/src/tunnel.d.ts +13 -11
  26. package/dist/gateway/src/tunnel.js +36 -36
  27. package/dist/gateway/src/tunnel.js.map +1 -1
  28. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  29. package/dist/shared-protocol/src/index.d.ts +11978 -3423
  30. package/dist/shared-protocol/src/index.js +114 -163
  31. package/dist/shared-protocol/src/index.js.map +1 -1
  32. package/package.json +11 -11
  33. package/src/agent-permission-http.ts +63 -20
  34. package/src/embedded.ts +60 -158
  35. package/src/index.ts +98 -199
  36. package/src/pairings.ts +7 -6
  37. package/src/relay.ts +97 -193
  38. package/src/sessions.ts +150 -213
  39. package/src/state-store.ts +25 -41
  40. package/src/tokens.ts +63 -109
  41. 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 DeviceState = "active" | "host_disconnected" | "terminated";
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 HostDevice {
13
+ export interface Session {
16
14
  id: string;
17
- hostDeviceId: string;
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
- capabilities: string[];
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 OUTPUT_BUFFER_MAX_PAYLOAD_BYTES = Number(
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 DeviceManager {
43
- private devices = new Map<string, HostDevice>();
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(hostDeviceId: string): HostDevice {
52
- let device = this.devices.get(hostDeviceId);
53
- if (!device) {
54
- device = {
55
- id: hostDeviceId,
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
- capabilities: [],
66
+ projectName: undefined,
71
67
  userId: undefined,
72
68
  };
73
- this.devices.set(hostDeviceId, device);
69
+ this.sessions.set(sessionId, session);
74
70
  }
75
- return device;
71
+ return session;
76
72
  }
77
73
 
78
- get(hostDeviceId: string): HostDevice | undefined {
79
- return this.devices.get(hostDeviceId);
74
+ get(sessionId: string): Session | undefined {
75
+ return this.sessions.get(sessionId);
80
76
  }
81
77
 
82
- setHost(hostDeviceId: string, socketDevice: ConnectedDevice): void {
83
- const device = this.getOrCreate(hostDeviceId);
84
- device.host = socketDevice;
85
- device.state = "active";
86
- device.hostDisconnectedAt = undefined;
87
- device.lastActivity = Date.now();
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(hostDeviceId: string, socketDevice: ConnectedDevice): void {
91
- const device = this.getOrCreate(hostDeviceId);
92
- device.clients.set(socketDevice.deviceId, socketDevice);
93
- if (!device.controllerId) {
94
- device.controllerId = socketDevice.deviceId;
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
- device.lastActivity = Date.now();
92
+ session.lastActivity = Date.now();
97
93
  }
98
94
 
99
95
  removeHost(
100
- hostDeviceId: string,
96
+ sessionId: string,
101
97
  ): { clients: Map<string, ConnectedDevice> } | undefined {
102
- const device = this.devices.get(hostDeviceId);
103
- if (!device) return undefined;
104
- device.host = undefined;
105
- device.state = "host_disconnected";
106
- device.hostDisconnectedAt = Date.now();
107
- return { clients: device.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(hostDeviceId: string, deviceId: string): void {
111
- const device = this.devices.get(hostDeviceId);
112
- if (!device) return;
113
- if (device.clients.delete(deviceId)) this.droppedClients++;
114
- if (device.controllerId === deviceId) {
115
- const next = device.clients.keys().next();
116
- device.controllerId = next.done ? undefined : next.value;
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(hostDeviceId);
115
+ this.maybeDelete(sessionId);
119
116
  }
120
117
 
121
- disconnectAuthorization(hostDeviceId: string, authorizationId: string): number {
122
- const device = this.devices.get(hostDeviceId);
123
- if (!device) return 0;
124
- let closed = 0;
125
- for (const [deviceId, client] of device.clients) {
126
- if (client.authorizationId !== authorizationId) continue;
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
- this.droppedClients += closed;
134
- if (device.controllerId && !device.clients.has(device.controllerId)) {
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.maybeDelete(hostDeviceId);
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(hostDeviceId: string, envelope: Envelope): void {
160
- const device = this.devices.get(hostDeviceId);
161
- if (!device) return;
162
- const payload = envelope.payload as { data?: unknown } | undefined;
163
- if (
164
- typeof payload?.data === "string" &&
165
- Buffer.byteLength(payload.data, "utf8") > OUTPUT_BUFFER_MAX_PAYLOAD_BYTES
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
- buffer.push(envelope);
177
- if (buffer.length > OUTPUT_BUFFER_CAPACITY) {
178
- buffer.shift();
141
+ buf.push(envelope);
142
+ if (buf.length > OUTPUT_BUFFER_CAPACITY) {
143
+ buf.shift();
179
144
  }
180
- device.lastActivity = Date.now();
145
+ session.lastActivity = Date.now();
181
146
  }
182
147
 
183
- cacheStatus(hostDeviceId: string, envelope: Envelope): void {
184
- const device = this.devices.get(hostDeviceId);
185
- if (!device) return;
186
- const terminalId = envelope.terminalId ?? "default";
187
- device.lastStatusByTerminal.set(terminalId, envelope);
188
- device.lastActivity = Date.now();
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(hostDeviceId: string): Envelope[] {
192
- const device = this.devices.get(hostDeviceId);
193
- if (!device) return [];
194
- return [...device.lastStatusByTerminal.values()];
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
- hostDeviceId: string,
163
+ sessionId: string,
199
164
  afterSeqByTerminal: Record<string, number>,
200
165
  fallbackAfterSeq = -1,
201
166
  ): Envelope[] {
202
- const device = this.devices.get(hostDeviceId);
203
- if (!device) return [];
167
+ const session = this.sessions.get(sessionId);
168
+ if (!session) return [];
204
169
  const result: Envelope[] = [];
205
- for (const [terminalId, buffer] of device.outputBuffers) {
170
+ for (const [terminalId, buf] of session.outputBuffers) {
206
171
  const afterSeq = afterSeqByTerminal[terminalId] ?? fallbackAfterSeq;
207
- for (const envelope of buffer) {
208
- if (envelope.seq !== undefined && envelope.seq > afterSeq) {
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(hostDeviceId: string, deviceId: string): boolean {
223
- const device = this.devices.get(hostDeviceId);
224
- if (!device) return false;
225
- device.controllerId = deviceId;
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(hostDeviceId: string, deviceId: string): boolean {
230
- const device = this.devices.get(hostDeviceId);
231
- if (!device) return false;
232
- if (device.controllerId !== deviceId) return false;
233
- device.controllerId = undefined;
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(hostDeviceId: string): void {
238
- const device = this.devices.get(hostDeviceId);
239
- if (!device) return;
240
- device.state = "terminated";
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(): HostDevice[] {
244
- return [...this.devices.values()].filter((device) => device.state !== "terminated");
206
+ listActive(): Session[] {
207
+ return [...this.sessions.values()].filter((s) => s.state !== "terminated");
245
208
  }
246
209
 
247
- getSummary(hostDeviceId: string) {
248
- const device = this.devices.get(hostDeviceId);
249
- if (!device) return undefined;
210
+ getSummary(sessionId: string) {
211
+ const session = this.sessions.get(sessionId);
212
+ if (!session) return undefined;
250
213
  return {
251
- id: device.hostDeviceId,
252
- hostDeviceId: device.hostDeviceId,
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
- !!device.host &&
259
- device.host.socket.readyState === device.host.socket.OPEN,
260
- clientCount: device.clients.size,
261
- controllerId: device.controllerId ?? null,
262
- lastActivity: device.lastActivity,
263
- createdAt: device.createdAt,
264
- bufferSize: [...device.outputBuffers.values()].reduce((sum, buf) => sum + buf.length, 0),
265
- machineId: device.machineId ?? null,
266
- hostname: device.hostname ?? null,
267
- platform: device.platform ?? null,
268
- cwd: device.cwd ?? null,
269
- capabilities: device.capabilities,
270
- userId: device.userId ?? null,
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
- hostDeviceId: string,
299
- _provider?: string,
235
+ sessionId: string,
236
+ provider?: string,
300
237
  machineId?: string,
301
238
  hostname?: string,
302
239
  platform?: string,
303
240
  cwd?: string,
304
- _projectName?: string,
305
- capabilities?: string[],
241
+ projectName?: string,
306
242
  ): void {
307
- const device = this.devices.get(hostDeviceId);
308
- if (!device) return;
309
- if (machineId) device.machineId = machineId;
310
- if (hostname) device.hostname = hostname;
311
- if (platform) device.platform = platform;
312
- if (cwd) device.cwd = cwd;
313
- if (capabilities) device.capabilities = capabilities;
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(hostDeviceId: string): void {
317
- const device = this.devices.get(hostDeviceId);
318
- if (!device) return;
319
- if (!device.host && device.clients.size === 0) {
320
- this.devices.delete(hostDeviceId);
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 [hostDeviceId, device] of this.devices) {
263
+ for (const [id, session] of this.sessions) {
264
+ // Remove sessions where host disconnected and window expired
327
265
  if (
328
- device.state === "host_disconnected" &&
329
- device.hostDisconnectedAt &&
330
- now - device.hostDisconnectedAt > HOST_RECONNECT_WINDOW
266
+ session.state === "host_disconnected" &&
267
+ session.hostDisconnectedAt &&
268
+ now - session.hostDisconnectedAt > HOST_RECONNECT_WINDOW
331
269
  ) {
332
- device.state = "terminated";
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
- device.state === "terminated" &&
338
- !device.host &&
339
- device.clients.size === 0
274
+ session.state === "terminated" &&
275
+ !session.host &&
276
+ session.clients.size === 0
340
277
  ) {
341
- this.devices.delete(hostDeviceId);
278
+ this.sessions.delete(id);
342
279
  }
343
280
  }
344
281
  }
@@ -1,35 +1,28 @@
1
- export interface StoredAuthorizationRecord {
2
- authorizationId: string;
1
+ export interface StoredTokenRecord {
3
2
  token: string;
4
- hostDeviceId: string;
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
- hostDeviceId: string;
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
- loadAuthorizations(): Promise<StoredAuthorizationRecord[]>;
20
- saveAuthorization(record: StoredAuthorizationRecord): Promise<void>;
21
- deleteAuthorization(authorizationId: string): Promise<void>;
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 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";
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 loadAuthorizations() {
67
+ async loadTokens() {
79
68
  const rows = await request<Array<Record<string, unknown>>>(
80
- `${AUTHORIZATION_TABLE}?select=authorization_id,token,host_device_id,client_device_id,client_name,created_at,last_used_at`,
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
- hostDeviceId: String(row.host_device_id ?? ""),
86
- clientDeviceId: maybeString(row.client_device_id),
87
- clientName: maybeString(row.client_name),
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.authorizationId && record.token && record.hostDeviceId);
78
+ })).filter((record) => record.token);
91
79
  },
92
- async saveAuthorization(record) {
80
+ async saveToken(record) {
93
81
  await request(
94
- `${AUTHORIZATION_TABLE}?on_conflict=authorization_id`,
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
- host_device_id: record.hostDeviceId,
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 deleteAuthorization(authorizationId) {
111
- await request(
112
- `${AUTHORIZATION_TABLE}?authorization_id=eq.${encodeURIComponent(authorizationId)}`,
113
- { method: "DELETE" },
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,host_device_id,expires_at,claimed`,
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
- hostDeviceId: String(row.host_device_id ?? ""),
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.hostDeviceId);
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
- host_device_id: record.hostDeviceId,
119
+ session_id: record.sessionId,
136
120
  expires_at: msToIso(record.expiresAt),
137
121
  claimed: record.claimed,
138
122
  }),