@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.10

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 (91) hide show
  1. package/README.md +14 -15
  2. package/dist/bin/codex-remote.mjs +81 -26
  3. package/dist/bin/self-relay-admin.mjs +8 -0
  4. package/dist/bin/self-relay-demo.mjs +1 -1
  5. package/dist/bin/self-relay-server.mjs +5 -2
  6. package/dist/shared/app-server-events.cjs +50 -22
  7. package/dist/shared/p2p-data-channel.cjs +50 -0
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +11 -11
  10. package/dist/src/lan-socket-server.mjs +8 -2
  11. package/dist/src/managed-app-server.mjs +27 -7
  12. package/dist/src/message-images.mjs +0 -18
  13. package/dist/src/onboarding.mjs +3 -5
  14. package/dist/src/platform/README.md +1 -1
  15. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  16. package/dist/src/platform/windows/background-process.mjs +7 -2
  17. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  18. package/dist/src/platform/windows/desktop-host.mjs +16 -1
  19. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  20. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
  21. package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
  22. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  23. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  24. package/dist/src/platform/windows/service-host.mjs +4 -7
  25. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  26. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  27. package/dist/src/runtime-discovery.mjs +43 -0
  28. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  29. package/dist/src/self-relay/admin-page.mjs +71 -0
  30. package/dist/src/self-relay/admin-server.mjs +61 -0
  31. package/dist/src/self-relay/admin-state.mjs +122 -0
  32. package/dist/src/self-relay/client.mjs +2 -0
  33. package/dist/src/self-relay/connector.mjs +23 -8
  34. package/dist/src/self-relay/demo.mjs +4 -5
  35. package/dist/src/self-relay/lifecycle.mjs +53 -49
  36. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  37. package/dist/src/self-relay/server.mjs +157 -17
  38. package/dist/src/server.mjs +484 -1274
  39. package/dist/src/service-doctor.mjs +13 -25
  40. package/dist/src/service-lifecycle.mjs +12 -12
  41. package/dist/src/shared-app-server.mjs +192 -34
  42. package/dist/src/shared-catalog.mjs +4 -14
  43. package/dist/src/shared-recovery.mjs +8 -6
  44. package/dist/src/shared-thread-stream.mjs +1 -1
  45. package/dist/web/activity-view.js +283 -0
  46. package/dist/web/capabilities.js +2 -9
  47. package/dist/web/chat-transport.js +21 -21
  48. package/dist/web/chat.css +148 -106
  49. package/dist/web/chat.js +1454 -2788
  50. package/dist/web/community.css +82 -0
  51. package/dist/web/community.html +36 -0
  52. package/dist/web/composer-controller.js +97 -0
  53. package/dist/web/conversation-controller.js +99 -0
  54. package/dist/web/disclosure-state-controller.js +95 -0
  55. package/dist/web/draft-controller.js +103 -0
  56. package/dist/web/harmony-platform.js +3 -2
  57. package/dist/web/history-cache.js +112 -18
  58. package/dist/web/history-controller.js +167 -0
  59. package/dist/web/index.html +113 -47
  60. package/dist/web/link-action-controller.js +212 -0
  61. package/dist/web/message-send-controller.js +177 -0
  62. package/dist/web/message-view.js +98 -0
  63. package/dist/web/p2p-data-channel.js +50 -0
  64. package/dist/web/p2p-probe.js +67 -27
  65. package/dist/web/page-resume.js +28 -0
  66. package/dist/web/pending-message-store.js +108 -0
  67. package/dist/web/queue-controller.js +82 -0
  68. package/dist/web/resources.json +1 -1
  69. package/dist/web/self-relay-session.js +28 -18
  70. package/dist/web/station-connection-controller.js +75 -0
  71. package/dist/web/task-list-view.js +289 -0
  72. package/dist/web/thread-attention-controller.js +124 -0
  73. package/dist/web/thread-context-controller.js +30 -0
  74. package/dist/web/thread-list-controller.js +61 -0
  75. package/dist/web/thread-list-sync.js +86 -0
  76. package/dist/web/thread-title-controller.js +57 -0
  77. package/dist/web/timeline-formatters.js +249 -0
  78. package/dist/web/timeline-reducer.js +77 -0
  79. package/dist/web/timeline-renderer.js +161 -0
  80. package/dist/web/timeline-scroll-controller.js +50 -0
  81. package/dist/web/usage-controller.js +258 -0
  82. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  83. package/package.json +16 -18
  84. package/tools/postinstall.mjs +140 -3
  85. package/dist/src/app-server-client.mjs +0 -468
  86. package/dist/src/app-server-tasks.mjs +0 -355
  87. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  88. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  89. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  90. package/dist/src/thread-catalog.mjs +0 -42
  91. package/dist/tools/find-desktop-pipe.mjs +0 -10
