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.
- package/README.md +78 -0
- package/experiments/spike-shell-stream/bin/analytics.js +209 -0
- package/experiments/spike-shell-stream/bin/sema.js +322 -0
- package/experiments/spike-shell-stream/bin/start.js +387 -0
- package/experiments/spike-shell-stream/mac-agent/agent.js +450 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.js +189 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.test.js +307 -0
- package/experiments/spike-shell-stream/mac-agent/session.js +38 -0
- package/experiments/spike-shell-stream/mobile-web/inbox.html +431 -0
- package/experiments/spike-shell-stream/mobile-web/index.html +1093 -0
- package/experiments/spike-shell-stream/mobile-web/landing.html +586 -0
- package/experiments/spike-shell-stream/mobile-web/pair.html +304 -0
- package/experiments/spike-shell-stream/relay-server/server.js +1085 -0
- package/experiments/spike-shell-stream/shared/crypto.js +138 -0
- package/experiments/spike-shell-stream/shared/crypto.test.js +350 -0
- package/package.json +52 -0
|
@@ -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
|
+
});
|