@walkhi/code-relax 0.1.0-beta.26 → 0.1.0-beta.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +80 -67
  2. package/dist/bin/codex-remote.mjs +286 -239
  3. package/dist/shared/app-server-events.cjs +102 -16
  4. package/dist/shared/code-browser.cjs +159 -0
  5. package/dist/shared/file-changes.cjs +31 -0
  6. package/dist/shared/file-citations.cjs +117 -0
  7. package/dist/shared/file-mentions.cjs +80 -35
  8. package/dist/shared/message-delivery.cjs +38 -0
  9. package/dist/shared/p2p-file-channel.cjs +74 -0
  10. package/dist/shared/server-requests.cjs +149 -41
  11. package/dist/shared/service-endpoints.cjs +11 -11
  12. package/dist/shared/timeline-sync.cjs +173 -156
  13. package/dist/skills/code-relax/SKILL.md +2 -1
  14. package/dist/src/bounded-process-log.mjs +63 -0
  15. package/dist/src/celsius-client.mjs +107 -0
  16. package/dist/src/code-browser.mjs +101 -0
  17. package/dist/src/desktop-launcher.mjs +30 -30
  18. package/dist/src/dots-approval-timeline.mjs +99 -0
  19. package/dist/src/dots-avatar.mjs +42 -0
  20. package/dist/src/dots-chat-projection.mjs +50 -0
  21. package/dist/src/dots-chat-server.mjs +412 -0
  22. package/dist/src/dots-message-images.mjs +319 -0
  23. package/dist/src/dots-profile.mjs +77 -0
  24. package/dist/src/dots-room-activity.mjs +94 -0
  25. package/dist/src/dots-room-client.mjs +135 -0
  26. package/dist/src/dots-room-projection.mjs +94 -0
  27. package/dist/src/dots-schedules.mjs +66 -0
  28. package/dist/src/dots-summary-tasks.mjs +32 -0
  29. package/dist/src/dots-task-catalog.mjs +411 -0
  30. package/dist/src/file-transfer-range.mjs +22 -0
  31. package/dist/src/file-upload-session.mjs +130 -124
  32. package/dist/src/hosted-history-app-server.mjs +103 -0
  33. package/dist/src/hosted-history-cache.mjs +113 -0
  34. package/dist/src/lan-socket-server.mjs +205 -160
  35. package/dist/src/managed-app-server.mjs +241 -238
  36. package/dist/src/message-upload-files.mjs +74 -0
  37. package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
  38. package/dist/src/platform/windows/codex-runtime.mjs +253 -251
  39. package/dist/src/platform/windows/desktop-host.mjs +38 -32
  40. package/dist/src/platform/windows/desktop-info.ps1 +2 -2
  41. package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
  42. package/dist/src/platform/windows/dots-profile.mjs +49 -0
  43. package/dist/src/platform/windows/launch-worker.mjs +41 -42
  44. package/dist/src/platform/windows/project-locations.mjs +16 -0
  45. package/dist/src/project-create.mjs +119 -0
  46. package/dist/src/project-management.mjs +69 -0
  47. package/dist/src/self-relay/account-connections.mjs +52 -0
  48. package/dist/src/self-relay/admin-page.mjs +80 -80
  49. package/dist/src/self-relay/admin-style.mjs +14 -9
  50. package/dist/src/self-relay/connector.mjs +22 -4
  51. package/dist/src/self-relay/file-wire.mjs +13 -0
  52. package/dist/src/self-relay/p2p-probe.mjs +152 -123
  53. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
  54. package/dist/src/self-relay/server.mjs +894 -849
  55. package/dist/src/server.mjs +463 -148
  56. package/dist/src/service-doctor.mjs +72 -61
  57. package/dist/src/service-lifecycle.mjs +51 -43
  58. package/dist/src/shared-app-server.mjs +166 -107
  59. package/dist/src/shared-catalog.mjs +132 -40
  60. package/dist/src/shared-thread-stream.mjs +103 -25
  61. package/dist/src/usage-reset.mjs +17 -0
  62. package/dist/src/workstation-downloads.mjs +120 -0
  63. package/package.json +1 -1
