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

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,20 +1,20 @@
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
19
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
20
20
  maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
@@ -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);
@@ -54,11 +58,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
54
58
  });
55
59
  commitTail = operation.catch(() => {});
56
60
  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
- }
61
+ }
62
+ function invitation(room) {
63
+ const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
64
+ return { code, expires: room.expires };
65
+ }
62
66
  let listenUrl = '';
63
67
  let adminTail = Promise.resolve();
64
68
  function relayUrl(request) {
@@ -79,6 +83,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
79
83
  });
80
84
  return;
81
85
  }
86
+ if (target.pathname === '/account/privacy' && request.method === 'GET') {
87
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache',
88
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
89
+ 'X-Content-Type-Options': 'nosniff' });
90
+ response.end(privacyPage); return;
91
+ }
82
92
  if (accounts && target.pathname.startsWith('/account/')) {
83
93
  void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
84
94
  return;
@@ -132,6 +142,33 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
132
142
  if (target.pathname === '/account/logout' && request.method === 'POST') {
133
143
  accountReply(response, 200, await accounts.logout(bearer)); return;
134
144
  }
145
+ if (target.pathname === '/account/me' && request.method === 'DELETE') {
146
+ const user = await accounts.current(bearer);
147
+ cancelledAccountIds.add(user.id);
148
+ try { await accounts.cancelAccount(bearer); }
149
+ catch (failure) { cancelledAccountIds.delete(user.id); throw failure; }
150
+ for (const room of rooms.values()) {
151
+ for (const [key, arm] of room.usageArms || []) if (arm.userId === user.id) room.usageArms.delete(key);
152
+ for (const [key, task] of room.usageTasks || []) if (task.userId === user.id) room.usageTasks.delete(key);
153
+ for (const [clientId, connection] of room.connections) {
154
+ if (connection.userId !== user.id) continue;
155
+ releaseRelay(connection); room.connections.delete(clientId);
156
+ if (room.host) send(room.host, { type: 'detach', session: connection.session });
157
+ connection.ws.close(4431, 'Account cancelled');
158
+ }
159
+ for (const [clientId, observer] of room.observers) {
160
+ if (observer.userId !== user.id) continue;
161
+ room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
162
+ }
163
+ for (const [clientId, upload] of room.uploadClients) {
164
+ if (upload.userId !== user.id) continue;
165
+ room.uploadClients.delete(clientId);
166
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
167
+ upload.ws.close(4431, 'Account cancelled');
168
+ }
169
+ }
170
+ accountReply(response, 200, { ok: true }); return;
171
+ }
135
172
  if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
136
173
  accountReply(response, 404, { error: 'Account route not found' }); return;
137
174
  }
@@ -149,26 +186,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
149
186
  }
150
187
  }
151
188
  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
- }
189
+ const accessKey = request.headers['x-code-relax-admin-key'];
190
+ const internalAccess = adminAccessKey && validSecret(accessKey) &&
191
+ timingSafeEqual(Buffer.from(accessKey), Buffer.from(adminAccessKey));
192
+ const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
193
+ const diagnosticRoute = ['/admin/api/usage-diagnostics', '/admin/api/usage-diagnostics/drain'].includes(target.pathname);
194
+ const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
195
+ timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
196
+ if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
172
197
  if (request.method === 'GET' && target.pathname === '/admin/api/status') {
173
198
  const state = adminState.read();
174
199
  adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
@@ -188,14 +213,18 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
188
213
  }
189
214
  adminReply(response, 200, await usageReport(database.pool, page, view)); return;
190
215
  }
216
+ const diagnosticSource = target.searchParams.get('source') || 'usage';
217
+ if (target.pathname.startsWith('/admin/api/usage-diagnostics') && !['usage', 'account-cleanup'].includes(diagnosticSource)) {
218
+ adminReply(response, 400, { error: 'Invalid diagnostic source' }); return;
219
+ }
191
220
  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'));