@@ -1,17 +1,20 @@
1
1
  import http from 'node:http';
2
2
  import { deviceStore, validId } from './device-store.mjs';
3
- import { randomUUID } from 'node:crypto';
3
+ import { randomUUID } from 'node:crypto';
4
4
  import { WebSocketServer } from 'ws';
5
5
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
6
6
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
7
7
  import { validProbeSignal } from './p2p-probe.mjs';
8
-
8
+ import { relayAdminState } from './admin-state.mjs';
9
+ import { adminPage } from './admin-page.mjs';
10
+
9
11
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
10
12
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
11
13
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
12
- publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
14
+ maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
13
15
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
14
16
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
17
+ if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
15
18
  if (publicRelayUrl) relayPublicAddress(publicRelayUrl);
16
19
  // Harmony's native WebSocket supplies the endpoint Origin automatically.
17
20
  // libwebsockets can use http even for WSS; trust only the configured endpoint.
@@ -20,6 +23,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
20
23
  const hostHash = digest(hostKey), rooms = new Map();
21
24
  const store = deviceStore(stateFile);
22
25
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
26
+ const adminState = relayAdminState(adminStateFile, maxRelaySessions);
27
+ let activeRelaySessions = 0;
23
28
  for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
24
29
  function commit(room, changes) {
25
30
  const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
@@ -46,6 +51,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
46
51
  }
47
52
  const server = http.createServer((request, response) => {
48
53
  const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
54
+ if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
55
+ void handleAdmin(request, response, target); return;
56
+ }
57
+ if (request.method === 'GET' && target.pathname === '/announcement') {
58
+ const body = JSON.stringify({ announcement: adminState.read().announcement });
59
+ response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
60
+ }
49
61
  if (request.method !== 'GET' || !['/pair', '/pair/resolve'].includes(target.pathname)) {
50
62
  response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
51
63
  }
@@ -57,7 +69,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
57
69
  response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
58
70
  }
59
71
  if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
60
- const body = JSON.stringify({ version: 3, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
72
+ const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
61
73
  response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
62
74
  }
63
75
  const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
@@ -65,6 +77,84 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
65
77
  const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: room.expires, appDownloadUrl });
66
78
  response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
67
79
  });
