sema-cli 0.1.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.
@@ -0,0 +1,1085 @@
1
+ const crypto = require("node:crypto");
2
+ const fs = require("node:fs");
3
+ const http = require("node:http");
4
+ const path = require("node:path");
5
+
6
+ const PORT = Number(process.env.PORT || 8787);
7
+ const HOST = process.env.HOST || "0.0.0.0";
8
+ const HISTORY_LIMIT = 64 * 1024;
9
+ const CODE_EXPIRE_MS = 10 * 60 * 1000; // 10 minutes
10
+ const SESSION_GRACE_MS = 60 * 1000; // 60 seconds after all clients disconnect
11
+ const CLEANUP_INTERVAL_MS = 60 * 1000; // 60 seconds
12
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
13
+ const RATE_LIMIT_MAX = 5; // 5 attempts per window per IP
14
+ const WS_RATE_WINDOW_MS = 60 * 1000; // 1 minute
15
+ const WS_RATE_MAX = 3; // 3 key_exchanges per window per device
16
+ const CODE_CHARSET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O/1/I/L
17
+ // Env-configurable so production (Railway/Fly) can mount a persistent volume.
18
+ // Local dev keeps the relative path (experiments/data/).
19
+ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "../../data");
20
+ const EVENTS_FILE = path.join(DATA_DIR, "events.jsonl");
21
+ const WAITLIST_FILE = path.join(DATA_DIR, "waitlist.jsonl");
22
+
23
+ // Ensure data directory exists
24
+ fs.mkdirSync(DATA_DIR, { recursive: true });
25
+
26
+ // --- State ---
27
+
28
+ const sessions = new Map();
29
+ const pendingCodes = new Map(); // code → {sessionId, macToken, createdAt}
30
+ const rateLimits = new Map(); // ip → {count, resetAt}
31
+ const wsRateLimits = new Map(); // "sessionId:deviceId" → {count, resetAt}
32
+
33
+ // --- Helpers ---
34
+
35
+ function generateCode() {
36
+ const bytes = crypto.randomBytes(6);
37
+ let code = "";
38
+ for (let i = 0; i < 6; i++) {
39
+ code += CODE_CHARSET[bytes[i] % CODE_CHARSET.length];
40
+ }
41
+ // Format as XXX-XXX
42
+ return `${code.slice(0, 3)}-${code.slice(3)}`;
43
+ }
44
+
45
+ function generateToken() {
46
+ return crypto.randomBytes(32).toString("hex");
47
+ }
48
+
49
+ function generateSessionId() {
50
+ return crypto.randomBytes(16).toString("hex");
51
+ }
52
+
53
+ function getSession(sessionId) {
54
+ if (!sessions.has(sessionId)) {
55
+ sessions.set(sessionId, {
56
+ id: sessionId,
57
+ mac: null,
58
+ mobiles: new Set(),
59
+ history: "",
60
+ alertActive: false,
61
+ lastSmartAlert: null,
62
+ macToken: null,
63
+ mobileToken: null,
64
+ emptySince: null,
65
+ macPublicKey: null,
66
+ devices: new Map(),
67
+ mobileSockets: new Map(),
68
+ command: null,
69
+ createdAt: Date.now(),
70
+ pendingCode: null,
71
+ });
72
+ }
73
+ return sessions.get(sessionId);
74
+ }
75
+
76
+ function appendHistory(session, text) {
77
+ session.history += text;
78
+ if (session.history.length > HISTORY_LIMIT) {
79
+ session.history = session.history.slice(session.history.length - HISTORY_LIMIT);
80
+ }
81
+ }
82
+
83
+ function logEvent(type, data) {
84
+ try {
85
+ const event = { t: new Date().toISOString(), type, ...data };
86
+ fs.appendFileSync(EVENTS_FILE, JSON.stringify(event) + "\n");
87
+ } catch {
88
+ // Analytics failure should not crash the server
89
+ }
90
+ }
91
+
92
+ function checkRateLimit(ip) {
93
+ const now = Date.now();
94
+ let entry = rateLimits.get(ip);
95
+
96
+ if (!entry || now >= entry.resetAt) {
97
+ entry = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
98
+ rateLimits.set(ip, entry);
99
+ }
100
+
101
+ entry.count++;
102
+ if (entry.count > RATE_LIMIT_MAX) {
103
+ const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
104
+ return { allowed: false, retryAfter };
105
+ }
106
+
107
+ return { allowed: true };
108
+ }
109
+
110
+ function checkWsRateLimit(key) {
111
+ const now = Date.now();
112
+ let entry = wsRateLimits.get(key);
113
+ if (!entry || now >= entry.resetAt) {
114
+ entry = { count: 0, resetAt: now + WS_RATE_WINDOW_MS };
115
+ wsRateLimits.set(key, entry);
116
+ }
117
+ entry.count++;
118
+ if (entry.count > WS_RATE_MAX) {
119
+ const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
120
+ return { allowed: false, retryAfter };
121
+ }
122
+ return { allowed: true };
123
+ }
124
+
125
+ function isPrivateIp(ip) {
126
+ const normalized = ip.replace(/^::ffff:/, "");
127
+ if (normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost") return true;
128
+ if (/^10\./.test(normalized)) return true;
129
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true;
130
+ if (/^192\.168\./.test(normalized)) return true;
131
+ // Tailscale CGNAT (100.64.0.0/10)
132
+ if (/^100\.(6[4-9]|[7-9]\d|1([01]\d|2[0-7]))\./.test(normalized)) return true;
133
+ return false;
134
+ }
135
+
136
+ function cleanup() {
137
+ const now = Date.now();
138
+
139
+ // Expire unused codes
140
+ for (const [code, entry] of pendingCodes) {
141
+ if (now - entry.createdAt > CODE_EXPIRE_MS) {
142
+ console.log(`[relay] code expired: ${code}`);
143
+ pendingCodes.delete(code);
144
+ // Clear pendingCode on the session so inbox shows "code unavailable"
145
+ const session = sessions.get(entry.sessionId);
146
+ if (session && session.pendingCode === code) {
147
+ session.pendingCode = null;
148
+ }
149
+ }
150
+ }
151
+
152
+ // Clean up empty sessions (no clients for grace period)
153
+ for (const [id, session] of sessions) {
154
+ if (!session.mac && session.mobiles.size === 0) {
155
+ if (!session.emptySince) {
156
+ session.emptySince = now;
157
+ } else if (now - session.emptySince > SESSION_GRACE_MS) {
158
+ console.log(`[relay] session cleaned: ${id}`);
159
+ logEvent("session.cleaned", { sessionId: id, lifetimeMs: now - session.createdAt });
160
+ sessions.delete(id);
161
+ }
162
+ } else {
163
+ session.emptySince = null;
164
+ }
165
+ }
166
+
167
+ // Clean up expired rate limit entries
168
+ for (const [ip, entry] of rateLimits) {
169
+ if (now >= entry.resetAt) rateLimits.delete(ip);
170
+ }
171
+ for (const [key, entry] of wsRateLimits) {
172
+ if (now >= entry.resetAt) wsRateLimits.delete(key);
173
+ }
174
+ }
175
+
176
+ // --- WebSocket framing ---
177
+
178
+ function sendFrame(socket, data) {
179
+ if (socket.destroyed) return;
180
+
181
+ const payload = Buffer.from(data);
182
+ let header;
183
+
184
+ if (payload.length < 126) {
185
+ header = Buffer.from([0x81, payload.length]);
186
+ } else if (payload.length < 65536) {
187
+ header = Buffer.alloc(4);
188
+ header[0] = 0x81;
189
+ header[1] = 126;
190
+ header.writeUInt16BE(payload.length, 2);
191
+ } else {
192
+ header = Buffer.alloc(10);
193
+ header[0] = 0x81;
194
+ header[1] = 127;
195
+ header.writeBigUInt64BE(BigInt(payload.length), 2);
196
+ }
197
+
198
+ socket.write(Buffer.concat([header, payload]));
199
+ }
200
+
201
+ function sendJson(client, message) {
202
+ sendFrame(client.socket, JSON.stringify(message));
203
+ }
204
+
205
+ function broadcastStatus(session) {
206
+ const message = {
207
+ type: "status",
208
+ sessionId: session.id,
209
+ macConnected: Boolean(session.mac),
210
+ mobileCount: session.mobiles.size,
211
+ };
212
+
213
+ if (session.mac) sendJson(session.mac, message);
214
+ for (const mobile of session.mobiles) sendJson(mobile, message);
215
+ }
216
+
217
+ function routeMessage(sender, message) {
218
+ const session = sessions.get(sender.sessionId);
219
+ if (!session) return;
220
+
221
+ // --- Key exchange: mobile sends public key → relay stores + forwards to mac ---
222
+ if (sender.role === "mobile" && message.type === "key_exchange") {
223
+ if (!session.mac) return;
224
+
225
+ if (!message.deviceId) {
226
+ console.error("[relay] key_exchange missing deviceId, rejected");
227
+ return;
228
+ }
229
+
230
+ // Rate limit key_exchange per session+device
231
+ const rlKey = `${sender.sessionId}:${message.deviceId}`;
232
+ const rl = checkWsRateLimit(rlKey);
233
+ if (!rl.allowed) {
234
+ console.warn(`[relay] key_exchange rate limited: ${rlKey}`);
235
+ sendJson(sender, {
236
+ type: "error",
237
+ sessionId: sender.sessionId,
238
+ message: `Too many key exchanges. Wait ${rl.retryAfter}s.`,
239
+ });
240
+ return;
241
+ }
242
+
243
+ const mobileId = message.deviceId;
244
+ sender.deviceId = mobileId;
245
+ session.devices.set(mobileId, {
246
+ publicKey: message.publicKey,
247
+ lastSeen: Date.now(),
248
+ });
249
+ session.mobileSockets.set(mobileId, sender);
250
+
251
+ console.log(`[relay] key_exchange session=${session.id} device=${mobileId}`);
252
+ logEvent("key_exchange.completed", { sessionId: session.id });
253
+ sendJson(session.mac, {
254
+ type: "key_exchange",
255
+ deviceId: mobileId,
256
+ publicKey: message.publicKey,
257
+ });
258
+ return;
259
+ }
260
+
261
+ // --- Device revoked: relay tells mac to drop key for a device ---
262
+ if (sender.role === "mac" && message.type === "device_revoked") {
263
+ console.log(`[relay] device_revoked session=${session.id} device=${message.deviceId}`);
264
+ return;
265
+ }
266
+
267
+ if (sender.role === "mac" && message.type === "output") {
268
+ // Clear stale alert state — CLI has continued past the prompt
269
+ if (session.alertActive) {
270
+ session.alertActive = false;
271
+ session.lastSmartAlert = null;
272
+ }
273
+ appendHistory(session, JSON.stringify(message));
274
+ console.log(`[relay] output session=${session.id} bytes=${Buffer.byteLength(JSON.stringify(message))}`);
275
+
276
+ // E2E: route to specific device if deviceId present
277
+ if (message.deviceId) {
278
+ const target = session.mobileSockets.get(message.deviceId);
279
+ if (target) sendJson(target, message);
280
+ return;
281
+ }
282
+
283
+ // Plaintext fallback: broadcast to all mobiles
284
+ for (const mobile of session.mobiles) {
285
+ sendJson(mobile, {
286
+ type: "output",
287
+ sessionId: session.id,
288
+ data: message.data || "",
289
+ });
290
+ }
291
+ return;
292
+ }
293
+
294
+ if (sender.role === "mac" && message.type === "key_ack") {
295
+ // Route key_ack to specific device
296
+ if (message.deviceId) {
297
+ const target = session.mobileSockets.get(message.deviceId);
298
+ if (target) sendJson(target, message);
299
+ }
300
+ return;
301
+ }
302
+
303
+ // key_rotation: mac → relay → specific mobile device
304
+ if (sender.role === "mac" && message.type === "key_rotation") {
305
+ if (message.deviceId) {
306
+ const target = session.mobileSockets.get(message.deviceId);
307
+ if (target) sendJson(target, message);
308
+ }
309
+ return;
310
+ }
311
+
312
+ // key_rot_ack: mobile → relay → mac
313
+ if (sender.role === "mobile" && message.type === "key_rot_ack") {
314
+ if (session.mac) sendJson(session.mac, message);
315
+ return;
316
+ }
317
+
318
+ if (sender.role === "mac" && message.type === "alert") {
319
+ session.alertActive = true;
320
+ console.log(`[relay] alert session=${session.id} reason=${message.reason || "idle"}`);
321
+ logEvent("alert.sent", { sessionId: session.id, kind: "alert" });
322
+ for (const mobile of session.mobiles) {
323
+ sendJson(mobile, {
324
+ type: "alert",
325
+ sessionId: session.id,
326
+ reason: message.reason || "idle",
327
+ });
328
+ }
329
+ return;
330
+ }
331
+
332
+ if (sender.role === "mac" && message.type === "smart_alert") {
333
+ session.alertActive = true;
334
+ session.lastSmartAlert = message;
335
+ console.log(`[relay] smart_alert session=${session.id} kind=${message.kind || "encrypted"}`);
336
+ logEvent("alert.sent", { sessionId: session.id, kind: "smart_alert" });
337
+
338
+ // E2E: route to specific device
339
+ if (message.deviceId) {
340
+ const target = session.mobileSockets.get(message.deviceId);
341
+ if (target) sendJson(target, message);
342
+ return;
343
+ }
344
+
345
+ // Plaintext fallback
346
+ for (const mobile of session.mobiles) {
347
+ sendJson(mobile, session.lastSmartAlert);
348
+ }
349
+ return;
350
+ }
351
+
352
+ if (sender.role === "mobile" && (message.type === "input" || message.type === "resize")) {
353
+ if (message.type === "input" && session.alertActive) {
354
+ session.alertActive = false;
355
+ }
356
+ console.log(`[relay] ${message.type} session=${session.id} bytes=${Buffer.byteLength(JSON.stringify(message))}`);
357
+ if (!session.mac) {
358
+ sendJson(sender, {
359
+ type: "error",
360
+ sessionId: session.id,
361
+ message: "Mac Agent is not connected.",
362
+ });
363
+ return;
364
+ }
365
+
366
+ sendJson(session.mac, message);
367
+ console.log(`[relay] ${message.type} forwarded session=${session.id}`);
368
+ return;
369
+ }
370
+ }
371
+
372
+ function parseFrames(client, chunk) {
373
+ client.buffer = Buffer.concat([client.buffer, chunk]);
374
+
375
+ while (client.buffer.length >= 2) {
376
+ const firstByte = client.buffer[0];
377
+ const secondByte = client.buffer[1];
378
+ const opcode = firstByte & 0x0f;
379
+ const masked = Boolean(secondByte & 0x80);
380
+ let payloadLength = secondByte & 0x7f;
381
+ let offset = 2;
382
+
383
+ if (payloadLength === 126) {
384
+ if (client.buffer.length < offset + 2) return;
385
+ payloadLength = client.buffer.readUInt16BE(offset);
386
+ offset += 2;
387
+ } else if (payloadLength === 127) {
388
+ if (client.buffer.length < offset + 8) return;
389
+ const bigLength = client.buffer.readBigUInt64BE(offset);
390
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
391
+ client.socket.destroy();
392
+ return;
393
+ }
394
+ payloadLength = Number(bigLength);
395
+ offset += 8;
396
+ }
397
+
398
+ const maskLength = masked ? 4 : 0;
399
+ const frameLength = offset + maskLength + payloadLength;
400
+ if (client.buffer.length < frameLength) return;
401
+
402
+ let payload = client.buffer.slice(offset + maskLength, frameLength);
403
+
404
+ if (masked) {
405
+ const mask = client.buffer.slice(offset, offset + 4);
406
+ payload = Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4]));
407
+ }
408
+
409
+ client.buffer = client.buffer.slice(frameLength);
410
+
411
+ if (opcode === 0x8) {
412
+ client.socket.end();
413
+ return;
414
+ }
415
+
416
+ if (opcode === 0x9) {
417
+ sendFrame(client.socket, "");
418
+ continue;
419
+ }
420
+
421
+ if (opcode !== 0x1) continue;
422
+
423
+ try {
424
+ routeMessage(client, JSON.parse(payload.toString("utf8")));
425
+ } catch (error) {
426
+ sendJson(client, {
427
+ type: "error",
428
+ sessionId: client.sessionId,
429
+ message: "Invalid JSON message.",
430
+ });
431
+ }
432
+ }
433
+ }
434
+
435
+ // --- Client registration (WebSocket) ---
436
+
437
+ function registerClient(socket, request) {
438
+ const url = new URL(request.url, `http://${request.headers.host}`);
439
+ const role = url.searchParams.get("role");
440
+ const sessionId = url.searchParams.get("sessionId");
441
+ const sessionToken = url.searchParams.get("sessionToken");
442
+
443
+ if (!["mac", "mobile"].includes(role)) {
444
+ socket.destroy();
445
+ return;
446
+ }
447
+
448
+ if (!sessionId || !sessionToken) {
449
+ // Send close frame with error message before destroying
450
+ sendFrame(socket, JSON.stringify({
451
+ type: "error",
452
+ message: "Missing sessionId or sessionToken. Use /pair to connect.",
453
+ }));
454
+ setTimeout(() => socket.destroy(), 100);
455
+ return;
456
+ }
457
+
458
+ const session = sessions.get(sessionId);
459
+ if (!session) {
460
+ sendFrame(socket, JSON.stringify({
461
+ type: "error",
462
+ message: "Session expired. Please re-pair.",
463
+ }));
464
+ setTimeout(() => socket.destroy(), 100);
465
+ return;
466
+ }
467
+
468
+ // Validate token based on role
469
+ if (role === "mac" && session.macToken !== sessionToken) {
470
+ sendFrame(socket, JSON.stringify({
471
+ type: "error",
472
+ sessionId,
473
+ message: "Invalid session token.",
474
+ }));
475
+ setTimeout(() => socket.destroy(), 100);
476
+ return;
477
+ }
478
+
479
+ if (role === "mobile" && session.mobileToken !== sessionToken) {
480
+ sendFrame(socket, JSON.stringify({
481
+ type: "error",
482
+ sessionId,
483
+ message: "Invalid session token.",
484
+ }));
485
+ setTimeout(() => socket.destroy(), 100);
486
+ return;
487
+ }
488
+
489
+ const client = {
490
+ socket,
491
+ role,
492
+ sessionId,
493
+ buffer: Buffer.alloc(0),
494
+ connectedAt: Date.now(),
495
+ };
496
+
497
+ if (role === "mac") {
498
+ if (session.mac) session.mac.socket.destroy();
499
+ session.mac = client;
500
+ session.emptySince = null;
501
+ } else {
502
+ session.mobiles.add(client);
503
+ session.emptySince = null;
504
+ // Skip history replay when E2E is enabled (history contains ciphertext that new key can't decrypt)
505
+ if (session.history && !session.macPublicKey) {
506
+ sendJson(client, {
507
+ type: "output",
508
+ sessionId,
509
+ data: session.history,
510
+ replay: true,
511
+ });
512
+ }
513
+ // Skip lastSmartAlert replay when E2E is enabled (it's encrypted)
514
+ if (session.alertActive && session.lastSmartAlert && !session.macPublicKey) {
515
+ sendJson(client, session.lastSmartAlert);
516
+ }
517
+ }
518
+
519
+ console.log(`[relay] ${role} connected session=${sessionId}`);
520
+ logEvent("ws.connected", { role, sessionId });
521
+ broadcastStatus(session);
522
+
523
+ socket.on("data", (chunk) => parseFrames(client, chunk));
524
+ socket.on("close", () => {
525
+ if (role === "mac" && session.mac === client) session.mac = null;
526
+ if (role === "mobile") {
527
+ session.mobiles.delete(client);
528
+ if (client.deviceId) {
529
+ session.mobileSockets.delete(client.deviceId);
530
+ }
531
+ }
532
+ if (!session.mac && session.mobiles.size === 0) {
533
+ session.emptySince = Date.now();
534
+ }
535
+ console.log(`[relay] ${role} disconnected session=${sessionId}`);
536
+ logEvent("ws.disconnected", { role, sessionId, durationMs: Date.now() - client.connectedAt });
537
+ broadcastStatus(session);
538
+ });
539
+ socket.on("error", () => socket.destroy());
540
+ }
541
+
542
+ function upgradeToWebSocket(request, socket) {
543
+ const key = request.headers["sec-websocket-key"];
544
+ if (!key) {
545
+ socket.destroy();
546
+ return;
547
+ }
548
+
549
+ const accept = crypto
550
+ .createHash("sha1")
551
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
552
+ .digest("base64");
553
+
554
+ socket.write(
555
+ [
556
+ "HTTP/1.1 101 Switching Protocols",
557
+ "Upgrade: websocket",
558
+ "Connection: Upgrade",
559
+ `Sec-WebSocket-Accept: ${accept}`,
560
+ "",
561
+ "",
562
+ ].join("\r\n"),
563
+ );
564
+
565
+ registerClient(socket, request);
566
+ }
567
+
568
+ // --- HTTP API ---
569
+
570
+ function readBody(request) {
571
+ return new Promise((resolve, reject) => {
572
+ const chunks = [];
573
+ let size = 0;
574
+ const MAX_BODY = 4096;
575
+
576
+ request.on("data", (chunk) => {
577
+ size += chunk.length;
578
+ if (size > MAX_BODY) {
579
+ request.destroy();
580
+ reject(new Error("Body too large"));
581
+ return;
582
+ }
583
+ chunks.push(chunk);
584
+ });
585
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
586
+ request.on("error", reject);
587
+ });
588
+ }
589
+
590
+ function sendJsonResponse(response, status, body) {
591
+ const json = JSON.stringify(body);
592
+ response.writeHead(status, {
593
+ "content-type": "application/json",
594
+ "access-control-allow-origin": "*",
595
+ "access-control-allow-methods": "GET, POST, OPTIONS",
596
+ "access-control-allow-headers": "content-type",
597
+ });
598
+ response.end(json);
599
+ }
600
+
601
+ function getClientIp(request) {
602
+ // Check X-Forwarded-For first (for future reverse proxy use)
603
+ const forwarded = request.headers["x-forwarded-for"];
604
+ if (forwarded) return forwarded.split(",")[0].trim();
605
+ return request.socket.remoteAddress || "unknown";
606
+ }
607
+
608
+ async function handleApiSessions(request, response) {
609
+ if (request.method === "OPTIONS") {
610
+ sendJsonResponse(response, 204, {});
611
+ return;
612
+ }
613
+
614
+ if (request.method !== "POST") {
615
+ sendJsonResponse(response, 405, { error: "Method not allowed" });
616
+ return;
617
+ }
618
+
619
+ let body = {};
620
+ try {
621
+ const raw = await readBody(request);
622
+ if (raw) body = JSON.parse(raw);
623
+ } catch {
624
+ sendJsonResponse(response, 400, { error: "Invalid JSON" });
625
+ return;
626
+ }
627
+
628
+ const sessionId = generateSessionId();
629
+ const macToken = generateToken();
630
+ const session = getSession(sessionId);
631
+ session.macToken = macToken;
632
+ session.macPublicKey = body.macPublicKey || null;
633
+ session.command = body.command || null;
634
+ session.createdAt = Date.now();
635
+
636
+ // Generate a unique code
637
+ let code;
638
+ do {
639
+ code = generateCode();
640
+ } while (pendingCodes.has(code));
641
+
642
+ session.pendingCode = code;
643
+
644
+ pendingCodes.set(code, {
645
+ sessionId,
646
+ macToken,
647
+ createdAt: Date.now(),
648
+ });
649
+
650
+ console.log(`[relay] session created: ${sessionId} code: ${code}`);
651
+ logEvent("session.created", { sessionId, command: session.command });
652
+
653
+ sendJsonResponse(response, 201, {
654
+ sessionId,
655
+ code,
656
+ macToken,
657
+ pairUrl: `/pair?code=${code}`,
658
+ });
659
+ }
660
+
661
+ async function handleApiPair(request, response) {
662
+ if (request.method === "OPTIONS") {
663
+ sendJsonResponse(response, 204, {});
664
+ return;
665
+ }
666
+
667
+ if (request.method !== "POST") {
668
+ sendJsonResponse(response, 405, { error: "Method not allowed" });
669
+ return;
670
+ }
671
+
672
+ // Rate limiting
673
+ const ip = getClientIp(request);
674
+ const rateCheck = checkRateLimit(ip);
675
+ if (!rateCheck.allowed) {
676
+ response.writeHead(429, {
677
+ "content-type": "application/json",
678
+ "retry-after": String(rateCheck.retryAfter),
679
+ "access-control-allow-origin": "*",
680
+ });
681
+ response.end(JSON.stringify({
682
+ error: "Too many attempts. Please wait a moment.",
683
+ retryAfter: rateCheck.retryAfter,
684
+ }));
685
+ return;
686
+ }
687
+
688
+ let body;
689
+ try {
690
+ const raw = await readBody(request);
691
+ body = JSON.parse(raw);
692
+ } catch {
693
+ sendJsonResponse(response, 400, { error: "Invalid JSON" });
694
+ return;
695
+ }
696
+
697
+ const code = String(body.code || "").toUpperCase().trim();
698
+ if (!code) {
699
+ sendJsonResponse(response, 400, { error: "Code is required." });
700
+ return;
701
+ }
702
+
703
+ const pending = pendingCodes.get(code);
704
+ if (!pending) {
705
+ logEvent("pair.failed", { reason: "invalid" });
706
+ sendJsonResponse(response, 404, { error: "Invalid or expired code." });
707
+ return;
708
+ }
709
+
710
+ // Check expiration
711
+ if (Date.now() - pending.createdAt > CODE_EXPIRE_MS) {
712
+ pendingCodes.delete(code);
713
+ logEvent("pair.failed", { reason: "expired" });
714
+ sendJsonResponse(response, 410, { error: "Code expired. Please generate a new code on your Mac." });
715
+ return;
716
+ }
717
+
718
+ // Consume the code (one-time use)
719
+ pendingCodes.delete(code);
720
+
721
+ // Generate mobile token and attach to session
722
+ const mobileToken = generateToken();
723
+ const session = sessions.get(pending.sessionId);
724
+ if (!session) {
725
+ sendJsonResponse(response, 410, { error: "Session expired. Please re-pair." });
726
+ return;
727
+ }
728
+ session.mobileToken = mobileToken;
729
+ session.pendingCode = null; // Mark pairing code as consumed
730
+
731
+ console.log(`[relay] paired: code=${code} session=${pending.sessionId}`);
732
+ logEvent("pair.completed", { sessionId: pending.sessionId });
733
+
734
+ sendJsonResponse(response, 200, {
735
+ sessionId: pending.sessionId,
736
+ mobileToken,
737
+ macPublicKey: session.macPublicKey,
738
+ command: session.command,
739
+ });
740
+ }
741
+
742
+ // --- Session list API ---
743
+
744
+ function handleApiSessionsList(response) {
745
+ // No auth — returns non-sensitive metadata only.
746
+ // Tokens and keys are protected by their own auth mechanisms.
747
+ // ⚠️ Local network only. Sprint 9+ may add IP allowlist.
748
+ const list = [];
749
+ for (const [id, s] of sessions) {
750
+ list.push({
751
+ id,
752
+ command: s.command,
753
+ createdAt: s.createdAt,
754
+ pendingCode: s.pendingCode,
755
+ deviceCount: s.devices.size,
756
+ hasMac: !!s.mac,
757
+ hasMobiles: s.mobiles.size > 0,
758
+ hasE2E: !!s.macPublicKey,
759
+ });
760
+ }
761
+ sendJsonResponse(response, 200, { sessions: list });
762
+ }
763
+
764
+ // --- Device management API ---
765
+
766
+ async function handleApiDevices(request, response, sessionId, deviceId, url) {
767
+ // Authenticate with macToken
768
+ const token = url.searchParams.get("token") || request.headers.authorization?.replace("Bearer ", "");
769
+ const session = sessions.get(sessionId);
770
+
771
+ if (!session || session.macToken !== token) {
772
+ sendJsonResponse(response, 401, { error: "Unauthorized" });
773
+ return;
774
+ }
775
+
776
+ // GET: list devices
777
+ if (request.method === "GET" && !deviceId) {
778
+ const devices = [];
779
+ for (const [id, info] of session.devices) {
780
+ devices.push({
781
+ deviceId: id,
782
+ lastSeen: info.lastSeen,
783
+ connected: session.mobileSockets.has(id),
784
+ });
785
+ }
786
+ sendJsonResponse(response, 200, { devices });
787
+ return;
788
+ }
789
+
790
+ // DELETE: revoke device
791
+ if (request.method === "DELETE" && deviceId) {
792
+ if (!session.devices.has(deviceId)) {
793
+ sendJsonResponse(response, 404, { error: "Device not found" });
794
+ return;
795
+ }
796
+
797
+ session.devices.delete(deviceId);
798
+
799
+ // Notify mac to drop key
800
+ if (session.mac) {
801
+ sendJson(session.mac, { type: "device_revoked", deviceId });
802
+ }
803
+
804
+ // Disconnect the device's WebSocket
805
+ const mobileClient = session.mobileSockets.get(deviceId);
806
+ if (mobileClient) {
807
+ mobileClient.socket.destroy();
808
+ session.mobileSockets.delete(deviceId);
809
+ }
810
+
811
+ console.log(`[relay] device revoked: session=${sessionId} device=${deviceId}`);
812
+ sendJsonResponse(response, 200, { ok: true });
813
+ return;
814
+ }
815
+
816
+ sendJsonResponse(response, 405, { error: "Method not allowed" });
817
+ }
818
+
819
+ // --- Waitlist API ---
820
+
821
+ const WAITLIST_RATE_WINDOW_MS = 60 * 1000;
822
+ const WAITLIST_RATE_MAX = 1;
823
+ const waitlistRateLimits = new Map();
824
+
825
+ async function handleApiWaitlist(request, response) {
826
+ if (request.method === "OPTIONS") {
827
+ sendJsonResponse(response, 204, {});
828
+ return;
829
+ }
830
+
831
+ if (request.method !== "POST") {
832
+ sendJsonResponse(response, 405, { error: "Method not allowed" });
833
+ return;
834
+ }
835
+
836
+ // Rate limit per IP
837
+ const ip = getClientIp(request);
838
+ const now = Date.now();
839
+ let rl = waitlistRateLimits.get(ip);
840
+ if (!rl || now >= rl.resetAt) {
841
+ rl = { count: 0, resetAt: now + WAITLIST_RATE_WINDOW_MS };
842
+ waitlistRateLimits.set(ip, rl);
843
+ }
844
+ rl.count++;
845
+ if (rl.count > WAITLIST_RATE_MAX) {
846
+ sendJsonResponse(response, 429, { error: "Too many submissions. Please wait." });
847
+ return;
848
+ }
849
+
850
+ let body;
851
+ try {
852
+ const raw = await readBody(request);
853
+ body = JSON.parse(raw);
854
+ } catch {
855
+ sendJsonResponse(response, 400, { error: "Invalid JSON" });
856
+ return;
857
+ }
858
+
859
+ const email = String(body.email || "").trim();
860
+ if (!email || !email.includes("@")) {
861
+ sendJsonResponse(response, 400, { error: "Valid email is required." });
862
+ return;
863
+ }
864
+
865
+ const pricing = String(body.pricing || "").trim();
866
+ const entry = {
867
+ email,
868
+ pricing: pricing || null,
869
+ ip,
870
+ t: new Date().toISOString(),
871
+ };
872
+
873
+ try {
874
+ fs.appendFileSync(WAITLIST_FILE, JSON.stringify(entry) + "\n");
875
+ } catch {
876
+ sendJsonResponse(response, 500, { error: "Failed to save" });
877
+ return;
878
+ }
879
+
880
+ logEvent("waitlist.signup", { pricing });
881
+ sendJsonResponse(response, 201, { ok: true });
882
+ }
883
+
884
+ // --- Stats API ---
885
+
886
+ function handleApiStats(response) {
887
+ let events = [];
888
+ try {
889
+ const raw = fs.readFileSync(EVENTS_FILE, "utf8").trim();
890
+ if (raw) events = raw.split("\n").map(line => JSON.parse(line));
891
+ } catch {
892
+ // File doesn't exist yet
893
+ }
894
+
895
+ let waitlist = [];
896
+ try {
897
+ const raw = fs.readFileSync(WAITLIST_FILE, "utf8").trim();
898
+ if (raw) waitlist = raw.split("\n").map(line => JSON.parse(line));
899
+ } catch {
900
+ // File doesn't exist yet
901
+ }
902
+
903
+ const stats = {
904
+ period: {
905
+ from: events.length ? events[0].t : null,
906
+ to: events.length ? events[events.length - 1].t : null,
907
+ totalEvents: events.length,
908
+ },
909
+ sessions: {
910
+ created: events.filter(e => e.type === "session.created").length,
911
+ cleaned: events.filter(e => e.type === "session.cleaned"),
912
+ active: sessions.size,
913
+ },
914
+ pairs: {
915
+ completed: events.filter(e => e.type === "pair.completed").length,
916
+ failed: events.filter(e => e.type === "pair.failed").length,
917
+ },
918
+ alerts: {
919
+ total: events.filter(e => e.type === "alert.sent").length,
920
+ smart: events.filter(e => e.type === "alert.sent" && e.kind === "smart_alert").length,
921
+ },
922
+ connections: {
923
+ mac: events.filter(e => e.type === "ws.connected" && e.role === "mac").length,
924
+ mobile: events.filter(e => e.type === "ws.connected" && e.role === "mobile").length,
925
+ },
926
+ keyExchanges: events.filter(e => e.type === "key_exchange.completed").length,
927
+ waitlist: waitlist.length,
928
+ };
929
+
930
+ // Calculate average connection duration
931
+ const disconnections = events.filter(e => e.type === "ws.disconnected" && e.durationMs);
932
+ if (disconnections.length) {
933
+ const totalMs = disconnections.reduce((sum, e) => sum + e.durationMs, 0);
934
+ stats.connections.avgDurationMs = Math.round(totalMs / disconnections.length);
935
+ stats.connections.avgDurationMin = Math.round(totalMs / disconnections.length / 60000);
936
+ }
937
+
938
+ // Calculate average session lifetime
939
+ const cleaned = events.filter(e => e.type === "session.cleaned" && e.lifetimeMs);
940
+ if (cleaned.length) {
941
+ const totalMs = cleaned.reduce((sum, e) => sum + e.lifetimeMs, 0);
942
+ stats.sessions.avgLifetimeMs = Math.round(totalMs / cleaned.length);
943
+ stats.sessions.avgLifetimeMin = Math.round(totalMs / cleaned.length / 60000);
944
+ }
945
+
946
+ // Pricing distribution from waitlist
947
+ if (waitlist.length) {
948
+ const pricingDist = {};
949
+ for (const w of waitlist) {
950
+ const p = w.pricing || "unspecified";
951
+ pricingDist[p] = (pricingDist[p] || 0) + 1;
952
+ }
953
+ stats.waitlistPricing = pricingDist;
954
+ }
955
+
956
+ sendJsonResponse(response, 200, stats);
957
+ }
958
+
959
+ // --- HTTP server ---
960
+
961
+ const server = http.createServer(async (request, response) => {
962
+ const url = new URL(request.url, `http://${request.headers.host}`);
963
+
964
+ // CORS preflight
965
+ if (request.method === "OPTIONS") {
966
+ sendJsonResponse(response, 204, {});
967
+ return;
968
+ }
969
+
970
+ if (url.pathname === "/health") {
971
+ sendJsonResponse(response, 200, { ok: true });
972
+ return;
973
+ }
974
+
975
+ // GET /api/sessions — list all active sessions (must come before POST /api/sessions)
976
+ if (url.pathname === "/api/sessions" && request.method === "GET") {
977
+ const ip = getClientIp(request);
978
+ if (!isPrivateIp(ip)) {
979
+ sendJsonResponse(response, 403, { error: "Forbidden" });
980
+ return;
981
+ }
982
+ handleApiSessionsList(response);
983
+ return;
984
+ }
985
+
986
+ // GET /api/stats — aggregated usage analytics (private IP only)
987
+ if (url.pathname === "/api/stats" && request.method === "GET") {
988
+ const ip = getClientIp(request);
989
+ if (!isPrivateIp(ip)) {
990
+ sendJsonResponse(response, 403, { error: "Forbidden" });
991
+ return;
992
+ }
993
+ handleApiStats(response);
994
+ return;
995
+ }
996
+
997
+ // POST /api/waitlist — email signup (public, rate-limited)
998
+ if (url.pathname === "/api/waitlist") {
999
+ await handleApiWaitlist(request, response);
1000
+ return;
1001
+ }
1002
+
1003
+ // / → landing page (public), /app → inbox (app entry)
1004
+ if ((url.pathname === "/" || url.pathname === "")
1005
+ && !url.searchParams.has("sessionId")
1006
+ && request.method === "GET") {
1007
+ response.writeHead(302, { Location: "/landing.html" });
1008
+ response.end();
1009
+ return;
1010
+ }
1011
+
1012
+ if (url.pathname === "/app") {
1013
+ response.writeHead(302, { Location: "/inbox.html" });
1014
+ response.end();
1015
+ return;
1016
+ }
1017
+
1018
+ if (url.pathname === "/api/sessions") {
1019
+ await handleApiSessions(request, response);
1020
+ return;
1021
+ }
1022
+
1023
+ if (url.pathname === "/api/pair") {
1024
+ await handleApiPair(request, response);
1025
+ return;
1026
+ }
1027
+
1028
+ // Device management: GET /api/sessions/:id/devices, DELETE /api/sessions/:id/devices/:deviceId
1029
+ const devicesMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/devices(?:\/([^/]+))?$/);
1030
+ if (devicesMatch) {
1031
+ await handleApiDevices(request, response, devicesMatch[1], devicesMatch[2], url);
1032
+ return;
1033
+ }
1034
+
1035
+ // Static files (mobile-web)
1036
+ const staticMap = {
1037
+ "/": "index.html",
1038
+ "/pair": "pair.html",
1039
+ };
1040
+ const fileName = staticMap[url.pathname] || url.pathname;
1041
+ const filePath = path.join(__dirname, "../mobile-web", fileName);
1042
+
1043
+ fs.readFile(filePath, (error, content) => {
1044
+ if (error) {
1045
+ response.writeHead(404, { "content-type": "text/plain" });
1046
+ response.end("Not found");
1047
+ return;
1048
+ }
1049
+
1050
+ const ext = path.extname(filePath);
1051
+ const contentTypes = {
1052
+ ".html": "text/html",
1053
+ ".css": "text/css",
1054
+ ".js": "application/javascript",
1055
+ ".json": "application/json",
1056
+ ".png": "image/png",
1057
+ ".svg": "image/svg+xml",
1058
+ };
1059
+ response.writeHead(200, {
1060
+ "content-type": contentTypes[ext] || "text/plain",
1061
+ });
1062
+ response.end(content);
1063
+ });
1064
+ });
1065
+
1066
+ server.on("upgrade", (request, socket) => {
1067
+ if (!request.url.startsWith("/ws")) {
1068
+ socket.destroy();
1069
+ return;
1070
+ }
1071
+ upgradeToWebSocket(request, socket);
1072
+ });
1073
+
1074
+ // --- Cleanup timer ---
1075
+
1076
+ setInterval(cleanup, CLEANUP_INTERVAL_MS);
1077
+
1078
+ // --- Start ---
1079
+
1080
+ server.listen(PORT, HOST, () => {
1081
+ console.log(`[relay] listening on http://${HOST}:${PORT}`);
1082
+ if (HOST === "0.0.0.0") {
1083
+ console.log(`\x1b[33m⚠ Listening on 0.0.0.0 — accessible on LAN. Use HOST=127.0.0.1 for local only.\x1b[0m`);
1084
+ }
1085
+ });