@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/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
- // Only controller can send input
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 (session.controllerId !== deviceId) return;
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(session.id, p.lastAckedSeq);
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(sessionId: string, afterSeq: number): Envelope[] {
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.values()) {
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) => (a.seq ?? 0) - (b.seq ?? 0));
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
- if (!token || !tokens.owns(token, sessionId)) {
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 (root path so /_next/... etc. are covered)
122
- const cookieVal = encodeURIComponent(`${sessionId}:${port}:${token}`);
123
- res.setHeader("Set-Cookie", `lsh_tunnel=${cookieVal}; Path=/; HttpOnly; SameSite=Lax`);
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 from query param or cookie in upgrade request
337
+ // Auth: device token OR Supabase JWT (userId owns session)
303
338
  const token = url.searchParams.get("token");
304
- if (!token || !tokens.owns(token, sessionId)) {
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
  }