@@ -1,849 +1,894 @@
1
- import { privacyPage } from './privacy-page.mjs';
2
- import http from 'node:http';
3
- import fs from 'node:fs';
4
- import { isIP } from 'node:net';
5
- import { deviceStore, validId } from './device-store.mjs';
6
- import { randomUUID, timingSafeEqual } from 'node:crypto';
7
- import { WebSocketServer } from 'ws';
8
- import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
9
- import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
10
- import { validProbeSignal } from './p2p-probe.mjs';
11
- import { MAX_ADMIN_REQUEST_BYTES, relayAdminState } from './admin-state.mjs';
12
- import { openRelayMysql } from './mysql-state.mjs';
13
- import { openEmailAccounts } from './email-accounts.mjs';
14
- import { usageMeter, usageReport } from './usage-meter.mjs';
15
- import { relayDiagnostics } from './diagnostics.mjs';
16
-
17
- // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
18
- export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
19
- maxConnections = 128, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
20
- maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
21
- publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
22
- if (!validSecret(hostKey)) throw new Error('Invalid host credential');
23
- if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
24
- if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
25
- if (publicRelayUrl) relayPublicAddress(publicRelayUrl);
26
- // Harmony's native WebSocket supplies the endpoint Origin automatically.
27
- // libwebsockets can use http even for WSS; trust only the configured endpoint.
28
- const nativeOrigins = publicRelayUrl ? ['http:', 'https:'].map(protocol => `${protocol}//${new URL(publicRelayUrl).host}`) : [];
29
- if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
30
- const hostHash = digest(hostKey), rooms = new Map();
31
- const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
32
- if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
33
- const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
34
- const meter = accounts ? usageMeter(database.pool) : null;
35
- const cancelledAccountIds = new Set();
36
- const accountCancellationWork = new Map();
37
- const diagnostics = database?.schemaVersions.includes('006_relay_diagnostics')
38
- ? relayDiagnostics(database.pool, error => log({ event: 'relay-diagnostic-error', message: error.message })) : null;
39
- const adminAccessFile = '/etc/codex-relay/admin-access.json';
40
- const adminAccessKey = fs.existsSync(adminAccessFile) ? JSON.parse(fs.readFileSync(adminAccessFile, 'utf8')).key : '';
41
- if (adminAccessKey && !validSecret(adminAccessKey)) throw new Error('Invalid admin access key');
42
- const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
43
- if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
44
- const store = database?.devices || deviceStore(stateFile);
45
- const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
46
- const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
47
- let activeRelaySessions = 0;
48
- for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
49
- let commitTail = Promise.resolve();
50
- function queueStore(operation) {
51
- const pending = commitTail.then(operation);
52
- commitTail = pending.catch(() => {});
53
- return pending;
54
- }
55
- function commit(room, changes) {
56
- return queueStore(async () => {
57
- changes = typeof changes === 'function' ? changes(room) : changes;
58
- const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
59
- if (room.durable) {
60
- const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
61
- await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
62
- }
63
- Object.assign(room, changes, { updatedAt: next.updatedAt });
64
- });
65
- }
66
- function registerRoom(id, durable, credential) {
67
- return queueStore(async () => {
68
- // Resolve concurrent first connections before writing the device identity.
69
- if (durable && rooms.has(id)) return rooms.get(id);
70
- const now = new Date().toISOString();
71
- const record = { id, revision: 0, status: 'active', clients: [], createdAt: now, updatedAt: now,
72
- ...(durable ? { hostHash: digest(credential) } : {}) };
73
- if (durable) await store.save(record);
74
- const room = { ...record, durable, connections: new Map(), observers: new Map(), uploadClients: new Map() };
75
- rooms.set(id, room);
76
- return room;
77
- });
78
- }
79
- function invitation(room) {
80
- const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
81
- return { code, expires: room.expires };
82
- }
83
- let listenUrl = '';
84
- let adminTail = Promise.resolve();
85
- function relayUrl(request) {
86
- if (publicRelayUrl) return relayPublicAddress(publicRelayUrl);
87
- const host = request.headers.host || new URL(listenUrl).host;
88
- const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
89
- return `${secure ? 'wss' : 'ws'}://${host}/relay`;
90
- }
91
- function findInvitation(code) {
92
- if (!validSecret(code)) return undefined;
93
- return [...rooms.values()].find(room => room.status === 'active' && room.pairingHash && Date.now() < room.expires && matches(code, room.pairingHash));
94
- }
95
- const server = http.createServer((request, response) => {
96
- const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
97
- if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
98
- adminTail = adminTail.then(() => handleAdmin(request, response, target)).catch(() => {
99
- if (!response.headersSent) adminReply(response, 500, { error: 'Admin operation failed' });
100
- });
101
- return;
102
- }
103
- if (target.pathname === '/account/privacy' && request.method === 'GET') {
104
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache',
105
- 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
106
- 'X-Content-Type-Options': 'nosniff' });
107
- response.end(privacyPage); return;
108
- }
109
- if (accounts && target.pathname.startsWith('/account/')) {
110
- void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
111
- return;
112
- }
113
- if (request.method === 'GET' && target.pathname === '/announcement') {
114
- const body = JSON.stringify({ announcement: adminState.read().announcement });
115
- response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
116
- }
117
- if (request.method !== 'GET' || !['/pair', '/pair/resolve'].includes(target.pathname)) {
118
- response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
119
- }
120
- const code = target.searchParams.get('invite') || '';
121
- const room = findInvitation(code);
122
- if (!room) {
123
- const json = target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '');
124
- const body = json ? JSON.stringify({ error: '配对邀请已过期或已使用' }) : pairingPage({ expired: true });
125
- response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
126
- }
127
- if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
128
- const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
129
- response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
130
- }
131
- const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
132
- const current = new URL(target.pathname + target.search, `${secure ? 'https' : 'http'}://${request.headers.host || new URL(listenUrl).host}`);
133
- const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: room.expires, appDownloadUrl });
134
- response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
135
- });
136
- function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
137
- const value = typeof body === 'string' ? body : JSON.stringify(body);
138
- response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store',
139
- 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
140
- response.end(value);
141
- }
142
- function accountReply(response, status, body) {
143
- if (response.headersSent || response.destroyed) return;
144
- const value = JSON.stringify(body);
145
- response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(value),
146
- 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
147
- response.end(value);
148
- }
149
- function cancelAccountConnections(userId, token) {
150
- const pending = accountCancellationWork.get(userId);
151
- if (pending) return pending;
152
- if (cancelledAccountIds.has(userId)) return Promise.resolve();
153
- cancelledAccountIds.add(userId);
154
- const work = (async () => {
155
- try { await accounts.cancelAccount(token); }
156
- catch (failure) { cancelledAccountIds.delete(userId); throw failure; }
157
- for (const room of rooms.values()) {
158
- for (const [key, arm] of room.usageArms || []) if (arm.userId === userId) room.usageArms.delete(key);
159
- for (const [key, task] of room.usageTasks || []) if (task.userId === userId) room.usageTasks.delete(key);
160
- for (const [clientId, connection] of room.connections) {
161
- if (connection.userId !== userId) continue;
162
- releaseRelay(connection); room.connections.delete(clientId);
163
- if (room.host) send(room.host, { type: 'detach', session: connection.session });
164
- connection.ws.close(4431, 'Account cancelled');
165
- }
166
- for (const [clientId, observer] of room.observers) {
167
- if (observer.userId !== userId) continue;
168
- room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
169
- }
170
- for (const [clientId, upload] of room.uploadClients) {
171
- if (upload.userId !== userId) continue;
172
- room.uploadClients.delete(clientId);
173
- if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
174
- upload.ws.close(4431, 'Account cancelled');
175
- }
176
- }
177
- })().finally(() => accountCancellationWork.delete(userId));
178
- accountCancellationWork.set(userId, work);
179
- return work;
180
- }
181
- async function handleAccount(request, response, target) {
182
- try {
183
- if (target.pathname === '/account/config' && request.method === 'GET') {
184
- accountReply(response, 200, { captchaAppId: accounts.captchaAppId }); return;
185
- }
186
- const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
187
- if (target.pathname === '/account/me' && request.method === 'GET') {
188
- const user = await accounts.current(bearer);
189
- accountReply(response, 200, { user, usage: await accounts.usage(user.id) }); return;
190
- }
191
- if (target.pathname === '/account/logout' && request.method === 'POST') {
192
- accountReply(response, 200, await accounts.logout(bearer)); return;
193
- }
194
- if (target.pathname === '/account/me' && request.method === 'DELETE') {
195
- const user = await accounts.current(bearer);
196
- await cancelAccountConnections(user.id, bearer);
197
- accountReply(response, 200, { ok: true }); return;
198
- }
199
- if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
200
- accountReply(response, 404, { error: 'Account route not found' }); return;
201
- }
202
- const body = JSON.parse(await readBody(request, 4096));
203
- if (!body || typeof body !== 'object' || Array.isArray(body)) { accountReply(response, 400, { error: 'Invalid request body' }); return; }
204
- if (target.pathname === '/account/email/request') {
205
- const ip = request.headers['x-real-ip'] || request.socket.remoteAddress || '';
206
- if (typeof ip !== 'string' || !isIP(ip)) { accountReply(response, 400, { error: 'Invalid client address' }); return; }
207
- accountReply(response, 202, await accounts.requestCode(body, ip));
208
- } else if (target.pathname === '/account/email/verify') {
209
- accountReply(response, 200, await accounts.verifyCode(body));
210
- } else accountReply(response, 404, { error: 'Account route not found' });
211
- } catch (failure) {
212
- accountReply(response, failure.status || 503, { error: failure.status ? failure.message : 'Account service unavailable' });
213
- }
214
- }
215
- async function handleAdmin(request, response, target) {
216
- const accessKey = request.headers['x-code-relax-admin-key'];
217
- const internalAccess = adminAccessKey && validSecret(accessKey) &&
218
- timingSafeEqual(Buffer.from(accessKey), Buffer.from(adminAccessKey));
219
- const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
220
- const diagnosticRoute = ['/admin/api/usage-diagnostics', '/admin/api/usage-diagnostics/drain'].includes(target.pathname);
221
- const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
222
- timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
223
- if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
224
- if (request.method === 'GET' && target.pathname === '/admin/api/status') {
225
- const state = adminState.read();
226
- adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
227
- activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
228
- websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
229
- activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
230
- processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
231
- announcement: state.announcement, announcements: state.announcements }); return;
232
- }
233
- if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
234
- const page = Number(target.searchParams.get('page') || '1');
235
- const view = target.searchParams.get('view') || 'user';
236
- if (!Number.isSafeInteger(page) || page < 1 || page > 100000 ||
237
- !['user', 'computer'].includes(view) || target.searchParams.has('user') ||
238
- target.searchParams.has('computer')) {
239
- adminReply(response, 400, { error: 'Invalid usage query' }); return;
240
- }
241
- adminReply(response, 200, await usageReport(database.pool, page, view)); return;
242
- }
243
- const diagnosticSource = target.searchParams.get('source') || 'usage';
244
- if (target.pathname.startsWith('/admin/api/usage-diagnostics') && !['usage', 'account-cleanup'].includes(diagnosticSource)) {
245
- adminReply(response, 400, { error: 'Invalid diagnostic source' }); return;
246
- }
247
- if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
248
- if (request.method === 'GET') adminReply(response, 200, await diagnostics.read(diagnosticSource));
249
- else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear(diagnosticSource));
250
- else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
251
- return;
252
- }
253
- if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
254
- if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain(diagnosticSource));
255
- else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
256
- return;
257
- }
258
- if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
259
- request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
260
- try {
261
- const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, MAX_ADMIN_REQUEST_BYTES));
262
- if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
263
- const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
264
- log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
265
- } else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
266
- const state = await adminState.createAnnouncement(body.title, body.message, body.publish === true);
267
- if (body.publish === true) {
268
- broadcastAnnouncement(state.announcement);
269
- log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
270
- } else log({ event: 'admin-announcement-draft-created', announcementId: state.announcements[0].id });
271
- } else if (target.pathname.startsWith('/admin/api/announcements/')) {
272
- const parts = target.pathname.slice('/admin/api/announcements/'.length).split('/');
273
- const id = parts[0];
274
- if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
275
- if (parts.length === 1 && request.method === 'PUT') {
276
- await adminState.updateDraft(id, body.title, body.message);
277
- log({ event: 'admin-announcement-draft-updated', announcementId: id });
278
- } else if (parts.length === 1 && request.method === 'DELETE') {
279
- await adminState.deleteAnnouncement(id);
280
- log({ event: 'admin-announcement-deleted', announcementId: id });
281
- } else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
282
- const state = await adminState.publishDraft(id, body.title, body.message);
283
- broadcastAnnouncement(state.announcement);
284
- log({ event: 'admin-announcement-published', announcementId: id });
285
- } else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
286
- const state = await adminState.withdraw(id);
287
- broadcastAnnouncement(state.announcement);
288
- log({ event: 'admin-announcement-withdrawn', announcementId: id });
289
- } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
290
- } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
291
- adminReply(response, 200, { ok: true });
292
- } catch (error) { adminReply(response, 400, { error: error.message }); }
293
- }
294
- function broadcastAnnouncement(announcement) {
295
- for (const room of rooms.values()) {
296
- for (const observer of room.observers.values()) send(observer.ws, { type: 'announcement', announcement });
297
- for (const connection of room.connections.values()) send(connection.ws, { type: 'announcement', announcement });
298
- }
299
- }
300
- function releaseRelay(connection) {
301
- if (!connection?.relayAdmitted) return;
302
- connection.relayAdmitted = false; activeRelaySessions = Math.max(0, activeRelaySessions - 1);
303
- }
304
- function admitRelay(connection, room) {
305
- if (connection.relayAdmitted || connection.relayExempt) return true;
306
- if (connection.capacityExempt) {
307
- connection.relayExempt = true;
308
- log({ event: 'relay-capacity-exempt', device: room.id }); return true;
309
- }
310
- const limit = adminState.read().maxRelaySessions;
311
- if (activeRelaySessions >= limit) {
312
- log({ event: 'relay-capacity-rejected', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return false;
313
- }
314
- connection.relayAdmitted = true; activeRelaySessions++;
315
- log({ event: 'relay-capacity-admitted', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return true;
316
- }
317
- const wss = new WebSocketServer({ noServer: true, maxPayload, perMessageDeflate: {
318
- serverNoContextTakeover: true, clientNoContextTakeover: true,
319
- threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
320
- } });
321
- let admissionStart = Date.now(), admissions = 0;
322
- server.on('upgrade', (request, socket, head) => {
323
- if (request.url !== '/relay' || (request.headers.origin && request.headers.origin !== 'null' && !nativeOrigins.includes(request.headers.origin))) {
324
- log({ event: 'upgrade-rejected', reason: request.url !== '/relay' ? 'path' : 'origin' });
325
- socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
326
- }
327
- if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
328
- if (wss.clients.size >= maxConnections || ++admissions > 240) {
329
- socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
330
- }
331
- wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
332
- });
333
- wss.on('connection', ws => {
334
- let role, room, session, clientId, alive = true;
335
- const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
336
- ws.on('pong', () => { alive = true; });
337
- const heartbeat = setInterval(() => {
338
- sampleInboundQueue();
339
- if (!alive) ws.terminate(); else { alive = false; ws.ping(); }
340
- }, 30000);
341
- const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
342
- const queueClientMessage = (connection, message, pause = 0) => {
343
- const bytes = Buffer.byteLength(JSON.stringify(message));
344
- connection.queuedBytes = (connection.queuedBytes || 0) + bytes;
345
- if (connection.queuedBytes > 40 * 1024 * 1024) {
346
- connection.ws.close(1013, 'Receiver too slow'); return;
347
- }
348
- connection.outbound = (connection.outbound || Promise.resolve()).then(async () => {
349
- if (pause > 0) await new Promise(resolve => setTimeout(resolve, pause));
350
- connection.queuedBytes -= bytes;
351
- send(connection.ws, message);
352
- }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
353
- };
354
- ws.on('error', () => {}); // close owns routing cleanup.
355
- ws.on('close', (code, reason) => {
356
- log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
357
- clearTimeout(authTimer); clearInterval(heartbeat);
358
- if (role === 'host' && room?.host === ws) {
359
- for (const task of room.usageTasks?.values() || []) {
360
- void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
361
- }
362
- room.usageTasks?.clear(); room.usageArms?.clear();
363
- room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
364
- room.uploadHost?.close(4410, 'Computer offline');
365
- for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline');
366
- for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
367
- if (!room.durable) rooms.delete(room.id);
368
- for (const connection of room.connections.values()) connection.ws.close(
369
- code === 4432 ? 4432 : 4410,
370
- code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline; authorization retained');
371
- } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
372
- const connection = room.connections.get(clientId);
373
- releaseRelay(connection);
374
- room.connections.delete(clientId);
375
- if (room.host) send(room.host, { type: 'detach', session });
376
- } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
377
- room.observers.delete(clientId);
378
- } else if (role === 'file-host' && room?.uploadHost === ws) {
379
- room.uploadHost = undefined;
380
- for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'File receiver inbound queue limit exceeded' : 'File receiver offline');
381
- } else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
382
- room.uploadClients.delete(clientId);
383
- if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
384
- }
385
- });
386
- let messageTail = Promise.resolve();
387
- // Include the in-flight message: async database work must not permit an
388
- // unbounded chain of closures retaining incoming payloads.
389
- let pendingMessages = 0, pendingBytes = 0;
390
- const maxPendingMessages = 128, maxPendingBytes = 16 * 1024 * 1024;
391
- let sampleStarted = Date.now(), peakMessages = 0, peakBytes = 0, maxWaitMs = 0, receivedMessages = 0;
392
- function sampleInboundQueue() {
393
- const now = Date.now();
394
- if (now - sampleStarted < 60000) return;
395
- // Summarize only intervals with an actual backlog; never log payloads or credentials.
396
- if (peakMessages > 1) log({ event: 'inbound-queue-sample', role: role || 'unauthenticated',
397
- windowMs: now - sampleStarted, receivedMessages, peakMessages, peakBytes, maxWaitMs,
398
- pendingMessages, pendingBytes });
399
- sampleStarted = now; peakMessages = pendingMessages; peakBytes = pendingBytes;
400
- maxWaitMs = 0; receivedMessages = 0;
401
- }
402
- ws.on('message', (bytes, binary) => {
403
- if (ws.readyState !== ws.OPEN) return;
404
- const size = bytes.length;
405
- if (pendingMessages >= maxPendingMessages || pendingBytes + size > maxPendingBytes) {
406
- log({ event: 'inbound-queue-limit', role: role || 'unauthenticated',
407
- pendingMessages, pendingBytes, incomingBytes: size, maxWaitMs });
408
- ws.close(4432, 'Inbound queue limit exceeded');
409
- return;
410
- }
411
- pendingMessages++; pendingBytes += size;
412
- receivedMessages++;
413
- peakMessages = Math.max(peakMessages, pendingMessages); peakBytes = Math.max(peakBytes, pendingBytes);
414
- const enqueuedAt = Date.now();
415
- messageTail = messageTail.then(async () => {
416
- try {
417
- maxWaitMs = Math.max(maxWaitMs, Date.now() - enqueuedAt);
418
- if (ws.readyState !== ws.OPEN) return;
419
- const message = decode(bytes, binary);
420
- if (!role) {
421
- let userId;
422
- if (!['host', 'file-host', 'observe'].includes(message.type) &&
423
- !(['pair', 'resume'].includes(message.type) && message.management === true)) {
424
- if (!['pair', 'resume', 'observe', 'file-client'].includes(message.type)) return fail();
425
- if (!accounts || !validSecret(message.accountToken)) return fail(4431, 'Account login required');
426
- try { userId = (await accounts.current(message.accountToken)).id; }
427
- catch { return fail(4431, 'Account session invalid'); }
428
- if (ws.readyState !== ws.OPEN) return;
429
- if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
430
- }
431
- if (message.type === 'file-host') {
432
- room = rooms.get(message.deviceId);
433
- if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
434
- role = 'file-host'; clearTimeout(authTimer);
435
- room.uploadClients ||= new Map();
436
- room.uploadHost?.close(4409, 'File receiver replaced');
437
- room.uploadHost = ws;
438
- send(ws, { type: 'file-host-ready' }); return;
439
- }
440
- if (message.type === 'file-client') {
441
- room = rooms.get(message.room);
442
- const client = room?.clients.find(item => item.id === message.clientId);
443
- if (!room?.host || !room.uploadHost || room.status !== 'active' || !client ||
444
- !matches(message.credential, client.hash)) return fail();
445
- room.uploadClients ||= new Map();
446
- if (room.uploadClients.has(message.clientId) ||
447
- [...rooms.values()].reduce((sum, item) => sum + (item.uploadClients?.size || 0), 0) >= 2) {
448
- return fail(4429, 'Too many file uploads');
449
- }
450
- role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
451
- room.uploadClients.set(clientId, { ws, session, userId });
452
- send(room.uploadHost, { type: 'file-open', session }); return;
453
- }
454
- if (message.type === 'host') {
455
- const durable = message.deviceId !== undefined;
456
- if (durable && (!validId(message.deviceId) || !validSecret(message.deviceSecret))) return fail();
457
- if (!durable && stateFile) return fail(4406, 'Device protocol upgrade required');
458
- let lanCandidates, hostName;
459
- try {
460
- lanCandidates = normalizeLanCandidates(message.lanCandidates);
461
- hostName = normalizeHostName(message.hostName);
462
- }
463
- catch { return fail(1008, 'Invalid LAN candidates'); }
464
- room = durable ? rooms.get(message.deviceId) : undefined;
465
- if (!room) {
466
- if (!durable && !matches(message.key, hostHash)) return fail();
467
- room = await registerRoom(durable ? message.deviceId : randomUUID(), durable, message.deviceSecret);
468
- if (ws.readyState !== ws.OPEN) return;
469
- }
470
- if (durable && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
471
- const oldHost = room.host;
472
- room.observers ||= new Map();
473
- if (oldHost && oldHost !== ws) {
474
- for (const task of room.usageTasks?.values() || []) {
475
- try { await meter?.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
476
- }
477
- room.usageTasks?.clear(); room.usageArms?.clear();
478
- }
479
- room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
480
- room.usageTasks ||= new Map(); room.usageArms ||= new Map();
481
- if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
482
- room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
483
- role = 'host'; clearTimeout(authTimer);
484
- if (oldHost && oldHost !== ws) {
485
- oldHost.close(4409, 'Computer connection replaced');
486
- for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer reconnecting');
487
- }
488
- for (const observer of room.observers.values()) {
489
- send(observer.ws, { type: 'observing', online: true });
490
- send(observer.ws, { type: 'computer-info', hostName: room.hostName });
491
- send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
492
- }
493
- const invite = room.clients.length ? {} : invitation(room);
494
- send(ws, { type: 'registered', protocolVersion: 7, room: room.id, paired: room.clients.length > 0,
495
- clientCount: room.clients.length, clients: room.clients, revision: room.revision, ...invite }); return;
496
- }
497
- room = rooms.get(message.room);
498
- if (!room || room.status !== 'active') return fail();
499
- let credential;
500
- if (message.type === 'pair' && !room.pairingInProgress && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
501
- if (!room.host) return fail(4410, 'Computer offline; authorization retained');
502
- credential = message.credential || secret();
503
- clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
504
- if (!validSecret(credential) || !validId(clientId)) return fail();
505
- const existing = room.clients.find(client => client.id === clientId);
506
- if (!existing && room.clients.length >= maxClientsPerHost) return fail(4429, 'Mobile device limit reached');
507
- const now = new Date().toISOString();
508
- const client = { id: clientId, hash: digest(credential), createdAt: existing?.createdAt || now, updatedAt: now };
509
- room.pairingInProgress = true;
510
- try {
511
- await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
512
- room.pairingHash = undefined;
513
- } finally { room.pairingInProgress = false; }
514
- if (!room.host) return fail(4410, 'Computer offline; authorization retained');
515
- send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
516
- } else {
517
- clientId = message.clientId;
518
- const client = room.clients.find(item => item.id === clientId) ||
519
- (!room.durable && !clientId ? room.clients.find(item => matches(message.credential, item.hash)) : undefined);
520
- if (client) clientId = client.id;
521
- if (!['resume', 'pair', 'observe'].includes(message.type) || !client || !matches(message.credential, client.hash)) {
522
- return fail(message.type === 'pair' ? 4408 : 4401, message.type === 'pair' ? 'Pairing code expired or used' : 'Device authorization rejected');
523
- }
524
- }
525
- if (ws.readyState !== ws.OPEN) return;
526
- if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
527
- if (message.type === 'observe') {
528
- role = 'observer'; clearTimeout(authTimer); room.observers ||= new Map();
529
- const oldObserver = room.observers.get(clientId);
530
- if (oldObserver?.ws && oldObserver.ws !== ws) oldObserver.ws.close(4409, 'Metadata connection replaced by same identity');
531
- room.observers.set(clientId, { ws, userId });
532
- send(ws, { type: 'observing', online: Boolean(room.host) });
533
- send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
534
- const announcement = adminState.read().announcement;
535
- if (announcement) send(ws, { type: 'announcement', announcement });
536
- if (room.hostName) send(ws, { type: 'computer-info', hostName: room.hostName });
537
- if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
538
- if (room.officialComputer) send(ws, { type: 'official-computer', computer: room.officialComputer });
539
- return;
540
- }
541
- if (!room.host) return fail(4410, 'Computer offline; authorization retained');
542
- const oldConnection = room.connections.get(clientId);
543
- role = 'client'; clearTimeout(authTimer); session = randomUUID();
544
- if (oldConnection?.ws && oldConnection.ws !== ws) {
545
- releaseRelay(oldConnection);
546
- send(room.host, { type: 'detach', session: oldConnection.session });
547
- oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
548
- }
549
- room.connections.set(clientId, { ws, session, p2pProbe: Boolean(userId) && room.p2pProbe && message.p2pProbe === 1,
550
- userId,
551
- repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
552
- relayAdmitted: false, relayExempt: false,
553
- capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
554
- send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
555
- send(ws, { type: 'computer-info', hostName: room.hostName });
556
- if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
557
- const announcement = adminState.read().announcement;
558
- if (announcement) send(ws, { type: 'announcement', announcement });
559
- send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
560
- }
561
- if (role === 'host') {
562
- if (room.host !== ws) return;
563
- if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
564
- if (message.type === 'usage-observer-lost') {
565
- for (const task of room.usageTasks.values()) {
566
- try { await meter.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
567
- }
568
- room.usageTasks.clear();
569
- return;
570
- }
571
- const armKey = `${message.session}:${message.requestId}`;
572
- const connection = [...room.connections.values()].find(item => item.session === message.session);
573
- if (message.type === 'usage-arm') {
574
- if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
575
- (message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
576
- diagnostics?.record('usage', 'arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
577
- return;
578
- }
579
- for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
580
- if (room.usageArms.size >= 256) return;
581
- room.usageArms.set(armKey, { userId: connection.userId,
582
- clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
583
- session: message.session, at: Date.now() });
584
- return;
585
- }
586
- if (message.type === 'usage-cancel') {
587
- room.usageArms.delete(armKey); return;
588
- }
589
- if (message.type === 'usage-message') {
590
- const arm = room.usageArms.get(armKey);
591
- room.usageArms.delete(armKey);
592
- if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) {
593
- diagnostics?.record('usage', 'message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
594
- return;
595
- }
596
- try {
597
- await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
598
- if (['start', 'steer'].includes(message.turnKind) &&
599
- plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
600
- const key = `${message.threadId}:${message.turnId}`;
601
- if (key.length <= 128) {
602
- const counted = await meter.action(arm, room.id, arm.clientId, 'turn', key);
603
- if (counted && message.turnKind === 'steer') diagnostics?.record('usage', 'turn-recovered', 'steer');
604
- if (!room.usageTasks.has(key) && message.observerReady === true && message.completed !== true &&
605
- (message.turnKind === 'start' || message.active === true)) {
606
- const task = await meter.turnOrigin(room.id, key);
607
- if (task) {
608
- await meter.pulse(task, true, room.id, task.clientId, counted ? key : randomUUID());
609
- room.usageTasks.set(key, task);
610
- }
611
- }
612
- }
613
- }
614
- } catch (error) { diagnostics?.record('usage', 'message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
615
- return;
616
- }
617
- if (!['turn/started', 'turn/completed', 'heartbeat', 'resume', 'inactive'].includes(message.method) ||
618
- !plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
619
- const key = `${message.threadId}:${message.turnId}`;
620
- if (key.length > 128) return;
621
- let task = room.usageTasks.get(key);
622
- try {
623
- if (message.method === 'resume' && !task) {
624
- task = await meter.turnOrigin(room.id, key);
625
- if (!task) { diagnostics?.record('usage', 'turn-resume', 'origin-missing'); return; }
626
- await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
627
- diagnostics?.record('usage', 'turn-resume', 'confirmed');
628
- room.usageTasks.set(key, task);
629
- } else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
630
- else if (task && message.method === 'inactive') {
631
- await meter.lost(task);
632
- room.usageTasks.delete(key);
633
- }
634
- else if (task && message.method === 'turn/completed') {
635
- await meter.pulse(task, false, room.id, task.clientId);
636
- room.usageTasks.delete(key);
637
- }
638
- } catch (error) { log({ event: 'usage-error', message: error.message }); }
639
- return;
640
- }
641
- if (message.type === 'lan-candidates') {
642
- room.lanCandidates = normalizeLanCandidates(message.candidates);
643
- for (const connection of room.connections.values()) send(connection.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
644
- for (const observer of room.observers.values()) send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
645
- return;
646
- }
647
- if (message.type === 'status') {
648
- send(ws, { type: 'status', commandId: message.commandId, paired: room.clients.length > 0, clientCount: room.clients.length, revision: room.revision }); return;
649
- }
650
- if (message.type === 'pair-code') {
651
- send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
652
- }
653
- if (message.type === 'revoke') {
654
- await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
655
- room.pairingHash = undefined;
656
- for (const connection of room.connections.values()) {
657
- releaseRelay(connection);
658
- send(ws, { type: 'detach', session: connection.session });
659
- connection.ws.close(4401, 'Device authorization revoked');
660
- }
661
- room.connections.clear();
662
- for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
663
- room.uploadClients?.clear();
664
- for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
665
- room.observers.clear();
666
- send(ws, { type: 'authorization-changed', clients: [], revision: room.revision });
667
- send(ws, { type: 'revoked', commandId: message.commandId, room: room.id }); return;
668
- }
669
- const connection = [...room.connections.values()].find(item => item.session === message.session);
670
- if (!connection) return; // Late frames from an ended session.
671
- if (message.type === 'data' && typeof message.payload === 'string') queueClientMessage(connection, { type: 'data', payload: message.payload });
672
- else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
673
- p2pProbeRepeat: connection.p2pProbe && connection.repeat });
674
- else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
675
- const diagnostic = message.purpose === 'diagnostic';
676
- if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
677
- if ((diagnostic ? connection.diagnosticProbeId : connection.probeId) !== message.probeId) return;
678
- if (++connection[diagnostic ? 'diagnosticSignals' : 'signals'] > 150) return fail(1008, 'Probe signal limit');
679
- send(connection.ws, { ...message, session: undefined });
680
- }
681
- else if (message.type === 'detach') connection.ws.close(1011, 'Local Bridge unavailable');
682
- else fail();
683
- } else if (role === 'file-host' && room.uploadHost === ws) {
684
- const client = [...room.uploadClients.values()].find(item => item.session === message.session);
685
- if (!client) return;
686
- if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
687
- if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
688
- send(client.ws, { type: 'file-frame', payload: message.payload });
689
- } else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
690
- else fail(1008, 'Invalid file frame');
691
- } else if (role === 'file-client' && room.uploadClients?.get(clientId)?.ws === ws) {
692
- if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
693
- if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
694
- send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
695
- } else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
696
- const computer = normalizeOfficialComputer(message);
697
- await commit(room, { officialComputer: computer });
698
- send(ws, { type: 'official-computer-saved', computer });
699
- } else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
700
- const connection = room.connections.get(clientId);
701
- if (!connection.p2pProbe || !validProbeSignal(message)) return fail(1008, 'Invalid probe signal');
702
- const diagnostic = message.purpose === 'diagnostic';
703
- if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
704
- const idField = diagnostic ? 'diagnosticProbeId' : 'probeId';
705
- const signalsField = diagnostic ? 'diagnosticSignals' : 'signals';
706
- const startedField = diagnostic ? 'diagnosticProbeStarted' : 'probeStarted';
707
- if (message.kind === 'start') {
708
- if (connection[idField] && (!connection.repeat || Date.now() - connection[startedField] < 2000)) {
709
- send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
710
- ...(diagnostic ? { purpose: 'diagnostic' } : {}), kind: 'close' }); return;
711
- }
712
- connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
713
- }
714
- if (connection[idField] !== message.probeId) return;
715
- if (++connection[signalsField] > 150) return fail(1008, 'Probe signal limit');
716
- send(room.host, { ...message, session });
717
- if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
718
- } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
719
- releaseRelay(room.connections.get(clientId));
720
- } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
721
- const connection = room.connections.get(clientId);
722
- if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
723
- if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
724
- ws.close(4428, 'Server busy'); return;
725
- }
726
- send(room.host, { type: 'data', session, payload: message.payload });
727
- } else fail();
728
- } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
729
- finally { pendingMessages--; pendingBytes -= size; }
730
- }).catch(() => ws.close(1011, 'Relay operation failed'));
731
- });
732
- });
733
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
734
- listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
735
- return {
736
- url: listenUrl, hostKey,
737
- async close() {
738
- for (const ws of wss.clients) ws.terminate();
739
- await new Promise(resolve => wss.close(resolve));
740
- await new Promise(resolve => server.close(resolve));
741
- if (database) await database.close();
742
- },
743
- };
744
- }
745
-
746
- function relayPublicAddress(value) {
747
- const parsed = new URL(value);
748
- if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.pathname !== '/relay' || parsed.search || parsed.hash || parsed.username || parsed.password) {
749
- throw new Error('Invalid public relay URL');
750
- }
751
- return parsed.href;
752
- }
753
-
754
- function pairingHeaders(type, body) {
755
- return { 'Content-Type': type, 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store',
756
- 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
757
- 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'" };
758
- }
759
-
760
- function pairingPage({ deepLink = '', expires = 0, appDownloadUrl = '', expired = false }) {
761
- const open = deepLink ? `<a class="primary" href="${escapeHtml(deepLink)}">打开 Code Relax</a>` : '';
762
- const download = appDownloadUrl ? `<a href="${escapeHtml(appDownloadUrl)}">安装 Code Relax</a>` : '<p>尚未安装时,请通过当前私测渠道安装 App,再重新打开此页面。</p>';
763
- const text = expired ? '这条设备配对邀请已过期或已经使用,请在电脑重新生成。'
764
- : `这是一次性设备配对邀请,有效期至 ${escapeHtml(new Date(expires).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }))}。每台移动设备只需配对一次。`;
765
- return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Code Relax 配对</title><style>body{margin:0;background:#0d1210;color:#eef4f0;font:16px system-ui,sans-serif}main{max-width:520px;margin:12vh auto;padding:32px}h1{font-size:28px}p{color:#aab7af;line-height:1.7}a{display:block;margin-top:16px;padding:14px 18px;border:1px solid #496257;border-radius:12px;color:#dce9e1;text-align:center;text-decoration:none}.primary{background:#1f5a40;border-color:#367c5b}</style><main><h1>Code Relax</h1><p>${text}</p>${expired ? '' : open + download}</main></html>`;
766
- }
767
-
768
- function escapeHtml(value) {
769
- return String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
770
- }
771
-
772
- function normalizeLanCandidates(value) {
773
- if (value === undefined) return [];
774
- if (!Array.isArray(value) || value.length > 8) throw new Error('Invalid LAN candidates');
775
- return value.map(candidate => {
776
- const endpoint = new URL(candidate?.address || '');
777
- if (endpoint.protocol !== 'http:' || !endpoint.port || endpoint.pathname !== '/' || endpoint.search || endpoint.hash ||
778
- endpoint.username || endpoint.password || !privateIpv4(endpoint.hostname) ||
779
- !['wifi', 'ethernet'].includes(candidate.networkType) || !plainLabel(candidate.networkName) || !plainLabel(candidate.interfaceName)) {
780
- throw new Error('Invalid LAN candidate');
781
- }
782
- return { address: endpoint.href.replace(/\/$/, ''), networkType: candidate.networkType,
783
- networkName: candidate.networkName.trim(), interfaceName: candidate.interfaceName.trim() };
784
- });
785
- }
786
-
787
- function normalizeHostName(value) {
788
- if (!plainLabel(value)) throw new Error('Invalid host name');
789
- return value.trim();
790
- }
791
-
792
- function normalizeOfficialComputer(value) {
793
- if (!plainText(value.environmentId, 256, false) || !plainText(value.name ?? '', 80, true) ||
794
- !plainText(value.hostname ?? '', 80, true)) throw new Error('Invalid official computer');
795
- return { environmentId: value.environmentId.trim(), name: (value.name || '').trim(),
796
- hostname: (value.hostname || '').trim(), updatedAt: new Date().toISOString() };
797
- }
798
-
799
- function privateIpv4(value) {
800
- const parts = value.split('.').map(Number);
801
- if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return false;
802
- return parts[0] === 10 || (parts[0] === 192 && parts[1] === 168) ||
803
- (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
804
- (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127);
805
- }
806
-
807
- function plainLabel(value) {
808
- return typeof value === 'string' && value.trim().length > 0 && value.trim().length <= 80 && !/[\u0000-\u001f\u007f]/.test(value);
809
- }
810
-
811
-
812
- function plainText(value, limit, empty) {
813
- return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
814
- }
815
-
816
- // Device-authorized management must not become an anonymous chat tunnel.
817
- function managementPayload(value) {
818
- try {
819
- const message = JSON.parse(value);
820
- if (['unsubscribe', 'cancel', 'response-ack'].includes(message.type)) return true;
821
- if (message.type === 'subscribe') return message.path === '/api/shared-recovery/events';
822
- if (message.type !== 'request') return false;
823
- const method = message.method || 'GET';
824
- if (method === 'GET') return ['/api/health', '/api/official-remote/status'].includes(message.path) ||
825
- /^\/api\/threads\/[0-9a-f-]{28,64}\/model-changes(?:\?|$)/i.test(message.path);
826
- return method === 'POST' && message.path === '/api/official-remote/pairing';
827
- } catch { return false; }
828
- }
829
-
830
- function controlPayload(value) {
831
- try {
832
- const message = JSON.parse(value);
833
- if (message.type === 'unsubscribe') return true;
834
- return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
835
- (message.path === '/api/health' || message.path.startsWith('/api/shared-recovery'));
836
- } catch { return false; }
837
- }
838
-
839
- function readBody(request, limit) {
840
- return new Promise((resolve, reject) => {
841
- let value = '';
842
- request.setEncoding('utf8');
843
- request.on('data', chunk => {
844
- value += chunk;
845
- if (Buffer.byteLength(value) > limit) { reject(new Error('Admin request too large')); request.destroy(); }
846
- });
847
- request.on('end', () => resolve(value)); request.on('error', reject);
848
- });
849
- }
1
+ import { privacyPage } from './privacy-page.mjs';
2
+ import http from 'node:http';
3
+ import fs from 'node:fs';
4
+ import { isIP } from 'node:net';
5
+ import { deviceStore, validId } from './device-store.mjs';
6
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
7
+ import { WebSocketServer } from 'ws';
8
+ import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
9
+ import { routedFileBytes, parseFileBytes } from './file-wire.mjs';
10
+ import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
11
+ import { validProbeSignal } from './p2p-probe.mjs';
12
+ import { MAX_ADMIN_REQUEST_BYTES, relayAdminState } from './admin-state.mjs';
13
+ import { openRelayMysql } from './mysql-state.mjs';
14
+ import { openEmailAccounts } from './email-accounts.mjs';
15
+ import { accountConnections } from './account-connections.mjs';
16
+ import { usageMeter, usageReport } from './usage-meter.mjs';
17
+ import { relayDiagnostics } from './diagnostics.mjs';
18
+
19
+ // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
20
+ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
21
+ maxConnections = 128, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
22
+ maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
23
+ publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
24
+ if (!validSecret(hostKey)) throw new Error('Invalid host credential');
25
+ if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
26
+ if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
27
+ if (publicRelayUrl) relayPublicAddress(publicRelayUrl);
28
+ // Harmony's native WebSocket supplies the endpoint Origin automatically.
29
+ // libwebsockets can use http even for WSS; trust only the configured endpoint.
30
+ const nativeOrigins = publicRelayUrl ? ['http:', 'https:'].map(protocol => `${protocol}//${new URL(publicRelayUrl).host}`) : [];
31
+ if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
32
+ const hostHash = digest(hostKey), rooms = new Map();
33
+ const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
34
+ if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
35
+ const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
36
+ const accountSockets = accountConnections();
37
+ const meter = accounts ? usageMeter(database.pool) : null;
38
+ const cancelledAccountIds = new Set();
39
+ const accountCancellationWork = new Map();
40
+ const diagnostics = database?.schemaVersions.includes('006_relay_diagnostics')
41
+ ? relayDiagnostics(database.pool, error => log({ event: 'relay-diagnostic-error', message: error.message })) : null;
42
+ const adminAccessFile = '/etc/codex-relay/admin-access.json';
43
+ const adminAccessKey = fs.existsSync(adminAccessFile) ? JSON.parse(fs.readFileSync(adminAccessFile, 'utf8')).key : '';
44
+ if (adminAccessKey && !validSecret(adminAccessKey)) throw new Error('Invalid admin access key');
45
+ const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
46
+ if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
47
+ const store = database?.devices || deviceStore(stateFile);
48
+ const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
49
+ const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
50
+ let activeRelaySessions = 0;
51
+ for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
52
+ let commitTail = Promise.resolve();
53
+ function queueStore(operation) {
54
+ const pending = commitTail.then(operation);
55
+ commitTail = pending.catch(() => {});
56
+ return pending;
57
+ }
58
+ function commit(room, changes) {
59
+ return queueStore(async () => {
60
+ changes = typeof changes === 'function' ? changes(room) : changes;
61
+ const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
62
+ if (room.durable) {
63
+ const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
64
+ await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
65
+ }
66
+ Object.assign(room, changes, { updatedAt: next.updatedAt });
67
+ });
68
+ }
69
+ function registerRoom(id, durable, credential) {
70
+ return queueStore(async () => {
71
+ // Resolve concurrent first connections before writing the device identity.
72
+ if (durable && rooms.has(id)) return rooms.get(id);
73
+ const now = new Date().toISOString();
74
+ const record = { id, revision: 0, status: 'active', clients: [], createdAt: now, updatedAt: now,
75
+ ...(durable ? { hostHash: digest(credential) } : {}) };
76
+ if (durable) await store.save(record);
77
+ const room = { ...record, durable, connections: new Map(), observers: new Map(), uploadClients: new Map() };
78
+ rooms.set(id, room);
79
+ return room;
80
+ });
81
+ }
82
+ function invitation(room) {
83
+ const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
84
+ return { code, expires: room.expires };
85
+ }
86
+ let listenUrl = '';
87
+ let adminTail = Promise.resolve();
88
+ function relayUrl(request) {
89
+ if (publicRelayUrl) return relayPublicAddress(publicRelayUrl);
90
+ const host = request.headers.host || new URL(listenUrl).host;
91
+ const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
92
+ return `${secure ? 'wss' : 'ws'}://${host}/relay`;
93
+ }
94
+ function findInvitation(code) {
95
+ if (!validSecret(code)) return undefined;
96
+ return [...rooms.values()].find(room => room.status === 'active' && room.pairingHash && Date.now() < room.expires && matches(code, room.pairingHash));
97
+ }
98
+ const server = http.createServer((request, response) => {
99
+ const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
100
+ if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
101
+ adminTail = adminTail.then(() => handleAdmin(request, response, target)).catch(() => {
102
+ if (!response.headersSent) adminReply(response, 500, { error: 'Admin operation failed' });
103
+ });
104
+ return;
105
+ }
106
+ if (target.pathname === '/account/privacy' && request.method === 'GET') {
107
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache',
108
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
109
+ 'X-Content-Type-Options': 'nosniff' });
110
+ response.end(privacyPage); return;
111
+ }
112
+ if (accounts && target.pathname.startsWith('/account/')) {
113
+ void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
114
+ return;
115
+ }
116
+ if (request.method === 'GET' && target.pathname === '/announcement') {
117
+ const body = JSON.stringify({ announcement: adminState.read().announcement });
118
+ response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
119
+ }
120
+ if (request.method !== 'GET' || !['/pair', '/pair/resolve'].includes(target.pathname)) {
121
+ response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
122
+ }
123
+ const code = target.searchParams.get('invite') || '';
124
+ const room = findInvitation(code);
125
+ if (!room) {
126
+ const json = target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '');
127
+ const body = json ? JSON.stringify({ error: '配对邀请已过期或已使用' }) : pairingPage({ expired: true });
128
+ response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
129
+ }
130
+ if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
131
+ const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
132
+ response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
133
+ }
134
+ const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
135
+ const current = new URL(target.pathname + target.search, `${secure ? 'https' : 'http'}://${request.headers.host || new URL(listenUrl).host}`);
136
+ const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: room.expires, appDownloadUrl });
137
+ response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
138
+ });
139
+ function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
140
+ const value = typeof body === 'string' ? body : JSON.stringify(body);
141
+ response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store',
142
+ 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
143
+ response.end(value);
144
+ }
145
+ function accountReply(response, status, body) {
146
+ if (response.headersSent || response.destroyed) return;
147
+ const value = JSON.stringify(body);
148
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(value),
149
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
150
+ response.end(value);
151
+ }
152
+ function cancelAccountConnections(userId, token) {
153
+ const pending = accountCancellationWork.get(userId);
154
+ if (pending) return pending;
155
+ if (cancelledAccountIds.has(userId)) return Promise.resolve();
156
+ cancelledAccountIds.add(userId);
157
+ const work = (async () => {
158
+ try { await accounts.cancelAccount(token); }
159
+ catch (failure) { cancelledAccountIds.delete(userId); throw failure; }
160
+ for (const room of rooms.values()) {
161
+ for (const [key, arm] of room.usageArms || []) if (arm.userId === userId) room.usageArms.delete(key);
162
+ for (const [key, task] of room.usageTasks || []) if (task.userId === userId) room.usageTasks.delete(key);
163
+ for (const [clientId, connection] of room.connections) {
164
+ if (connection.userId !== userId) continue;
165
+ releaseRelay(connection); room.connections.delete(clientId);
166
+ if (room.host) send(room.host, { type: 'detach', session: connection.session });
167
+ connection.ws.close(4431, 'Account cancelled');
168
+ }
169
+ for (const [clientId, observer] of room.observers) {
170
+ if (observer.userId !== userId) continue;
171
+ room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
172
+ }
173
+ for (const [clientId, upload] of room.uploadClients) {
174
+ if (upload.userId !== userId) continue;
175
+ room.uploadClients.delete(clientId);
176
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
177
+ upload.ws.close(4431, 'Account cancelled');
178
+ }
179
+ }
180
+ })().finally(() => accountCancellationWork.delete(userId));
181
+ accountCancellationWork.set(userId, work);
182
+ return work;
183
+ }
184
+ async function handleAccount(request, response, target) {
185
+ try {
186
+ if (target.pathname === '/account/config' && request.method === 'GET') {
187
+ accountReply(response, 200, { captchaAppId: accounts.captchaAppId }); return;
188
+ }
189
+ const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
190
+ if (target.pathname === '/account/me' && request.method === 'GET') {
191
+ const user = await accounts.current(bearer);
192
+ accountReply(response, 200, { user, usage: await accounts.usage(user.id) }); return;
193
+ }
194
+ if (target.pathname === '/account/logout' && request.method === 'POST') {
195
+ const result = await accounts.logout(bearer);
196
+ accountSockets.revoke(bearer);
197
+ accountReply(response, 200, result); return;
198
+ }
199
+ if (target.pathname === '/account/me' && request.method === 'DELETE') {
200
+ const user = await accounts.current(bearer);
201
+ await cancelAccountConnections(user.id, bearer);
202
+ accountReply(response, 200, { ok: true }); return;
203
+ }
204
+ if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
205
+ accountReply(response, 404, { error: 'Account route not found' }); return;
206
+ }
207
+ const body = JSON.parse(await readBody(request, 4096));
208
+ if (!body || typeof body !== 'object' || Array.isArray(body)) { accountReply(response, 400, { error: 'Invalid request body' }); return; }
209
+ if (target.pathname === '/account/email/request') {
210
+ const ip = request.headers['x-real-ip'] || request.socket.remoteAddress || '';
211
+ if (typeof ip !== 'string' || !isIP(ip)) { accountReply(response, 400, { error: 'Invalid client address' }); return; }
212
+ accountReply(response, 202, await accounts.requestCode(body, ip));
213
+ } else if (target.pathname === '/account/email/verify') {
214
+ accountReply(response, 200, await accounts.verifyCode(body));
215
+ } else accountReply(response, 404, { error: 'Account route not found' });
216
+ } catch (failure) {
217
+ accountReply(response, failure.status || 503, { error: failure.status ? failure.message : 'Account service unavailable' });
218
+ }
219
+ }
220
+ async function handleAdmin(request, response, target) {
221
+ const accessKey = request.headers['x-code-relax-admin-key'];
222
+ const internalAccess = adminAccessKey && validSecret(accessKey) &&
223
+ timingSafeEqual(Buffer.from(accessKey), Buffer.from(adminAccessKey));
224
+ const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
225
+ const diagnosticRoute = ['/admin/api/usage-diagnostics', '/admin/api/usage-diagnostics/drain'].includes(target.pathname);
226
+ const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
227
+ timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
228
+ if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
229
+ if (request.method === 'GET' && target.pathname === '/admin/api/status') {
230
+ const state = adminState.read();
231
+ adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
232
+ activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
233
+ websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
234
+ activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
235
+ processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
236
+ announcement: state.announcement, announcements: state.announcements }); return;
237
+ }
238
+ if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
239
+ const page = Number(target.searchParams.get('page') || '1');
240
+ const view = target.searchParams.get('view') || 'user';
241
+ if (!Number.isSafeInteger(page) || page < 1 || page > 100000 ||
242
+ !['user', 'computer'].includes(view) || target.searchParams.has('user') ||
243
+ target.searchParams.has('computer')) {
244
+ adminReply(response, 400, { error: 'Invalid usage query' }); return;
245
+ }
246
+ adminReply(response, 200, await usageReport(database.pool, page, view)); return;
247
+ }
248
+ const diagnosticSource = target.searchParams.get('source') || 'usage';
249
+ if (target.pathname.startsWith('/admin/api/usage-diagnostics') && !['usage', 'account-cleanup'].includes(diagnosticSource)) {
250
+ adminReply(response, 400, { error: 'Invalid diagnostic source' }); return;
251
+ }
252
+ if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
253
+ if (request.method === 'GET') adminReply(response, 200, await diagnostics.read(diagnosticSource));
254
+ else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear(diagnosticSource));
255
+ else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
256
+ return;
257
+ }
258
+ if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
259
+ if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain(diagnosticSource));
260
+ else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
261
+ return;
262
+ }
263
+ if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
264
+ request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
265
+ try {
266
+ const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, MAX_ADMIN_REQUEST_BYTES));
267
+ if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
268
+ const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
269
+ log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
270
+ } else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
271
+ const state = await adminState.createAnnouncement(body.title, body.message, body.publish === true);
272
+ if (body.publish === true) {
273
+ broadcastAnnouncement(state.announcement);
274
+ log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
275
+ } else log({ event: 'admin-announcement-draft-created', announcementId: state.announcements[0].id });
276
+ } else if (target.pathname.startsWith('/admin/api/announcements/')) {
277
+ const parts = target.pathname.slice('/admin/api/announcements/'.length).split('/');
278
+ const id = parts[0];
279
+ if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
280
+ if (parts.length === 1 && request.method === 'PUT') {
281
+ await adminState.updateDraft(id, body.title, body.message);
282
+ log({ event: 'admin-announcement-draft-updated', announcementId: id });
283
+ } else if (parts.length === 1 && request.method === 'DELETE') {
284
+ await adminState.deleteAnnouncement(id);
285
+ log({ event: 'admin-announcement-deleted', announcementId: id });
286
+ } else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
287
+ const state = await adminState.publishDraft(id, body.title, body.message);
288
+ broadcastAnnouncement(state.announcement);
289
+ log({ event: 'admin-announcement-published', announcementId: id });
290
+ } else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
291
+ const state = await adminState.withdraw(id);
292
+ broadcastAnnouncement(state.announcement);
293
+ log({ event: 'admin-announcement-withdrawn', announcementId: id });
294
+ } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
295
+ } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
296
+ adminReply(response, 200, { ok: true });
297
+ } catch (error) { adminReply(response, 400, { error: error.message }); }
298
+ }
299
+ function broadcastAnnouncement(announcement) {
300
+ for (const room of rooms.values()) {
301
+ for (const observer of room.observers.values()) send(observer.ws, { type: 'announcement', announcement });
302
+ for (const connection of room.connections.values()) send(connection.ws, { type: 'announcement', announcement });
303
+ }
304
+ }
305
+ function releaseRelay(connection) {
306
+ if (!connection?.relayAdmitted) return;
307
+ connection.relayAdmitted = false; activeRelaySessions = Math.max(0, activeRelaySessions - 1);
308
+ }
309
+ function admitRelay(connection, room) {
310
+ if (connection.relayAdmitted || connection.relayExempt) return true;
311
+ if (connection.capacityExempt) {
312
+ connection.relayExempt = true;
313
+ log({ event: 'relay-capacity-exempt', device: room.id }); return true;
314
+ }
315
+ const limit = adminState.read().maxRelaySessions;
316
+ if (activeRelaySessions >= limit) {
317
+ log({ event: 'relay-capacity-rejected', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return false;
318
+ }
319
+ connection.relayAdmitted = true; activeRelaySessions++;
320
+ log({ event: 'relay-capacity-admitted', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return true;
321
+ }
322
+ const wss = new WebSocketServer({ noServer: true, maxPayload, perMessageDeflate: {
323
+ serverNoContextTakeover: true, clientNoContextTakeover: true,
324
+ threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
325
+ } });
326
+ let admissionStart = Date.now(), admissions = 0;
327
+ server.on('upgrade', (request, socket, head) => {
328
+ if (request.url !== '/relay' || (request.headers.origin && request.headers.origin !== 'null' && !nativeOrigins.includes(request.headers.origin))) {
329
+ log({ event: 'upgrade-rejected', reason: request.url !== '/relay' ? 'path' : 'origin' });
330
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
331
+ }
332
+ if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
333
+ if (wss.clients.size >= maxConnections || ++admissions > 240) {
334
+ socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
335
+ }
336
+ wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
337
+ });
338
+ wss.on('connection', ws => {
339
+ let role, room, session, clientId, alive = true;
340
+ let accountConnection;
341
+ function invalidateAccountConnection() {
342
+ if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
343
+ releaseRelay(room.connections.get(clientId));
344
+ room.connections.delete(clientId);
345
+ // Detach also closes the workstation RTC peer; closing only the
346
+ // phone's signaling socket would leave the direct data path alive.
347
+ if (room.host) send(room.host, { type: 'detach', session });
348
+ } else if (role === 'file-client' && room?.uploadClients.get(session)?.ws === ws) {
349
+ room.uploadClients.delete(session);
350
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
351
+ }
352
+ ws.close(4431, 'Account session invalid');
353
+ }
354
+ const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
355
+ ws.on('pong', () => { alive = true; });
356
+ const heartbeat = setInterval(() => {
357
+ sampleInboundQueue();
358
+ if (!alive) ws.terminate(); else { alive = false; ws.ping(); }
359
+ }, 30000);
360
+ const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
361
+ const queueClientMessage = (connection, message, pause = 0) => {
362
+ const bytes = Buffer.byteLength(JSON.stringify(message));
363
+ connection.queuedBytes = (connection.queuedBytes || 0) + bytes;
364
+ if (connection.queuedBytes > 40 * 1024 * 1024) {
365
+ connection.ws.close(1013, 'Receiver too slow'); return;
366
+ }
367
+ connection.outbound = (connection.outbound || Promise.resolve()).then(async () => {
368
+ if (pause > 0) await new Promise(resolve => setTimeout(resolve, pause));
369
+ connection.queuedBytes -= bytes;
370
+ send(connection.ws, message);
371
+ }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
372
+ };
373
+ ws.on('error', () => {}); // close owns routing cleanup.
374
+ ws.on('close', (code, reason) => {
375
+ log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
376
+ clearTimeout(authTimer); clearInterval(heartbeat);
377
+ accountConnection?.dispose();
378
+ if (role === 'host' && room?.host === ws) {
379
+ for (const task of room.usageTasks?.values() || []) {
380
+ void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
381
+ }
382
+ room.usageTasks?.clear(); room.usageArms?.clear();
383
+ room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
384
+ room.uploadHost?.close(4410, 'Computer offline');
385
+ for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline');
386
+ for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
387
+ if (!room.durable) rooms.delete(room.id);
388
+ for (const connection of room.connections.values()) connection.ws.close(
389
+ code === 4432 ? 4432 : 4410,
390
+ code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline; authorization retained');
391
+ } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
392
+ const connection = room.connections.get(clientId);
393
+ releaseRelay(connection);
394
+ room.connections.delete(clientId);
395
+ if (room.host) send(room.host, { type: 'detach', session });
396
+ } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
397
+ room.observers.delete(clientId);
398
+ } else if (role === 'file-host' && room?.uploadHost === ws) {
399
+ room.uploadHost = undefined;
400
+ for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'File receiver inbound queue limit exceeded' : 'File receiver offline');
401
+ } else if (role === 'file-client' && room?.uploadClients?.get(session)?.ws === ws) {
402
+ room.uploadClients.delete(session);
403
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
404
+ }
405
+ });
406
+ let messageTail = Promise.resolve();
407
+ // Include the in-flight message: async database work must not permit an
408
+ // unbounded chain of closures retaining incoming payloads.
409
+ let pendingMessages = 0, pendingBytes = 0;
410
+ const maxPendingMessages = 128, maxPendingBytes = 16 * 1024 * 1024;
411
+ let sampleStarted = Date.now(), peakMessages = 0, peakBytes = 0, maxWaitMs = 0, receivedMessages = 0;
412
+ function sampleInboundQueue() {
413
+ const now = Date.now();
414
+ if (now - sampleStarted < 60000) return;
415
+ // Summarize only intervals with an actual backlog; never log payloads or credentials.
416
+ if (peakMessages > 1) log({ event: 'inbound-queue-sample', role: role || 'unauthenticated',
417
+ windowMs: now - sampleStarted, receivedMessages, peakMessages, peakBytes, maxWaitMs,
418
+ pendingMessages, pendingBytes });
419
+ sampleStarted = now; peakMessages = pendingMessages; peakBytes = pendingBytes;
420
+ maxWaitMs = 0; receivedMessages = 0;
421
+ }
422
+ ws.on('message', (bytes, binary) => {
423
+ if (ws.readyState !== ws.OPEN) return;
424
+ const size = bytes.length;
425
+ if (pendingMessages >= maxPendingMessages || pendingBytes + size > maxPendingBytes) {
426
+ log({ event: 'inbound-queue-limit', role: role || 'unauthenticated',
427
+ pendingMessages, pendingBytes, incomingBytes: size, maxWaitMs });
428
+ ws.close(4432, 'Inbound queue limit exceeded');
429
+ return;
430
+ }
431
+ pendingMessages++; pendingBytes += size;
432
+ receivedMessages++;
433
+ peakMessages = Math.max(peakMessages, pendingMessages); peakBytes = Math.max(peakBytes, pendingBytes);
434
+ const enqueuedAt = Date.now();
435
+ messageTail = messageTail.then(async () => {
436
+ try {
437
+ maxWaitMs = Math.max(maxWaitMs, Date.now() - enqueuedAt);
438
+ if (ws.readyState !== ws.OPEN) return;
439
+ if (accountConnection && !accountConnection.valid()) return;
440
+ if (binary) {
441
+ if (role === 'file-host' && room.uploadHost === ws) {
442
+ const frame = parseFileBytes(bytes);
443
+ const client = room.uploadClients.get(frame.session);
444
+ if (!client || client.ws.readyState !== ws.OPEN) return;
445
+ if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
446
+ client.ws.send(frame.data); return;
447
+ }
448
+ if (role === 'file-client' && room.uploadClients?.get(session)?.ws === ws) {
449
+ if (room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
450
+ if (room.uploadHost.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
451
+ room.uploadHost.send(routedFileBytes(session, bytes)); return;
452
+ }
453
+ return fail(1008, 'Invalid binary frame');
454
+ }
455
+ const message = decode(bytes, binary);
456
+ if (!role) {
457
+ let userId;
458
+ if (!['host', 'file-host', 'observe'].includes(message.type) &&
459
+ !(['pair', 'resume'].includes(message.type) && message.management === true)) {
460
+ if (!['pair', 'resume', 'observe', 'file-client'].includes(message.type)) return fail();
461
+ if (!accounts || !validSecret(message.accountToken)) return fail(4431, 'Account login required');
462
+ accountConnection = accountSockets.track(message.accountToken, invalidateAccountConnection);
463
+ try {
464
+ const user = await accounts.current(message.accountToken);
465
+ if (!accountConnection.activate(user.expiresAt)) return;
466
+ userId = user.id;
467
+ }
468
+ catch { return fail(4431, 'Account session invalid'); }
469
+ if (ws.readyState !== ws.OPEN) return;
470
+ if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
471
+ }
472
+ if (message.type === 'file-host') {
473
+ room = rooms.get(message.deviceId);
474
+ if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
475
+ role = 'file-host'; clearTimeout(authTimer);
476
+ room.uploadClients ||= new Map();
477
+ room.uploadHost?.close(4409, 'File receiver replaced');
478
+ room.uploadHost = ws;
479
+ send(ws, { type: 'file-host-ready' }); return;
480
+ }
481
+ if (message.type === 'file-client') {
482
+ room = rooms.get(message.room);
483
+ const client = room?.clients.find(item => item.id === message.clientId);
484
+ if (!room?.host || !room.uploadHost || room.status !== 'active' || !client ||
485
+ !matches(message.credential, client.hash)) return fail();
486
+ room.uploadClients ||= new Map();
487
+ if (!['upload', 'download'].includes(message.purpose)) return fail(1008, 'Invalid file purpose');
488
+ const same = [...room.uploadClients.values()].filter(item => item.clientId === message.clientId && item.purpose === message.purpose);
489
+ const all = [...rooms.values()].flatMap(item => [...(item.uploadClients?.values() || [])]);
490
+ if (same.length >= (message.purpose === 'upload' ? 1 : 3) ||
491
+ all.length >= 8 || (message.purpose === 'upload' && all.filter(item => item.purpose === 'upload').length >= 2)) {
492
+ return fail(4429, 'Too many file transfers');
493
+ }
494
+ role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
495
+ room.uploadClients.set(session, { ws, session, clientId, userId, purpose: message.purpose });
496
+ send(room.uploadHost, { type: 'file-open', session, purpose: message.purpose }); return;
497
+ }
498
+ if (message.type === 'host') {
499
+ const durable = message.deviceId !== undefined;
500
+ if (durable && (!validId(message.deviceId) || !validSecret(message.deviceSecret))) return fail();
501
+ if (!durable && stateFile) return fail(4406, 'Device protocol upgrade required');
502
+ let lanCandidates, hostName;
503
+ try {
504
+ lanCandidates = normalizeLanCandidates(message.lanCandidates);
505
+ hostName = normalizeHostName(message.hostName);
506
+ }
507
+ catch { return fail(1008, 'Invalid LAN candidates'); }
508
+ room = durable ? rooms.get(message.deviceId) : undefined;
509
+ if (!room) {
510
+ if (!durable && !matches(message.key, hostHash)) return fail();
511
+ room = await registerRoom(durable ? message.deviceId : randomUUID(), durable, message.deviceSecret);
512
+ if (ws.readyState !== ws.OPEN) return;
513
+ }
514
+ if (durable && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
515
+ const oldHost = room.host;
516
+ room.observers ||= new Map();
517
+ if (oldHost && oldHost !== ws) {
518
+ for (const task of room.usageTasks?.values() || []) {
519
+ try { await meter?.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
520
+ }
521
+ room.usageTasks?.clear(); room.usageArms?.clear();
522
+ }
523
+ room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
524
+ room.usageTasks ||= new Map(); room.usageArms ||= new Map();
525
+ if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
526
+ room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
527
+ role = 'host'; clearTimeout(authTimer);
528
+ if (oldHost && oldHost !== ws) {
529
+ oldHost.close(4409, 'Computer connection replaced');
530
+ for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer reconnecting');
531
+ }
532
+ for (const observer of room.observers.values()) {
533
+ send(observer.ws, { type: 'observing', online: true });
534
+ send(observer.ws, { type: 'computer-info', hostName: room.hostName });
535
+ send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
536
+ }
537
+ const invite = room.clients.length ? {} : invitation(room);
538
+ send(ws, { type: 'registered', protocolVersion: 7, room: room.id, paired: room.clients.length > 0,
539
+ clientCount: room.clients.length, clients: room.clients, revision: room.revision, ...invite }); return;
540
+ }
541
+ room = rooms.get(message.room);
542
+ if (!room || room.status !== 'active') return fail();
543
+ let credential;
544
+ if (message.type === 'pair' && !room.pairingInProgress && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
545
+ if (!room.host) return fail(4410, 'Computer offline; authorization retained');
546
+ credential = message.credential || secret();
547
+ clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
548
+ if (!validSecret(credential) || !validId(clientId)) return fail();
549
+ const existing = room.clients.find(client => client.id === clientId);
550
+ if (!existing && room.clients.length >= maxClientsPerHost) return fail(4429, 'Mobile device limit reached');
551
+ const now = new Date().toISOString();
552
+ const client = { id: clientId, hash: digest(credential), createdAt: existing?.createdAt || now, updatedAt: now };
553
+ room.pairingInProgress = true;
554
+ try {
555
+ await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
556
+ room.pairingHash = undefined;
557
+ } finally { room.pairingInProgress = false; }
558
+ if (!room.host) return fail(4410, 'Computer offline; authorization retained');
559
+ send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
560
+ } else {
561
+ clientId = message.clientId;
562
+ const client = room.clients.find(item => item.id === clientId) ||
563
+ (!room.durable && !clientId ? room.clients.find(item => matches(message.credential, item.hash)) : undefined);
564
+ if (client) clientId = client.id;
565
+ if (!['resume', 'pair', 'observe'].includes(message.type) || !client || !matches(message.credential, client.hash)) {
566
+ return fail(message.type === 'pair' ? 4408 : 4401, message.type === 'pair' ? 'Pairing code expired or used' : 'Device authorization rejected');
567
+ }
568
+ }
569
+ if (ws.readyState !== ws.OPEN) return;
570
+ if (accountConnection && !accountConnection.valid()) return;
571
+ if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
572
+ if (message.type === 'observe') {
573
+ role = 'observer'; clearTimeout(authTimer); room.observers ||= new Map();
574
+ const oldObserver = room.observers.get(clientId);
575
+ if (oldObserver?.ws && oldObserver.ws !== ws) oldObserver.ws.close(4409, 'Metadata connection replaced by same identity');
576
+ room.observers.set(clientId, { ws, userId });
577
+ send(ws, { type: 'observing', online: Boolean(room.host) });
578
+ send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
579
+ const announcement = adminState.read().announcement;
580
+ if (announcement) send(ws, { type: 'announcement', announcement });
581
+ if (room.hostName) send(ws, { type: 'computer-info', hostName: room.hostName });
582
+ if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
583
+ if (room.officialComputer) send(ws, { type: 'official-computer', computer: room.officialComputer });
584
+ return;
585
+ }
586
+ if (!room.host) return fail(4410, 'Computer offline; authorization retained');
587
+ const oldConnection = room.connections.get(clientId);
588
+ role = 'client'; clearTimeout(authTimer); session = randomUUID();
589
+ if (oldConnection?.ws && oldConnection.ws !== ws) {
590
+ releaseRelay(oldConnection);
591
+ send(room.host, { type: 'detach', session: oldConnection.session });
592
+ oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
593
+ }
594
+ room.connections.set(clientId, { ws, session, p2pProbe: Boolean(userId) && room.p2pProbe && message.p2pProbe === 1,
595
+ userId,
596
+ repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
597
+ relayAdmitted: false, relayExempt: false,
598
+ capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
599
+ send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
600
+ send(ws, { type: 'computer-info', hostName: room.hostName });
601
+ if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
602
+ const announcement = adminState.read().announcement;
603
+ if (announcement) send(ws, { type: 'announcement', announcement });
604
+ send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
605
+ }
606
+ if (role === 'host') {
607
+ if (room.host !== ws) return;
608
+ if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
609
+ if (message.type === 'usage-observer-lost') {
610
+ for (const task of room.usageTasks.values()) {
611
+ try { await meter.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
612
+ }
613
+ room.usageTasks.clear();
614
+ return;
615
+ }
616
+ const armKey = `${message.session}:${message.requestId}`;
617
+ const connection = [...room.connections.values()].find(item => item.session === message.session);
618
+ if (message.type === 'usage-arm') {
619
+ if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
620
+ (message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
621
+ diagnostics?.record('usage', 'arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
622
+ return;
623
+ }
624
+ for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
625
+ if (room.usageArms.size >= 256) return;
626
+ room.usageArms.set(armKey, { userId: connection.userId,
627
+ clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
628
+ session: message.session, at: Date.now() });
629
+ return;
630
+ }
631
+ if (message.type === 'usage-cancel') {
632
+ room.usageArms.delete(armKey); return;
633
+ }
634
+ if (message.type === 'usage-message') {
635
+ const arm = room.usageArms.get(armKey);
636
+ room.usageArms.delete(armKey);
637
+ if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) {
638
+ diagnostics?.record('usage', 'message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
639
+ return;
640
+ }
641
+ try {
642
+ await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
643
+ if (['start', 'steer'].includes(message.turnKind) &&
644
+ plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
645
+ const key = `${message.threadId}:${message.turnId}`;
646
+ if (key.length <= 128) {
647
+ const counted = await meter.action(arm, room.id, arm.clientId, 'turn', key);
648
+ if (counted && message.turnKind === 'steer') diagnostics?.record('usage', 'turn-recovered', 'steer');
649
+ if (!room.usageTasks.has(key) && message.observerReady === true && message.completed !== true &&
650
+ (message.turnKind === 'start' || message.active === true)) {
651
+ const task = await meter.turnOrigin(room.id, key);
652
+ if (task) {
653
+ await meter.pulse(task, true, room.id, task.clientId, counted ? key : randomUUID());
654
+ room.usageTasks.set(key, task);
655
+ }
656
+ }
657
+ }
658
+ }
659
+ } catch (error) { diagnostics?.record('usage', 'message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
660
+ return;
661
+ }
662
+ if (!['turn/started', 'turn/completed', 'heartbeat', 'resume', 'inactive'].includes(message.method) ||
663
+ !plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
664
+ const key = `${message.threadId}:${message.turnId}`;
665
+ if (key.length > 128) return;
666
+ let task = room.usageTasks.get(key);
667
+ try {
668
+ if (message.method === 'resume' && !task) {
669
+ task = await meter.turnOrigin(room.id, key);
670
+ if (!task) { diagnostics?.record('usage', 'turn-resume', 'origin-missing'); return; }
671
+ await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
672
+ diagnostics?.record('usage', 'turn-resume', 'confirmed');
673
+ room.usageTasks.set(key, task);
674
+ } else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
675
+ else if (task && message.method === 'inactive') {
676
+ await meter.lost(task);
677
+ room.usageTasks.delete(key);
678
+ }
679
+ else if (task && message.method === 'turn/completed') {
680
+ await meter.pulse(task, false, room.id, task.clientId);
681
+ room.usageTasks.delete(key);
682
+ }
683
+ } catch (error) { log({ event: 'usage-error', message: error.message }); }
684
+ return;
685
+ }
686
+ if (message.type === 'lan-candidates') {
687
+ room.lanCandidates = normalizeLanCandidates(message.candidates);
688
+ for (const connection of room.connections.values()) send(connection.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
689
+ for (const observer of room.observers.values()) send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
690
+ return;
691
+ }
692
+ if (message.type === 'status') {
693
+ send(ws, { type: 'status', commandId: message.commandId, paired: room.clients.length > 0, clientCount: room.clients.length, revision: room.revision }); return;
694
+ }
695
+ if (message.type === 'pair-code') {
696
+ send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
697
+ }
698
+ if (message.type === 'revoke') {
699
+ await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
700
+ room.pairingHash = undefined;
701
+ for (const connection of room.connections.values()) {
702
+ releaseRelay(connection);
703
+ send(ws, { type: 'detach', session: connection.session });
704
+ connection.ws.close(4401, 'Device authorization revoked');
705
+ }
706
+ room.connections.clear();
707
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
708
+ room.uploadClients?.clear();
709
+ for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
710
+ room.observers.clear();
711
+ send(ws, { type: 'authorization-changed', clients: [], revision: room.revision });
712
+ send(ws, { type: 'revoked', commandId: message.commandId, room: room.id }); return;
713
+ }
714
+ const connection = [...room.connections.values()].find(item => item.session === message.session);
715
+ if (!connection) return; // Late frames from an ended session.
716
+ if (message.type === 'data' && typeof message.payload === 'string') queueClientMessage(connection, { type: 'data', payload: message.payload });
717
+ else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
718
+ p2pProbeRepeat: connection.p2pProbe && connection.repeat });
719
+ else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
720
+ const diagnostic = message.purpose === 'diagnostic';
721
+ if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
722
+ if ((diagnostic ? connection.diagnosticProbeId : connection.probeId) !== message.probeId) return;
723
+ if (++connection[diagnostic ? 'diagnosticSignals' : 'signals'] > 150) return fail(1008, 'Probe signal limit');
724
+ send(connection.ws, { ...message, session: undefined });
725
+ }
726
+ else if (message.type === 'detach') connection.ws.close(1011, 'Local Bridge unavailable');
727
+ else fail();
728
+ } else if (role === 'file-host' && room.uploadHost === ws) {
729
+ const client = room.uploadClients.get(message.session);
730
+ if (!client) return;
731
+ if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
732
+ if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
733
+ send(client.ws, { type: 'file-frame', payload: message.payload });
734
+ } else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
735
+ else fail(1008, 'Invalid file frame');
736
+ } else if (role === 'file-client' && room.uploadClients?.get(session)?.ws === ws) {
737
+ if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
738
+ if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
739
+ send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
740
+ } else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
741
+ const computer = normalizeOfficialComputer(message);
742
+ await commit(room, { officialComputer: computer });
743
+ send(ws, { type: 'official-computer-saved', computer });
744
+ } else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
745
+ const connection = room.connections.get(clientId);
746
+ if (!connection.p2pProbe || !validProbeSignal(message)) return fail(1008, 'Invalid probe signal');
747
+ const diagnostic = message.purpose === 'diagnostic';
748
+ if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
749
+ const idField = diagnostic ? 'diagnosticProbeId' : 'probeId';
750
+ const signalsField = diagnostic ? 'diagnosticSignals' : 'signals';
751
+ const startedField = diagnostic ? 'diagnosticProbeStarted' : 'probeStarted';
752
+ if (message.kind === 'start') {
753
+ if (connection[idField] && (!connection.repeat || Date.now() - connection[startedField] < 2000)) {
754
+ send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
755
+ ...(diagnostic ? { purpose: 'diagnostic' } : {}), kind: 'close' }); return;
756
+ }
757
+ connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
758
+ }
759
+ if (connection[idField] !== message.probeId) return;
760
+ if (++connection[signalsField] > 150) return fail(1008, 'Probe signal limit');
761
+ send(room.host, { ...message, session });
762
+ if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
763
+ } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
764
+ releaseRelay(room.connections.get(clientId));
765
+ } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
766
+ const connection = room.connections.get(clientId);
767
+ if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
768
+ if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
769
+ ws.close(4428, 'Server busy'); return;
770
+ }
771
+ send(room.host, { type: 'data', session, payload: message.payload });
772
+ } else fail();
773
+ } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
774
+ finally { pendingMessages--; pendingBytes -= size; }
775
+ }).catch(() => ws.close(1011, 'Relay operation failed'));
776
+ });
777
+ });
778
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
779
+ listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
780
+ return {
781
+ url: listenUrl, hostKey,
782
+ async close() {
783
+ for (const ws of wss.clients) ws.terminate();
784
+ await new Promise(resolve => wss.close(resolve));
785
+ await new Promise(resolve => server.close(resolve));
786
+ if (database) await database.close();
787
+ },
788
+ };
789
+ }
790
+
791
+ function relayPublicAddress(value) {
792
+ const parsed = new URL(value);
793
+ if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.pathname !== '/relay' || parsed.search || parsed.hash || parsed.username || parsed.password) {
794
+ throw new Error('Invalid public relay URL');
795
+ }
796
+ return parsed.href;
797
+ }
798
+
799
+ function pairingHeaders(type, body) {
800
+ return { 'Content-Type': type, 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store',
801
+ 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
802
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'" };
803
+ }
804
+
805
+ function pairingPage({ deepLink = '', expires = 0, appDownloadUrl = '', expired = false }) {
806
+ const open = deepLink ? `<a class="primary" href="${escapeHtml(deepLink)}">打开 Code Relax</a>` : '';
807
+ const download = appDownloadUrl ? `<a href="${escapeHtml(appDownloadUrl)}">安装 Code Relax</a>` : '<p>尚未安装时,请通过当前私测渠道安装 App,再重新打开此页面。</p>';
808
+ const text = expired ? '这条设备配对邀请已过期或已经使用,请在电脑重新生成。'
809
+ : `这是一次性设备配对邀请,有效期至 ${escapeHtml(new Date(expires).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }))}。每台移动设备只需配对一次。`;
810
+ return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Code Relax 配对</title><style>body{margin:0;background:#0d1210;color:#eef4f0;font:16px system-ui,sans-serif}main{max-width:520px;margin:12vh auto;padding:32px}h1{font-size:28px}p{color:#aab7af;line-height:1.7}a{display:block;margin-top:16px;padding:14px 18px;border:1px solid #496257;border-radius:12px;color:#dce9e1;text-align:center;text-decoration:none}.primary{background:#1f5a40;border-color:#367c5b}</style><main><h1>Code Relax</h1><p>${text}</p>${expired ? '' : open + download}</main></html>`;
811
+ }
812
+
813
+ function escapeHtml(value) {
814
+ return String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
815
+ }
816
+
817
+ function normalizeLanCandidates(value) {
818
+ if (value === undefined) return [];
819
+ if (!Array.isArray(value) || value.length > 8) throw new Error('Invalid LAN candidates');
820
+ return value.map(candidate => {
821
+ const endpoint = new URL(candidate?.address || '');
822
+ if (endpoint.protocol !== 'http:' || !endpoint.port || endpoint.pathname !== '/' || endpoint.search || endpoint.hash ||
823
+ endpoint.username || endpoint.password || !privateIpv4(endpoint.hostname) ||
824
+ !['wifi', 'ethernet'].includes(candidate.networkType) || !plainLabel(candidate.networkName) || !plainLabel(candidate.interfaceName)) {
825
+ throw new Error('Invalid LAN candidate');
826
+ }
827
+ return { address: endpoint.href.replace(/\/$/, ''), networkType: candidate.networkType,
828
+ networkName: candidate.networkName.trim(), interfaceName: candidate.interfaceName.trim() };
829
+ });
830
+ }
831
+
832
+ function normalizeHostName(value) {
833
+ if (!plainLabel(value)) throw new Error('Invalid host name');
834
+ return value.trim();
835
+ }
836
+
837
+ function normalizeOfficialComputer(value) {
838
+ if (!plainText(value.environmentId, 256, false) || !plainText(value.name ?? '', 80, true) ||
839
+ !plainText(value.hostname ?? '', 80, true)) throw new Error('Invalid official computer');
840
+ return { environmentId: value.environmentId.trim(), name: (value.name || '').trim(),
841
+ hostname: (value.hostname || '').trim(), updatedAt: new Date().toISOString() };
842
+ }
843
+
844
+ function privateIpv4(value) {
845
+ const parts = value.split('.').map(Number);
846
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return false;
847
+ return parts[0] === 10 || (parts[0] === 192 && parts[1] === 168) ||
848
+ (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
849
+ (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127);
850
+ }
851
+
852
+ function plainLabel(value) {
853
+ return typeof value === 'string' && value.trim().length > 0 && value.trim().length <= 80 && !/[\u0000-\u001f\u007f]/.test(value);
854
+ }
855
+
856
+
857
+ function plainText(value, limit, empty) {
858
+ return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
859
+ }
860
+
861
+ // Device-authorized management must not become an anonymous chat tunnel.
862
+ function managementPayload(value) {
863
+ try {
864
+ const message = JSON.parse(value);
865
+ if (['unsubscribe', 'cancel', 'response-ack'].includes(message.type)) return true;
866
+ if (message.type === 'subscribe') return message.path === '/api/shared-recovery/events';
867
+ if (message.type !== 'request') return false;
868
+ const method = message.method || 'GET';
869
+ if (method === 'GET') return ['/api/health', '/api/official-remote/status'].includes(message.path) ||
870
+ /^\/api\/threads\/[0-9a-f-]{28,64}\/model-changes(?:\?|$)/i.test(message.path);
871
+ return method === 'POST' && message.path === '/api/official-remote/pairing';
872
+ } catch { return false; }
873
+ }
874
+
875
+ function controlPayload(value) {
876
+ try {
877
+ const message = JSON.parse(value);
878
+ if (message.type === 'unsubscribe') return true;
879
+ return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
880
+ (message.path === '/api/health' || message.path.startsWith('/api/shared-recovery'));
881
+ } catch { return false; }
882
+ }
883
+
884
+ function readBody(request, limit) {
885
+ return new Promise((resolve, reject) => {
886
+ let value = '';
887
+ request.setEncoding('utf8');
888
+ request.on('data', chunk => {
889
+ value += chunk;
890
+ if (Buffer.byteLength(value) > limit) { reject(new Error('Admin request too large')); request.destroy(); }
891
+ });
892
+ request.on('end', () => resolve(value)); request.on('error', reject);
893
+ });
894
+ }