@mh-gg/cli 0.1.1-alpha.20260613T085325975Z

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 (71) hide show
  1. package/README.md +5 -0
  2. package/bin/matterhorn.cjs +57 -0
  3. package/package.json +49 -0
  4. package/runtime/bin/appFrontend/artifacts.cjs +25 -0
  5. package/runtime/bin/appFrontend/buildServers.cjs +176 -0
  6. package/runtime/bin/appFrontend/commandEnv.cjs +74 -0
  7. package/runtime/bin/appFrontend/commandPolicy.cjs +23 -0
  8. package/runtime/bin/appFrontend/devServers.cjs +150 -0
  9. package/runtime/bin/appFrontend/httpServers.cjs +221 -0
  10. package/runtime/bin/appFrontend/paths.cjs +103 -0
  11. package/runtime/bin/appFrontend/ports.cjs +36 -0
  12. package/runtime/bin/appFrontend/processes.cjs +127 -0
  13. package/runtime/bin/appFrontend.cjs +45 -0
  14. package/runtime/bin/appHostCommand.cjs +381 -0
  15. package/runtime/bin/matterhorn.cjs +501 -0
  16. package/runtime/bin/matterhornAppLoader.cjs +588 -0
  17. package/runtime/bin/matterhornApps.cjs +223 -0
  18. package/runtime/bin/matterhornDeploy.cjs +108 -0
  19. package/runtime/bin/matterhornEmitAppBundle.cjs +20 -0
  20. package/runtime/bin/matterhornInstall.cjs +609 -0
  21. package/runtime/host/callAuth.cjs +76 -0
  22. package/runtime/host/host.cjs +103 -0
  23. package/runtime/host/hostAnnouncement.cjs +70 -0
  24. package/runtime/host/hostClients/constants.cjs +7 -0
  25. package/runtime/host/hostClients/frontendBundleRefresh.cjs +158 -0
  26. package/runtime/host/hostClients/frontendRequests.cjs +166 -0
  27. package/runtime/host/hostClients/index.cjs +68 -0
  28. package/runtime/host/hostClients/rejections.cjs +37 -0
  29. package/runtime/host/hostSession.cjs +160 -0
  30. package/runtime/host/inlineProgressBar.cjs +128 -0
  31. package/runtime/host/localPeerServer.cjs +114 -0
  32. package/runtime/host/localRelayClient.cjs +151 -0
  33. package/runtime/host/matterhornrc.cjs +75 -0
  34. package/runtime/host/memberRootRegistry.cjs +132 -0
  35. package/runtime/host/nodePeer.cjs +127 -0
  36. package/runtime/host/nodePeerRacePatch.cjs +106 -0
  37. package/runtime/host/peerJsConfig.cjs +26 -0
  38. package/runtime/host/pushEgress.cjs +48 -0
  39. package/runtime/host/pushStorage.cjs +233 -0
  40. package/runtime/host/relay/config.cjs +179 -0
  41. package/runtime/host/relay/connectionCleanup.cjs +34 -0
  42. package/runtime/host/relay/connectionDispatcher.cjs +140 -0
  43. package/runtime/host/relay/matterhornOperationEvents.cjs +100 -0
  44. package/runtime/host/relay/matterhornRuntimeEventBridge.cjs +182 -0
  45. package/runtime/host/relay/nostrRelay.cjs +30 -0
  46. package/runtime/host/relay/peerStartup.cjs +81 -0
  47. package/runtime/host/relay.cjs +653 -0
  48. package/runtime/host/relayClientRouting.cjs +1054 -0
  49. package/runtime/host/relayConfig.cjs +156 -0
  50. package/runtime/host/relayHostAuth.cjs +39 -0
  51. package/runtime/host/relayHostMessages.cjs +367 -0
  52. package/runtime/host/relayHttp.cjs +48 -0
  53. package/runtime/host/relayIdentity.cjs +496 -0
  54. package/runtime/host/relayIncomingGate.cjs +153 -0
  55. package/runtime/host/relayMeshEnvelopes.cjs +522 -0
  56. package/runtime/host/relayPeerLifecycle.cjs +96 -0
  57. package/runtime/host/relayPeerSignals.cjs +175 -0
  58. package/runtime/host/relayRoomRuntimePersistence.cjs +129 -0
  59. package/runtime/host/relayStatus.cjs +160 -0
  60. package/runtime/host/sfuRelay.cjs +553 -0
  61. package/runtime/host/sqliteRelayStorage.cjs +352 -0
  62. package/runtime/host/wireValidation/client.cjs +213 -0
  63. package/runtime/host/wireValidation/host.cjs +33 -0
  64. package/runtime/host/wireValidation/index.cjs +13 -0
  65. package/runtime/host/wireValidation/peerSignal.cjs +35 -0
  66. package/runtime/host/wireValidation/presenceEvent.cjs +49 -0
  67. package/runtime/host/wireValidation/push.cjs +49 -0
  68. package/runtime/host/wireValidation/relay.cjs +131 -0
  69. package/runtime/host/wireValidation/shared.cjs +49 -0
  70. package/runtime/scripts/ensureWorkspaceSdkBuild.cjs +148 -0
  71. package/runtime/scripts/killChildTree.cjs +18 -0