221
+ if (request.method === 'GET') adminReply(response, 200, await diagnostics.read(diagnosticSource));
222
+ else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear(diagnosticSource));
194
223
  else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
195
224
  return;
196
225
  }
197
226
  if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
198
- if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain('usage'));
227
+ if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain(diagnosticSource));
199
228
  else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
200
229
  return;
201
230
  }
@@ -262,23 +291,26 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
262
291
  serverNoContextTakeover: true, clientNoContextTakeover: true,
263
292
  threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
264
293
  } });
265
- let admissionStart = Date.now(), admissions = 0;
266
- server.on('upgrade', (request, socket, head) => {
294
+ let admissionStart = Date.now(), admissions = 0;
295
+ server.on('upgrade', (request, socket, head) => {
267
296
  if (request.url !== '/relay' || (request.headers.origin && request.headers.origin !== 'null' && !nativeOrigins.includes(request.headers.origin))) {
268
297
  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
- });
298
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
299
+ }
300
+ if (Date.now() - admissionStart >= 60000) { admissionStart = Date.now(); admissions = 0; }
301
+ if (wss.clients.size >= maxConnections || ++admissions > 60) {
302
+ socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
303
+ }
304
+ wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
305
+ });
277
306
  wss.on('connection', ws => {
278
307
  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);
308
+ const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
309
+ ws.on('pong', () => { alive = true; });
310
+ const heartbeat = setInterval(() => {
311
+ sampleInboundQueue();
312
+ if (!alive) ws.terminate(); else { alive = false; ws.ping(); }
313
+ }, 30000);
282
314
  const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
283
315
  const queueClientMessage = (connection, message, pause = 0) => {
284
316
  const bytes = Buffer.byteLength(JSON.stringify(message));
@@ -292,10 +324,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
292
324
  send(connection.ws, message);
293
325
  }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
294
326
  };
295
- ws.on('error', () => {}); // close owns routing cleanup.
327
+ ws.on('error', () => {}); // close owns routing cleanup.
296
328
  ws.on('close', (code, reason) => {
297
- log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
298
- clearTimeout(authTimer); clearInterval(heartbeat);
329
+ log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
330
+ clearTimeout(authTimer); clearInterval(heartbeat);
299
331
  if (role === 'host' && room?.host === ws) {
300
332
  for (const task of room.usageTasks?.values() || []) {
301
333
  void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
@@ -303,10 +335,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
303
335
  room.usageTasks?.clear(); room.usageArms?.clear();
304
336
  room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
305
337
  room.uploadHost?.close(4410, 'Computer offline');
306
- for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
338
+ for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline');
307
339
  for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
308
340
  if (!room.durable) rooms.delete(room.id);
309
- for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
341
+ for (const connection of room.connections.values()) connection.ws.close(
342
+ code === 4432 ? 4432 : 4410,
343
+ code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline; authorization retained');
310
344
  } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
311
345
  const connection = room.connections.get(clientId);
312
346
  releaseRelay(connection);
@@ -316,18 +350,57 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
316
350
  room.observers.delete(clientId);
317
351
  } else if (role === 'file-host' && room?.uploadHost === ws) {
318
352
  room.uploadHost = undefined;
319
- for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
353
+ 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
354
  } else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
321
355
  room.uploadClients.delete(clientId);
322
356
  if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
323
357
  }
324
- });
358
+ });
325
359
  let messageTail = Promise.resolve();
