@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,496 @@
1
+ const crypto = require("node:crypto");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const { relayMeshAddressFromAddress } = require("@mh-gg/relay-core");
5
+ const { canonicalJson } = require("@mh-gg/event/canonicalJson");
6
+ const { userDataDir } = require("./matterhornrc.cjs");
7
+
8
+ const RELAY_CLAIM_KIND = "matterhorn.relay-claim.v1";
9
+ const RELAY_CONTROL_KIND = "matterhorn.relay-control.v1";
10
+ const RELAY_IDENTITY_KIND = "matterhorn.relay-identity.v1";
11
+ const RELAY_INVITE_PREFIX = "matterhorn-relay:";
12
+ const RELAY_PUBLIC_KEY_FINGERPRINT_PREFIX = "sha256-spki:";
13
+ const RELAY_CONTROL_MAX_AGE_MS = 120_000;
14
+ const RELAY_CONTROL_MAX_FUTURE_MS = 30_000;
15
+ const DEFAULT_RELAY_ALIAS_DISCRIMINATOR = "0000";
16
+ const PRIVATE_DIR_MODE = 0o700;
17
+ const PRIVATE_FILE_MODE = 0o600;
18
+
19
+ function clonePlain(value) {
20
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
21
+ }
22
+
23
+ function isObject(value) {
24
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+
27
+ function normalizeRelayAlias(value) {
28
+ const text = String(value || "")
29
+ .trim()
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9_-]+/g, "-")
32
+ .replace(/^-+|-+$/g, "")
33
+ .slice(0, 64);
34
+ if (!text || !/^[a-z0-9_-]{1,64}$/.test(text)) return undefined;
35
+ return text;
36
+ }
37
+
38
+ function normalizeRelayDiscriminator(value) {
39
+ if (value === undefined || value === null || value === "") return DEFAULT_RELAY_ALIAS_DISCRIMINATOR;
40
+ const text = String(value).trim();
41
+ return /^[0-9]{4}$/.test(text) ? text : undefined;
42
+ }
43
+
44
+ function relayNameParts(value) {
45
+ const raw = String(value || "").trim();
46
+ const hashIndex = raw.lastIndexOf("#");
47
+ const aliasRaw = hashIndex >= 0 ? raw.slice(0, hashIndex) : raw;
48
+ const discriminatorRaw = hashIndex >= 0 ? raw.slice(hashIndex + 1) : undefined;
49
+ const alias = normalizeRelayAlias(aliasRaw);
50
+ const discriminator = normalizeRelayDiscriminator(discriminatorRaw);
51
+ if (!alias || !discriminator) return undefined;
52
+ return {
53
+ alias,
54
+ discriminator,
55
+ logicalName: `${alias}#${discriminator}`
56
+ };
57
+ }
58
+
59
+ function normalizeRelayName(value) {
60
+ return relayNameParts(value)?.logicalName;
61
+ }
62
+
63
+ function chmodPrivate(file, mode) {
64
+ try {
65
+ fs.chmodSync(file, mode);
66
+ } catch {}
67
+ }
68
+
69
+ function ensurePrivateDir(dir) {
70
+ fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
71
+ chmodPrivate(dir, PRIVATE_DIR_MODE);
72
+ }
73
+
74
+ function atomicWritePrivateJson(file, value) {
75
+ ensurePrivateDir(path.dirname(file));
76
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
77
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: PRIVATE_FILE_MODE });
78
+ chmodPrivate(tmp, PRIVATE_FILE_MODE);
79
+ fs.renameSync(tmp, file);
80
+ chmodPrivate(file, PRIVATE_FILE_MODE);
81
+ }
82
+
83
+ function publicKeySpkiDer(publicKeyPem) {
84
+ return crypto.createPublicKey(String(publicKeyPem || "")).export({ type: "spki", format: "der" });
85
+ }
86
+
87
+ function publicKeyFingerprint(publicKeyPem) {
88
+ return `${RELAY_PUBLIC_KEY_FINGERPRINT_PREFIX}${crypto.createHash("sha256").update(publicKeySpkiDer(publicKeyPem)).digest("base64url")}`;
89
+ }
90
+
91
+ function fingerprintMatches(publicKeyPem, fingerprint) {
92
+ try {
93
+ return fingerprint === publicKeyFingerprint(publicKeyPem);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ // The Ed25519 identity key signs relay claims/control. VAPID push grants are
100
+ // wrapped with X25519 ECDH, which Ed25519 keys cannot do, so the relay also
101
+ // carries a dedicated X25519 grant keypair. Its public key is advertised in the
102
+ // signed claim (as base64url SPKI DER) so clients can wrap grants the relay can
103
+ // later unwrap with the matching private key.
104
+ function generateGrantKeyPair() {
105
+ return crypto.generateKeyPairSync("x25519", {
106
+ publicKeyEncoding: { type: "spki", format: "pem" },
107
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
108
+ });
109
+ }
110
+
111
+ // Adds the X25519 grant keypair to an identity that predates push support,
112
+ // preserving the Ed25519 identity (and its fingerprint/trust pins) untouched.
113
+ function ensureGrantKeys(identity) {
114
+ if (!isObject(identity)) return identity;
115
+ if (typeof identity.grantPrivateKeyPem === "string" && identity.grantPrivateKeyPem
116
+ && typeof identity.grantPublicKeyPem === "string" && identity.grantPublicKeyPem) {
117
+ return identity;
118
+ }
119
+ const grant = generateGrantKeyPair();
120
+ return { ...identity, grantPublicKeyPem: grant.publicKey, grantPrivateKeyPem: grant.privateKey };
121
+ }
122
+
123
+ function generateRelayIdentity(logicalName, now = Date.now()) {
124
+ const name = relayNameParts(logicalName);
125
+ if (!name) throw new Error("Relay name must contain an alias and optional four-digit discriminator, such as relay-a#0000.");
126
+ const keys = crypto.generateKeyPairSync("ed25519", {
127
+ publicKeyEncoding: { type: "spki", format: "pem" },
128
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
129
+ });
130
+ const grant = generateGrantKeyPair();
131
+ return {
132
+ kind: RELAY_IDENTITY_KIND,
133
+ alias: name.alias,
134
+ discriminator: name.discriminator,
135
+ logicalName: name.logicalName,
136
+ publicKeyPem: keys.publicKey,
137
+ privateKeyPem: keys.privateKey,
138
+ publicKeyFingerprint: publicKeyFingerprint(keys.publicKey),
139
+ grantPublicKeyPem: grant.publicKey,
140
+ grantPrivateKeyPem: grant.privateKey,
141
+ sequence: 1,
142
+ createdAt: now
143
+ };
144
+ }
145
+
146
+ function normalizedRelayIdentity(identity) {
147
+ if (!isObject(identity)) return undefined;
148
+ const name = relayNameParts(identity.logicalName || identity.alias);
149
+ if (!name) return undefined;
150
+ return {
151
+ ...identity,
152
+ alias: name.alias,
153
+ discriminator: name.discriminator,
154
+ logicalName: name.logicalName
155
+ };
156
+ }
157
+
158
+ function validRelayIdentity(identity, logicalName) {
159
+ const name = relayNameParts(logicalName);
160
+ const normalized = normalizedRelayIdentity(identity);
161
+ if (!name || !normalized || normalized.logicalName !== name.logicalName) return false;
162
+ if (normalized.kind !== RELAY_IDENTITY_KIND) return false;
163
+ if (typeof normalized.publicKeyPem !== "string" || !normalized.publicKeyPem) return false;
164
+ if (typeof normalized.privateKeyPem !== "string" || !normalized.privateKeyPem) return false;
165
+ if (!fingerprintMatches(normalized.publicKeyPem, normalized.publicKeyFingerprint)) return false;
166
+ if (!Number.isInteger(normalized.sequence) || normalized.sequence < 1) return false;
167
+ return true;
168
+ }
169
+
170
+ function ensureRelayIdentity(current, logicalName, now = Date.now()) {
171
+ const name = relayNameParts(logicalName);
172
+ if (!name) throw new Error("Relay name must contain an alias and optional four-digit discriminator, such as relay-a#0000.");
173
+ const normalized = normalizedRelayIdentity(current);
174
+ if (validRelayIdentity(normalized, name.logicalName)) return ensureGrantKeys(clonePlain(normalized));
175
+ return generateRelayIdentity(name.logicalName, now);
176
+ }
177
+
178
+ function unsignedClaim(claim) {
179
+ if (!isObject(claim)) return undefined;
180
+ const { signature, ...unsigned } = claim;
181
+ return unsigned;
182
+ }
183
+
184
+ function signRelayClaim(identity, input = {}, now = Date.now()) {
185
+ if (!validRelayIdentity(identity, identity?.logicalName)) throw new Error("Relay identity is invalid.");
186
+ const claim = {
187
+ kind: RELAY_CLAIM_KIND,
188
+ alias: identity.alias,
189
+ discriminator: identity.discriminator,
190
+ logicalName: identity.logicalName,
191
+ relayAddress: input.relayAddress,
192
+ roomPeerId: input.roomPeerId,
193
+ relayMeshPeerId: input.relayMeshPeerId,
194
+ publicKeyPem: identity.publicKeyPem,
195
+ publicKeyFingerprint: identity.publicKeyFingerprint,
196
+ sequence: Number.isInteger(identity.sequence) ? identity.sequence : 1,
197
+ issuedAt: Number.isFinite(input.issuedAt) ? input.issuedAt : now
198
+ };
199
+ if (typeof input.previousRelayAddress === "string" && input.previousRelayAddress) {
200
+ claim.previousRelayAddress = input.previousRelayAddress;
201
+ }
202
+ // X25519 grant key (base64url SPKI DER) clients wrap VAPID grants to. Included
203
+ // in the signed payload so it is tamper-evident like the rest of the claim.
204
+ if (typeof identity.grantPublicKeyPem === "string" && identity.grantPublicKeyPem) {
205
+ claim.grantPublicKey = publicKeySpkiDer(identity.grantPublicKeyPem).toString("base64url");
206
+ }
207
+ const payload = Buffer.from(canonicalJson(claim), "utf8");
208
+ claim.signature = crypto.sign(null, payload, identity.privateKeyPem).toString("base64url");
209
+ return claim;
210
+ }
211
+
212
+ function verifyRelayClaim(claim) {
213
+ if (!isObject(claim)) return { ok: false, code: "invalid-claim", message: "Relay claim must be an object." };
214
+ if (claim.kind !== RELAY_CLAIM_KIND) return { ok: false, code: "invalid-claim", message: "Relay claim kind is invalid." };
215
+ const name = relayNameParts(claim.logicalName);
216
+ if (!name || name.logicalName !== claim.logicalName) return { ok: false, code: "invalid-claim", message: "Relay claim logicalName is invalid." };
217
+ if (claim.alias !== undefined && claim.alias !== name.alias) return { ok: false, code: "invalid-claim", message: "Relay claim alias is invalid." };
218
+ if (claim.discriminator !== undefined && claim.discriminator !== name.discriminator) return { ok: false, code: "invalid-claim", message: "Relay claim discriminator is invalid." };
219
+ for (const field of ["relayAddress", "roomPeerId", "relayMeshPeerId", "publicKeyPem", "publicKeyFingerprint", "signature"]) {
220
+ if (typeof claim[field] !== "string" || !claim[field]) return { ok: false, code: "invalid-claim", message: `Relay claim ${field} is invalid.` };
221
+ }
222
+ if (!Number.isInteger(claim.sequence) || claim.sequence < 1) return { ok: false, code: "invalid-claim", message: "Relay claim sequence is invalid." };
223
+ if (!Number.isFinite(claim.issuedAt) || claim.issuedAt <= 0) return { ok: false, code: "invalid-claim", message: "Relay claim issuedAt is invalid." };
224
+ if (!fingerprintMatches(claim.publicKeyPem, claim.publicKeyFingerprint)) {
225
+ return { ok: false, code: "invalid-claim", message: "Relay claim public key fingerprint does not match." };
226
+ }
227
+ try {
228
+ const payload = Buffer.from(canonicalJson(unsignedClaim(claim)), "utf8");
229
+ const signature = Buffer.from(claim.signature, "base64url");
230
+ if (!crypto.verify(null, payload, claim.publicKeyPem, signature)) {
231
+ return { ok: false, code: "invalid-signature", message: "Relay claim signature is invalid." };
232
+ }
233
+ } catch {
234
+ return { ok: false, code: "invalid-signature", message: "Relay claim signature is invalid." };
235
+ }
236
+ return {
237
+ ok: true,
238
+ claim: {
239
+ ...clonePlain(claim),
240
+ alias: name.alias,
241
+ discriminator: name.discriminator,
242
+ logicalName: name.logicalName
243
+ }
244
+ };
245
+ }
246
+
247
+ function unsignedRelayControl(control) {
248
+ if (!isObject(control)) return undefined;
249
+ const { signature, ...unsigned } = control;
250
+ return unsigned;
251
+ }
252
+
253
+ function signRelayControl(identity, input = {}, now = Date.now()) {
254
+ if (!validRelayIdentity(identity, identity?.logicalName)) throw new Error("Relay identity is invalid.");
255
+ const control = {
256
+ kind: RELAY_CONTROL_KIND,
257
+ type: input.type || "relay.disconnecting",
258
+ relayAddress: input.relayAddress,
259
+ roomPeerId: input.roomPeerId,
260
+ relayMeshPeerId: input.relayMeshPeerId,
261
+ issuedAt: Number.isFinite(input.issuedAt) ? input.issuedAt : now,
262
+ nonce: typeof input.nonce === "string" && input.nonce ? input.nonce : crypto.randomBytes(16).toString("base64url")
263
+ };
264
+ if (typeof input.roomName === "string" && input.roomName) control.roomName = input.roomName;
265
+ const payload = Buffer.from(canonicalJson(control), "utf8");
266
+ control.signature = crypto.sign(null, payload, identity.privateKeyPem).toString("base64url");
267
+ return control;
268
+ }
269
+
270
+ function verifyRelayControl(control, relayClaim, options = {}) {
271
+ if (!isObject(control)) return { ok: false, code: "invalid-control", message: "Relay control must be an object." };
272
+ if (control.kind !== RELAY_CONTROL_KIND) return { ok: false, code: "invalid-control", message: "Relay control kind is invalid." };
273
+ if (control.type !== "relay.disconnecting") return { ok: false, code: "invalid-control", message: "Relay control type is invalid." };
274
+ for (const field of ["relayAddress", "roomPeerId", "relayMeshPeerId", "nonce", "signature"]) {
275
+ if (typeof control[field] !== "string" || !control[field]) return { ok: false, code: "invalid-control", message: `Relay control ${field} is invalid.` };
276
+ }
277
+ if (!Number.isFinite(control.issuedAt) || control.issuedAt <= 0) return { ok: false, code: "invalid-control", message: "Relay control issuedAt is invalid." };
278
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
279
+ const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : RELAY_CONTROL_MAX_AGE_MS;
280
+ const maxFutureMs = Number.isFinite(options.maxFutureMs) ? options.maxFutureMs : RELAY_CONTROL_MAX_FUTURE_MS;
281
+ if (control.issuedAt < now - maxAgeMs || control.issuedAt > now + maxFutureMs) {
282
+ return { ok: false, code: "stale-control", message: "Relay control is stale." };
283
+ }
284
+ if (typeof options.expectedRelayAddress === "string" && options.expectedRelayAddress && control.relayAddress !== options.expectedRelayAddress) {
285
+ return { ok: false, code: "address-mismatch", message: "Relay control address does not match the current primary relay." };
286
+ }
287
+ const claimResult = verifyRelayClaim(relayClaim);
288
+ if (!claimResult.ok) return claimResult;
289
+ const claim = claimResult.claim;
290
+ if (claim.relayAddress !== control.relayAddress) {
291
+ return { ok: false, code: "address-mismatch", message: "Relay control does not match the relay claim address." };
292
+ }
293
+ if (claim.roomPeerId !== control.roomPeerId || claim.relayMeshPeerId !== control.relayMeshPeerId) {
294
+ return { ok: false, code: "identity-mismatch", message: "Relay control peer identity does not match the relay claim." };
295
+ }
296
+ try {
297
+ const payload = Buffer.from(canonicalJson(unsignedRelayControl(control)), "utf8");
298
+ const signature = Buffer.from(control.signature, "base64url");
299
+ if (!crypto.verify(null, payload, claim.publicKeyPem, signature)) {
300
+ return { ok: false, code: "invalid-signature", message: "Relay control signature is invalid." };
301
+ }
302
+ } catch {
303
+ return { ok: false, code: "invalid-signature", message: "Relay control signature is invalid." };
304
+ }
305
+ return { ok: true, control: clonePlain(control), claim };
306
+ }
307
+
308
+ function encodeRelayInvite(claim) {
309
+ const verification = verifyRelayClaim(claim);
310
+ if (!verification.ok) throw new Error(verification.message);
311
+ return `${RELAY_INVITE_PREFIX}${Buffer.from(JSON.stringify(claim), "utf8").toString("base64url")}`;
312
+ }
313
+
314
+ function decodeRelayInvite(value) {
315
+ const text = String(value || "").trim();
316
+ if (!text.startsWith(RELAY_INVITE_PREFIX)) return { ok: false, code: "not-invite", message: "Relay reference is not an invite." };
317
+ try {
318
+ const claim = JSON.parse(Buffer.from(text.slice(RELAY_INVITE_PREFIX.length), "base64url").toString("utf8"));
319
+ return verifyRelayClaim(claim);
320
+ } catch {
321
+ return { ok: false, code: "invalid-invite", message: "Relay invite token is invalid." };
322
+ }
323
+ }
324
+
325
+ function relayTrustFile(dataDir = userDataDir()) {
326
+ return path.join(dataDir, "relay-trust.json");
327
+ }
328
+
329
+ function readRelayTrust(file = relayTrustFile()) {
330
+ if (!fs.existsSync(file)) return { relays: {} };
331
+ try {
332
+ const trust = JSON.parse(fs.readFileSync(file, "utf8"));
333
+ return isObject(trust) && isObject(trust.relays) ? trust : { relays: {} };
334
+ } catch {
335
+ return { relays: {} };
336
+ }
337
+ }
338
+
339
+ function writeRelayTrust(trust, file = relayTrustFile()) {
340
+ atomicWritePrivateJson(file, { relays: isObject(trust?.relays) ? trust.relays : {} });
341
+ }
342
+
343
+ function createTrustEntry(claim, pinnedAt = Date.now()) {
344
+ return {
345
+ logicalName: claim.logicalName,
346
+ alias: claim.alias,
347
+ discriminator: claim.discriminator,
348
+ publicKeyPem: claim.publicKeyPem,
349
+ publicKeyFingerprint: claim.publicKeyFingerprint,
350
+ relayAddress: claim.relayAddress,
351
+ roomPeerId: claim.roomPeerId,
352
+ relayMeshPeerId: claim.relayMeshPeerId,
353
+ sequence: claim.sequence,
354
+ claim: clonePlain(claim),
355
+ pinnedAt,
356
+ updatedAt: pinnedAt
357
+ };
358
+ }
359
+
360
+ function createRelayTrustStore(options = {}) {
361
+ const file = options.file || relayTrustFile(options.dataDir);
362
+ const defaultSignaling = options.defaultSignaling;
363
+
364
+ function read() {
365
+ return readRelayTrust(file);
366
+ }
367
+
368
+ function write(trust) {
369
+ writeRelayTrust(trust, file);
370
+ }
371
+
372
+ function rememberClaim(claim, rememberOptions = {}) {
373
+ if (!claim) return { ok: false, code: "missing-claim", message: "Relay claim is required." };
374
+ const verification = verifyRelayClaim(claim);
375
+ if (!verification.ok) return verification;
376
+ const verified = verification.claim;
377
+ const expectedName = normalizeRelayName(rememberOptions.expectedLogicalName);
378
+ if (expectedName && verified.logicalName !== expectedName) {
379
+ return { ok: false, code: "name-mismatch", message: `Relay claim is for ${verified.logicalName}, not ${expectedName}.` };
380
+ }
381
+ if (rememberOptions.expectedRelayAddress) {
382
+ const expectedAddress = relayMeshAddressFromAddress(rememberOptions.expectedRelayAddress, defaultSignaling);
383
+ const claimAddress = relayMeshAddressFromAddress(verified.relayAddress, defaultSignaling);
384
+ if (expectedAddress && claimAddress && expectedAddress !== claimAddress) {
385
+ return { ok: false, code: "address-mismatch", message: "Relay claim address does not match the connected relay." };
386
+ }
387
+ }
388
+ const trust = read();
389
+ const existing = trust.relays[verified.logicalName];
390
+ if (existing && existing.publicKeyFingerprint !== verified.publicKeyFingerprint) {
391
+ return { ok: false, code: "key-mismatch", message: `Relay ${verified.logicalName} is pinned to a different signing key.` };
392
+ }
393
+ if (existing && Number(existing.sequence || 0) >= verified.sequence) {
394
+ return { ok: true, stale: true, entry: existing, claim: verified };
395
+ }
396
+ trust.relays[verified.logicalName] = {
397
+ ...(existing || {}),
398
+ ...createTrustEntry(verified, existing?.pinnedAt || Date.now()),
399
+ updatedAt: Date.now()
400
+ };
401
+ write(trust);
402
+ return { ok: true, entry: trust.relays[verified.logicalName], claim: verified };
403
+ }
404
+
405
+ function claimForName(name) {
406
+ const logicalName = normalizeRelayName(name);
407
+ if (!logicalName) return undefined;
408
+ return read().relays[logicalName]?.claim;
409
+ }
410
+
411
+ function pinForAddress(address) {
412
+ const normalized = relayMeshAddressFromAddress(address, defaultSignaling);
413
+ if (!normalized) return undefined;
414
+ return Object.values(read().relays).find((entry) => {
415
+ return relayMeshAddressFromAddress(entry?.relayAddress, defaultSignaling) === normalized;
416
+ });
417
+ }
418
+
419
+ function claims() {
420
+ return Object.values(read().relays).map((entry) => entry?.claim).filter(Boolean);
421
+ }
422
+
423
+ function remove(name) {
424
+ const logicalName = normalizeRelayName(name);
425
+ if (!logicalName) return false;
426
+ const trust = read();
427
+ const existed = Boolean(trust.relays[logicalName]);
428
+ delete trust.relays[logicalName];
429
+ write(trust);
430
+ return existed;
431
+ }
432
+
433
+ function resolveReference(reference) {
434
+ const value = String(reference || "").trim();
435
+ if (!value) return { ok: false, code: "missing-reference", message: "Relay reference is required." };
436
+ if (value.startsWith(RELAY_INVITE_PREFIX)) {
437
+ const decoded = decodeRelayInvite(value);
438
+ if (!decoded.ok) return decoded;
439
+ const remembered = rememberClaim(decoded.claim);
440
+ if (!remembered.ok) return remembered;
441
+ return { ok: true, relayAddress: decoded.claim.relayAddress, relayClaim: decoded.claim, pinned: true };
442
+ }
443
+ if (value.startsWith("peerjs:")) {
444
+ const relayAddress = relayMeshAddressFromAddress(value, defaultSignaling);
445
+ if (!relayAddress) return { ok: false, code: "invalid-address", message: `Relay address is invalid: ${value}` };
446
+ return { ok: true, relayAddress };
447
+ }
448
+ const logicalName = normalizeRelayName(value);
449
+ if (!logicalName) return { ok: false, code: "invalid-alias", message: `Relay alias is invalid: ${value}` };
450
+ const claim = claimForName(logicalName);
451
+ if (!claim) {
452
+ return {
453
+ ok: false,
454
+ code: "alias-unpinned",
455
+ message: `Relay alias "${logicalName}" is not pinned yet. Run "matterhorn-sdk relay invite" on that relay and pass the matterhorn-relay: token, or use an explicit signed relay address first.`
456
+ };
457
+ }
458
+ return { ok: true, relayAddress: claim.relayAddress, relayClaim: claim, alias: logicalName };
459
+ }
460
+
461
+ return {
462
+ claimForName,
463
+ claims,
464
+ file,
465
+ pinForAddress,
466
+ read,
467
+ rememberClaim,
468
+ remove,
469
+ resolveReference
470
+ };
471
+ }
472
+
473
+ module.exports = {
474
+ DEFAULT_RELAY_ALIAS_DISCRIMINATOR,
475
+ RELAY_CLAIM_KIND,
476
+ RELAY_CONTROL_KIND,
477
+ RELAY_IDENTITY_KIND,
478
+ RELAY_INVITE_PREFIX,
479
+ RELAY_PUBLIC_KEY_FINGERPRINT_PREFIX,
480
+ createRelayTrustStore,
481
+ decodeRelayInvite,
482
+ encodeRelayInvite,
483
+ ensureRelayIdentity,
484
+ generateRelayIdentity,
485
+ normalizeRelayAlias,
486
+ normalizeRelayDiscriminator,
487
+ normalizeRelayName,
488
+ publicKeyFingerprint,
489
+ readRelayTrust,
490
+ relayTrustFile,
491
+ signRelayClaim,
492
+ signRelayControl,
493
+ verifyRelayControl,
494
+ verifyRelayClaim,
495
+ writeRelayTrust
496
+ };
@@ -0,0 +1,153 @@
1
+ const { PROTOCOL } = require("@mh-gg/host-config");
2
+ const {
3
+ isNostrMessage,
4
+ messageJsonByteLength,
5
+ sendMessage
6
+ } = require("@mh-gg/relay-core");
7
+
8
+ function isHostMessage(message) {
9
+ return typeof message?.type === "string" && message.type.startsWith("host.");
10
+ }
11
+
12
+ function isCallSignalMessage(message) {
13
+ return message?.type === "client/peer-signal";
14
+ }
15
+
16
+ function isFrontendDownloadRequest(message) {
17
+ return message?.type === "client/frontend-chunk";
18
+ }
19
+
20
+ function isPresenceMessage(message) {
21
+ return message?.type === "client/presence";
22
+ }
23
+
24
+ function isRuntimeRoomRequest(message) {
25
+ return message?.type === "client/operation"
26
+ || message?.type === "client/matterhorn-state"
27
+ || message?.type === "client/snapshot-request";
28
+ }
29
+
30
+ function isRelayEnvelopeMessage(message) {
31
+ return message?.type === "relay.client"
32
+ || message?.type === "relay.host"
33
+ || message?.type === "relay.peer-signal"
34
+ || message?.type === "relay.broadcast"
35
+ || message?.type === "relay.client.close"
36
+ || message?.type === "relay.ping"
37
+ || message?.type === "relay.pong"
38
+ || message?.type === "relay.matterhorn-operation"
39
+ || message?.type === "relay.push"
40
+ || message?.type === "relay.push.ack";
41
+ }
42
+
43
+ function incomingMessageClass(message) {
44
+ if (isNostrMessage(message)) return "nostr";
45
+ if (isHostMessage(message)) return "host";
46
+ if (isRelayEnvelopeMessage(message) || (typeof message?.type === "string" && message.type.startsWith("relay."))) return "mesh";
47
+ if (isCallSignalMessage(message)) return "call";
48
+ return "room";
49
+ }
50
+
51
+ function incomingMessageLimit(config, messageClass) {
52
+ switch (messageClass) {
53
+ case "host": return config.maxHostIpcMessageBytes;
54
+ case "mesh": return config.maxMeshMessageBytes;
55
+ case "nostr": return config.maxNostrMessageBytes;
56
+ case "call": return config.maxCallSignalBytes;
57
+ default: return config.maxRoomMessageBytes;
58
+ }
59
+ }
60
+
61
+ function rateLimitForMessage(config, conn, messageClass) {
62
+ if (messageClass === "host") return { key: "host", limit: config.maxHostIpcMessagesPerWindow };
63
+ if (messageClass === "mesh" || messageClass === "nostr") return { key: messageClass, limit: config.maxMeshMessagesPerWindow };
64
+ if (isFrontendDownloadRequest(conn.MatterhornIncomingMessage)) {
65
+ return { key: "frontend", limit: Math.max(config.maxRoomMessagesPerWindow * 4, 512) };
66
+ }
67
+ if (isPresenceMessage(conn.MatterhornIncomingMessage)) {
68
+ return { key: "presence", limit: Math.max(config.maxRoomMessagesPerWindow * 4, 128) };
69
+ }
70
+ if (isRuntimeRoomRequest(conn.MatterhornIncomingMessage)) return { key: "room", limit: config.maxRoomMessagesPerWindow };
71
+ if (conn.MatterhornAuthenticated) return { key: "room", limit: config.maxRoomMessagesPerWindow };
72
+ return { key: "unauthenticated", limit: config.maxUnauthenticatedMessagesPerWindow };
73
+ }
74
+
75
+ function sendIncomingRejection(conn, messageClass, code, message) {
76
+ if (messageClass === "nostr") {
77
+ sendMessage(conn, ["NOTICE", message]);
78
+ return;
79
+ }
80
+ if (messageClass === "host") {
81
+ sendMessage(conn, { type: "host.message.error", code, message });
82
+ return;
83
+ }
84
+ if (messageClass === "mesh") {
85
+ sendMessage(conn, { type: "relay.error", code, message });
86
+ return;
87
+ }
88
+ if (messageClass === "room" || messageClass === "call") {
89
+ sendMessage(conn, {
90
+ type: "host/error",
91
+ protocol: PROTOCOL,
92
+ code,
93
+ message
94
+ });
95
+ }
96
+ }
97
+
98
+ function createRelayIncomingGate(config) {
99
+ const connectionRates = new WeakMap();
100
+
101
+ function rateStateFor(conn, now) {
102
+ let state = connectionRates.get(conn);
103
+ if (!state || now - state.startedAt >= config.rateWindowMs) {
104
+ state = { startedAt: now, counts: Object.create(null) };
105
+ connectionRates.set(conn, state);
106
+ }
107
+ return state;
108
+ }
109
+
110
+ function acceptsMessageRate(conn, messageClass) {
111
+ const { key, limit } = rateLimitForMessage(config, conn, messageClass);
112
+ if (!Number.isFinite(limit) || limit <= 0) return true;
113
+ const state = rateStateFor(conn, Date.now());
114
+ state.counts[key] = (state.counts[key] || 0) + 1;
115
+ return state.counts[key] <= limit;
116
+ }
117
+
118
+ function accept(conn, message) {
119
+ conn.MatterhornIncomingMessage = message;
120
+ const messageClass = incomingMessageClass(message);
121
+ const messageBytes = messageJsonByteLength(message);
122
+ const maxBytes = incomingMessageLimit(config, messageClass);
123
+ if (messageBytes === undefined || messageBytes > maxBytes) {
124
+ const label = messageClass === "host"
125
+ ? "Host IPC message is too large"
126
+ : messageClass === "nostr"
127
+ ? "Nostr message is too large"
128
+ : "Room message is too large.";
129
+ sendIncomingRejection(conn, messageClass, "message-too-large", label);
130
+ return false;
131
+ }
132
+ if (!acceptsMessageRate(conn, messageClass)) {
133
+ sendIncomingRejection(conn, messageClass, "rate-limited", "Too many messages; slow down.");
134
+ return false;
135
+ }
136
+ return true;
137
+ }
138
+
139
+ function cleanup(conn) {
140
+ connectionRates.delete(conn);
141
+ }
142
+
143
+ return {
144
+ accept,
145
+ cleanup
146
+ };
147
+ }
148
+
149
+ module.exports = {
150
+ createRelayIncomingGate,
151
+ incomingMessageClass,
152
+ isRelayEnvelopeMessage
153
+ };