@walkhi/code-relax 0.1.0-beta.23 → 0.1.0-beta.25

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.
@@ -1,22 +1,22 @@
1
+ import { privacyPage } from './privacy-page.mjs';
1
2
  import http from 'node:http';
2
3
  import fs from 'node:fs';
3
4
  import { isIP } from 'node:net';
4
- import { deviceStore, validId } from './device-store.mjs';
5
+ import { deviceStore, validId } from './device-store.mjs';
5
6
  import { randomUUID, timingSafeEqual } from 'node:crypto';
6
- import { WebSocketServer } from 'ws';
7
+ import { WebSocketServer } from 'ws';
7
8
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
8
9
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
9
10
  import { validProbeSignal } from './p2p-probe.mjs';
10
11
  import { relayAdminState } from './admin-state.mjs';
11
12
  import { openRelayMysql } from './mysql-state.mjs';
12
- import { adminPage } from './admin-page.mjs';
13
13
  import { openEmailAccounts } from './email-accounts.mjs';
14
14
  import { usageMeter, usageReport } from './usage-meter.mjs';
15
15
  import { relayDiagnostics } from './diagnostics.mjs';
16
16
 
17
- // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
17
+ // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
18
18
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
19
- maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
19
+ maxConnections = 128, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
20
20
  maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
21
21
  publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
22
22
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
@@ -32,8 +32,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
32
32
  if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
33
33
  const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
34
34
  const meter = accounts ? usageMeter(database.pool) : null;
35
+ const cancelledAccountIds = new Set();
35
36
  const diagnostics = database?.schemaVersions.includes('006_relay_diagnostics')
36
37
  ? relayDiagnostics(database.pool, error => log({ event: 'relay-diagnostic-error', message: error.message })) : null;
38
+ const adminAccessFile = '/etc/codex-relay/admin-access.json';
39
+ const adminAccessKey = fs.existsSync(adminAccessFile) ? JSON.parse(fs.readFileSync(adminAccessFile, 'utf8')).key : '';
40
+ if (adminAccessKey && !validSecret(adminAccessKey)) throw new Error('Invalid admin access key');
37
41
  const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
38
42
  if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
39
43
  const store = database?.devices || deviceStore(stateFile);
@@ -42,23 +46,39 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
42
46
  let activeRelaySessions = 0;
43
47
  for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
44
48
  let commitTail = Promise.resolve();
49
+ function queueStore(operation) {
50
+ const pending = commitTail.then(operation);
51
+ commitTail = pending.catch(() => {});
52
+ return pending;
53
+ }
45
54
  function commit(room, changes) {
46
- const operation = commitTail.then(async () => {
47
- changes = typeof changes === 'function' ? changes(room) : changes;
48
- const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
49
- if (room.durable) {
50
- const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
51
- await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
52
- }
53
- Object.assign(room, changes, { updatedAt: next.updatedAt });
55
+ return queueStore(async () => {
56
+ changes = typeof changes === 'function' ? changes(room) : changes;
57
+ const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
58
+ if (room.durable) {
59
+ const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
60
+ await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
61
+ }
62
+ Object.assign(room, changes, { updatedAt: next.updatedAt });
54
63
  });
55
- commitTail = operation.catch(() => {});
56
- return operation;
57
- }
58
- function invitation(room) {
59
- const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
60
- return { code, expires: room.expires };
61
- }
64
+ }
65
+ function registerRoom(id, durable, credential) {
66
+ return queueStore(async () => {
67
+ // Resolve concurrent first connections before writing the device identity.
68
+ if (durable && rooms.has(id)) return rooms.get(id);
69
+ const now = new Date().toISOString();
70
+ const record = { id, revision: 0, status: 'active', clients: [], createdAt: now, updatedAt: now,
71
+ ...(durable ? { hostHash: digest(credential) } : {}) };
72
+ if (durable) await store.save(record);
73
+ const room = { ...record, durable, connections: new Map(), observers: new Map(), uploadClients: new Map() };
74
+ rooms.set(id, room);
75
+ return room;
76
+ });
77
+ }
78
+ function invitation(room) {
79
+ const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
80
+ return { code, expires: room.expires };
81
+ }
62
82
  let listenUrl = '';
63
83
  let adminTail = Promise.resolve();
64
84
  function relayUrl(request) {
@@ -79,6 +99,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
79
99
  });