360
+ // Include the in-flight message: async database work must not permit an
361
+ // unbounded chain of closures retaining incoming payloads.
362
+ let pendingMessages = 0, pendingBytes = 0;
363
+ const maxPendingMessages = 128, maxPendingBytes = 16 * 1024 * 1024;
364
+ let sampleStarted = Date.now(), peakMessages = 0, peakBytes = 0, maxWaitMs = 0, receivedMessages = 0;
365
+ function sampleInboundQueue() {
366
+ const now = Date.now();
367
+ if (now - sampleStarted < 60000) return;
368
+ // Summarize only intervals with an actual backlog; never log payloads or credentials.
369
+ if (peakMessages > 1) log({ event: 'inbound-queue-sample', role: role || 'unauthenticated',
370
+ windowMs: now - sampleStarted, receivedMessages, peakMessages, peakBytes, maxWaitMs,
371
+ pendingMessages, pendingBytes });
372
+ sampleStarted = now; peakMessages = pendingMessages; peakBytes = pendingBytes;
373
+ maxWaitMs = 0; receivedMessages = 0;
374
+ }
326
375
  ws.on('message', (bytes, binary) => {
376
+ if (ws.readyState !== ws.OPEN) return;
377
+ const size = bytes.length;
378
+ if (pendingMessages >= maxPendingMessages || pendingBytes + size > maxPendingBytes) {
379
+ log({ event: 'inbound-queue-limit', role: role || 'unauthenticated',
380
+ pendingMessages, pendingBytes, incomingBytes: size, maxWaitMs });
381
+ ws.close(4432, 'Inbound queue limit exceeded');
382
+ return;
383
+ }
384
+ pendingMessages++; pendingBytes += size;
385
+ receivedMessages++;
386
+ peakMessages = Math.max(peakMessages, pendingMessages); peakBytes = Math.max(peakBytes, pendingBytes);
387
+ const enqueuedAt = Date.now();
327
388
  messageTail = messageTail.then(async () => {
328
389
  try {
390
+ maxWaitMs = Math.max(maxWaitMs, Date.now() - enqueuedAt);
391
+ if (ws.readyState !== ws.OPEN) return;
329
392
  const message = decode(bytes, binary);
330
393
  if (!role) {
394
+ let userId;
395
+ if (!['host', 'file-host', 'observe'].includes(message.type) &&
396
+ !(['pair', 'resume'].includes(message.type) && message.management === true)) {
397
+ if (!['pair', 'resume', 'observe', 'file-client'].includes(message.type)) return fail();
398
+ if (!accounts || !validSecret(message.accountToken)) return fail(4431, 'Account login required');
399
+ try { userId = (await accounts.current(message.accountToken)).id; }
400
+ catch { return fail(4431, 'Account session invalid'); }
401
+ if (ws.readyState !== ws.OPEN) return;
402
+ if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
403
+ }
331
404
  if (message.type === 'file-host') {
332
405
  room = rooms.get(message.deviceId);
333
406
  if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
@@ -348,7 +421,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
348
421
  return fail(4429, 'Too many file uploads');
349
422
  }
350
423
  role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
351
- room.uploadClients.set(clientId, { ws, session });
424
+ room.uploadClients.set(clientId, { ws, session, userId });
352
425
  send(room.uploadHost, { type: 'file-open', session }); return;
353
426
  }
354
427
  if (message.type === 'host') {
@@ -368,12 +441,12 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
368
441
  // The legacy deployment key remains accepted, but is no longer required for first registration.
369
442
  if (!durable && !matches(message.key, hostHash)) return fail();
370
443
  if (rooms.size >= 256) return fail(4429, 'Device limit reached');
371
- const now = new Date().toISOString();
444
+ const now = new Date().toISOString();
372
445
  room = { id: durable ? message.deviceId : randomUUID(), durable,
373
446
  revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
374
447
  ...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
375
448
  await commit(room, {}); rooms.set(room.id, room);
376
- }
449
+ }
377
450
  const oldHost = room.host;
378
451
  room.observers ||= new Map();
379
452
  if (oldHost && oldHost !== ws) {
@@ -427,11 +500,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
427
500
  return fail(message.type === 'pair' ? 4408 : 4401, message.type === 'pair' ? 'Pairing code expired or used' : 'Device authorization rejected');
428
501
  }
429
502
  }
503
+ if (ws.readyState !== ws.OPEN) return;
504
+ if (cancelledAccountIds.has(userId)) return fail(4431, 'Account cancelled');
430
505
  if (message.type === 'observe') {
431
506
  role = 'observer'; clearTimeout(authTimer); room.observers ||= new Map();
432
507
  const oldObserver = room.observers.get(clientId);
433
508
  if (oldObserver?.ws && oldObserver.ws !== ws) oldObserver.ws.close(4409, 'Metadata connection replaced by same identity');
434
- room.observers.set(clientId, { ws });
509
+ room.observers.set(clientId, { ws, userId });
435
510
  send(ws, { type: 'observing', online: Boolean(room.host) });
436
511
  send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
437
512
  const announcement = adminState.read().announcement;
@@ -442,11 +517,6 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
442
517
  return;
443
518
  }
444
519
  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
520
  const oldConnection = room.connections.get(clientId);
451
521
  role = 'client'; clearTimeout(authTimer); session = randomUUID();
452
522
  if (oldConnection?.ws && oldConnection.ws !== ws) {
@@ -454,7 +524,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
454
524
  send(room.host, { type: 'detach', session: oldConnection.session });
455
525
  oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
456
526
  }
457
- room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
527
+ room.connections.set(clientId, { ws, session, p2pProbe: Boolean(userId) && room.p2pProbe && message.p2pProbe === 1,
458
528
  userId,
459
529
  repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
460
530
  relayAdmitted: false, relayExempt: false,
@@ -465,7 +535,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
465
535
  const announcement = adminState.read().announcement;
466
536
  if (announcement) send(ws, { type: 'announcement', announcement });
467
537
  send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
468
- }
538
+ }
469
539
  if (role === 'host') {
470
540
  if (room.host !== ws) return;
471
541
  if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
@@ -627,15 +697,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
627
697
  releaseRelay(room.connections.get(clientId));
628
698
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
629
699
  const connection = room.connections.get(clientId);
630
- if (!controlPayload(message.payload) && !admitRelay(connection, room)) {
700
+ if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
701
+ if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
631
702
  ws.close(4428, 'Server busy'); return;
632
703
  }
633
704
  send(room.host, { type: 'data', session, payload: message.payload });
634
- } else fail();
705
+ } else fail();
635
706
  } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
707
+ finally { pendingMessages--; pendingBytes -= size; }
636
708
  }).catch(() => ws.close(1011, 'Relay operation failed'));
