@linkshell/gateway 0.2.12 → 0.2.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linkshell/gateway",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "main": "dist/gateway/src/index.js",
6
6
  "exports": {
package/src/embedded.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { z, ZodError } from "zod";
12
12
  import { SessionManager } from "./sessions.js";
13
13
  import { PairingManager } from "./pairings.js";
14
+ import { TokenManager } from "./tokens.js";
14
15
  import { handleSocketMessage } from "./relay.js";
15
16
 
16
17
  export interface EmbeddedGatewayOptions {
@@ -33,7 +34,10 @@ const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image upl
33
34
  const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
34
35
 
35
36
  const createPairingBody = z.object({ sessionId: z.string().optional() });
36
- const claimPairingBody = z.object({ pairingCode: z.string().length(6) });
37
+ const claimPairingBody = z.object({
38
+ pairingCode: z.string().length(6),
39
+ deviceToken: z.string().min(1).optional(),
40
+ });
37
41
 
38
42
  class BodyTooLargeError extends Error {}
39
43
 
@@ -66,6 +70,13 @@ function getClientIp(req: IncomingMessage): string {
66
70
  return req.socket.remoteAddress ?? "unknown";
67
71
  }
68
72
 
73
+ function extractBearerToken(req: IncomingMessage): string | null {
74
+ const auth = req.headers.authorization;
75
+ if (!auth) return null;
76
+ const match = auth.match(/^Bearer\s+(.+)$/i);
77
+ return match?.[1] ?? null;
78
+ }
79
+
69
80
  /**
70
81
  * Start an embedded gateway. Returns a handle to get URLs and close it.
71
82
  * Used by CLI when no external --gateway is provided.
@@ -86,6 +97,7 @@ export function startEmbeddedGateway(
86
97
 
87
98
  const sessionManager = new SessionManager();
88
99
  const pairingManager = new PairingManager();
100
+ const tokenManager = new TokenManager();
89
101
 
90
102
  const server = createServer(async (req, res) => {
91
103
  if (req.method === "OPTIONS") {
@@ -126,30 +138,53 @@ export function startEmbeddedGateway(
126
138
  json(res, result.status, { error: result.error });
127
139
  return;
128
140
  }
129
- json(res, 200, { sessionId: result.sessionId });
141
+ const token = tokenManager.register(body.deviceToken);
142
+ tokenManager.bind(token, result.sessionId);
143
+ json(res, 200, { sessionId: result.sessionId, deviceToken: token });
130
144
  return;
131
145
  }
132
146
 
133
147
  if (method === "GET" && url.pathname === "/sessions") {
134
- const sessions = sessionManager.listActive().map((s) => ({
135
- id: s.id,
136
- state: s.state,
137
- hasHost: !!s.host,
138
- clientCount: s.clients.size,
139
- controllerId: s.controllerId ?? null,
140
- lastActivity: s.lastActivity,
141
- createdAt: s.createdAt,
142
- provider: s.provider ?? null,
143
- hostname: s.hostname ?? null,
144
- platform: s.platform ?? null,
145
- }));
148
+ const token = extractBearerToken(req);
149
+ if (!token || !tokenManager.validate(token)) {
150
+ json(res, 401, {
151
+ error: "unauthorized",
152
+ message: "Valid device token required",
153
+ });
154
+ return;
155
+ }
156
+ const allowedIds = tokenManager.getSessionIds(token);
157
+ const sessions = sessionManager
158
+ .listActive()
159
+ .filter((s) => allowedIds.has(s.id))
160
+ .map((s) => ({
161
+ id: s.id,
162
+ state: s.state,
163
+ hasHost: !!s.host,
164
+ clientCount: s.clients.size,
165
+ controllerId: s.controllerId ?? null,
166
+ lastActivity: s.lastActivity,
167
+ createdAt: s.createdAt,
168
+ provider: s.provider ?? null,
169
+ hostname: s.hostname ?? null,
170
+ platform: s.platform ?? null,
171
+ }));
146
172
  json(res, 200, { sessions });
147
173
  return;
148
174
  }
149
175
 
150
176
  const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
151
177
  if (method === "GET" && sessionMatch) {
152
- const summary = sessionManager.getSummary(sessionMatch[1]!);
178
+ const token = extractBearerToken(req);
179
+ const targetId = sessionMatch[1]!;
180
+ if (!token || !tokenManager.owns(token, targetId)) {
181
+ json(res, 401, {
182
+ error: "unauthorized",
183
+ message: "Valid device token required",
184
+ });
185
+ return;
186
+ }
187
+ const summary = sessionManager.getSummary(targetId);
153
188
  if (!summary) {
154
189
  json(res, 404, { error: "session_not_found" });
155
190
  return;
@@ -221,6 +256,15 @@ export function startEmbeddedGateway(
221
256
  }
222
257
 
223
258
  const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
259
+
260
+ if (role === "client") {
261
+ const token = url.searchParams.get("token");
262
+ if (!token || !tokenManager.owns(token, sessionId)) {
263
+ socket.close(4001, "unauthorized");
264
+ return;
265
+ }
266
+ }
267
+
224
268
  const device = { socket, role, deviceId, connectedAt: Date.now() };
225
269
 
226
270
  if (role === "host") {
@@ -318,6 +362,7 @@ export function startEmbeddedGateway(
318
362
  wss.clients.forEach((ws) => ws.close(1001, "shutting down"));
319
363
  sessionManager.destroy();
320
364
  pairingManager.destroy();
365
+ tokenManager.destroy();
321
366
  server.close(() => res());
322
367
  }),
323
368
  });
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { z, ZodError } from "zod";
12
12
  import { SessionManager } from "./sessions.js";
13
13
  import { PairingManager } from "./pairings.js";
14
+ import { TokenManager } from "./tokens.js";
14
15
  import { handleSocketMessage } from "./relay.js";
15
16
 
16
17
  const port = Number(process.env.PORT ?? 8787);
@@ -29,6 +30,7 @@ function log(level: "debug" | "info" | "warn" | "error", msg: string): void {
29
30
 
30
31
  const sessionManager = new SessionManager();
31
32
  const pairingManager = new PairingManager();
33
+ const tokenManager = new TokenManager();
32
34
 
33
35
  const PING_INTERVAL = 20_000;
34
36
  const MAX_BODY_SIZE = 4096;
@@ -93,6 +95,13 @@ function getClientIp(req: IncomingMessage): string {
93
95
  return req.socket.remoteAddress ?? "unknown";
94
96
  }
95
97
 
98
+ function extractBearerToken(req: IncomingMessage): string | null {
99
+ const auth = req.headers.authorization;
100
+ if (!auth) return null;
101
+ const match = auth.match(/^Bearer\s+(.+)$/i);
102
+ return match?.[1] ?? null;
103
+ }
104
+
96
105
  // ── CORS ────────────────────────────────────────────────────────────
97
106
 
98
107
  function setCors(res: ServerResponse): void {
@@ -105,7 +114,10 @@ function setCors(res: ServerResponse): void {
105
114
  // ── HTTP API ────────────────────────────────────────────────────────
106
115
 
107
116
  const createPairingBody = z.object({ sessionId: z.string().optional() });
108
- const claimPairingBody = z.object({ pairingCode: z.string().length(6) });
117
+ const claimPairingBody = z.object({
118
+ pairingCode: z.string().length(6),
119
+ deviceToken: z.string().min(1).optional(),
120
+ });
109
121
 
110
122
  const server = createServer(async (req, res) => {
111
123
  setCors(res);
@@ -183,24 +195,38 @@ async function handleRequest(
183
195
  json(res, result.status, { error: result.error });
184
196
  return;
185
197
  }
186
- json(res, 200, { sessionId: result.sessionId });
198
+ const token = tokenManager.register(body.deviceToken);
199
+ tokenManager.bind(token, result.sessionId);
200
+ json(res, 200, { sessionId: result.sessionId, deviceToken: token });
187
201
  return;
188
202
  }
189
203
 
190
204
  // Session list
191
205
  if (method === "GET" && url.pathname === "/sessions") {
192
- const sessions = sessionManager.listActive().map((s) => ({
193
- id: s.id,
194
- state: s.state,
195
- hasHost: !!s.host,
196
- clientCount: s.clients.size,
197
- controllerId: s.controllerId ?? null,
198
- lastActivity: s.lastActivity,
199
- createdAt: s.createdAt,
200
- provider: s.provider ?? null,
201
- hostname: s.hostname ?? null,
202
- platform: s.platform ?? null,
203
- }));
206
+ const token = extractBearerToken(req);
207
+ if (!token || !tokenManager.validate(token)) {
208
+ json(res, 401, {
209
+ error: "unauthorized",
210
+ message: "Valid device token required",
211
+ });
212
+ return;
213
+ }
214
+ const allowedIds = tokenManager.getSessionIds(token);
215
+ const sessions = sessionManager
216
+ .listActive()
217
+ .filter((s) => allowedIds.has(s.id))
218
+ .map((s) => ({
219
+ id: s.id,
220
+ state: s.state,
221
+ hasHost: !!s.host,
222
+ clientCount: s.clients.size,
223
+ controllerId: s.controllerId ?? null,
224
+ lastActivity: s.lastActivity,
225
+ createdAt: s.createdAt,
226
+ provider: s.provider ?? null,
227
+ hostname: s.hostname ?? null,
228
+ platform: s.platform ?? null,
229
+ }));
204
230
  json(res, 200, { sessions });
205
231
  return;
206
232
  }
@@ -208,7 +234,16 @@ async function handleRequest(
208
234
  // Session detail
209
235
  const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
210
236
  if (method === "GET" && sessionMatch) {
211
- const summary = sessionManager.getSummary(sessionMatch[1]!);
237
+ const token = extractBearerToken(req);
238
+ const targetId = sessionMatch[1]!;
239
+ if (!token || !tokenManager.owns(token, targetId)) {
240
+ json(res, 401, {
241
+ error: "unauthorized",
242
+ message: "Valid device token required",
243
+ });
244
+ return;
245
+ }
246
+ const summary = sessionManager.getSummary(targetId);
212
247
  if (!summary) {
213
248
  json(res, 404, { error: "session_not_found" });
214
249
  return;
@@ -271,6 +306,14 @@ wss.on(
271
306
 
272
307
  const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
273
308
 
309
+ if (role === "client") {
310
+ const token = url.searchParams.get("token");
311
+ if (!token || !tokenManager.owns(token, sessionId)) {
312
+ socket.close(4001, "unauthorized");
313
+ return;
314
+ }
315
+ }
316
+
274
317
  const device = {
275
318
  socket,
276
319
  role,
@@ -397,6 +440,7 @@ function shutdown() {
397
440
  wss.clients.forEach((ws) => ws.close(1001, "server shutting down"));
398
441
  sessionManager.destroy();
399
442
  pairingManager.destroy();
443
+ tokenManager.destroy();
400
444
  server.close(() => {
401
445
  process.stdout.write("[gateway] stopped\n");
402
446
  process.exit(0);
package/src/tokens.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ const CLEANUP_INTERVAL = 5 * 60_000;
4
+ const SESSION_TTL = 7 * 24 * 60 * 60_000; // 7 days — prune stale bindings
5
+
6
+ interface TokenRecord {
7
+ token: string;
8
+ sessionIds: Set<string>;
9
+ createdAt: number;
10
+ lastUsedAt: number;
11
+ }
12
+
13
+ export class TokenManager {
14
+ private tokens = new Map<string, TokenRecord>();
15
+ private sessionToToken = new Map<string, string>();
16
+ private cleanupTimer: ReturnType<typeof setInterval>;
17
+
18
+ constructor() {
19
+ this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
20
+ }
21
+
22
+ register(deviceToken?: string): string {
23
+ if (deviceToken && this.tokens.has(deviceToken)) {
24
+ const record = this.tokens.get(deviceToken)!;
25
+ record.lastUsedAt = Date.now();
26
+ return deviceToken;
27
+ }
28
+ const token = deviceToken || randomUUID();
29
+ this.tokens.set(token, {
30
+ token,
31
+ sessionIds: new Set(),
32
+ createdAt: Date.now(),
33
+ lastUsedAt: Date.now(),
34
+ });
35
+ return token;
36
+ }
37
+
38
+ bind(token: string, sessionId: string): boolean {
39
+ const record = this.tokens.get(token);
40
+ if (!record) return false;
41
+ record.sessionIds.add(sessionId);
42
+ record.lastUsedAt = Date.now();
43
+ this.sessionToToken.set(sessionId, token);
44
+ return true;
45
+ }
46
+
47
+ validate(token: string): boolean {
48
+ const record = this.tokens.get(token);
49
+ if (!record) return false;
50
+ record.lastUsedAt = Date.now();
51
+ return true;
52
+ }
53
+
54
+ owns(token: string, sessionId: string): boolean {
55
+ const record = this.tokens.get(token);
56
+ if (!record) return false;
57
+ record.lastUsedAt = Date.now();
58
+ return record.sessionIds.has(sessionId);
59
+ }
60
+
61
+ getSessionIds(token: string): Set<string> {
62
+ const record = this.tokens.get(token);
63
+ if (!record) return new Set();
64
+ record.lastUsedAt = Date.now();
65
+ return record.sessionIds;
66
+ }
67
+
68
+ getTokenForSession(sessionId: string): string | undefined {
69
+ return this.sessionToToken.get(sessionId);
70
+ }
71
+
72
+ private cleanup(): void {
73
+ const now = Date.now();
74
+ for (const [token, record] of this.tokens) {
75
+ if (now - record.lastUsedAt > SESSION_TTL) {
76
+ for (const sid of record.sessionIds) {
77
+ this.sessionToToken.delete(sid);
78
+ }
79
+ this.tokens.delete(token);
80
+ }
81
+ }
82
+ }
83
+
84
+ destroy(): void {
85
+ clearInterval(this.cleanupTimer);
86
+ }
87
+ }