80
100
  return;
81
101
  }
102
+ if (target.pathname === '/account/privacy' && request.method === 'GET') {
103
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache',
104
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
105
+ 'X-Content-Type-Options': 'nosniff' });
106
+ response.end(privacyPage); return;
107
+ }
82
108
  if (accounts && target.pathname.startsWith('/account/')) {
83
109
  void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
84
110
  return;
@@ -132,6 +158,33 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
132
158
  if (target.pathname === '/account/logout' && request.method === 'POST') {
133
159
  accountReply(response, 200, await accounts.logout(bearer)); return;
134
160
  }
161
+ if (target.pathname === '/account/me' && request.method === 'DELETE') {
162
+ const user = await accounts.current(bearer);
163
+ cancelledAccountIds.add(user.id);
164
+ try { await accounts.cancelAccount(bearer); }
165
+ catch (failure) { cancelledAccountIds.delete(user.id); throw failure; }
166
+ for (const room of rooms.values()) {
167
+ for (const [key, arm] of room.usageArms || []) if (arm.userId === user.id) room.usageArms.delete(key);
168
+ for (const [key, task] of room.usageTasks || []) if (task.userId === user.id) room.usageTasks.delete(key);
169
+ for (const [clientId, connection] of room.connections) {
170
+ if (connection.userId !== user.id) continue;
171
+ releaseRelay(connection); room.connections.delete(clientId);
172
+ if (room.host) send(room.host, { type: 'detach', session: connection.session });
173
+ connection.ws.close(4431, 'Account cancelled');
174
+ }
175
+ for (const [clientId, observer] of room.observers) {
176
+ if (observer.userId !== user.id) continue;
177
+ room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
178
+ }
179
+ for (const [clientId, upload] of room.uploadClients) {
180
+ if (upload.userId !== user.id) continue;
181
+ room.uploadClients.delete(clientId);
182
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
183
+ upload.ws.close(4431, 'Account cancelled');
184
+ }
185
+ }
186
+ accountReply(response, 200, { ok: true }); return;
187
+ }
135
188
  if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
136
189
  accountReply(response, 404, { error: 'Account route not found' }); return;
137
190
  }
@@ -149,26 +202,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
149
202
  }
150
203
  }
151
204
  async function handleAdmin(request, response, target) {
152
- if (request.method === 'GET' && target.pathname === '/admin') {
153
- adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
154
- }
155
- if (target.pathname.startsWith('/admin/api/')) {
156
- const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
157
- const diagnosticRoute = target.pathname === '/admin/api/usage-diagnostics' ||
158
- target.pathname === '/admin/api/usage-diagnostics/drain';
159
- const keyAccepted = diagnosticRoute && diagnosticKey && token &&
160
- timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
161
- let email;
162
- if (!keyAccepted) {
163
- try { email = (await accounts.current(token)).email; }
164
- catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
165
- const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
166
- if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
167
- }
168
- if (target.pathname === '/admin/api/session' && request.method === 'GET') {
169
- adminReply(response, 200, { email }); return;
170
- }
171
- }
205
+ const accessKey = request.headers['x-code-relax-admin-key'];
206
+ const internalAccess = adminAccessKey && validSecret(accessKey) &&
207
+ timingSafeEqual(Buffer.from(accessKey), Buffer.from(adminAccessKey));
208
+ const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
209
+ const diagnosticRoute = ['/admin/api/usage-diagnostics', '/admin/api/usage-diagnostics/drain'].includes(target.pathname);
210
+ const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
211
+ timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
212
+ if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
172
213
  if (request.method === 'GET' && target.pathname === '/admin/api/status') {
173
214
  const state = adminState.read();
174
215
  adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
@@ -188,14 +229,18 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
188
229
  }
189
230
  adminReply(response, 200, await usageReport(database.pool, page, view)); return;
190
231
  }
232
+ const diagnosticSource = target.searchParams.get('source') || 'usage';
233
+ if (target.pathname.startsWith('/admin/api/usage-diagnostics') && !['usage', 'account-cleanup'].includes(diagnosticSource)) {
234
+ adminReply(response, 400, { error: 'Invalid diagnostic source' }); return;
235
+ }
191
236
  if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
