@mh-gg/relay 0.1.1-alpha.20260626T104441232Z
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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/package.json +45 -0
- package/src/callAuth.cjs +76 -0
- package/src/index.cjs +45 -0
- package/src/localPeerServer.cjs +116 -0
- package/src/logging.cjs +164 -0
- package/src/matterhornrc.cjs +79 -0
- package/src/memberRootRegistry.cjs +132 -0
- package/src/metrics.cjs +57 -0
- package/src/mlsDeliveryService.cjs +224 -0
- package/src/nodePeer.cjs +127 -0
- package/src/nodePeerRacePatch.cjs +93 -0
- package/src/peerJsConfig.cjs +46 -0
- package/src/peerWait.cjs +38 -0
- package/src/privateRelay.cjs +46 -0
- package/src/pushEgress.cjs +48 -0
- package/src/pushStorage.cjs +252 -0
- package/src/relay/chunkReceiver.cjs +32 -0
- package/src/relay/cli.cjs +43 -0
- package/src/relay/config.cjs +189 -0
- package/src/relay/connectionAttachment.cjs +52 -0
- package/src/relay/connectionCleanup.cjs +34 -0
- package/src/relay/connectionDispatcher.cjs +93 -0
- package/src/relay/logging.cjs +33 -0
- package/src/relay/matterhornOperationEvents.cjs +86 -0
- package/src/relay/matterhornRuntimeEventBridge.cjs +191 -0
- package/src/relay/matterhornRuntimeEventBridge.implementation.cjs +190 -0
- package/src/relay/mediaMeshBindings.cjs +48 -0
- package/src/relay/nostrRelay.cjs +131 -0
- package/src/relay/operationHeaderGate.cjs +168 -0
- package/src/relay/operationHeaderRoleKeys.cjs +124 -0
- package/src/relay/peerStarter.cjs +21 -0
- package/src/relay/peerStartup.cjs +81 -0
- package/src/relay/pendingPushRetry.cjs +52 -0
- package/src/relay/publicApi.cjs +74 -0
- package/src/relay/relayControl.cjs +257 -0
- package/src/relay/relayDialer.cjs +56 -0
- package/src/relay/runtimeInput.cjs +64 -0
- package/src/relay/serverBindings.cjs +30 -0
- package/src/relay/serviceWiring.cjs +130 -0
- package/src/relay/sfuGuests.cjs +28 -0
- package/src/relay/shutdown.cjs +111 -0
- package/src/relay.cjs +276 -0
- package/src/relayBackendTrace.cjs +28 -0
- package/src/relayClientDeviceClaims.cjs +63 -0
- package/src/relayClientEphemeralTokens.cjs +140 -0
- package/src/relayClientMessageRoutes.cjs +218 -0
- package/src/relayClientPayloadCrypto.cjs +51 -0
- package/src/relayClientPersonalState.cjs +64 -0
- package/src/relayClientPresence.cjs +143 -0
- package/src/relayClientPushRouting.cjs +166 -0
- package/src/relayClientRoomInfo.cjs +151 -0
- package/src/relayClientRouting.cjs +633 -0
- package/src/relayConfig.cjs +165 -0
- package/src/relayHostAuth.cjs +39 -0
- package/src/relayHostMessages.cjs +214 -0
- package/src/relayHostRuntime.cjs +212 -0
- package/src/relayHttp.cjs +54 -0
- package/src/relayIdentity.cjs +343 -0
- package/src/relayIdentityControlValidation.cjs +38 -0
- package/src/relayIncomingGate.cjs +244 -0
- package/src/relayMeshEnvelopeHandler.cjs +516 -0
- package/src/relayMeshPush.cjs +191 -0
- package/src/relayPeerLifecycle.cjs +96 -0
- package/src/relayPeerSignals.cjs +197 -0
- package/src/relayRoomRegistrationPersistence.cjs +87 -0
- package/src/relayRoomRuntimePersistence.cjs +129 -0
- package/src/relaySetPropagation.cjs +156 -0
- package/src/relaySharedMessages.cjs +38 -0
- package/src/relayStatus.cjs +263 -0
- package/src/relayTrustHandshake.cjs +131 -0
- package/src/relayTrustStore.cjs +212 -0
- package/src/relayWrapKeys.cjs +382 -0
- package/src/roomRelayPolicy.cjs +272 -0
- package/src/sfuMediaForwarder.cjs +69 -0
- package/src/sfuRelay.cjs +5 -0
- package/src/sfuRelayCore.cjs +388 -0
- package/src/sqliteRelayStorage.cjs +600 -0
- package/src/trustedRelaySetup.cjs +237 -0
- package/src/wireValidation/client.cjs +255 -0
- package/src/wireValidation/host.cjs +33 -0
- package/src/wireValidation/index.cjs +13 -0
- package/src/wireValidation/launcherDiagnostics.cjs +77 -0
- package/src/wireValidation/memberKey.cjs +41 -0
- package/src/wireValidation/peerSignal.cjs +84 -0
- package/src/wireValidation/presenceEvent.cjs +49 -0
- package/src/wireValidation/push.cjs +47 -0
- package/src/wireValidation/pushShared.cjs +29 -0
- package/src/wireValidation/relay.cjs +164 -0
- package/src/wireValidation/shared.cjs +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matterhorn contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# @mh-gg/relay
|
|
2
|
+
|
|
3
|
+
Matterhorn relay: signaling and forwarding server runtime for matterhorn rooms. Owns the relay process entry point, client/host message routing, mesh and SFU bindings, push storage, relay identity/trust, and the shared wire-validation and config primitives consumed by both the relay and the host.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/relay",
|
|
3
|
+
"version": "0.1.1-alpha.20260626T104441232Z",
|
|
4
|
+
"description": "Matterhorn relay: signaling and forwarding server runtime for matterhorn rooms.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.cjs",
|
|
9
|
+
"./src/*": "./src/*",
|
|
10
|
+
"./relay.cjs": "./src/relay.cjs",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@roamhq/wrtc": "^0.10.0",
|
|
15
|
+
"nostr-tools": "^2.23.5",
|
|
16
|
+
"peer": "^1.0.2",
|
|
17
|
+
"peerjs": "^1.5.5",
|
|
18
|
+
"ws": "^8.21.0",
|
|
19
|
+
"xhr2": "^0.2.1",
|
|
20
|
+
"@mh-gg/host-runtime": "^0.1.1-alpha.20260626T104441232Z",
|
|
21
|
+
"@mh-gg/base": "^0.1.1-alpha.20260626T104441232Z",
|
|
22
|
+
"@mh-gg/host-config": "^0.1.1-alpha.20260626T104441232Z",
|
|
23
|
+
"@mh-gg/relay-core": "^0.1.1-alpha.20260626T104441232Z",
|
|
24
|
+
"@mh-gg/host-store": "^0.1.1-alpha.20260626T104441232Z",
|
|
25
|
+
"@mh-gg/event": "^0.1.1-alpha.20260626T104441232Z",
|
|
26
|
+
"@mh-gg/protocol": "^0.1.1-alpha.20260626T104441232Z",
|
|
27
|
+
"@mh-gg/relay-runtime": "^0.1.1-alpha.20260626T104441232Z",
|
|
28
|
+
"@mh-gg/push": "^0.1.1-alpha.20260626T104441232Z",
|
|
29
|
+
"@mh-gg/host-ipc": "^0.1.1-alpha.20260626T104441232Z",
|
|
30
|
+
"@mh-gg/relay-mesh": "^0.1.1-alpha.20260626T104441232Z"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22.12"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"README.md",
|
|
39
|
+
"package.json"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test test/*.test.cjs",
|
|
43
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=60 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/callAuth.cjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const { canonicalJson, MATTERHORN_CALL_SIGNAL_NOSTR_KIND } = require("@mh-gg/event");
|
|
2
|
+
const { finalizeEvent, verifyEvent } = require("nostr-tools/pure");
|
|
3
|
+
const { hexToBytes } = require("nostr-tools/utils");
|
|
4
|
+
|
|
5
|
+
const CALL_SIGNAL_MAX_AGE_SECONDS = 10 * 60;
|
|
6
|
+
const CALL_SIGNAL_FUTURE_SKEW_SECONDS = 60;
|
|
7
|
+
|
|
8
|
+
function isHex(value, length) {
|
|
9
|
+
return typeof value === "string" && new RegExp(`^[0-9a-f]{${length}}$`, "i").test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function signedContent(input) {
|
|
13
|
+
return canonicalJson({
|
|
14
|
+
protocol: "matterhorn-sdk",
|
|
15
|
+
version: 1,
|
|
16
|
+
kind: "call-signal",
|
|
17
|
+
roomName: input.roomName,
|
|
18
|
+
sourceClientId: input.sourceClientId,
|
|
19
|
+
targetClientId: input.targetClientId,
|
|
20
|
+
signal: input.signal
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function eventTemplate(input, createdAt) {
|
|
25
|
+
return {
|
|
26
|
+
kind: MATTERHORN_CALL_SIGNAL_NOSTR_KIND,
|
|
27
|
+
created_at: createdAt,
|
|
28
|
+
tags: [
|
|
29
|
+
["protocol", "matterhorn-sdk"],
|
|
30
|
+
["room", input.roomName],
|
|
31
|
+
["source", input.sourceClientId],
|
|
32
|
+
["target", input.targetClientId],
|
|
33
|
+
["session", input.signal.sessionId]
|
|
34
|
+
],
|
|
35
|
+
content: signedContent(input)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function eventForSignature(input) {
|
|
40
|
+
return {
|
|
41
|
+
...eventTemplate(input, input.auth.createdAt),
|
|
42
|
+
pubkey: input.auth.pubkey,
|
|
43
|
+
id: input.auth.eventId,
|
|
44
|
+
sig: input.auth.sig
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function verifyCallSignal(input) {
|
|
49
|
+
const auth = input.auth;
|
|
50
|
+
if (!auth || auth.alg !== "nostr-secp256k1") return false;
|
|
51
|
+
if (!isHex(input.expectedPubkey, 64) || auth.pubkey.toLowerCase() !== input.expectedPubkey.toLowerCase()) return false;
|
|
52
|
+
if (!isHex(auth.pubkey, 64) || !isHex(auth.eventId, 64) || !isHex(auth.sig, 128)) return false;
|
|
53
|
+
if (!Number.isInteger(auth.createdAt) || auth.createdAt <= 0) return false;
|
|
54
|
+
const now = Math.floor(Date.now() / 1000);
|
|
55
|
+
if (auth.createdAt < now - CALL_SIGNAL_MAX_AGE_SECONDS || auth.createdAt > now + CALL_SIGNAL_FUTURE_SKEW_SECONDS) return false;
|
|
56
|
+
return verifyEvent(eventForSignature(input));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function signCallSignal(input) {
|
|
60
|
+
const event = finalizeEvent(
|
|
61
|
+
eventTemplate(input, Math.floor(Date.now() / 1000)),
|
|
62
|
+
hexToBytes(input.privateKey)
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
alg: "nostr-secp256k1",
|
|
66
|
+
pubkey: event.pubkey,
|
|
67
|
+
eventId: event.id,
|
|
68
|
+
sig: event.sig,
|
|
69
|
+
createdAt: event.created_at
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
signCallSignal,
|
|
75
|
+
verifyCallSignal
|
|
76
|
+
};
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Public barrel for @mh-gg/relay.
|
|
2
|
+
// Re-exports the relay runtime plus the shared primitives (config, identity,
|
|
3
|
+
// peer signaling, rc-file defaults, wire validation) that the host package and
|
|
4
|
+
// the matterhorn CLI consume.
|
|
5
|
+
|
|
6
|
+
const relay = require("./relay.cjs");
|
|
7
|
+
const relayConfig = require("./relayConfig.cjs");
|
|
8
|
+
const logging = require("./logging.cjs");
|
|
9
|
+
const metrics = require("./metrics.cjs");
|
|
10
|
+
const peerJsConfig = require("./peerJsConfig.cjs");
|
|
11
|
+
const localPeerServer = require("./localPeerServer.cjs");
|
|
12
|
+
const matterhornrc = require("./matterhornrc.cjs");
|
|
13
|
+
const relayIdentity = require("./relayIdentity.cjs");
|
|
14
|
+
const relayPolicy = require("./roomRelayPolicy.cjs");
|
|
15
|
+
const relaySetPropagation = require("./relaySetPropagation.cjs");
|
|
16
|
+
const relayTrustHandshake = require("./relayTrustHandshake.cjs");
|
|
17
|
+
const relayWrapKeys = require("./relayWrapKeys.cjs");
|
|
18
|
+
const mlsDeliveryService = require("./mlsDeliveryService.cjs");
|
|
19
|
+
const sqliteRelayStorage = require("./sqliteRelayStorage.cjs");
|
|
20
|
+
const wireValidation = require("./wireValidation/index.cjs");
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
// relay runtime (host/relay.cjs)
|
|
24
|
+
...relay,
|
|
25
|
+
// relay config primitives
|
|
26
|
+
...relayConfig,
|
|
27
|
+
...logging,
|
|
28
|
+
...metrics,
|
|
29
|
+
// peerjs signaling config
|
|
30
|
+
...peerJsConfig,
|
|
31
|
+
// local peerjs server
|
|
32
|
+
...localPeerServer,
|
|
33
|
+
// rc-file defaults / data dir
|
|
34
|
+
...matterhornrc,
|
|
35
|
+
// relay identity / trust
|
|
36
|
+
...relayIdentity,
|
|
37
|
+
...relayPolicy,
|
|
38
|
+
...relaySetPropagation,
|
|
39
|
+
...relayTrustHandshake,
|
|
40
|
+
...relayWrapKeys,
|
|
41
|
+
...mlsDeliveryService,
|
|
42
|
+
...sqliteRelayStorage,
|
|
43
|
+
// wire validation barrel
|
|
44
|
+
...wireValidation
|
|
45
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const { setTimeout: delay } = require("node:timers/promises");
|
|
2
|
+
|
|
3
|
+
const DEFAULT_LOCAL_PEERJS_HOST = "127.0.0.1";
|
|
4
|
+
const DEFAULT_LOCAL_PEERJS_PORT = 9000;
|
|
5
|
+
const DEFAULT_LOCAL_PEERJS_PATH = "/peerjs";
|
|
6
|
+
|
|
7
|
+
function localPeerServerConfig(options = {}) {
|
|
8
|
+
return {
|
|
9
|
+
host: options.host || process.env.MATTERHORN_PEERJS_LOCAL_HOST || DEFAULT_LOCAL_PEERJS_HOST,
|
|
10
|
+
port: Number(options.port || process.env.MATTERHORN_PEERJS_LOCAL_PORT || DEFAULT_LOCAL_PEERJS_PORT),
|
|
11
|
+
path: options.path || process.env.MATTERHORN_PEERJS_LOCAL_PATH || DEFAULT_LOCAL_PEERJS_PATH,
|
|
12
|
+
secure: false
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function applyPeerServerEnv(peerServer) {
|
|
17
|
+
process.env.MATTERHORN_PEERJS_HOST = peerServer.host;
|
|
18
|
+
process.env.MATTERHORN_PEERJS_PORT = String(peerServer.port);
|
|
19
|
+
process.env.MATTERHORN_PEERJS_PATH = peerServer.path;
|
|
20
|
+
process.env.MATTERHORN_PEERJS_SECURE = String(peerServer.secure);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function peerServerUrl(config) {
|
|
24
|
+
return `http://${config.host}:${config.port}${config.path}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function peerServerIdUrl(config) {
|
|
28
|
+
const base = peerServerUrl(config).replace(/\/$/, "");
|
|
29
|
+
return `${base}/peerjs/id`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function waitForPeerServer(config, options = {}) {
|
|
33
|
+
const timeoutMs = Number(options.timeoutMs || 5000);
|
|
34
|
+
const deadline = Date.now() + timeoutMs;
|
|
35
|
+
let lastError;
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(peerServerIdUrl(config));
|
|
39
|
+
if (response.ok) return true;
|
|
40
|
+
lastError = new Error(`PeerJS health returned ${response.status}`);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
lastError = error;
|
|
43
|
+
}
|
|
44
|
+
await delay(100);
|
|
45
|
+
}
|
|
46
|
+
throw lastError || new Error("Timed out waiting for local PeerJS server");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function closePeerServer(server) {
|
|
50
|
+
const target = server?.close ? server : server?._server;
|
|
51
|
+
if (!target?.close) return Promise.resolve();
|
|
52
|
+
return new Promise((resolve) => target.close(() => resolve()));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function startLocalPeerServer(options = {}) {
|
|
56
|
+
const config = localPeerServerConfig(options);
|
|
57
|
+
if (options.reuseExisting !== false) {
|
|
58
|
+
try {
|
|
59
|
+
await waitForPeerServer(config, { timeoutMs: options.reuseTimeoutMs || 300 });
|
|
60
|
+
applyPeerServerEnv(config);
|
|
61
|
+
return {
|
|
62
|
+
...config,
|
|
63
|
+
url: peerServerUrl(config),
|
|
64
|
+
reused: true,
|
|
65
|
+
server: undefined,
|
|
66
|
+
close: () => Promise.resolve()
|
|
67
|
+
};
|
|
68
|
+
} catch (matterhornIgnoredError) {
|
|
69
|
+
globalThis.__matterhornIgnoredError?.(matterhornIgnoredError, "host/localPeerServer.cjs");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let PeerServer;
|
|
74
|
+
try {
|
|
75
|
+
({ PeerServer } = require("peer"));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new Error(`The local PeerJS server requires the peer package. Run pnpm install once at the Matterhorn workspace root. ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
if (typeof PeerServer !== "function") throw new Error("The peer package did not export PeerServer.");
|
|
80
|
+
const server = PeerServer({
|
|
81
|
+
host: config.host,
|
|
82
|
+
port: config.port,
|
|
83
|
+
path: config.path,
|
|
84
|
+
proxied: false
|
|
85
|
+
});
|
|
86
|
+
let startupError;
|
|
87
|
+
server?.once?.("error", (error) => {
|
|
88
|
+
startupError = error;
|
|
89
|
+
});
|
|
90
|
+
applyPeerServerEnv(config);
|
|
91
|
+
try {
|
|
92
|
+
await waitForPeerServer(config, options);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
await closePeerServer(server).catch(() => undefined);
|
|
95
|
+
throw startupError || error;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
...config,
|
|
99
|
+
url: peerServerUrl(config),
|
|
100
|
+
reused: false,
|
|
101
|
+
server,
|
|
102
|
+
close: () => closePeerServer(server)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
DEFAULT_LOCAL_PEERJS_HOST,
|
|
108
|
+
DEFAULT_LOCAL_PEERJS_PATH,
|
|
109
|
+
DEFAULT_LOCAL_PEERJS_PORT,
|
|
110
|
+
applyPeerServerEnv,
|
|
111
|
+
localPeerServerConfig,
|
|
112
|
+
peerServerIdUrl,
|
|
113
|
+
peerServerUrl,
|
|
114
|
+
startLocalPeerServer,
|
|
115
|
+
waitForPeerServer
|
|
116
|
+
};
|
package/src/logging.cjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
|
|
3
|
+
const REDACTED = "<redacted>";
|
|
4
|
+
const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
|
|
5
|
+
const SENSITIVE_KEYS = new Set([
|
|
6
|
+
"adminToken",
|
|
7
|
+
"auth",
|
|
8
|
+
"bearer",
|
|
9
|
+
"credential",
|
|
10
|
+
"grantPrivateKeyPem",
|
|
11
|
+
"inviteSecret",
|
|
12
|
+
"ipcSecret",
|
|
13
|
+
"memberSecret",
|
|
14
|
+
"operation",
|
|
15
|
+
"passphrase",
|
|
16
|
+
"payload",
|
|
17
|
+
"privateKey",
|
|
18
|
+
"privateKeyPem",
|
|
19
|
+
"roomSecret",
|
|
20
|
+
"secret",
|
|
21
|
+
"signature",
|
|
22
|
+
"token",
|
|
23
|
+
"turnCredential",
|
|
24
|
+
"vapidPrivateKey"
|
|
25
|
+
].map((key) => key.toLowerCase()));
|
|
26
|
+
|
|
27
|
+
const QUERY_SECRET_KEYS = new Set([
|
|
28
|
+
"access_token",
|
|
29
|
+
"admin",
|
|
30
|
+
"adminToken",
|
|
31
|
+
"code",
|
|
32
|
+
"credential",
|
|
33
|
+
"invite",
|
|
34
|
+
"key",
|
|
35
|
+
"passphrase",
|
|
36
|
+
"password",
|
|
37
|
+
"secret",
|
|
38
|
+
"sig",
|
|
39
|
+
"signature",
|
|
40
|
+
"token"
|
|
41
|
+
].map((key) => key.toLowerCase()));
|
|
42
|
+
|
|
43
|
+
function fingerprint(value, length = 12) {
|
|
44
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, length);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function safeIdentifier(value, options = {}) {
|
|
48
|
+
const text = String(value || "");
|
|
49
|
+
if (!text) return "";
|
|
50
|
+
const prefix = Number.isInteger(options.prefix) ? options.prefix : 8;
|
|
51
|
+
return `${text.slice(0, prefix)}#${fingerprint(text, 8)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function redactUrl(text) {
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(text);
|
|
57
|
+
if (url.username) url.username = REDACTED;
|
|
58
|
+
if (url.password) url.password = REDACTED;
|
|
59
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
60
|
+
if (QUERY_SECRET_KEYS.has(key.toLowerCase())) url.searchParams.set(key, REDACTED);
|
|
61
|
+
}
|
|
62
|
+
if (url.hash && /(?:invite|secret|token|key|admin|passphrase|credential)=/i.test(url.hash)) {
|
|
63
|
+
url.hash = "#<redacted>";
|
|
64
|
+
}
|
|
65
|
+
return url.toString();
|
|
66
|
+
} catch {
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function redactString(value) {
|
|
72
|
+
let text = String(value);
|
|
73
|
+
text = redactUrl(text);
|
|
74
|
+
text = text.replace(/matterhorn-relay:[A-Za-z0-9._~+/=-]+/g, "matterhorn-relay:<redacted>");
|
|
75
|
+
text = text.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>");
|
|
76
|
+
text = text.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, REDACTED);
|
|
77
|
+
text = text.replace(/\b(roomSecret|inviteSecret|ipcSecret|passphrase|token|secret|credential)=([^&\s]+)/gi, "$1=<redacted>");
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shouldRedactKey(key) {
|
|
82
|
+
const normalized = String(key || "").toLowerCase();
|
|
83
|
+
if (SENSITIVE_KEYS.has(normalized)) return true;
|
|
84
|
+
return /(secret|privatekey|token|passphrase|credential|signature|plaintext|payload|operation)/i.test(normalized);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function redactLogValue(value, seen = new WeakSet()) {
|
|
88
|
+
if (value instanceof Error) {
|
|
89
|
+
return {
|
|
90
|
+
name: value.name,
|
|
91
|
+
message: redactString(value.message || ""),
|
|
92
|
+
code: value.code
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === "string") return redactString(value);
|
|
96
|
+
if (typeof value !== "object" || value === null) return value;
|
|
97
|
+
if (seen.has(value)) return "[Circular]";
|
|
98
|
+
seen.add(value);
|
|
99
|
+
if (Array.isArray(value)) return value.map((item) => redactLogValue(item, seen));
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const [key, item] of Object.entries(value)) {
|
|
102
|
+
out[key] = shouldRedactKey(key) ? REDACTED : redactLogValue(item, seen);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeLevel(level, fallback = "info") {
|
|
108
|
+
return Object.prototype.hasOwnProperty.call(LEVELS, level) ? level : fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createStructuredLogger(options = {}) {
|
|
112
|
+
const level = normalizeLevel(options.level || process.env.MATTERHORN_LOG_LEVEL, "info");
|
|
113
|
+
const json = options.json === true || process.env.MATTERHORN_LOG_FORMAT === "json" || process.env.NODE_ENV === "production";
|
|
114
|
+
const sink = options.sink || console;
|
|
115
|
+
|
|
116
|
+
function enabled(entryLevel) {
|
|
117
|
+
return LEVELS[entryLevel] >= LEVELS[level];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function emit(entryLevel, event, fields = {}) {
|
|
121
|
+
if (!enabled(entryLevel)) return;
|
|
122
|
+
const entry = {
|
|
123
|
+
ts: new Date().toISOString(),
|
|
124
|
+
level: entryLevel,
|
|
125
|
+
event,
|
|
126
|
+
...redactLogValue(fields)
|
|
127
|
+
};
|
|
128
|
+
const line = json ? JSON.stringify(entry) : formatTextEntry(entry);
|
|
129
|
+
const writer = entryLevel === "error" ? sink.error : entryLevel === "warn" ? sink.warn : sink.log;
|
|
130
|
+
(writer || sink.log || console.log).call(sink, line);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
debug: (event, fields) => emit("debug", event, fields),
|
|
135
|
+
info: (event, fields) => emit("info", event, fields),
|
|
136
|
+
warn: (event, fields) => emit("warn", event, fields),
|
|
137
|
+
error: (event, fields) => emit("error", event, fields)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatTextEntry(entry) {
|
|
142
|
+
const { ts, level, event, ...fields } = entry;
|
|
143
|
+
const suffix = Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : "";
|
|
144
|
+
return `${ts} ${level} ${event}${suffix}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function wrapPrivacySafeLogger(logger = console) {
|
|
148
|
+
return {
|
|
149
|
+
log: (...args) => logger.log?.(...args.map((item) => redactLogValue(item))),
|
|
150
|
+
info: (...args) => (logger.info || logger.log)?.(...args.map((item) => redactLogValue(item))),
|
|
151
|
+
warn: (...args) => logger.warn?.(...args.map((item) => redactLogValue(item))),
|
|
152
|
+
error: (...args) => logger.error?.(...args.map((item) => redactLogValue(item)))
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
LEVELS,
|
|
158
|
+
REDACTED,
|
|
159
|
+
createStructuredLogger,
|
|
160
|
+
fingerprint,
|
|
161
|
+
redactLogValue,
|
|
162
|
+
safeIdentifier,
|
|
163
|
+
wrapPrivacySafeLogger
|
|
164
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
function userDataDir() {
|
|
6
|
+
return process.env.MATTERHORN_HOME || path.join(os.homedir(), ".matterhorn");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function usermatterhornrcFile() {
|
|
10
|
+
return path.join(userDataDir(), ".matterhornrc");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseKeyValueText(text) {
|
|
14
|
+
const values = {};
|
|
15
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
16
|
+
const line = rawLine.trim();
|
|
17
|
+
if (!line || line.startsWith("#")) continue;
|
|
18
|
+
const match = /^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)$/.exec(line);
|
|
19
|
+
if (!match) continue;
|
|
20
|
+
values[match[1]] = match[2].trim().replace(/^["']|["']$/g, "");
|
|
21
|
+
}
|
|
22
|
+
return values;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizematterhornrc(values) {
|
|
26
|
+
const host = values.host || values.HOST_URL || values.appUrl || values.app_url;
|
|
27
|
+
const normalized = host ? { host: String(host).trim() } : {};
|
|
28
|
+
const players = Array.isArray(values.players) ? values.players : values.playerPacks;
|
|
29
|
+
if (Array.isArray(players)) {
|
|
30
|
+
normalized.players = players
|
|
31
|
+
.filter((player) => player && typeof player === "object" && typeof player.url === "string")
|
|
32
|
+
.map((player) => ({
|
|
33
|
+
url: player.url,
|
|
34
|
+
...(player.integrity === undefined ? {} : { integrity: String(player.integrity) }),
|
|
35
|
+
...(player.id === undefined ? {} : { id: String(player.id) }),
|
|
36
|
+
...(player.name === undefined ? {} : { name: String(player.name) }),
|
|
37
|
+
...(player.version === undefined ? {} : { version: String(player.version) }),
|
|
38
|
+
...(player.addedAt === undefined ? {} : { addedAt: String(player.addedAt) })
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parsematterhornrc(text) {
|
|
45
|
+
try {
|
|
46
|
+
return normalizematterhornrc(JSON.parse(text));
|
|
47
|
+
} catch {
|
|
48
|
+
return normalizematterhornrc(parseKeyValueText(text));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readmatterhornrc(file = usermatterhornrcFile()) {
|
|
53
|
+
try {
|
|
54
|
+
return parsematterhornrc(fs.readFileSync(file, "utf8"));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) return {};
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writematterhornrc(values, file = usermatterhornrcFile()) {
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
fs.writeFileSync(file, `${JSON.stringify(values, null, 2)}\n`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function applymatterhornrcDefaults(target = process.env, file = usermatterhornrcFile()) {
|
|
67
|
+
const values = readmatterhornrc(file);
|
|
68
|
+
if (values.host && target.APP_URL === undefined && target.HOST_URL === undefined) {
|
|
69
|
+
target.HOST_URL = values.host;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
applymatterhornrcDefaults,
|
|
75
|
+
readmatterhornrc,
|
|
76
|
+
userDataDir,
|
|
77
|
+
usermatterhornrcFile,
|
|
78
|
+
writematterhornrc
|
|
79
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
function registryPathFromOptions(options = {}) {
|
|
5
|
+
if (options.registryPath) return options.registryPath;
|
|
6
|
+
if (options.memberRootRegistryPath) return options.memberRootRegistryPath;
|
|
7
|
+
if (options.storagePath) return `${options.storagePath}.member-roots.json`;
|
|
8
|
+
if (options.dataDir) return path.join(options.dataDir, "member-roots.json");
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function clone(value) {
|
|
13
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function safeKeyPart(value) {
|
|
17
|
+
return String(value || "").replace(/[\u0000-\u001f\u007f:]/g, "_");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rootKeyFor(roomName, memberId) {
|
|
21
|
+
return `${safeKeyPart(roomName)}:${safeKeyPart(memberId)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function claimRoom(claim) {
|
|
25
|
+
return claim?.roomId || claim?.roomName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeClaim(claim) {
|
|
29
|
+
const roomName = claimRoom(claim);
|
|
30
|
+
if (!roomName || !claim?.memberId || !claim?.rootPublicKey) return undefined;
|
|
31
|
+
return {
|
|
32
|
+
roomName,
|
|
33
|
+
memberId: claim.memberId,
|
|
34
|
+
rootPublicKey: claim.rootPublicKey,
|
|
35
|
+
firstSeenAt: Number.isFinite(Number(claim.createdAt)) ? Number(claim.createdAt) : Date.now(),
|
|
36
|
+
updatedAt: Date.now(),
|
|
37
|
+
lastDeviceId: claim.deviceId,
|
|
38
|
+
lastKeyId: claim.keyId
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createMemoryRegistry(initial = {}) {
|
|
43
|
+
const roots = new Map(Object.entries(initial));
|
|
44
|
+
return {
|
|
45
|
+
path: undefined,
|
|
46
|
+
entries() {
|
|
47
|
+
return Object.fromEntries(roots.entries());
|
|
48
|
+
},
|
|
49
|
+
get(roomName, memberId) {
|
|
50
|
+
return clone(roots.get(rootKeyFor(roomName, memberId)));
|
|
51
|
+
},
|
|
52
|
+
remember(claim) {
|
|
53
|
+
const normalized = normalizeClaim(claim);
|
|
54
|
+
if (!normalized) return { ok: false, error: "member root claim is incomplete" };
|
|
55
|
+
const key = rootKeyFor(normalized.roomName, normalized.memberId);
|
|
56
|
+
const existing = roots.get(key);
|
|
57
|
+
if (existing && existing.rootPublicKey !== normalized.rootPublicKey) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: "member root public key changed",
|
|
61
|
+
existing: clone(existing),
|
|
62
|
+
incoming: clone(normalized)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const next = existing ? { ...existing, ...normalized, firstSeenAt: existing.firstSeenAt || normalized.firstSeenAt } : normalized;
|
|
66
|
+
roots.set(key, next);
|
|
67
|
+
return { ok: true, entry: clone(next) };
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readRegistryFile(file) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
75
|
+
if (parsed && parsed.version === 1 && parsed.roots && typeof parsed.roots === "object" && !Array.isArray(parsed.roots)) {
|
|
76
|
+
return parsed.roots;
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error && error.code === "ENOENT") return {};
|
|
80
|
+
}
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeRegistryFile(file, roots) {
|
|
85
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
86
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
87
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 1, roots }, null, 2));
|
|
88
|
+
fs.renameSync(tmp, file);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createMemberRootRegistry(options = {}) {
|
|
92
|
+
const file = registryPathFromOptions(options);
|
|
93
|
+
if (!file) return createMemoryRegistry(options.initialRoots);
|
|
94
|
+
let roots = readRegistryFile(file);
|
|
95
|
+
function persist() {
|
|
96
|
+
writeRegistryFile(file, roots);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
path: file,
|
|
100
|
+
entries() {
|
|
101
|
+
roots = readRegistryFile(file);
|
|
102
|
+
return clone(roots);
|
|
103
|
+
},
|
|
104
|
+
get(roomName, memberId) {
|
|
105
|
+
roots = readRegistryFile(file);
|
|
106
|
+
return clone(roots[rootKeyFor(roomName, memberId)]);
|
|
107
|
+
},
|
|
108
|
+
remember(claim) {
|
|
109
|
+
const normalized = normalizeClaim(claim);
|
|
110
|
+
if (!normalized) return { ok: false, error: "member root claim is incomplete" };
|
|
111
|
+
roots = readRegistryFile(file);
|
|
112
|
+
const key = rootKeyFor(normalized.roomName, normalized.memberId);
|
|
113
|
+
const existing = roots[key];
|
|
114
|
+
if (existing && existing.rootPublicKey !== normalized.rootPublicKey) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: "member root public key changed",
|
|
118
|
+
existing: clone(existing),
|
|
119
|
+
incoming: clone(normalized)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
roots[key] = existing ? { ...existing, ...normalized, firstSeenAt: existing.firstSeenAt || normalized.firstSeenAt } : normalized;
|
|
123
|
+
persist();
|
|
124
|
+
return { ok: true, entry: clone(roots[key]) };
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
createMemberRootRegistry,
|
|
131
|
+
rootKeyFor
|
|
132
|
+
};
|