80
+ function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
81
+ const value = typeof body === 'string' ? body : JSON.stringify(body);
82
+ response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store',
83
+ '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'" });
84
+ response.end(value);
85
+ }
86
+ async function handleAdmin(request, response, target) {
87
+ if (request.method === 'GET' && target.pathname === '/admin') {
88
+ adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
89
+ }
90
+ if (request.method === 'GET' && target.pathname === '/admin/api/status') {
91
+ const state = adminState.read();
92
+ adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
93
+ activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
94
+ websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
95
+ activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
96
+ processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
97
+ announcement: state.announcement, announcements: state.announcements }); return;
98
+ }
99
+ if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
100
+ request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
101
+ try {
102
+ const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
103
+ if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
104
+ const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
105
+ log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
106
+ } else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
107
+ const state = adminState.createAnnouncement(body.title, body.message, body.publish === true);
108
+ if (body.publish === true) {
109
+ broadcastAnnouncement(state.announcement);
110
+ log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
111
+ } else log({ event: 'admin-announcement-draft-created', announcementId: state.announcements[0].id });
112
+ } else if (target.pathname.startsWith('/admin/api/announcements/')) {
113
+ const parts = target.pathname.slice('/admin/api/announcements/'.length).split('/');
114
+ const id = parts[0];
115
+ if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
116
+ if (parts.length === 1 && request.method === 'PUT') {
117
+ adminState.updateDraft(id, body.title, body.message);
118
+ log({ event: 'admin-announcement-draft-updated', announcementId: id });
119
+ } else if (parts.length === 1 && request.method === 'DELETE') {
120
+ adminState.deleteAnnouncement(id);
121
+ log({ event: 'admin-announcement-deleted', announcementId: id });
122
+ } else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
123
+ const state = adminState.publishDraft(id, body.title, body.message);
124
+ broadcastAnnouncement(state.announcement);
125
+ log({ event: 'admin-announcement-published', announcementId: id });
126
+ } else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
127
+ const state = adminState.withdraw(id);
128
+ broadcastAnnouncement(state.announcement);
129
+ log({ event: 'admin-announcement-withdrawn', announcementId: id });
130
+ } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
131
+ } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
132
+ adminReply(response, 200, { ok: true });
133
+ } catch (error) { adminReply(response, 400, { error: error.message }); }
134
+ }
135
+ function broadcastAnnouncement(announcement) {
136
+ for (const room of rooms.values()) {
137
+ for (const observer of room.observers.values()) send(observer.ws, { type: 'announcement', announcement });
138
+ for (const connection of room.connections.values()) send(connection.ws, { type: 'announcement', announcement });
139
+ }
140
+ }
141
+ function releaseRelay(connection) {
142
+ if (!connection?.relayAdmitted) return;
143
+ connection.relayAdmitted = false; activeRelaySessions = Math.max(0, activeRelaySessions - 1);
144
+ }
145
+ function admitRelay(connection, room) {
146
+ if (connection.relayAdmitted || connection.relayExempt) return true;
147
+ if (connection.capacityExempt) {
148
+ connection.relayExempt = true;
149
+ log({ event: 'relay-capacity-exempt', device: room.id }); return true;
150
+ }
151
+ const limit = adminState.read().maxRelaySessions;
152
+ if (activeRelaySessions >= limit) {
153
+ log({ event: 'relay-capacity-rejected', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return false;
154
+ }
155
+ connection.relayAdmitted = true; activeRelaySessions++;
156
+ log({ event: 'relay-capacity-admitted', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return true;
157
+ }
68
158
  const wss = new WebSocketServer({ noServer: true, maxPayload, perMessageDeflate: {
69
159
  serverNoContextTakeover: true, clientNoContextTakeover: true,
70
160
  threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
@@ -81,12 +171,24 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
81
171
  }
82
172
  wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
83
173
  });
84
- wss.on('connection', ws => {
174
+ wss.on('connection', ws => {
85
175
  let role, room, session, clientId, alive = true;
86
176
  const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
87
177
  ws.on('pong', () => { alive = true; });
88
178
  const heartbeat = setInterval(() => { if (!alive) ws.terminate(); else { alive = false; ws.ping(); } }, 30000);
89
- const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
179
+ const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
180
+ const queueClientMessage = (connection, message, pause = 0) => {
181
+ const bytes = Buffer.byteLength(JSON.stringify(message));
182
+ connection.queuedBytes = (connection.queuedBytes || 0) + bytes;
183
+ if (connection.queuedBytes > 40 * 1024 * 1024) {
184
+ connection.ws.close(1013, 'Receiver too slow'); return;
185
+ }
186
+ connection.outbound = (connection.outbound || Promise.resolve()).then(async () => {
187
+ if (pause > 0) await new Promise(resolve => setTimeout(resolve, pause));
188
+ connection.queuedBytes -= bytes;
189
+ send(connection.ws, message);
190
+ }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
191
+ };
90
192
  ws.on('error', () => {}); // close owns routing cleanup.
91
193
  ws.on('close', (code, reason) => {
92
194
  log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
@@ -97,6 +199,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
97
199
  if (!room.durable) rooms.delete(room.id);
98
200
  for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
99
201
  } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
202
+ releaseRelay(room.connections.get(clientId));
100
203
  room.connections.delete(clientId);
101
204
  if (room.host) send(room.host, { type: 'detach', session });
102
205
  } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
@@ -117,11 +220,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
117
220
  hostName = normalizeHostName(message.hostName);
118
221
  }
119
222
  catch { return fail(1008, 'Invalid LAN candidates'); }
120
- room = durable ? rooms.get(message.deviceId) : undefined;
121
- if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
122
- if (!room) {
123
- if (!matches(message.key, hostHash)) return fail();
124
- if (rooms.size >= 256) return fail(4429, 'Device limit reached');
223
+ room = durable ? rooms.get(message.deviceId) : undefined;
224
+ if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
225
+ if (!room) {
226
+ // Durable computers prove future ownership with their generated device secret.
227
+ // The legacy deployment key remains accepted, but is no longer required for first registration.
228
+ if (!durable && !matches(message.key, hostHash)) return fail();
229
+ if (rooms.size >= 256) return fail(4429, 'Device limit reached');
125
230
  const now = new Date().toISOString();
126
231
  room = { id: durable ? message.deviceId : randomUUID(), durable,
127
232
  revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), createdAt: now, updatedAt: now,
@@ -143,7 +248,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
143
248
  send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
144
249
  }
145
250
  const invite = room.clients.length ? {} : invitation(room);
146
- send(ws, { type: 'registered', protocolVersion: 3, room: room.id, paired: room.clients.length > 0,
251
+ send(ws, { type: 'registered', protocolVersion: 7, room: room.id, paired: room.clients.length > 0,
147
252
  clientCount: room.clients.length, clients: room.clients, revision: room.revision, ...invite }); return;
148
253
  }
149
254
  room = rooms.get(message.room);
@@ -177,6 +282,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
177
282
  room.observers.set(clientId, { ws });
178
283
  send(ws, { type: 'observing', online: Boolean(room.host) });
179
284
  send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
285
+ const announcement = adminState.read().announcement;
286
+ if (announcement) send(ws, { type: 'announcement', announcement });
180
287
  if (room.hostName) send(ws, { type: 'computer-info', hostName: room.hostName });
181
288
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
182
289
  if (room.officialComputer) send(ws, { type: 'official-computer', computer: room.officialComputer });
@@ -186,14 +293,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
186
293
  const oldConnection = room.connections.get(clientId);
187
294
  role = 'client'; clearTimeout(authTimer); session = randomUUID();
188
295
  if (oldConnection?.ws && oldConnection.ws !== ws) {
296
+ releaseRelay(oldConnection);
189
297
  send(room.host, { type: 'detach', session: oldConnection.session });
190
298
  oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
191
299
  }
192
300
  room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
193
- repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0 });
194
- send(ws, { type: 'paired', protocolVersion: 3, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
301
+ repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
302
+ relayAdmitted: false, relayExempt: false,
303
+ capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
304
+ send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
195
305
  send(ws, { type: 'computer-info', hostName: room.hostName });
196
306
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
307
+ const announcement = adminState.read().announcement;
308
+ if (announcement) send(ws, { type: 'announcement', announcement });
197
309
  send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
198
310
  }
199
311
  if (role === 'host') {
@@ -214,6 +326,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
214
326
  commit(room, { clients: [], revision: room.revision + 1 });
215
327
  room.pairingHash = undefined;
216
328
  for (const connection of room.connections.values()) {
329
+ releaseRelay(connection);
217
330
  send(ws, { type: 'detach', session: connection.session });
218
331
  connection.ws.close(4401, 'Device authorization revoked');
219
332
  }
@@ -225,8 +338,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
225
338
  }
226
339
  const connection = [...room.connections.values()].find(item => item.session === message.session);
227
340
  if (!connection) return; // Late frames from an ended session.
228
- if (message.type === 'data' && typeof message.payload === 'string') send(connection.ws, { type: 'data', payload: message.payload });
229
- else if (message.type === 'ready') send(connection.ws, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
341
+ if (message.type === 'data' && typeof message.payload === 'string') queueClientMessage(connection, { type: 'data', payload: message.payload });
342
+ else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
230
343
  p2pProbeRepeat: connection.p2pProbe && connection.repeat });
231
344
  else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
232
345
  if (connection.probeId !== message.probeId) return;
@@ -251,7 +364,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
251
364
  if (connection.probeId !== message.probeId) return;
252
365
  if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
253
366
  send(room.host, { ...message, session });
367
+ } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
368
+ releaseRelay(room.connections.get(clientId));
254
369
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
370
+ const connection = room.connections.get(clientId);
371
+ if (!controlPayload(message.payload) && !admitRelay(connection, room)) {
372
+ ws.close(4428, 'Server busy'); return;
373
+ }
255
374
  send(room.host, { type: 'data', session, payload: message.payload });
256
375
  } else fail();
257
376
  } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
@@ -261,7 +380,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
261
380
  listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
262
381
  return {
263
382
  url: listenUrl, hostKey,
264
- async close() {
383
+ async close() {
265
384
  for (const ws of wss.clients) ws.terminate();
266
385
  await new Promise(resolve => wss.close(resolve));
267
386
  await new Promise(resolve => server.close(resolve));
@@ -338,3 +457,24 @@ function plainLabel(value) {
338
457
  function plainText(value, limit, empty) {
339
458
  return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
340
459
  }
460
+
461
+ function controlPayload(value) {
462
+ try {
463
+ const message = JSON.parse(value);
464
+ if (message.type === 'unsubscribe') return true;
465
+ return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
466
+ (message.path === '/api/health' || message.path.startsWith('/api/shared-recovery'));
467
+ } catch { return false; }
468
+ }
469
+
470
+ function readBody(request, limit) {
471
+ return new Promise((resolve, reject) => {
472
+ let value = '';
473
+ request.setEncoding('utf8');
474
+ request.on('data', chunk => {
475
+ value += chunk;
476
+ if (Buffer.byteLength(value) > limit) { reject(new Error('Admin request too large')); request.destroy(); }
477
+ });
478
+ request.on('end', () => resolve(value)); request.on('error', reject);
479
+ });
480
+ }