192
- if (request.method === 'GET') adminReply(response, 200, await diagnostics.read('usage'));
193
- else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear('usage'));
237
+ if (request.method === 'GET') adminReply(response, 200, await diagnostics.read(diagnosticSource));
238
+ else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear(diagnosticSource));
194
239
  else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
195
240
  return;
196
241
  }
197
242
  if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
198
- if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain('usage'));
243
+ if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain(diagnosticSource));
199
244
  else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
200
245
  return;
201
246
  }
@@ -262,23 +307,26 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
262
307
  serverNoContextTakeover: true, clientNoContextTakeover: true,
263
308
  threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
264
309
  } });
265
- let admissionStart = Date.now(), admissions = 0;
266
- server.on('upgrade', (request, socket, head) => {
310
+ let admissionStart = Date.now(), admissions = 0;
311
+ server.on('upgrade', (request, socket, head) => {
267
312
  if (request.url !== '/relay' || (request.headers.origin && request.headers.origin !== 'null' && !nativeOrigins.includes(request.headers.origin))) {
268
313
  log({ event: 'upgrade-rejected', reason: request.url !== '/relay' ? 'path' : 'origin' });
269
- socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
270
- }
271
- if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
272
- if (wss.clients.size >= maxConnections || ++admissions > 60) {
273
- socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
274
- }
275
- wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
276
- });
314
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
315
+ }
316
+ if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
317
+ if (wss.clients.size >= maxConnections || ++admissions > 240) {
318
+ socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
319
+ }
320
+ wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
321
+ });
277
322
  wss.on('connection', ws => {
278
323
  let role, room, session, clientId, alive = true;
279
- const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
280
- ws.on('pong', () => { alive = true; });
281
- const heartbeat = setInterval(() => { if (!alive) ws.terminate(); else { alive = false; ws.ping(); } }, 30000);
324
+ const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
325
+ ws.on('pong', () => { alive = true; });
326
+ const heartbeat = setInterval(() => {
327
+ sampleInboundQueue();
328
+ if (!alive) ws.terminate(); else { alive = false; ws.ping(); }
329
+ }, 30000);
282
330
  const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
283
331
  const queueClientMessage = (connection, message, pause = 0) => {
284
332
  const bytes = Buffer.byteLength(JSON.stringify(message));
@@ -292,10 +340,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
292
340
  send(connection.ws, message);
293
341
  }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
294
342
  };
295
- ws.on('error', () => {}); // close owns routing cleanup.
343
+ ws.on('error', () => {}); // close owns routing cleanup.
296
344
  ws.on('close', (code, reason) => {
297
- log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
298
- clearTimeout(authTimer); clearInterval(heartbeat);
345
+ log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
346
+ clearTimeout(authTimer); clearInterval(heartbeat);
299
347
  if (role === 'host' && room?.host === ws) {
300
348
  for (const task of room.usageTasks?.values() || []) {
301
349
  void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
@@ -303,10 +351,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
303
351
  room.usageTasks?.clear(); room.usageArms?.clear();
304
352
  room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
305
353
  room.uploadHost?.close(4410, 'Computer offline');
306
- for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
354
+ for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline');
307
355
  for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
308
356
  if (!room.durable) rooms.delete(room.id);
309
- for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
357
+ for (const connection of room.connections.values()) connection.ws.close(
358
+ code === 4432 ? 4432 : 4410,
359
+ code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline; authorization retained');
310
360
  } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
311
361
  const connection = room.connections.get(clientId);
312
362
  releaseRelay(connection);
@@ -316,18 +366,57 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
316
366
  room.observers.delete(clientId);
317
367
  } else if (role === 'file-host' && room?.uploadHost === ws) {
318
368
  room.uploadHost = undefined;
319
- for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
369
+ 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');
320
370
  } else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
321
371
  room.uploadClients.delete(clientId);
322
372
  if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
323
373
  }
324
- });
374
+ });
325
375
  let messageTail = Promise.resolve();
