@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.
- package/README.md +5 -0
- package/bin/matterhorn.cjs +57 -0
- package/package.json +49 -0
- package/runtime/bin/appFrontend/artifacts.cjs +25 -0
- package/runtime/bin/appFrontend/buildServers.cjs +176 -0
- package/runtime/bin/appFrontend/commandEnv.cjs +74 -0
- package/runtime/bin/appFrontend/commandPolicy.cjs +23 -0
- package/runtime/bin/appFrontend/devServers.cjs +150 -0
- package/runtime/bin/appFrontend/httpServers.cjs +221 -0
- package/runtime/bin/appFrontend/paths.cjs +103 -0
- package/runtime/bin/appFrontend/ports.cjs +36 -0
- package/runtime/bin/appFrontend/processes.cjs +127 -0
- package/runtime/bin/appFrontend.cjs +45 -0
- package/runtime/bin/appHostCommand.cjs +381 -0
- package/runtime/bin/matterhorn.cjs +501 -0
- package/runtime/bin/matterhornAppLoader.cjs +588 -0
- package/runtime/bin/matterhornApps.cjs +223 -0
- package/runtime/bin/matterhornDeploy.cjs +108 -0
- package/runtime/bin/matterhornEmitAppBundle.cjs +20 -0
- package/runtime/bin/matterhornInstall.cjs +609 -0
- package/runtime/host/callAuth.cjs +76 -0
- package/runtime/host/host.cjs +103 -0
- package/runtime/host/hostAnnouncement.cjs +70 -0
- package/runtime/host/hostClients/constants.cjs +7 -0
- package/runtime/host/hostClients/frontendBundleRefresh.cjs +158 -0
- package/runtime/host/hostClients/frontendRequests.cjs +166 -0
- package/runtime/host/hostClients/index.cjs +68 -0
- package/runtime/host/hostClients/rejections.cjs +37 -0
- package/runtime/host/hostSession.cjs +160 -0
- package/runtime/host/inlineProgressBar.cjs +128 -0
- package/runtime/host/localPeerServer.cjs +114 -0
- package/runtime/host/localRelayClient.cjs +151 -0
- package/runtime/host/matterhornrc.cjs +75 -0
- package/runtime/host/memberRootRegistry.cjs +132 -0
- package/runtime/host/nodePeer.cjs +127 -0
- package/runtime/host/nodePeerRacePatch.cjs +106 -0
- package/runtime/host/peerJsConfig.cjs +26 -0
- package/runtime/host/pushEgress.cjs +48 -0
- package/runtime/host/pushStorage.cjs +233 -0
- package/runtime/host/relay/config.cjs +179 -0
- package/runtime/host/relay/connectionCleanup.cjs +34 -0
- package/runtime/host/relay/connectionDispatcher.cjs +140 -0
- package/runtime/host/relay/matterhornOperationEvents.cjs +100 -0
- package/runtime/host/relay/matterhornRuntimeEventBridge.cjs +182 -0
- package/runtime/host/relay/nostrRelay.cjs +30 -0
- package/runtime/host/relay/peerStartup.cjs +81 -0
- package/runtime/host/relay.cjs +653 -0
- package/runtime/host/relayClientRouting.cjs +1054 -0
- package/runtime/host/relayConfig.cjs +156 -0
- package/runtime/host/relayHostAuth.cjs +39 -0
- package/runtime/host/relayHostMessages.cjs +367 -0
- package/runtime/host/relayHttp.cjs +48 -0
- package/runtime/host/relayIdentity.cjs +496 -0
- package/runtime/host/relayIncomingGate.cjs +153 -0
- package/runtime/host/relayMeshEnvelopes.cjs +522 -0
- package/runtime/host/relayPeerLifecycle.cjs +96 -0
- package/runtime/host/relayPeerSignals.cjs +175 -0
- package/runtime/host/relayRoomRuntimePersistence.cjs +129 -0
- package/runtime/host/relayStatus.cjs +160 -0
- package/runtime/host/sfuRelay.cjs +553 -0
- package/runtime/host/sqliteRelayStorage.cjs +352 -0
- package/runtime/host/wireValidation/client.cjs +213 -0
- package/runtime/host/wireValidation/host.cjs +33 -0
- package/runtime/host/wireValidation/index.cjs +13 -0
- package/runtime/host/wireValidation/peerSignal.cjs +35 -0
- package/runtime/host/wireValidation/presenceEvent.cjs +49 -0
- package/runtime/host/wireValidation/push.cjs +49 -0
- package/runtime/host/wireValidation/relay.cjs +131 -0
- package/runtime/host/wireValidation/shared.cjs +49 -0
- package/runtime/scripts/ensureWorkspaceSdkBuild.cjs +148 -0
- package/runtime/scripts/killChildTree.cjs +18 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { userDataDir } = require("./matterhornrc.cjs");
|
|
5
|
+
const { randomRelayPeerId, relayMeshAddressFromAddress, relayMeshAddressForPeerId, relayMeshPeerIdForRoomPeerId } = require("@mh-gg/relay-core");
|
|
6
|
+
const { peerJsSignalingConfig } = require("./peerJsConfig.cjs");
|
|
7
|
+
const { ensureRelayIdentity, normalizeRelayAlias, normalizeRelayName } = require("./relayIdentity.cjs");
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RELAY_IPC_HOST = "127.0.0.1";
|
|
10
|
+
const DEFAULT_RELAY_IPC_PORT = 42777;
|
|
11
|
+
const PRIVATE_DIR_MODE = 0o700;
|
|
12
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
13
|
+
|
|
14
|
+
function relayConfigFile(dataDir = userDataDir()) {
|
|
15
|
+
return path.join(dataDir, "relay.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readRelayConfig(file = relayConfigFile()) {
|
|
19
|
+
if (!fs.existsSync(file)) return {};
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function chmodPrivate(file, mode) {
|
|
28
|
+
try {
|
|
29
|
+
fs.chmodSync(file, mode);
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensurePrivateDir(dir) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
35
|
+
chmodPrivate(dir, PRIVATE_DIR_MODE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fsyncDir(dir) {
|
|
39
|
+
if (process.platform === "win32") return;
|
|
40
|
+
let fd;
|
|
41
|
+
try {
|
|
42
|
+
fd = fs.openSync(dir, "r");
|
|
43
|
+
fs.fsyncSync(fd);
|
|
44
|
+
} catch {
|
|
45
|
+
} finally {
|
|
46
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function atomicWritePrivateFile(file, data) {
|
|
51
|
+
ensurePrivateDir(path.dirname(file));
|
|
52
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
53
|
+
let fd;
|
|
54
|
+
try {
|
|
55
|
+
fd = fs.openSync(tmp, "wx", PRIVATE_FILE_MODE);
|
|
56
|
+
fs.writeFileSync(fd, data);
|
|
57
|
+
fs.fsyncSync(fd);
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
fd = undefined;
|
|
60
|
+
chmodPrivate(tmp, PRIVATE_FILE_MODE);
|
|
61
|
+
fs.renameSync(tmp, file);
|
|
62
|
+
chmodPrivate(file, PRIVATE_FILE_MODE);
|
|
63
|
+
fsyncDir(path.dirname(file));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (fd !== undefined) {
|
|
66
|
+
try { fs.closeSync(fd); } catch {}
|
|
67
|
+
}
|
|
68
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeRelayConfig(config, file = relayConfigFile()) {
|
|
74
|
+
atomicWritePrivateFile(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function randomIpcSecret() {
|
|
78
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function relayPeerIdForOptions(options, current) {
|
|
82
|
+
const relayName = options.relayName === undefined ? undefined : normalizeRelayName(options.relayName);
|
|
83
|
+
const relayAlias = options.relayName === undefined ? undefined : normalizeRelayAlias(String(options.relayName).split("#")[0]);
|
|
84
|
+
if (options.relayName !== undefined && (!relayName || !relayAlias)) {
|
|
85
|
+
throw new Error("Relay name must contain an alias and optional four-digit discriminator, such as relay-a#0000.");
|
|
86
|
+
}
|
|
87
|
+
if (options.relayPeerId) return options.relayPeerId;
|
|
88
|
+
if (relayName && normalizeRelayName(current.relayIdentity?.logicalName) === relayName && current.relayPeerId) return current.relayPeerId;
|
|
89
|
+
if (relayAlias) return relayAlias;
|
|
90
|
+
return current.relayPeerId || randomRelayPeerId("relay");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ensureRelayConfig(options = {}) {
|
|
94
|
+
const file = options.file || relayConfigFile(options.dataDir);
|
|
95
|
+
const current = readRelayConfig(file);
|
|
96
|
+
const relayPeerId = relayPeerIdForOptions(options, current);
|
|
97
|
+
const relayMeshPeerId = relayMeshPeerIdForRoomPeerId(relayPeerId);
|
|
98
|
+
const peerJsSignaling = options.peerJsSignaling || peerJsSignalingConfig();
|
|
99
|
+
const forcePeerJsSignaling = options.forcePeerJsSignaling === true;
|
|
100
|
+
const currentRelayAddress = forcePeerJsSignaling ? undefined : relayMeshAddressFromAddress(current.relayAddress, peerJsSignaling);
|
|
101
|
+
const shouldRewriteRelayAddress = forcePeerJsSignaling || options.relayPeerId || relayPeerId !== current.relayPeerId;
|
|
102
|
+
const ipcSecret = typeof current.ipcSecret === "string" && current.ipcSecret
|
|
103
|
+
? current.ipcSecret
|
|
104
|
+
: randomIpcSecret();
|
|
105
|
+
const relayName = normalizeRelayName(options.relayName) || normalizeRelayName(current.relayIdentity?.logicalName) || normalizeRelayName(relayPeerId);
|
|
106
|
+
const relayIdentity = ensureRelayIdentity(current.relayIdentity, relayName);
|
|
107
|
+
const config = {
|
|
108
|
+
relayPeerId,
|
|
109
|
+
relayMeshPeerId,
|
|
110
|
+
relayAddress: shouldRewriteRelayAddress ? relayMeshAddressForPeerId(relayPeerId, peerJsSignaling) : currentRelayAddress || relayMeshAddressForPeerId(relayPeerId, peerJsSignaling),
|
|
111
|
+
ipcHost: options.ipcHost || current.ipcHost || DEFAULT_RELAY_IPC_HOST,
|
|
112
|
+
ipcPort: Number(options.ipcPort ?? current.ipcPort ?? DEFAULT_RELAY_IPC_PORT),
|
|
113
|
+
ipcSecret,
|
|
114
|
+
relayIdentity,
|
|
115
|
+
createdAt: current.createdAt || Date.now()
|
|
116
|
+
};
|
|
117
|
+
writeRelayConfig(config, file);
|
|
118
|
+
return config;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function rotateRelayConfigPeerId(config, options = {}) {
|
|
122
|
+
const file = options.file || relayConfigFile(options.dataDir);
|
|
123
|
+
const current = readRelayConfig(file);
|
|
124
|
+
const peerJsSignaling = options.peerJsSignaling || peerJsSignalingConfig();
|
|
125
|
+
const logicalName = normalizeRelayName(config?.relayIdentity?.logicalName || current.relayIdentity?.logicalName || "relay") || "relay#0000";
|
|
126
|
+
const relayAlias = normalizeRelayAlias(logicalName.split("#")[0]) || "relay";
|
|
127
|
+
const relayPeerId = options.relayPeerId || randomRelayPeerId(relayAlias);
|
|
128
|
+
const relayMeshPeerId = relayMeshPeerIdForRoomPeerId(relayPeerId);
|
|
129
|
+
const relayAddress = relayMeshAddressForPeerId(relayPeerId, peerJsSignaling);
|
|
130
|
+
const relayIdentity = ensureRelayIdentity(current.relayIdentity || config.relayIdentity, logicalName);
|
|
131
|
+
relayIdentity.sequence = Number.isInteger(relayIdentity.sequence) ? relayIdentity.sequence + 1 : 2;
|
|
132
|
+
relayIdentity.updatedAt = Date.now();
|
|
133
|
+
const next = {
|
|
134
|
+
...current,
|
|
135
|
+
relayPeerId,
|
|
136
|
+
relayMeshPeerId,
|
|
137
|
+
relayAddress,
|
|
138
|
+
ipcHost: config.ipcHost || current.ipcHost || DEFAULT_RELAY_IPC_HOST,
|
|
139
|
+
ipcPort: Number(config.ipcPort ?? current.ipcPort ?? DEFAULT_RELAY_IPC_PORT),
|
|
140
|
+
ipcSecret: config.ipcSecret || current.ipcSecret || randomIpcSecret(),
|
|
141
|
+
relayIdentity,
|
|
142
|
+
createdAt: current.createdAt || config.createdAt || Date.now()
|
|
143
|
+
};
|
|
144
|
+
writeRelayConfig(next, file);
|
|
145
|
+
return next;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
DEFAULT_RELAY_IPC_HOST,
|
|
150
|
+
DEFAULT_RELAY_IPC_PORT,
|
|
151
|
+
ensureRelayConfig,
|
|
152
|
+
readRelayConfig,
|
|
153
|
+
relayConfigFile,
|
|
154
|
+
rotateRelayConfigPeerId,
|
|
155
|
+
writeRelayConfig
|
|
156
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const { sendMessage } = require("@mh-gg/relay-core");
|
|
2
|
+
|
|
3
|
+
function createRelayHostAuth(ipcSecret) {
|
|
4
|
+
function hasHostIpcAuth(message) {
|
|
5
|
+
return message?.auth?.type === "relay-ipc" && message.auth.secret === ipcSecret;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function sendHostAuthError(conn, type) {
|
|
9
|
+
sendMessage(conn, {
|
|
10
|
+
type,
|
|
11
|
+
code: "auth-required",
|
|
12
|
+
message: "Relay IPC auth failed"
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requireHostIpcAuth(conn, message) {
|
|
17
|
+
if (hasHostIpcAuth(message)) {
|
|
18
|
+
conn.MatterhornHostAuthorized = true;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (message.type === "host.register") {
|
|
22
|
+
sendHostAuthError(conn, "host.register.error");
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (message.type === "host.snapshot" || !conn.MatterhornHostAuthorized) {
|
|
26
|
+
sendHostAuthError(conn, "host.auth.error");
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
requireHostIpcAuth
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
createRelayHostAuth
|
|
39
|
+
};
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
const { relayRoomStoreSnapshot } = require("@mh-gg/relay-runtime");
|
|
2
|
+
const { sendMessage } = require("@mh-gg/relay-core");
|
|
3
|
+
const { manifestHash } = require("@mh-gg/base");
|
|
4
|
+
const { ROOM_STATE_SCHEMA_VERSION, createRoomDeviceKeyAuthenticator, isRoomDeviceSignedOperation } = require("@mh-gg/host-runtime");
|
|
5
|
+
const { canonicalAppRef, loadMatterhornApp } = require("../bin/matterhornAppLoader.cjs");
|
|
6
|
+
|
|
7
|
+
function appUsesExampleOperationAuth(appRef, app) {
|
|
8
|
+
const sourceRef = canonicalAppRef(appRef || app?.ref || "");
|
|
9
|
+
return app?.host?.runner === "matterhorn-example-host" ||
|
|
10
|
+
app?.deployment?.host?.runner === "matterhorn-example-host" ||
|
|
11
|
+
sourceRef.startsWith("@mh-gg/example-");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createExampleOperationAuthenticator() {
|
|
15
|
+
const authenticateRoomDevice = createRoomDeviceKeyAuthenticator();
|
|
16
|
+
return async function authenticateExampleActor(auth, actor, operation) {
|
|
17
|
+
if (isRoomDeviceSignedOperation(operation)) return authenticateRoomDevice(auth, actor, operation);
|
|
18
|
+
if (!auth || auth.signature !== "sig") throw new Error("Bad example signature");
|
|
19
|
+
return actor;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createRelayHostMessageHandler({
|
|
24
|
+
config,
|
|
25
|
+
relayPluginRuntimes,
|
|
26
|
+
hostConnections,
|
|
27
|
+
clientConnections,
|
|
28
|
+
mesh,
|
|
29
|
+
roomStores,
|
|
30
|
+
sendToClient,
|
|
31
|
+
catchUpMatterhornRuntime = async () => {},
|
|
32
|
+
prepareRoomRuntimeInput = (input) => input
|
|
33
|
+
}) {
|
|
34
|
+
function sendRoomMismatch(conn, expectedRoomName, requestedRoomName) {
|
|
35
|
+
sendMessage(conn, {
|
|
36
|
+
type: "host.message.error",
|
|
37
|
+
code: "room-mismatch",
|
|
38
|
+
message: `Host connection is registered for ${expectedRoomName || "no room"}, not ${requestedRoomName || "unknown room"}`
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requireRegisteredRoom(conn, requestedRoomName) {
|
|
43
|
+
if (!conn.hostRoomName) {
|
|
44
|
+
sendMessage(conn, { type: "host.message.error", code: "host-not-registered", message: "Host must register a room before sending room commands" });
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const roomName = requestedRoomName || conn.hostRoomName;
|
|
48
|
+
if (roomName !== conn.hostRoomName) {
|
|
49
|
+
sendRoomMismatch(conn, conn.hostRoomName, roomName);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return roomName;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function roomAppSourceRef(roomApp) {
|
|
56
|
+
if (typeof roomApp?.sourceRef === "string" && roomApp.sourceRef) return roomApp.sourceRef;
|
|
57
|
+
const frontend = Array.isArray(roomApp?.frontends) ? roomApp.frontends.find((item) => typeof item?.sourceRef === "string" && item.sourceRef) : undefined;
|
|
58
|
+
return frontend?.sourceRef;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function roomRuntimeSourceRef(message) {
|
|
62
|
+
if (typeof message.appRef === "string" && message.appRef) return message.appRef;
|
|
63
|
+
return roomAppSourceRef(message.store?.roomApp);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function roomExpectsRuntime(message) {
|
|
67
|
+
return Boolean(relayPluginRuntimes && roomRuntimeSourceRef(message));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function matterhornRuntimeState(store, roomName, appPackId) {
|
|
71
|
+
const state = store?.state;
|
|
72
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return undefined;
|
|
73
|
+
if (state.schemaVersion !== 1) return undefined;
|
|
74
|
+
if (state.roomId !== roomName) return undefined;
|
|
75
|
+
if (state.appPack?.id !== appPackId) return undefined;
|
|
76
|
+
if (!state.appPack?.hash || !Number.isInteger(state.version)) return undefined;
|
|
77
|
+
if (!state.plugins || typeof state.plugins !== "object" || Array.isArray(state.plugins)) return undefined;
|
|
78
|
+
if (!state.pluginVersions || typeof state.pluginVersions !== "object" || Array.isArray(state.pluginVersions)) return undefined;
|
|
79
|
+
if (!Array.isArray(state.seenOperations)) return undefined;
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clonePlain(value) {
|
|
84
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function roomAppPackForRuntime(app) {
|
|
88
|
+
return {
|
|
89
|
+
id: app.appPack.id,
|
|
90
|
+
version: app.appPack.version,
|
|
91
|
+
hash: app.appPack.hash || manifestHash(app.appPack),
|
|
92
|
+
protocolHash: app.appPack.protocolHash || app.appPack.compatibility?.appProtocolHash,
|
|
93
|
+
...(app.matterhorn ? { matterhorn: clonePlain(app.matterhorn) } : {}),
|
|
94
|
+
...(app.appPack.metadata ? { metadata: clonePlain(app.appPack.metadata) } : {})
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function schemaDefinedPrimaryPlugin(app) {
|
|
99
|
+
return Array.isArray(app.hostPlugins) ? app.hostPlugins.find((plugin) => plugin?.schemaDefined === true) : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function memberRoleForGuest(guest, adminIds = [], memberId) {
|
|
103
|
+
if (guest?.role === "admin" || adminIds.includes(guest?.id) || adminIds.includes(guest?.memberId) || adminIds.includes(memberId)) return "admin";
|
|
104
|
+
if (guest?.role === "owner" || guest?.role === "moderator" || guest?.role === "member") return guest.role;
|
|
105
|
+
return "member";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function legacyMembersFromStore(store) {
|
|
109
|
+
const members = {};
|
|
110
|
+
for (const [key, member] of Object.entries(store?.members || {})) {
|
|
111
|
+
const id = member.profileId || member.memberId || member.id || key;
|
|
112
|
+
if (!id) continue;
|
|
113
|
+
members[id] = {
|
|
114
|
+
...(members[id] || {}),
|
|
115
|
+
id,
|
|
116
|
+
memberId: id,
|
|
117
|
+
role: member.role || "member",
|
|
118
|
+
credentialId: member.credentialId,
|
|
119
|
+
deviceId: member.deviceId
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const guests = store?.state?.guests && typeof store.state.guests === "object" ? store.state.guests : {};
|
|
123
|
+
const adminIds = Array.isArray(store?.state?.adminIds) ? store.state.adminIds : [];
|
|
124
|
+
for (const [key, guest] of Object.entries(guests)) {
|
|
125
|
+
const id = guest.memberId || guest.id || key;
|
|
126
|
+
if (!id) continue;
|
|
127
|
+
members[id] = {
|
|
128
|
+
...(members[id] || {}),
|
|
129
|
+
id,
|
|
130
|
+
memberId: id,
|
|
131
|
+
name: guest.name,
|
|
132
|
+
displayName: guest.name,
|
|
133
|
+
avatar: guest.avatar,
|
|
134
|
+
role: memberRoleForGuest(guest, adminIds, id),
|
|
135
|
+
status: guest.bannedAt ? "banned" : undefined,
|
|
136
|
+
bannedAt: guest.bannedAt
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return members;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function legacySeenOperations(store) {
|
|
143
|
+
const stateSeen = store?.state?.seenOperations;
|
|
144
|
+
if (Array.isArray(stateSeen)) return stateSeen.filter((id) => typeof id === "string");
|
|
145
|
+
return (Array.isArray(store?.seenMutations) ? store.seenMutations : [])
|
|
146
|
+
.map((mutation) => typeof mutation === "string" ? mutation : mutation?.id)
|
|
147
|
+
.filter((id) => typeof id === "string");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasLegacyStateData(value) {
|
|
151
|
+
if (value === null || value === undefined) return false;
|
|
152
|
+
if (Array.isArray(value)) return value.length > 0 && value.some(hasLegacyStateData);
|
|
153
|
+
if (typeof value === "object") return Object.keys(value).length > 0 && Object.values(value).some(hasLegacyStateData);
|
|
154
|
+
if (typeof value === "string") return value.length > 0;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const legacyStateMetadataKeys = new Set(["version", "createdAt", "updatedAt", "seenOperations", "adminIds", "guests"]);
|
|
159
|
+
const weakSchemaMatchKeys = new Set(["activity"]);
|
|
160
|
+
|
|
161
|
+
function hasLegacyPrimaryPluginState(state) {
|
|
162
|
+
return Object.entries(state).some(([key, value]) => !legacyStateMetadataKeys.has(key) && hasLegacyStateData(value));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function primaryPluginInitialState(primaryPlugin, app) {
|
|
166
|
+
const pluginInitial = primaryPlugin?.stateSchemaDescriptor?.state?.initial;
|
|
167
|
+
if (pluginInitial && typeof pluginInitial === "object" && !Array.isArray(pluginInitial)) return pluginInitial;
|
|
168
|
+
const modelInitial = app?.appPack?.composition?.primaryPlugin?.model?.state?.initial;
|
|
169
|
+
if (modelInitial && typeof modelInitial === "object" && !Array.isArray(modelInitial)) return modelInitial;
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function legacyStateMatchesCurrentPrimaryPlugin(state, primaryPlugin, app) {
|
|
174
|
+
if (primaryPlugin?.schemaDefined !== true) return true;
|
|
175
|
+
const initial = primaryPluginInitialState(primaryPlugin, app);
|
|
176
|
+
if (!initial) return true;
|
|
177
|
+
const modelKeys = Object.keys(initial).filter((key) => !legacyStateMetadataKeys.has(key) && !weakSchemaMatchKeys.has(key));
|
|
178
|
+
if (modelKeys.length === 0) return true;
|
|
179
|
+
return modelKeys.some((key) => Object.prototype.hasOwnProperty.call(state, key) && hasLegacyStateData(state[key]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function legacyHostStateAsRuntimeState(store, roomName, app) {
|
|
183
|
+
const state = store?.state;
|
|
184
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return undefined;
|
|
185
|
+
if (!hasLegacyPrimaryPluginState(state)) return undefined;
|
|
186
|
+
const primaryPlugin = schemaDefinedPrimaryPlugin(app);
|
|
187
|
+
if (!primaryPlugin?.id) return undefined;
|
|
188
|
+
if (!legacyStateMatchesCurrentPrimaryPlugin(state, primaryPlugin, app)) return undefined;
|
|
189
|
+
const timestamp = Number(state.updatedAt || state.createdAt || Date.now());
|
|
190
|
+
return {
|
|
191
|
+
schemaVersion: ROOM_STATE_SCHEMA_VERSION,
|
|
192
|
+
roomId: roomName,
|
|
193
|
+
appPack: roomAppPackForRuntime(app),
|
|
194
|
+
version: Number.isInteger(state.version) && state.version >= 0 ? state.version : 0,
|
|
195
|
+
createdAt: Number(state.createdAt || timestamp),
|
|
196
|
+
updatedAt: timestamp,
|
|
197
|
+
members: legacyMembersFromStore(store),
|
|
198
|
+
revokedCredentialIds: [],
|
|
199
|
+
pluginVersions: { [primaryPlugin.id]: primaryPlugin.version },
|
|
200
|
+
plugins: { [primaryPlugin.id]: clonePlain(state) },
|
|
201
|
+
seenOperations: legacySeenOperations(store)
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function operationAuthFromMessage(message) {
|
|
206
|
+
const auth = message.store?.roomApp?.operationAuth || message.operationAuth;
|
|
207
|
+
const grants = message.operationRoleKeyGrants || auth?.grants;
|
|
208
|
+
const authorities = message.operationGrantAuthorities || auth?.authorities;
|
|
209
|
+
const requireSignedGrants = message.requireSignedOperationRoleKeyGrants ?? auth?.requireSignedGrants;
|
|
210
|
+
const grantCount = Array.isArray(grants) ? grants.length : grants ? Object.keys(grants).length : 0;
|
|
211
|
+
return { grants, authorities, requireSignedGrants, hasGrants: grantCount > 0 };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function enableRoomAppRuntime(message) {
|
|
215
|
+
const appRef = roomRuntimeSourceRef(message);
|
|
216
|
+
if (!relayPluginRuntimes || !appRef) return undefined;
|
|
217
|
+
|
|
218
|
+
const app = loadMatterhornApp(appRef, { cwd: message.appCwd || process.cwd() });
|
|
219
|
+
const auth = operationAuthFromMessage(message);
|
|
220
|
+
const appPack = roomAppPackForRuntime(app);
|
|
221
|
+
const storeRoomApp = message.store?.roomApp || {};
|
|
222
|
+
const composition = {
|
|
223
|
+
roomName: message.roomName,
|
|
224
|
+
appPack,
|
|
225
|
+
hostPack: app.hostPack,
|
|
226
|
+
plugins: app.hostPlugins,
|
|
227
|
+
expectedPlugins: app.hostPlugins,
|
|
228
|
+
playerPacks: app.playerPacks,
|
|
229
|
+
roomApp: storeRoomApp.matterhorn ? storeRoomApp : (app.matterhorn ? { ...storeRoomApp, matterhorn: app.matterhorn } : storeRoomApp),
|
|
230
|
+
capabilities: ["room.state", "room.roles"]
|
|
231
|
+
};
|
|
232
|
+
const state = matterhornRuntimeState(message.store, message.roomName, appPack.id) || legacyHostStateAsRuntimeState(message.store, message.roomName, app);
|
|
233
|
+
if (state) composition.state = state;
|
|
234
|
+
if (auth.hasGrants) {
|
|
235
|
+
composition.operationRoleKeyGrants = auth.grants;
|
|
236
|
+
composition.operationGrantAuthorities = auth.authorities;
|
|
237
|
+
if (auth.requireSignedGrants === false) composition.allowUnsignedOperationRoleKeyGrants = true;
|
|
238
|
+
} else if (appUsesExampleOperationAuth(appRef, app)) {
|
|
239
|
+
composition.authenticateActor = createExampleOperationAuthenticator();
|
|
240
|
+
}
|
|
241
|
+
const ready = relayPluginRuntimes.enableRoomComposition(prepareRoomRuntimeInput(composition));
|
|
242
|
+
return Promise.resolve(ready).then(async (result) => {
|
|
243
|
+
await catchUpMatterhornRuntime(message.roomName, message.store?.roomSecret);
|
|
244
|
+
return result;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function finishRegister(conn, message, incomingStore) {
|
|
249
|
+
if (incomingStore) roomStores.set(message.roomName, incomingStore);
|
|
250
|
+
hostConnections.set(message.roomName, conn);
|
|
251
|
+
conn.hostRoomName = message.roomName;
|
|
252
|
+
sendMessage(conn, {
|
|
253
|
+
type: "host.register.ok",
|
|
254
|
+
roomName: message.roomName,
|
|
255
|
+
relayPeerId: config.relayPeerId,
|
|
256
|
+
relayAddress: config.relayAddress
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sendRuntimeError(conn, error) {
|
|
261
|
+
sendMessage(conn, {
|
|
262
|
+
type: "host.register.error",
|
|
263
|
+
code: "room-app-runtime",
|
|
264
|
+
message: error?.message || "Room app runtime registration failed."
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function handleRegister(conn, message) {
|
|
269
|
+
if (typeof message.roomName !== "string" || !message.roomName) {
|
|
270
|
+
sendMessage(conn, { type: "host.register.error", message: "roomName is required" });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (conn.hostRoomName && conn.hostRoomName !== message.roomName) {
|
|
274
|
+
sendRoomMismatch(conn, conn.hostRoomName, message.roomName);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const incomingStore = relayRoomStoreSnapshot(message.roomName, message.store);
|
|
279
|
+
mesh.learnRelayHints(message.relayHints);
|
|
280
|
+
let runtimeReady;
|
|
281
|
+
try {
|
|
282
|
+
runtimeReady = enableRoomAppRuntime({ ...message, store: incomingStore || message.store });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
sendRuntimeError(conn, error);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!runtimeReady && roomExpectsRuntime(message)) {
|
|
288
|
+
sendRuntimeError(conn, new Error(`Room "${message.roomName}" has an app composition but the relay could not enable its plugin runtime. Provide a loadable appRef or roomApp.sourceRef.`));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (runtimeReady && typeof runtimeReady.then === "function") {
|
|
292
|
+
void runtimeReady
|
|
293
|
+
.then(() => finishRegister(conn, message, incomingStore))
|
|
294
|
+
.catch((error) => sendRuntimeError(conn, error));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
finishRegister(conn, message, incomingStore);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function handleSnapshot(conn, message) {
|
|
301
|
+
const roomName = requireRegisteredRoom(conn, typeof message.roomName === "string" ? message.roomName : undefined);
|
|
302
|
+
if (!roomName) return;
|
|
303
|
+
const incomingStore = relayRoomStoreSnapshot(roomName, message.store);
|
|
304
|
+
const snapshotMessage = { ...message, roomName, store: incomingStore || message.store };
|
|
305
|
+
const finishSnapshot = () => {
|
|
306
|
+
if (incomingStore) roomStores.set(roomName, incomingStore);
|
|
307
|
+
if (incomingStore?.roomApp) relayPluginRuntimes?.updateRoomApp?.(roomName, incomingStore.roomApp);
|
|
308
|
+
};
|
|
309
|
+
let runtimeReady;
|
|
310
|
+
try {
|
|
311
|
+
runtimeReady = enableRoomAppRuntime(snapshotMessage);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
sendRuntimeError(conn, error);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (runtimeReady && typeof runtimeReady.then === "function") {
|
|
317
|
+
void runtimeReady
|
|
318
|
+
.then(finishSnapshot)
|
|
319
|
+
.catch((error) => sendRuntimeError(conn, error));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
finishSnapshot();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function closeClient(peerId) {
|
|
326
|
+
const client = clientConnections.get(peerId);
|
|
327
|
+
if (client) {
|
|
328
|
+
client.close?.();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
mesh.sendEnvelope({
|
|
332
|
+
type: "relay.client.close",
|
|
333
|
+
peerId
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function handleHostMessage(conn, message) {
|
|
338
|
+
switch (message.type) {
|
|
339
|
+
case "host.register":
|
|
340
|
+
handleRegister(conn, message);
|
|
341
|
+
return;
|
|
342
|
+
case "host.snapshot":
|
|
343
|
+
handleSnapshot(conn, message);
|
|
344
|
+
return;
|
|
345
|
+
case "host.send": {
|
|
346
|
+
const roomName = requireRegisteredRoom(conn, message.roomName);
|
|
347
|
+
if (!roomName) return;
|
|
348
|
+
sendToClient(message.peerId, message.message, roomName);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
case "host.close":
|
|
352
|
+
if (!requireRegisteredRoom(conn, message.roomName)) return;
|
|
353
|
+
closeClient(message.peerId);
|
|
354
|
+
return;
|
|
355
|
+
default:
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
handleHostMessage
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
createRelayHostMessageHandler
|
|
367
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const http = require("node:http");
|
|
2
|
+
|
|
3
|
+
function createRelayHttpServer({ wss, relayInformation, health, allowedWebSocketPaths = ["/room", "/nostr"] }) {
|
|
4
|
+
const server = http.createServer((request, response) => {
|
|
5
|
+
const acceptsNip11 = String(request.headers.accept || "").includes("application/nostr+json");
|
|
6
|
+
if (request.method === "GET" && request.url === "/" && acceptsNip11) {
|
|
7
|
+
response.writeHead(200, {
|
|
8
|
+
"access-control-allow-origin": "*",
|
|
9
|
+
"content-type": "application/nostr+json"
|
|
10
|
+
});
|
|
11
|
+
response.end(JSON.stringify(relayInformation()));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (request.method === "GET" && request.url === "/health") {
|
|
16
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
17
|
+
response.end(JSON.stringify(health()));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (request.method === "GET" && request.url === "/") {
|
|
22
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
23
|
+
response.end("matterhorn peer relay\n\nThis is a Nostr relay bridge for matterhorn PeerJS data channels.\n");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
28
|
+
response.end("not found\n");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
server.on("upgrade", (request, socket, head) => {
|
|
32
|
+
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
33
|
+
if (!allowedWebSocketPaths.includes(url.pathname)) {
|
|
34
|
+
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
35
|
+
socket.destroy();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
39
|
+
wss.emit("connection", ws, request);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return server;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
createRelayHttpServer
|
|
48
|
+
};
|