637
709
  });
638
- });
710
+ });
639
711
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
640
712
  listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
641
713
  return {
@@ -645,7 +717,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
645
717
  await new Promise(resolve => wss.close(resolve));
646
718
  await new Promise(resolve => server.close(resolve));
647
719
  if (database) await database.close();
648
- },
720
+ },
649
721
  };
650
722
  }
651
723
 
@@ -719,6 +791,20 @@ function plainText(value, limit, empty) {
719
791
  return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
720
792
  }
721
793
 
794
+ // Device-authorized management must not become an anonymous chat tunnel.
795
+ function managementPayload(value) {
796
+ try {
797
+ const message = JSON.parse(value);
798
+ if (['unsubscribe', 'cancel', 'response-ack'].includes(message.type)) return true;
799
+ if (message.type === 'subscribe') return message.path === '/api/shared-recovery/events';
800
+ if (message.type !== 'request') return false;
801
+ const method = message.method || 'GET';
802
+ if (method === 'GET') return ['/api/health', '/api/official-remote/status'].includes(message.path) ||
803
+ /^\/api\/threads\/[0-9a-f-]{28,64}\/model-changes(?:\?|$)/i.test(message.path);
804
+ return method === 'POST' && message.path === '/api/official-remote/pairing';
805
+ } catch { return false; }
806
+ }
807
+
722
808
  function controlPayload(value) {
723
809
  try {
724
810
  const message = JSON.parse(value);