376
+ // Include the in-flight message: async database work must not permit an
377
+ // unbounded chain of closures retaining incoming payloads.
378
+ let pendingMessages = 0, pendingBytes = 0;
379
+ const maxPendingMessages = 128, maxPendingBytes = 16 * 1024 * 1024;
380
+ let sampleStarted = Date.now(), peakMessages = 0, peakBytes = 0, maxWaitMs = 0, receivedMessages = 0;
381
+ function sampleInboundQueue() {
382
+ const now = Date.now();
383
+ if (now - sampleStarted < 60000) return;
384
+ // Summarize only intervals with an actual backlog; never log payloads or credentials.
385
+ if (peakMessages > 1) log({ event: 'inbound-queue-sample', role: role || 'unauthenticated',
386
+ windowMs: now - sampleStarted, receivedMessages, peakMessages, peakBytes, maxWaitMs,
387
+ pendingMessages, pendingBytes });
388
+ sampleStarted = now; peakMessages = pendingMessages; peakBytes = pendingBytes;
389
+ maxWaitMs = 0; receivedMessages = 0;
390
+ }
326
391
  ws.on('message', (bytes, binary) => {
392
+ if (ws.readyState !== ws.OPEN) return;
393
+ const size = bytes.length;
394
+ if (pendingMessages >= maxPendingMessages || pendingBytes + size > maxPendingBytes) {
395
+ log({ event: 'inbound-queue-limit', role: role || 'unauthenticated',
396
+ pendingMessages, pendingBytes, incomingBytes: size, maxWaitMs });
397
+ ws.close(4432, 'Inbound queue limit exceeded');
398
+ return;
399
+ }
400
+ pendingMessages++; pendingBytes += size;
401
+ receivedMessages++;
402
+ peakMessages = Math.max(peakMessages, pendingMessages); peakBytes = Math.max(peakBytes, pendingBytes);
403
+ const enqueuedAt = Date.now();
327
404
  messageTail = messageTail.then(async () => {
328
405
  try {
406
+ maxWaitMs = Math.max(maxWaitMs, Date.now() - enqueuedAt);
407
+ if (ws.readyState !== ws.OPEN) return;
329
408
  const message = decode(bytes, binary);
330
409
  if (!role) {
410
+ let userId;
411
+ if (!['host', 'file-host', 'observe'].includes(message.type) &&
412
+ !(['pair', 'resume'].includes(message.type) && message.management === true)) {
413
+ if (!['pair', 'resume', 'observe', 'file-client'].includes(message.type)) return fail();
414
+ if (!accounts || !validSecret(message.accountToken)) return fail(4431, 'Account login required');
415
+ try { userId = (await accounts.current(message.accountToken)).id; }
416
+ catch { return fail(4431, 'Account session invalid'); }
417
+ if (ws.readyState !== ws.OPEN) return;
418
+ if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
419
+ }
331
420
  if (message.type === 'file-host') {
332
421
  room = rooms.get(message.deviceId);
333
422
  if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
@@ -348,7 +437,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
348
437
  return fail(4429, 'Too many file uploads');
349
438
  }
350
439
  role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
351
- room.uploadClients.set(clientId, { ws, session });
440
+ room.uploadClients.set(clientId, { ws, session, userId });
352
441
  send(room.uploadHost, { type: 'file-open', session }); return;
353
442
  }
354
443
  if (message.type === 'host') {
@@ -362,18 +451,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
362
451
  }
363
452
  catch { return fail(1008, 'Invalid LAN candidates'); }
364
453
  room = durable ? rooms.get(message.deviceId) : undefined;
365
- if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
366
454
  if (!room) {
367
- // Durable computers prove future ownership with their generated device secret.
368
- // The legacy deployment key remains accepted, but is no longer required for first registration.
369
455
  if (!durable && !matches(message.key, hostHash)) return fail();
370
- if (rooms.size >= 256) return fail(4429, 'Device limit reached');
371
- const now = new Date().toISOString();
372
- room = { id: durable ? message.deviceId : randomUUID(), durable,
373
- revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
374
- ...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
375
- await commit(room, {}); rooms.set(room.id, room);
376
- }
456
+ room = await registerRoom(durable ? message.deviceId : randomUUID(), durable, message.deviceSecret);
457
+ if (ws.readyState !== ws.OPEN) return;
458
+ }
459
+ if (durable && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
377
460
  const oldHost = room.host;
378
461
  room.observers ||= new Map();
379
462
  if (oldHost && oldHost !== ws) {
@@ -417,6 +500,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
417
500
  await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
418
501
  room.pairingHash = undefined;
419
502
  } finally { room.pairingInProgress = false; }
503
+ if (!room.host) return fail(4410, 'Computer offline; authorization retained');
420
504
  send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
421
505
  } else {
422
506
  clientId = message.clientId;
@@ -427,11 +511,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
427
511
  return fail(message.type === 'pair' ? 4408 : 4401, message.type === 'pair' ? 'Pairing code expired or used' : 'Device authorization rejected');
428
512
  }
429
513
  }
514
+ if (ws.readyState !== ws.OPEN) return;
515
+ if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
430
516
  if (message.type === 'observe') {
431
517
  role = 'observer'; clearTimeout(authTimer); room.observers ||= new Map();
432
518
  const oldObserver = room.observers.get(clientId);
433
519
  if (oldObserver?.ws && oldObserver.ws !== ws) oldObserver.ws.close(4409, 'Metadata connection replaced by same identity');
434
- room.observers.set(clientId, { ws });
520
+ room.observers.set(clientId, { ws, userId });
435
521
  send(ws, { type: 'observing', online: Boolean(room.host) });
436
522
  send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
437
523
  const announcement = adminState.read().announcement;
@@ -442,11 +528,6 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
442
528
  return;
443
529
  }
444
530
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
445
- let userId;
446
- if (accounts && message.accountToken !== undefined) {
447
- try { userId = (await accounts.current(message.accountToken)).id; }
448
- catch { return fail(4431, 'Account session invalid'); }
449
- }
450
531
  const oldConnection = room.connections.get(clientId);
451
532
  role = 'client'; clearTimeout(authTimer); session = randomUUID();
452
533
  if (oldConnection?.ws && oldConnection.ws !== ws) {
@@ -454,7 +535,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
454
535
  send(room.host, { type: 'detach', session: oldConnection.session });
455
536
  oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
456
537
  }
457
- room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
538
+ room.connections.set(clientId, { ws, session, p2pProbe: Boolean(userId) && room.p2pProbe && message.p2pProbe === 1,
458
539
  userId,
459
540
  repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
460
541
  relayAdmitted: false, relayExempt: false,
@@ -465,7 +546,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
465
546
  const announcement = adminState.read().announcement;
466
547
  if (announcement) send(ws, { type: 'announcement', announcement });
467
548
  send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
468
- }
549
+ }
469
550
  if (role === 'host') {
470
551
  if (room.host !== ws) return;
471
552
  if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
@@ -627,15 +708,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
627
708
  releaseRelay(room.connections.get(clientId));
628
709
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
629
710
  const connection = room.connections.get(clientId);
630
- if (!controlPayload(message.payload) && !admitRelay(connection, room)) {
711
+ if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
712
+ if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
631
713
  ws.close(4428, 'Server busy'); return;
632
714
  }
633
715
  send(room.host, { type: 'data', session, payload: message.payload });
634
- } else fail();
716
+ } else fail();
635
717
  } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
718
+ finally { pendingMessages--; pendingBytes -= size; }
636
719
  }).catch(() => ws.close(1011, 'Relay operation failed'));