@@ -0,0 +1,1054 @@
1
+ const { PROTOCOL } = require("@mh-gg/host-config");
2
+ const { createMemberRootRegistry } = require("./memberRootRegistry.cjs");
3
+ const { validateClientMessage } = require("./wireValidation/index.cjs");
4
+ const { MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME, verifyRoomDeviceKeyClaim, verifyRoomDeviceOperationSignature, verifyRoomDeviceSignedOperation, verifyRoomMemberKeyClaim } = require("@mh-gg/protocol");
5
+ const { sendMessage, relayMeshPeerIdForRoomPeerId } = require("@mh-gg/relay-core");
6
+ const crypto = require("node:crypto");
7
+ const { unwrapVapidGrant, vapidPublicKeyFromPrivateKey } = require("@mh-gg/push");
8
+
9
+ const PRESENCE_STATUSES = new Set(["online", "away", "busy", "invisible", "offline"]);
10
+ const PRESENCE_REPLAY_TTL_MS = 90_000;
11
+ const EPHEMERAL_TOKEN_REPLAY_TTL_MS = 60_000;
12
+
13
+ function createRelayClientRouting({
14
+ clientConnections,
15
+ clientRooms,
16
+ config,
17
+ debug,
18
+ hostConnections,
19
+ isClosing,
20
+ mesh,
21
+ peerSignals,
22
+ publishMatterhornOperation = () => {},
23
+ relayPluginRuntimes,
24
+ encryptedRoomRuntimes,
25
+ sfu,
26
+ memberRootRegistry = createMemberRootRegistry(config),
27
+ pushStorage,
28
+ pushEgress,
29
+ eventStorage
30
+ }) {
31
+ const clientActors = new Map();
32
+ const presenceCache = new Map();
33
+ const ephemeralTokenCache = new Map();
34
+ const ephemeralTokensByConn = new Map();
35
+ const personalStateCache = new Map();
36
+ const deviceKeyClaims = new Map();
37
+
38
+ function sendClientMessage(conn, peerId, message) {
39
+ if (conn && typeof peerId === "string" && peerId && !conn.MatterhornPeerId) conn.MatterhornPeerId = peerId;
40
+ sendMessage(conn, message);
41
+ }
42
+
43
+ function actorFromClientMessage(message) {
44
+ const actor = message?.operation?.actor || message?.actor;
45
+ if (!actor || typeof actor !== "object" || Array.isArray(actor)) return undefined;
46
+ if (typeof actor.memberId !== "string" || typeof actor.role !== "string") return undefined;
47
+ return actor;
48
+ }
49
+
50
+ function rememberClientActor(conn, peerId, message) {
51
+ const actor = actorFromClientMessage(message);
52
+ if (!actor) return;
53
+ clientActors.set(conn, actor);
54
+ clientActors.set(peerId, actor);
55
+ }
56
+
57
+ function deviceKeyClaimKey(claim) {
58
+ if (!claim) return undefined;
59
+ const room = claim.roomId || claim.roomName;
60
+ if (!room || !claim.memberId || !claim.deviceId || !claim.keyId) return undefined;
61
+ return `${room}:${claim.memberId}:${claim.deviceId}:${claim.keyId}`;
62
+ }
63
+
64
+ function operationDeviceKeyClaimKey(operation) {
65
+ const auth = operation?.auth;
66
+ if (!auth || auth.scheme !== MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME) return undefined;
67
+ return `${operation.roomId}:${auth.memberId}:${auth.deviceId}:${auth.keyId}`;
68
+ }
69
+
70
+ function rememberDeviceKeyClaim(claim) {
71
+ const verification = verifyRoomMemberKeyClaim(claim);
72
+ if (!verification.ok) return false;
73
+ const key = deviceKeyClaimKey(claim);
74
+ if (!key) return false;
75
+ const rootResult = memberRootRegistry.remember(claim);
76
+ if (!rootResult.ok) return false;
77
+ deviceKeyClaims.set(key, clonePlain(claim));
78
+ return true;
79
+ }
80
+
81
+ function claimForOperation(message) {
82
+ const candidates = [message?.memberKey, message?.deviceKeyClaim, message?.operation?.auth?.claim];
83
+ for (const claim of candidates) {
84
+ if (claim && rememberDeviceKeyClaim(claim)) return claim;
85
+ }
86
+ const key = operationDeviceKeyClaimKey(message?.operation);
87
+ return key ? deviceKeyClaims.get(key) : undefined;
88
+ }
89
+
90
+ function validateOperationDeviceSignature(message) {
91
+ const auth = message?.operation?.auth;
92
+ if (!auth || auth.scheme !== MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME) return { ok: true, legacy: true };
93
+ const claim = claimForOperation(message);
94
+ if (!claim) return { ok: false, error: "Operation member key claim is missing." };
95
+ return verifyRoomDeviceOperationSignature(message.operation, claim);
96
+ }
97
+
98
+ function presenceText(value, max) {
99
+ if (typeof value !== "string") return undefined;
100
+ const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
101
+ if (!text) return undefined;
102
+ return text.slice(0, max);
103
+ }
104
+
105
+ function clonePlain(value) {
106
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
107
+ }
108
+
109
+ function tagValue(tags, name) {
110
+ if (!Array.isArray(tags)) return undefined;
111
+ const tag = tags.find((item) => Array.isArray(item) && item[0] === name && typeof item[1] === "string");
112
+ return tag?.[1];
113
+ }
114
+
115
+ function cachedPresenceKey(message) {
116
+ return presenceText(message?.clientId, 120)
117
+ || presenceText(message?.presence?.memberId, 120)
118
+ || presenceText(tagValue(message?.presenceEvent?.tags, "client"), 120);
119
+ }
120
+
121
+ function cachedPresenceExpiresAt(message, now = Date.now()) {
122
+ const taggedExpiration = Number(tagValue(message?.presenceEvent?.tags, "expiration"));
123
+ if (Number.isFinite(taggedExpiration) && taggedExpiration > 0) return taggedExpiration * 1000;
124
+ const lastPingAt = Number(message?.presence?.lastPingAt || message?.presence?.updatedAt);
125
+ if (Number.isFinite(lastPingAt) && lastPingAt > 0) return lastPingAt + PRESENCE_REPLAY_TTL_MS;
126
+ return now + PRESENCE_REPLAY_TTL_MS;
127
+ }
128
+
129
+ function prunePresenceCache(roomName, now = Date.now()) {
130
+ const roomCache = presenceCache.get(roomName);
131
+ if (!roomCache) return undefined;
132
+ for (const [key, item] of roomCache) {
133
+ if (item.expiresAt <= now) roomCache.delete(key);
134
+ }
135
+ if (roomCache.size === 0) {
136
+ presenceCache.delete(roomName);
137
+ return undefined;
138
+ }
139
+ return roomCache;
140
+ }
141
+
142
+ function tokenKey(message) {
143
+ const id = presenceText(message?.token?.id || message?.tokenId, 160);
144
+ const clientId = presenceText(message?.clientId || message?.token?.clientId, 120);
145
+ if (!id || !clientId) return undefined;
146
+ return `${clientId}:${id}`;
147
+ }
148
+
149
+ function tokenExpiresAt(message, now = Date.now()) {
150
+ const expiresAt = Number(message?.token?.expiresAt);
151
+ if (Number.isFinite(expiresAt) && expiresAt > 0) return expiresAt;
152
+ return now + EPHEMERAL_TOKEN_REPLAY_TTL_MS;
153
+ }
154
+
155
+ function pruneEphemeralTokenCache(roomName, now = Date.now()) {
156
+ const roomCache = ephemeralTokenCache.get(roomName);
157
+ if (!roomCache) return undefined;
158
+ for (const [key, item] of roomCache) {
159
+ if (item.expiresAt <= now) roomCache.delete(key);
160
+ }
161
+ if (roomCache.size === 0) {
162
+ ephemeralTokenCache.delete(roomName);
163
+ return undefined;
164
+ }
165
+ return roomCache;
166
+ }
167
+
168
+ function cachePresenceMessage(roomName, message) {
169
+ if (!roomName || message?.type !== "host/presence") return;
170
+ const key = cachedPresenceKey(message);
171
+ if (!key) return;
172
+ const now = Date.now();
173
+ const expiresAt = cachedPresenceExpiresAt(message, now);
174
+ if (expiresAt <= now) return;
175
+ const roomCache = prunePresenceCache(roomName, now) || new Map();
176
+ roomCache.set(key, { message: clonePlain(message), expiresAt });
177
+ presenceCache.set(roomName, roomCache);
178
+ }
179
+
180
+ function replayPresenceCache(conn, peerId, roomName) {
181
+ const roomCache = prunePresenceCache(roomName);
182
+ if (!roomCache) return;
183
+ for (const item of roomCache.values()) sendClientMessage(conn, peerId, clonePlain(item.message));
184
+ }
185
+
186
+ function replayEphemeralTokenCache(conn, peerId, roomName) {
187
+ const roomCache = pruneEphemeralTokenCache(roomName);
188
+ if (!roomCache) return;
189
+ for (const item of roomCache.values()) sendClientMessage(conn, peerId, clonePlain(item.message));
190
+ }
191
+
192
+ function rememberConnToken(conn, key, roomName, message) {
193
+ const tokens = ephemeralTokensByConn.get(conn) || new Map();
194
+ tokens.set(key, { roomName, message: clonePlain(message) });
195
+ ephemeralTokensByConn.set(conn, tokens);
196
+ }
197
+
198
+ function forgetConnToken(conn, key) {
199
+ const tokens = ephemeralTokensByConn.get(conn);
200
+ if (!tokens) return;
201
+ tokens.delete(key);
202
+ if (tokens.size === 0) ephemeralTokensByConn.delete(conn);
203
+ }
204
+
205
+ function cacheEphemeralTokenMessage(conn, roomName, message) {
206
+ if (!roomName || message?.type !== "host/ephemeral-token") return;
207
+ const key = tokenKey(message);
208
+ if (!key) return;
209
+ const roomCache = pruneEphemeralTokenCache(roomName) || new Map();
210
+ if (message.action === "release") {
211
+ roomCache.delete(key);
212
+ forgetConnToken(conn, key);
213
+ if (roomCache.size === 0) ephemeralTokenCache.delete(roomName);
214
+ return;
215
+ }
216
+ const now = Date.now();
217
+ const expiresAt = tokenExpiresAt(message, now);
218
+ if (expiresAt <= now) return;
219
+ roomCache.set(key, { message: clonePlain(message), expiresAt });
220
+ ephemeralTokenCache.set(roomName, roomCache);
221
+ if (conn) rememberConnToken(conn, key, roomName, message);
222
+ }
223
+
224
+ function presenceMemberId(message) {
225
+ return presenceText(message?.actor?.memberId, 120)
226
+ || presenceText(message?.memberId, 120)
227
+ || presenceText(message?.clientId, 120);
228
+ }
229
+
230
+ function actorDisplayName(actor, memberId) {
231
+ return presenceText(actor?.displayName, 80)
232
+ || presenceText(actor?.name, 80)
233
+ || memberId;
234
+ }
235
+
236
+ function canSeeInvisiblePresence(actor, memberId) {
237
+ return actor?.memberId === memberId || actor?.role === "moderator" || actor?.role === "admin" || actor?.role === "owner";
238
+ }
239
+
240
+ function hostPresenceMessage(message, recipientActor) {
241
+ if (message.presenceEvent) {
242
+ return {
243
+ type: "host/presence",
244
+ protocol: PROTOCOL,
245
+ roomName: message.roomName,
246
+ clientId: message.clientId,
247
+ presenceEvent: message.presenceEvent
248
+ };
249
+ }
250
+ const actor = actorFromClientMessage(message) || {};
251
+ const memberId = presenceMemberId(message);
252
+ if (!memberId) return undefined;
253
+ const declaredStatus = PRESENCE_STATUSES.has(message.status) ? message.status : "online";
254
+ const hiddenForRecipient = declaredStatus === "invisible" && !canSeeInvisiblePresence(recipientActor, memberId);
255
+ const status = hiddenForRecipient ? "offline" : declaredStatus;
256
+ const now = Number.isFinite(Number(message.at)) ? Number(message.at) : Date.now();
257
+ const presence = {
258
+ memberId,
259
+ name: actorDisplayName(actor, memberId),
260
+ status,
261
+ activity: hiddenForRecipient ? null : presenceText(message.activity, 160) || null,
262
+ updatedAt: now,
263
+ lastPingAt: now,
264
+ visible: status !== "offline" && status !== "invisible"
265
+ };
266
+ if (!hiddenForRecipient) presence.declaredStatus = declaredStatus;
267
+ return {
268
+ type: "host/presence",
269
+ protocol: PROTOCOL,
270
+ roomName: message.roomName,
271
+ clientId: message.clientId,
272
+ presence
273
+ };
274
+ }
275
+
276
+ function meshPresenceMessage(message) {
277
+ if (message.presenceEvent) return hostPresenceMessage(message);
278
+ if (message.status !== "invisible") return hostPresenceMessage(message);
279
+ return hostPresenceMessage({ ...message, status: "offline", activity: undefined });
280
+ }
281
+
282
+ function sendHostUnavailable(conn, peerId) {
283
+ sendClientMessage(conn, peerId, {
284
+ type: "host/error",
285
+ protocol: PROTOCOL,
286
+ code: "host-unavailable",
287
+ message: "The room runtime is not connected to this relay."
288
+ });
289
+ }
290
+
291
+ function sendMatterhornSnapshot(conn, peerId, roomName, actor) {
292
+ if (!relayPluginRuntimes?.hasRoom?.(roomName)) {
293
+ debug(`matterhorn-state unavailable ${roomName}: runtime missing`);
294
+ return false;
295
+ }
296
+ debug(`matterhorn-state snapshot requested ${roomName}`);
297
+ void relayPluginRuntimes.snapshot(roomName, actor).then((snapshot) => {
298
+ if (!snapshot) {
299
+ debug(`matterhorn-state unavailable ${roomName}: empty snapshot`);
300
+ sendHostUnavailable(conn, peerId);
301
+ return;
302
+ }
303
+ debug(`matterhorn-state snapshot sent ${roomName} version=${snapshot.state?.version ?? "unknown"}`);
304
+ sendClientMessage(conn, peerId, {
305
+ type: "host/matterhorn-state",
306
+ protocol: PROTOCOL,
307
+ roomName,
308
+ state: snapshot.state
309
+ });
310
+ }).catch((error) => {
311
+ debug(`matterhorn-state snapshot failed ${roomName}: ${error?.message || String(error)}`);
312
+ sendClientMessage(conn, peerId, {
313
+ type: "host/error",
314
+ protocol: PROTOCOL,
315
+ code: "matterhorn-state-unavailable",
316
+ message: error?.message || "Matterhorn state is unavailable."
317
+ });
318
+ });
319
+ return true;
320
+ }
321
+
322
+ function messageDebugSummary(conn, message) {
323
+ const keys = message && typeof message === "object" && !Array.isArray(message) ? Object.keys(message).sort() : [];
324
+ return JSON.stringify({
325
+ peerId: conn.MatterhornPeerId || conn.peer || conn.id || "unknown",
326
+ type: message?.type,
327
+ roomName: message?.roomName,
328
+ clientId: message?.clientId,
329
+ keys
330
+ });
331
+ }
332
+
333
+ function sendInvalidClientMessage(conn, message, validation) {
334
+ debug(`invalid client message ${validation.message}; ${messageDebugSummary(conn, message)}`);
335
+ sendClientMessage(conn, conn.MatterhornPeerId || mesh.scopedPeerId(conn), {
336
+ type: "host/error",
337
+ protocol: PROTOCOL,
338
+ code: validation.code,
339
+ message: validation.message
340
+ });
341
+ }
342
+
343
+ function sendRelayHostEnvelope(relayConn, peerId, roomName, message) {
344
+ sendMessage(relayConn, {
345
+ type: "relay.host",
346
+ id: mesh.nextMessageId("relay.host"),
347
+ roomName,
348
+ peerId,
349
+ message
350
+ });
351
+ }
352
+
353
+ function sendToClient(peerId, message, roomName) {
354
+ const conn = clientConnections.get(peerId);
355
+ if (conn && conn.open !== false) {
356
+ debug(`client.send direct ${peerId} ${message?.type || "message"}`);
357
+ sendClientMessage(conn, peerId, message);
358
+ return;
359
+ }
360
+
361
+ if (mesh.sendDiscoveredClientRoute(peerId, message, undefined, roomName)) {
362
+ return;
363
+ }
364
+
365
+ const relayConn = mesh.remoteClientRoute(peerId);
366
+ if (relayConn && relayConn.open !== false) {
367
+ debug(`client.send relay route ${peerId} ${message?.type || "message"}`);
368
+ sendRelayHostEnvelope(relayConn, peerId, roomName, message);
369
+ return;
370
+ }
371
+
372
+ const latestRoute = mesh.latestClientRoute(roomName, message?.clientId);
373
+ if (latestRoute?.conn) {
374
+ if (clientConnections.get(latestRoute.peerId) === latestRoute.conn) {
375
+ debug(`client.send latest direct ${latestRoute.peerId} ${message?.type || "message"}`);
376
+ sendClientMessage(latestRoute.conn, latestRoute.peerId, message);
377
+ return;
378
+ }
379
+ debug(`client.send latest relay route ${latestRoute.peerId} ${message?.type || "message"}`);
380
+ sendRelayHostEnvelope(latestRoute.conn, latestRoute.peerId, roomName, message);
381
+ return;
382
+ }
383
+
384
+ debug(`client.send relay envelope ${peerId} ${message?.type || "message"}`);
385
+ mesh.sendEnvelope({
386
+ type: "relay.host",
387
+ id: mesh.nextMessageId("relay.host"),
388
+ roomName,
389
+ peerId,
390
+ message
391
+ });
392
+ }
393
+
394
+ function broadcastToRoom(roomName, message) {
395
+ cachePresenceMessage(roomName, message);
396
+ cacheEphemeralTokenMessage(undefined, roomName, message);
397
+ for (const [conn, clientRoomName] of clientRooms) {
398
+ if (clientRoomName === roomName && conn.open !== false) sendClientMessage(conn, conn.MatterhornPeerId || mesh.scopedPeerId(conn), message);
399
+ }
400
+ }
401
+
402
+ async function materializeBroadcastMessage(message, actor) {
403
+ return typeof message === "function" ? await message(actor) : message;
404
+ }
405
+
406
+ async function sendBroadcastRoute(route, roomName, message, sentConns, sentPeerIds) {
407
+ if (!route?.conn || route.conn.open === false || sentPeerIds.has(route.peerId)) return;
408
+ sentPeerIds.add(route.peerId);
409
+ const actor = clientActors.get(route.peerId) || clientActors.get(route.conn);
410
+ const resolvedMessage = await materializeBroadcastMessage(message, actor);
411
+ if (!resolvedMessage) return;
412
+ if (clientConnections.get(route.peerId) === route.conn) {
413
+ if (!sentConns.has(route.conn)) {
414
+ debug(`client.broadcast latest direct ${route.peerId} ${resolvedMessage?.type || "message"}`);
415
+ sendClientMessage(route.conn, route.peerId, resolvedMessage);
416
+ sentConns.add(route.conn);
417
+ }
418
+ return;
419
+ }
420
+ debug(`client.broadcast latest relay route ${route.peerId} ${resolvedMessage?.type || "message"}`);
421
+ sendRelayHostEnvelope(route.conn, route.peerId, roomName, resolvedMessage);
422
+ }
423
+
424
+ async function broadcastHostToRoom(roomName, message) {
425
+ const sentConns = new Set();
426
+ const sentPeerIds = new Set();
427
+ const sends = [];
428
+ for (const [conn, clientRoomName] of clientRooms) {
429
+ if (clientRoomName !== roomName || conn.open === false) continue;
430
+ const actor = clientActors.get(conn);
431
+ sends.push(materializeBroadcastMessage(message, actor).then((resolvedMessage) => {
432
+ if (!resolvedMessage) return;
433
+ debug(`client.broadcast direct ${conn.MatterhornPeerId || conn.peer || "unknown"} ${resolvedMessage?.type || "message"}`);
434
+ sendClientMessage(conn, conn.MatterhornPeerId || mesh.scopedPeerId(conn), resolvedMessage);
435
+ }));
436
+ sentConns.add(conn);
437
+ if (conn.MatterhornPeerId) sentPeerIds.add(conn.MatterhornPeerId);
438
+ }
439
+
440
+ for (const route of mesh.clientRoutesForRoom(roomName)) {
441
+ sends.push(sendBroadcastRoute(route, roomName, message, sentConns, sentPeerIds));
442
+ }
443
+ await Promise.all(sends);
444
+ }
445
+
446
+ function relayHints() {
447
+ return mesh.relayHints();
448
+ }
449
+
450
+ function relayClaims() {
451
+ return [
452
+ config.relayClaim,
453
+ ...(typeof config.relayTrustStore?.claims === "function" ? config.relayTrustStore.claims() : [])
454
+ ].filter(Boolean);
455
+ }
456
+
457
+ function relayHintMessage(roomName) {
458
+ const roomApp = relayPluginRuntimes?.roomApp?.(roomName);
459
+ const runtimeCapabilities = relayPluginRuntimes?.runtimeCapabilities?.(roomName);
460
+ const message = {
461
+ type: "relay.hints",
462
+ roomName,
463
+ relayHints: relayHints(),
464
+ icedRelayHints: mesh.icedRelayHints(),
465
+ relayClaim: config.relayClaim,
466
+ relayClaims: relayClaims(),
467
+ relayHealth: typeof mesh.relayHealth === "function" ? mesh.relayHealth() : [],
468
+ iceServers: config.iceServers,
469
+ sfu: sfu.status(roomName)
470
+ };
471
+ if (roomApp) message.roomApp = roomApp;
472
+ if (runtimeCapabilities) message.runtimeCapabilities = runtimeCapabilities;
473
+ return message;
474
+ }
475
+
476
+ function relayRuntimeOwnsRoom(roomName) {
477
+ return Boolean(relayPluginRuntimes?.hasRoom?.(roomName));
478
+ }
479
+
480
+ function cachedFrontendProof(message) {
481
+ const proof = message?.cachedFrontend;
482
+ if (!proof || typeof proof !== "object" || Array.isArray(proof)) return undefined;
483
+ if (typeof proof.id !== "string" || !proof.id) return undefined;
484
+ if (typeof proof.integrity !== "string" || !proof.integrity) return undefined;
485
+ const result = { id: proof.id, integrity: proof.integrity };
486
+ if (Number.isInteger(proof.byteLength) && proof.byteLength >= 0) result.byteLength = proof.byteLength;
487
+ return result;
488
+ }
489
+
490
+ function frontendCacheStatus(roomApp, message) {
491
+ const frontend = Array.isArray(roomApp?.frontends) ? roomApp.frontends[0] : undefined;
492
+ if (!frontend?.id) return undefined;
493
+ const proof = cachedFrontendProof(message);
494
+ const sameFrontend = proof?.id === frontend.id && proof.integrity === frontend.integrity;
495
+ const sameLength = proof?.byteLength === undefined || frontend.byteLength === undefined || proof.byteLength === frontend.byteLength;
496
+ return {
497
+ frontendId: frontend.id,
498
+ integrity: frontend.integrity,
499
+ byteLength: frontend.byteLength,
500
+ clientHasCurrentBundle: Boolean(sameFrontend && sameLength)
501
+ };
502
+ }
503
+
504
+ function roomInfoMessage(roomName, request) {
505
+ const roomApp = relayPluginRuntimes?.roomApp?.(roomName);
506
+ if (!roomApp) return undefined;
507
+ const runtimeCapabilities = relayPluginRuntimes?.runtimeCapabilities?.(roomName);
508
+ const message = {
509
+ type: "room.info",
510
+ protocol: PROTOCOL,
511
+ roomName,
512
+ roomApp,
513
+ relayHints: relayHints(),
514
+ icedRelayHints: mesh.icedRelayHints(),
515
+ relayClaim: config.relayClaim,
516
+ relayClaims: relayClaims(),
517
+ relayHealth: typeof mesh.relayHealth === "function" ? mesh.relayHealth() : [],
518
+ iceServers: config.iceServers,
519
+ sfu: sfu.status(roomName)
520
+ };
521
+ if (request?.clientId) message.clientId = request.clientId;
522
+ if (runtimeCapabilities) message.runtimeCapabilities = runtimeCapabilities;
523
+ const cache = frontendCacheStatus(roomApp, request);
524
+ if (cache) message.frontendCache = cache;
525
+ return message;
526
+ }
527
+
528
+ function shouldForwardRoomInfoToHost(roomName) {
529
+ if (!hostConnections.get(roomName)) return false;
530
+ const roomApp = relayPluginRuntimes?.roomApp?.(roomName);
531
+ const frontend = Array.isArray(roomApp?.frontends) ? roomApp.frontends[0] : undefined;
532
+ return frontend?.dynamic === true;
533
+ }
534
+
535
+ function relaySfuMessage(roomName) {
536
+ return {
537
+ type: "relay.sfu",
538
+ roomName,
539
+ sfu: sfu.status(roomName)
540
+ };
541
+ }
542
+
543
+ function broadcastSfuStatus(roomName) {
544
+ if (isClosing() || !roomName) return;
545
+ broadcastToRoom(roomName, relaySfuMessage(roomName));
546
+ }
547
+
548
+ function forwardClientToMesh(conn, peerId, roomName, message) {
549
+ if (mesh.relayConnectionCount() === 0) return false;
550
+ mesh.sendEnvelope({
551
+ type: "relay.client",
552
+ id: mesh.nextMessageId("relay.client"),
553
+ roomName,
554
+ peerId,
555
+ message
556
+ }, conn);
557
+ return true;
558
+ }
559
+
560
+ function forwardClientRequest(conn, peerId, roomName, message) {
561
+ const host = hostConnections.get(roomName);
562
+ if (host) {
563
+ sendMessage(host, {
564
+ type: "relay/client-message",
565
+ peerId,
566
+ message
567
+ });
568
+ return true;
569
+ }
570
+ return forwardClientToMesh(conn, peerId, roomName, message);
571
+ }
572
+
573
+ function roomIndexSearchResultsMessage(roomName, request, result) {
574
+ return {
575
+ type: "host/room-index-search-results",
576
+ protocol: PROTOCOL,
577
+ roomName,
578
+ clientId: request.clientId,
579
+ requestId: request.requestId,
580
+ suite: request.suite,
581
+ keyId: request.keyId,
582
+ stream: request.stream,
583
+ mode: request.mode || "all",
584
+ events: Array.isArray(result?.events) ? result.events : [],
585
+ matches: Array.isArray(result?.matches) ? result.matches : [],
586
+ total: Number.isInteger(result?.total) ? result.total : 0,
587
+ queryTokenCount: Number.isInteger(result?.queryTokenCount) ? result.queryTokenCount : 0
588
+ };
589
+ }
590
+
591
+ function handleRoomIndexSearch(conn, peerId, roomName, message) {
592
+ const runtime = encryptedRoomRuntimes?.getRoom?.(roomName);
593
+ if (!runtime || typeof runtime.searchNgrams !== "function") return false;
594
+ const result = runtime.searchNgrams({
595
+ suite: message.suite,
596
+ keyId: message.keyId,
597
+ tokens: message.tokens,
598
+ stream: message.stream,
599
+ mode: message.mode,
600
+ minScore: message.minScore,
601
+ limit: message.limit,
602
+ offset: message.offset,
603
+ order: message.order
604
+ });
605
+ sendClientMessage(conn, peerId, roomIndexSearchResultsMessage(roomName, message, result));
606
+ return true;
607
+ }
608
+
609
+ function handleRelayPluginOperation(conn, peerId, roomName, message) {
610
+ if (!relayPluginRuntimes?.hasRoom?.(roomName)) return false;
611
+ void relayPluginRuntimes.handleClientOperation({
612
+ roomName,
613
+ peerId,
614
+ message,
615
+ sendToClient,
616
+ broadcastToRoom: broadcastHostToRoom,
617
+ broadcastToMesh: (envelope) => mesh.sendEnvelope(envelope)
618
+ }).then((result) => {
619
+ if (result?.ok && !result.duplicate && message.operation?.auth?.scheme !== MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME) {
620
+ publishMatterhornOperation(roomName, message.operation);
621
+ }
622
+ }).catch((error) => {
623
+ sendClientMessage(conn, peerId, {
624
+ type: "host/error",
625
+ protocol: PROTOCOL,
626
+ code: "operation-runtime-error",
627
+ message: error?.message || "Relay operation runtime failed."
628
+ });
629
+ });
630
+ return true;
631
+ }
632
+
633
+ function handleClientPresence(peerId, roomName, message) {
634
+ const relayMessage = meshPresenceMessage(message);
635
+ if (relayMessage) cachePresenceMessage(roomName, relayMessage);
636
+ void broadcastHostToRoom(roomName, (actor) => hostPresenceMessage(message, actor));
637
+ if (relayMessage) {
638
+ mesh.sendEnvelope({
639
+ type: "relay.broadcast",
640
+ id: mesh.nextMessageId("relay.broadcast"),
641
+ roomName,
642
+ message: relayMessage
643
+ });
644
+ }
645
+ }
646
+
647
+ function hostEphemeralTokenMessage(message) {
648
+ if (message.action === "release") {
649
+ return {
650
+ type: "host/ephemeral-token",
651
+ protocol: PROTOCOL,
652
+ roomName: message.roomName,
653
+ clientId: message.clientId,
654
+ action: "release",
655
+ tokenId: message.tokenId
656
+ };
657
+ }
658
+ return {
659
+ type: "host/ephemeral-token",
660
+ protocol: PROTOCOL,
661
+ roomName: message.roomName,
662
+ clientId: message.clientId,
663
+ action: "upsert",
664
+ token: message.token
665
+ };
666
+ }
667
+
668
+ function handleClientEphemeralToken(conn, roomName, message) {
669
+ const relayMessage = hostEphemeralTokenMessage(message);
670
+ cacheEphemeralTokenMessage(conn, roomName, relayMessage);
671
+ broadcastHostToRoom(roomName, relayMessage);
672
+ mesh.sendEnvelope({
673
+ type: "relay.broadcast",
674
+ id: mesh.nextMessageId("relay.broadcast"),
675
+ roomName,
676
+ message: relayMessage
677
+ });
678
+ }
679
+
680
+ function hostPersonalStateMessage(message) {
681
+ return {
682
+ type: "host/personal-state",
683
+ protocol: PROTOCOL,
684
+ roomName: message.roomName,
685
+ clientId: message.clientId,
686
+ stateId: message.stateId,
687
+ updatedAt: message.updatedAt,
688
+ payload: message.payload
689
+ };
690
+ }
691
+
692
+ function pendingPushTtlSeconds(message) {
693
+ return Number.isInteger(message?.ttlSeconds) && message.ttlSeconds > 0 ? message.ttlSeconds : 60 * 60;
694
+ }
695
+
696
+ function persistPendingPush(relayPush, reason) {
697
+ const userId = relayPush?.target?.userId;
698
+ if (!pushStorage?.upsertPendingPush || !relayPush?.pushId || !userId) return;
699
+ pushStorage.upsertPendingPush({
700
+ pushId: relayPush.pushId,
701
+ userId,
702
+ payload: relayPush,
703
+ ttlSeconds: pendingPushTtlSeconds(relayPush)
704
+ });
705
+ debug(`push pending ${relayPush.pushId} ${reason || "deferred"}`);
706
+ }
707
+
708
+ function rememberPersonalState(roomName, message) {
709
+ const previous = personalStateCache.get(roomName);
710
+ if (previous && Number(previous.updatedAt) > Number(message.updatedAt)) return previous;
711
+ personalStateCache.set(roomName, message);
712
+ return message;
713
+ }
714
+
715
+ function rememberPersonalStateFromHostMessage(roomName, message) {
716
+ if (message?.type !== "host/personal-state") return;
717
+ rememberPersonalState(roomName, message);
718
+ }
719
+
720
+ function replayPersonalStateCache(conn, peerId, roomName) {
721
+ const cached = personalStateCache.get(roomName);
722
+ if (cached) sendClientMessage(conn, peerId, cached);
723
+ }
724
+
725
+ function personalStateHostMessageForRoom(roomName) {
726
+ return personalStateCache.get(roomName);
727
+ }
728
+
729
+ function handleClientPersonalState(conn, peerId, roomName, message) {
730
+ if (message.action === "subscribe") {
731
+ sendClientMessage(conn, peerId, relayHintMessage(roomName));
732
+ replayPersonalStateCache(conn, peerId, roomName);
733
+ forwardClientToMesh(conn, peerId, roomName, message);
734
+ return;
735
+ }
736
+ const relayMessage = rememberPersonalState(roomName, hostPersonalStateMessage(message));
737
+ broadcastHostToRoom(roomName, relayMessage);
738
+ mesh.sendEnvelope({
739
+ type: "relay.broadcast",
740
+ id: mesh.nextMessageId("relay.broadcast"),
741
+ roomName,
742
+ message: relayMessage
743
+ });
744
+ }
745
+
746
+ function dropClientEphemeralTokens(conn) {
747
+ const tokens = ephemeralTokensByConn.get(conn);
748
+ if (!tokens) return;
749
+ ephemeralTokensByConn.delete(conn);
750
+ for (const [key, item] of tokens) {
751
+ const roomCache = ephemeralTokenCache.get(item.roomName);
752
+ roomCache?.delete(key);
753
+ if (roomCache?.size === 0) ephemeralTokenCache.delete(item.roomName);
754
+ const releaseMessage = {
755
+ ...item.message,
756
+ action: "release",
757
+ tokenId: item.message.token?.id || item.message.tokenId,
758
+ token: undefined
759
+ };
760
+ broadcastHostToRoom(item.roomName, releaseMessage);
761
+ mesh.sendEnvelope({
762
+ type: "relay.broadcast",
763
+ id: mesh.nextMessageId("relay.broadcast"),
764
+ roomName: item.roomName,
765
+ message: releaseMessage
766
+ });
767
+ }
768
+ }
769
+
770
+ function handleClientPushGrant(conn, message) {
771
+ const isTargetLocal = (message.relayId === config.relayPeerId || message.relayId === config.relayMeshPeerId);
772
+ if (isTargetLocal) {
773
+ try {
774
+ // VAPID grants are X25519-wrapped, so unwrap with the relay's X25519
775
+ // grant key — not the Ed25519 identity key (which can't do ECDH).
776
+ const relayPrivateKey = crypto.createPrivateKey(config.relayIdentity.grantPrivateKeyPem);
777
+ const { vapidPrivateKey } = unwrapVapidGrant({
778
+ grant: message.grant,
779
+ relayPrivateKey
780
+ });
781
+ const vapidPublicKey = vapidPublicKeyFromPrivateKey(vapidPrivateKey);
782
+ pushStorage?.upsertGrant({
783
+ userId: message.userId,
784
+ vapidPrivateKey,
785
+ vapidPublicKey
786
+ });
787
+ return { ok: true };
788
+ } catch (err) {
789
+ if (process.env.MATTERHORN_DEBUG === "1") {
790
+ console.error("Failed to unwrap VAPID grant:", err);
791
+ }
792
+ return { ok: false, code: "grant-unwrap-failed", message: err?.message || "Failed to unwrap push grant." };
793
+ }
794
+ } else {
795
+ const targetMeshPeerId = message.relayId.endsWith("-relay") ? message.relayId : relayMeshPeerIdForRoomPeerId(message.relayId);
796
+ const targetConn = mesh.connections().find(c => c.peer === targetMeshPeerId || c.MatterhornRelayAddress === message.relayId);
797
+ const envelope = {
798
+ type: "relay.client",
799
+ id: mesh.nextMessageId("relay.client"),
800
+ roomName: "push",
801
+ peerId: mesh.scopedPeerId(conn),
802
+ message: { ...message, roomName: "push" }
803
+ };
804
+ if (targetConn && targetConn.open !== false) {
805
+ sendMessage(targetConn, envelope);
806
+ } else {
807
+ mesh.sendEnvelope(envelope);
808
+ }
809
+ return { ok: true };
810
+ }
811
+ }
812
+
813
+ // A client asked the relay to deliver a push to other users. The relay only
814
+ // routes/delivers it — it does not read room operations or decrypt anything.
815
+ // The opaque payload is delivered locally if this relay holds the user's grant,
816
+ // otherwise it is fanned out over the mesh for the user's home relay to deliver.
817
+ function handleClientPushSend(message) {
818
+ const userIds = Array.isArray(message.userIds) ? message.userIds : [];
819
+ const pushIds = [];
820
+ for (const userId of userIds) {
821
+ if (typeof userId !== "string" || !userId) continue;
822
+ const pushId = `${message.pushId || mesh.nextMessageId("push")}:${userId}`;
823
+ pushIds.push(pushId);
824
+ const relayPush = {
825
+ type: "relay.push",
826
+ id: pushId,
827
+ pushId,
828
+ target: { userId },
829
+ payload: message.payload,
830
+ ttlSeconds: message.ttlSeconds,
831
+ urgency: message.urgency
832
+ };
833
+ const grant = pushStorage?.selectGrant(userId);
834
+ if (grant) {
835
+ pushEgress?.sendRelayPush(relayPush).then((result) => {
836
+ if (!result || result.delivered <= 0) persistPendingPush(relayPush, result?.reason || "local-egress-deferred");
837
+ }).catch((err) => {
838
+ if (process.env.MATTERHORN_DEBUG === "1") console.error(`Failed to send web push for user ${userId}:`, err);
839
+ persistPendingPush(relayPush, "local-egress-error");
840
+ });
841
+ } else {
842
+ persistPendingPush(relayPush, "mesh-pending-ack");
843
+ const fanout = mesh.sendEnvelope(relayPush);
844
+ if (!fanout) debug(`push pending ${pushId} mesh-unavailable`);
845
+ }
846
+ }
847
+ return pushIds;
848
+ }
849
+
850
+ function routeClientMessage(conn, message) {
851
+ const validation = validateClientMessage(message);
852
+ if (!validation.ok) {
853
+ sendInvalidClientMessage(conn, message, validation);
854
+ return;
855
+ }
856
+ if (message.type === "client/push-grant") {
857
+ const peerId = mesh.scopedPeerId(conn);
858
+ clientConnections.set(peerId, conn);
859
+ rememberClientActor(conn, peerId, message);
860
+ const result = handleClientPushGrant(conn, message);
861
+ if (result?.ok === false) {
862
+ sendClientMessage(conn, peerId, {
863
+ type: "client/push-grant.error",
864
+ protocol: PROTOCOL,
865
+ code: result.code || "push-grant-error",
866
+ message: result.message || "Push grant failed."
867
+ });
868
+ } else {
869
+ sendClientMessage(conn, peerId, {
870
+ type: "client/push-grant.ok",
871
+ protocol: PROTOCOL
872
+ });
873
+ }
874
+ return;
875
+ }
876
+ if (message.type === "client/push-send") {
877
+ const peerId = mesh.scopedPeerId(conn);
878
+ clientConnections.set(peerId, conn);
879
+ rememberClientActor(conn, peerId, message);
880
+ const pushIds = handleClientPushSend(message);
881
+ sendClientMessage(conn, peerId, {
882
+ type: "client/push-send.ok",
883
+ protocol: PROTOCOL,
884
+ pushIds
885
+ });
886
+ return;
887
+ }
888
+ const roomName = typeof message.roomName === "string" ? message.roomName : undefined;
889
+ if (message.type === "client/push-register") {
890
+ const peerId = mesh.scopedPeerId(conn);
891
+ clientConnections.set(peerId, conn);
892
+ rememberClientActor(conn, peerId, message);
893
+ if (roomName) {
894
+ clientRooms.set(conn, roomName);
895
+ mesh.rememberClientRoute(conn, roomName, message.clientId, peerId);
896
+ }
897
+ pushStorage?.upsertSubscription({
898
+ userId: message.userId,
899
+ deviceId: message.clientId,
900
+ subscription: message.subscription
901
+ });
902
+ const grant = pushStorage?.selectGrant(message.userId);
903
+ sendClientMessage(conn, peerId, {
904
+ type: "client/push-register.ok",
905
+ protocol: PROTOCOL,
906
+ hasGrant: Boolean(grant)
907
+ });
908
+ const events = eventStorage ? eventStorage.queryEvents([{ kinds: [9002] }]) : [];
909
+ let latestPayload = null;
910
+ for (const event of events) {
911
+ try {
912
+ const content = JSON.parse(event.content || "{}");
913
+ const payload = content.payload;
914
+ if (payload && payload.userId === message.userId) {
915
+ if (!latestPayload || payload.updatedAt > latestPayload.updatedAt) {
916
+ latestPayload = payload;
917
+ }
918
+ }
919
+ } catch {}
920
+ }
921
+ if (latestPayload && Array.isArray(latestPayload.relayIds)) {
922
+ const localAddresses = [config.relayAddress, config.relayPeerId, config.relayMeshPeerId];
923
+ for (const relayId of latestPayload.relayIds) {
924
+ if (localAddresses.includes(relayId)) continue;
925
+ const targetMeshPeerId = relayId.endsWith("-relay") ? relayId : relayMeshPeerIdForRoomPeerId(relayId);
926
+ const targetConn = mesh.connections().find(c => c.peer === targetMeshPeerId || c.MatterhornRelayAddress === relayId);
927
+ const envelope = {
928
+ type: "relay.client",
929
+ id: mesh.nextMessageId("relay.client"),
930
+ roomName: roomName || "push",
931
+ peerId: peerId,
932
+ message: { ...message, roomName: roomName || "push" }
933
+ };
934
+ if (targetConn && targetConn.open !== false) {
935
+ sendMessage(targetConn, envelope);
936
+ } else {
937
+ mesh.sendEnvelope(envelope);
938
+ }
939
+ }
940
+ }
941
+ return;
942
+ }
943
+ if (!roomName) return;
944
+ mesh.learnRelayHints(message.relayHints);
945
+ if (message.type === "client/peer-signal") {
946
+ peerSignals.routePeerSignalMessage(conn, message);
947
+ return;
948
+ }
949
+
950
+ const peerId = mesh.scopedPeerId(conn);
951
+ clientConnections.set(peerId, conn);
952
+ clientRooms.set(conn, roomName);
953
+ mesh.rememberClientRoute(conn, roomName, message.clientId, peerId);
954
+ rememberClientActor(conn, peerId, message);
955
+
956
+ if (message.type === "client/hello") {
957
+ if (message.memberKey) rememberDeviceKeyClaim(message.memberKey);
958
+ if (message.deviceKeyClaim) rememberDeviceKeyClaim(message.deviceKeyClaim);
959
+ sendClientMessage(conn, peerId, relayHintMessage(roomName));
960
+ replayPresenceCache(conn, peerId, roomName);
961
+ replayEphemeralTokenCache(conn, peerId, roomName);
962
+ if (relayRuntimeOwnsRoom(roomName)) return;
963
+ forwardClientRequest(conn, peerId, roomName, message);
964
+ return;
965
+ }
966
+
967
+ if (message.type === "client/member-key") {
968
+ if (!rememberDeviceKeyClaim(message.claim)) {
969
+ sendClientMessage(conn, peerId, { type: "host/error", protocol: PROTOCOL, code: "invalid-member-key", message: "Matterhorn member key claim is invalid." });
970
+ }
971
+ return;
972
+ }
973
+
974
+ if (message.type === "client/room-info") {
975
+ const hints = relayHintMessage(roomName);
976
+ const info = roomInfoMessage(roomName, message);
977
+ const shouldForward = shouldForwardRoomInfoToHost(roomName);
978
+ sendClientMessage(conn, peerId, hints);
979
+ if (shouldForward) {
980
+ if (forwardClientRequest(conn, peerId, roomName, message)) return;
981
+ }
982
+ if (info) {
983
+ sendClientMessage(conn, peerId, info);
984
+ return;
985
+ }
986
+ if (forwardClientToMesh(conn, peerId, roomName, message)) return;
987
+ sendHostUnavailable(conn, peerId);
988
+ return;
989
+ }
990
+
991
+ if (message.type === "client/matterhorn-state" || message.type === "client/snapshot-request") {
992
+ replayPresenceCache(conn, peerId, roomName);
993
+ replayEphemeralTokenCache(conn, peerId, roomName);
994
+ if (sendMatterhornSnapshot(conn, peerId, roomName, clientActors.get(conn) || actorFromClientMessage(message))) return;
995
+ if (!forwardClientToMesh(conn, peerId, roomName, message)) sendHostUnavailable(conn, peerId);
996
+ return;
997
+ }
998
+
999
+ if (message.type === "client/presence") {
1000
+ handleClientPresence(peerId, roomName, message);
1001
+ return;
1002
+ }
1003
+
1004
+ if (message.type === "client/ephemeral-token") {
1005
+ handleClientEphemeralToken(conn, roomName, message);
1006
+ return;
1007
+ }
1008
+
1009
+ if (message.type === "client/personal-state") {
1010
+ handleClientPersonalState(conn, peerId, roomName, message);
1011
+ return;
1012
+ }
1013
+
1014
+ if (message.type === "client/room-index-search") {
1015
+ if (handleRoomIndexSearch(conn, peerId, roomName, message)) return;
1016
+ if (!forwardClientToMesh(conn, peerId, roomName, message)) {
1017
+ sendClientMessage(conn, peerId, roomIndexSearchResultsMessage(roomName, message, { events: [], matches: [], total: 0, queryTokenCount: message.tokens.length }));
1018
+ }
1019
+ return;
1020
+ }
1021
+
1022
+ if (message.type === "client/operation") {
1023
+ const signatureCheck = validateOperationDeviceSignature(message);
1024
+ if (!signatureCheck.ok) {
1025
+ sendClientMessage(conn, peerId, { type: "host/error", protocol: PROTOCOL, code: "invalid-operation-signature", message: signatureCheck.error || "Matterhorn operation signature is invalid." });
1026
+ return;
1027
+ }
1028
+ if (handleRelayPluginOperation(conn, peerId, roomName, message)) return;
1029
+ if (!forwardClientToMesh(conn, peerId, roomName, message)) sendHostUnavailable(conn, peerId);
1030
+ return;
1031
+ }
1032
+
1033
+ if (message.type === "client/frontend-manifest" || message.type === "client/frontend-chunk") {
1034
+ if (!forwardClientRequest(conn, peerId, roomName, message)) sendHostUnavailable(conn, peerId);
1035
+ }
1036
+ }
1037
+
1038
+ return {
1039
+ broadcastHostToRoom,
1040
+ broadcastSfuStatus,
1041
+ broadcastToRoom,
1042
+ relayHintMessage,
1043
+ relaySfuMessage,
1044
+ routeClientMessage,
1045
+ dropClientEphemeralTokens,
1046
+ rememberPersonalStateFromHostMessage,
1047
+ personalStateHostMessageForRoom,
1048
+ sendToClient
1049
+ };
1050
+ }
1051
+
1052
+ module.exports = {
1053
+ createRelayClientRouting
1054
+ };