637
720
  });
638
- });
721
+ });
639
722
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
640
723
  listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
641
724
  return {
@@ -645,7 +728,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
645
728
  await new Promise(resolve => wss.close(resolve));
646
729
  await new Promise(resolve => server.close(resolve));
647
730
  if (database) await database.close();
648
- },
731
+ },
649
732
  };
650
733
  }
651
734
 
@@ -719,6 +802,20 @@ function plainText(value, limit, empty) {
719
802
  return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
720
803
  }
721
804
 
805
+ // Device-authorized management must not become an anonymous chat tunnel.
806
+ function managementPayload(value) {
807
+ try {
808
+ const message = JSON.parse(value);
809
+ if (['unsubscribe', 'cancel', 'response-ack'].includes(message.type)) return true;
810
+ if (message.type === 'subscribe') return message.path === '/api/shared-recovery/events';
811
+ if (message.type !== 'request') return false;
812
+ const method = message.method || 'GET';
813
+ if (method === 'GET') return ['/api/health', '/api/official-remote/status'].includes(message.path) ||
814
+ /^\/api\/threads\/[0-9a-f-]{28,64}\/model-changes(?:\?|$)/i.test(message.path);
815
+ return method === 'POST' && message.path === '/api/official-remote/pairing';
816
+ } catch { return false; }
817
+ }
818
+
722
819
  function controlPayload(value) {
723
820
  try {
724
821
  const message = JSON.parse(value);