@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,352 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { DatabaseSync } = require("node:sqlite");
4
+ const { matchFilters } = require("nostr-tools/filter");
5
+
6
+ function tagValue(tags, name) {
7
+ const tag = Array.isArray(tags) ? tags.find((item) => Array.isArray(item) && item[0] === name) : undefined;
8
+ return tag ? tag[1] : undefined;
9
+ }
10
+
11
+ function expirationTime(event) {
12
+ const raw = Number(tagValue(event.tags, "expiration"));
13
+ return Number.isFinite(raw) && raw > 0 ? Math.trunc(raw) : undefined;
14
+ }
15
+
16
+ function isModerationEvent(event) {
17
+ return event.kind === 5;
18
+ }
19
+
20
+ function eventBytes(event) {
21
+ return Buffer.byteLength(JSON.stringify(event), "utf8");
22
+ }
23
+
24
+ function sqliteFile(storagePath) {
25
+ return path.join(storagePath, "events.sqlite");
26
+ }
27
+
28
+ function placeholders(values) {
29
+ return values.map(() => "?").join(", ");
30
+ }
31
+
32
+ function escapeLike(value) {
33
+ return String(value).replace(/[\\%_]/g, (match) => `\\${match}`);
34
+ }
35
+
36
+ function stringValues(value) {
37
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
38
+ }
39
+
40
+ function integerValues(value) {
41
+ return Array.isArray(value) ? value.filter((item) => Number.isInteger(item)) : undefined;
42
+ }
43
+
44
+ function parseEvent(row) {
45
+ if (!row) return undefined;
46
+ try {
47
+ return JSON.parse(row.event_json);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ class SqliteRelayStorage {
54
+ constructor(storagePath) {
55
+ this.storagePath = storagePath;
56
+ fs.mkdirSync(storagePath, { recursive: true });
57
+ this.db = new DatabaseSync(sqliteFile(storagePath));
58
+ this.db.exec(`
59
+ PRAGMA journal_mode = WAL;
60
+ PRAGMA synchronous = NORMAL;
61
+
62
+ CREATE TABLE IF NOT EXISTS party_events (
63
+ id TEXT PRIMARY KEY,
64
+ party_id TEXT NOT NULL,
65
+ kind INTEGER NOT NULL,
66
+ pubkey TEXT NOT NULL,
67
+ created_at INTEGER NOT NULL,
68
+ received_at INTEGER NOT NULL,
69
+ event_bytes INTEGER NOT NULL DEFAULT 0,
70
+ expires_at INTEGER,
71
+ moderation INTEGER NOT NULL DEFAULT 0,
72
+ event_json TEXT NOT NULL
73
+ );
74
+
75
+ CREATE INDEX IF NOT EXISTS idx_party_events_party_created
76
+ ON party_events (party_id, created_at);
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_party_events_id
79
+ ON party_events (id);
80
+
81
+ CREATE INDEX IF NOT EXISTS idx_party_events_kind
82
+ ON party_events (kind);
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_party_events_pubkey
85
+ ON party_events (pubkey);
86
+
87
+ CREATE INDEX IF NOT EXISTS idx_party_events_created
88
+ ON party_events (created_at);
89
+
90
+ CREATE INDEX IF NOT EXISTS idx_party_events_party
91
+ ON party_events (party_id);
92
+
93
+ CREATE INDEX IF NOT EXISTS idx_party_events_received
94
+ ON party_events (received_at);
95
+
96
+ CREATE INDEX IF NOT EXISTS idx_party_events_expires
97
+ ON party_events (expires_at);
98
+ `);
99
+ this.ensureColumn("event_bytes", "INTEGER NOT NULL DEFAULT 0");
100
+ this.ensureColumn("expires_at", "INTEGER");
101
+ this.ensureColumn("moderation", "INTEGER NOT NULL DEFAULT 0");
102
+ this.insertStatement = this.db.prepare(`
103
+ INSERT OR IGNORE INTO party_events
104
+ (id, party_id, kind, pubkey, created_at, received_at, event_bytes, expires_at, moderation, event_json)
105
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
106
+ `);
107
+ this.deleteStatement = this.db.prepare("DELETE FROM party_events WHERE id = ?");
108
+ this.hasStatement = this.db.prepare("SELECT 1 FROM party_events WHERE id = ? LIMIT 1");
109
+ this.selectByIdStatement = this.db.prepare("SELECT event_json FROM party_events WHERE id = ? LIMIT 1");
110
+ this.selectAllStatement = this.db.prepare("SELECT event_json FROM party_events ORDER BY created_at ASC, id ASC");
111
+ }
112
+
113
+ ensureColumn(name, definition) {
114
+ const columns = this.db.prepare("PRAGMA table_info('party_events')").all().map((row) => row.name);
115
+ if (!columns.includes(name)) this.db.exec(`ALTER TABLE party_events ADD COLUMN ${name} ${definition}`);
116
+ }
117
+
118
+ loadEvents() {
119
+ const events = [];
120
+ for (const row of this.selectAllStatement.all()) {
121
+ try {
122
+ events.push(JSON.parse(row.event_json));
123
+ } catch {
124
+ // A bad row should not make the relay unbootable.
125
+ }
126
+ }
127
+ return events;
128
+ }
129
+
130
+ hasEvent(id) {
131
+ return Boolean(this.hasStatement.get(id));
132
+ }
133
+
134
+ getEvent(id) {
135
+ return parseEvent(this.selectByIdStatement.get(id));
136
+ }
137
+
138
+ queryFilterRows(filter) {
139
+ const clauses = [];
140
+ const params = [];
141
+ const ids = stringValues(filter.ids);
142
+ const kinds = integerValues(filter.kinds);
143
+ const authors = stringValues(filter.authors);
144
+ const partyIds = stringValues(filter["#d"]);
145
+
146
+ if (ids) {
147
+ if (ids.length === 0) return [];
148
+ clauses.push(`(${ids.map(() => "id LIKE ? ESCAPE '\\'").join(" OR ")})`);
149
+ params.push(...ids.map((id) => `${escapeLike(id)}%`));
150
+ }
151
+ if (kinds) {
152
+ if (kinds.length === 0) return [];
153
+ clauses.push(`kind IN (${placeholders(kinds)})`);
154
+ params.push(...kinds);
155
+ }
156
+ if (authors) {
157
+ if (authors.length === 0) return [];
158
+ clauses.push(`(${authors.map(() => "pubkey LIKE ? ESCAPE '\\'").join(" OR ")})`);
159
+ params.push(...authors.map((pubkey) => `${escapeLike(pubkey)}%`));
160
+ }
161
+ if (partyIds) {
162
+ if (partyIds.length === 0) return [];
163
+ clauses.push(`party_id IN (${placeholders(partyIds)})`);
164
+ params.push(...partyIds);
165
+ }
166
+ if (Number.isInteger(filter.since)) {
167
+ clauses.push("created_at >= ?");
168
+ params.push(filter.since);
169
+ }
170
+ if (Number.isInteger(filter.until)) {
171
+ clauses.push("created_at <= ?");
172
+ params.push(filter.until);
173
+ }
174
+
175
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
176
+ return this.db.prepare(`SELECT event_json FROM party_events ${where} ORDER BY created_at ASC, id ASC`).all(...params);
177
+ }
178
+
179
+ queryEvents(filters) {
180
+ const events = new Map();
181
+ for (const filter of filters) {
182
+ for (const row of this.queryFilterRows(filter)) {
183
+ const event = parseEvent(row);
184
+ if (event && matchFilters([filter], event)) events.set(event.id, event);
185
+ }
186
+ }
187
+ return Array.from(events.values()).sort((left, right) => left.created_at - right.created_at || left.id.localeCompare(right.id));
188
+ }
189
+
190
+ insertEvent(event) {
191
+ const serialized = JSON.stringify(event);
192
+ const result = this.insertStatement.run(
193
+ event.id,
194
+ tagValue(event.tags, "d") || "",
195
+ event.kind,
196
+ event.pubkey,
197
+ event.created_at,
198
+ Date.now(),
199
+ Buffer.byteLength(serialized, "utf8"),
200
+ expirationTime(event) ?? null,
201
+ isModerationEvent(event) ? 1 : 0,
202
+ serialized
203
+ );
204
+ return result.changes > 0;
205
+ }
206
+
207
+ deleteEvents(ids) {
208
+ for (const id of ids) this.deleteStatement.run(id);
209
+ }
210
+
211
+ trimEvents(maxEvents) {
212
+ return this.pruneEvents({ maxEvents });
213
+ }
214
+
215
+ retentionOrder() {
216
+ return "moderation ASC, MIN(created_at, CAST(received_at / 1000 AS INTEGER)) ASC, id ASC";
217
+ }
218
+
219
+ deleteRows(rows) {
220
+ const ids = rows.map((row) => row.id);
221
+ this.deleteEvents(ids);
222
+ return ids;
223
+ }
224
+
225
+ pruneTotalEvents(maxEvents) {
226
+ if (!Number.isInteger(maxEvents) || maxEvents < 0) return [];
227
+ const rows = this.db.prepare(`
228
+ SELECT id FROM party_events
229
+ ORDER BY ${this.retentionOrder()}
230
+ LIMIT (
231
+ SELECT MAX(COUNT(*) - ?, 0) FROM party_events
232
+ )
233
+ `).all(maxEvents);
234
+ return this.deleteRows(rows);
235
+ }
236
+
237
+ pruneRoomEventCounts(maxEventsPerRoom) {
238
+ if (!Number.isInteger(maxEventsPerRoom) || maxEventsPerRoom < 1) return [];
239
+ const removed = [];
240
+ const rooms = this.db.prepare(`
241
+ SELECT party_id, COUNT(*) AS count
242
+ FROM party_events
243
+ WHERE party_id != ''
244
+ GROUP BY party_id
245
+ HAVING count > ?
246
+ ORDER BY party_id ASC
247
+ `).all(maxEventsPerRoom);
248
+ for (const room of rooms) {
249
+ const rows = this.db.prepare(`
250
+ SELECT id FROM party_events
251
+ WHERE party_id = ?
252
+ ORDER BY ${this.retentionOrder()}
253
+ LIMIT ?
254
+ `).all(room.party_id, room.count - maxEventsPerRoom);
255
+ removed.push(...this.deleteRows(rows));
256
+ }
257
+ return removed;
258
+ }
259
+
260
+ pruneRoomBytes(maxBytesPerRoom) {
261
+ if (!Number.isInteger(maxBytesPerRoom) || maxBytesPerRoom < 1) return [];
262
+ const removed = [];
263
+ const rooms = this.db.prepare(`
264
+ SELECT party_id, SUM(event_bytes) AS bytes
265
+ FROM party_events
266
+ WHERE party_id != ''
267
+ GROUP BY party_id
268
+ HAVING bytes > ?
269
+ ORDER BY party_id ASC
270
+ `).all(maxBytesPerRoom);
271
+ for (const room of rooms) {
272
+ let remainingBytes = room.bytes;
273
+ const rows = this.db.prepare(`
274
+ SELECT id, event_bytes FROM party_events
275
+ WHERE party_id = ?
276
+ ORDER BY ${this.retentionOrder()}
277
+ `).all(room.party_id);
278
+ const deleteRows = [];
279
+ for (const row of rows) {
280
+ if (remainingBytes <= maxBytesPerRoom) break;
281
+ deleteRows.push(row);
282
+ remainingBytes -= row.event_bytes;
283
+ }
284
+ removed.push(...this.deleteRows(deleteRows));
285
+ }
286
+ return removed;
287
+ }
288
+
289
+ pruneExpiredEvents(nowSeconds) {
290
+ if (!Number.isInteger(nowSeconds)) return [];
291
+ const rows = this.db.prepare(`
292
+ SELECT id FROM party_events
293
+ WHERE expires_at IS NOT NULL
294
+ AND expires_at <= ?
295
+ AND moderation = 0
296
+ ORDER BY ${this.retentionOrder()}
297
+ `).all(nowSeconds);
298
+ return this.deleteRows(rows);
299
+ }
300
+
301
+ pruneEvents(policy = {}) {
302
+ const nowSeconds = Number.isInteger(policy.nowSeconds) ? policy.nowSeconds : Math.floor(Date.now() / 1000);
303
+ return [
304
+ ...this.pruneExpiredEvents(nowSeconds),
305
+ ...this.pruneTotalEvents(policy.maxEvents),
306
+ ...this.pruneRoomEventCounts(policy.maxEventsPerRoom),
307
+ ...this.pruneRoomBytes(policy.maxBytesPerRoom)
308
+ ];
309
+ }
310
+
311
+ countRecentEventsByPubkey(pubkey, sinceSeconds, nowSeconds) {
312
+ const row = this.db.prepare(`
313
+ SELECT COUNT(*) AS count
314
+ FROM party_events
315
+ WHERE pubkey = ?
316
+ AND MIN(created_at, CAST(received_at / 1000 AS INTEGER)) >= ?
317
+ AND MIN(created_at, CAST(received_at / 1000 AS INTEGER)) <= ?
318
+ `).get(pubkey, sinceSeconds, nowSeconds);
319
+ return row.count;
320
+ }
321
+
322
+ stats() {
323
+ const eventRow = this.db.prepare("SELECT COUNT(*) AS count FROM party_events").get();
324
+ const authorRow = this.db.prepare("SELECT COUNT(DISTINCT pubkey) AS count FROM party_events").get();
325
+ const partyRow = this.db.prepare("SELECT COUNT(DISTINCT party_id) AS count FROM party_events WHERE party_id != ''").get();
326
+ const kinds = {};
327
+ for (const row of this.db.prepare("SELECT kind, COUNT(*) AS count FROM party_events GROUP BY kind").all()) {
328
+ kinds[row.kind] = row.count;
329
+ }
330
+ return {
331
+ addressableEvents: partyRow.count,
332
+ authors: authorRow.count,
333
+ events: eventRow.count,
334
+ kinds,
335
+ parties: partyRow.count
336
+ };
337
+ }
338
+
339
+ close() {
340
+ this.db.close();
341
+ }
342
+ }
343
+
344
+ function createSqliteRelayStorage(storagePath) {
345
+ return new SqliteRelayStorage(storagePath);
346
+ }
347
+
348
+ module.exports = {
349
+ SqliteRelayStorage,
350
+ createSqliteRelayStorage,
351
+ sqliteFile
352
+ };
@@ -0,0 +1,213 @@
1
+ const { safeValidate, validateClientHello, validateRoomOperation } = require("@mh-gg/protocol");
2
+ const { hasText, invalid, isObject, ok, protocolOne, protocolResult } = require("./shared.cjs");
3
+ const { validatePresenceEvent } = require("./presenceEvent.cjs");
4
+ const { validateClientPeerSignal, validatePeerSignalFields } = require("./peerSignal.cjs");
5
+ const { validateClientPushRegisterMessage, validateClientPushGrantMessage, validateClientPushSendMessage } = require("./push.cjs");
6
+
7
+ function checkBase(message, type, requireClientId = true) {
8
+ if (!isObject(message)) return invalid(type, "message must be an object");
9
+ if (message.type !== type) return invalid(type, "type is invalid");
10
+ if (!protocolOne(message)) return invalid(type, "protocol is invalid");
11
+ if (!hasText(message.roomName)) return invalid(type, "roomName is required");
12
+ if (requireClientId && !hasText(message.clientId)) return invalid(type, "clientId is required");
13
+ return ok();
14
+ }
15
+
16
+ function validateClientHelloMessage(message) {
17
+ const result = safeValidate(validateClientHello, message);
18
+ if (!result.ok) return protocolResult("client/hello", result);
19
+ if (!protocolOne(message)) return invalid("client/hello", "protocol is invalid");
20
+ return ok();
21
+ }
22
+
23
+ function validateClientOperationMessage(message) {
24
+ const baseResult = checkBase(message, "client/operation");
25
+ if (!baseResult.ok) return baseResult;
26
+ const result = safeValidate(validateRoomOperation, message.operation);
27
+ if (!result.ok) return protocolResult("client/operation", result);
28
+ if (message.operation.roomId !== message.roomName) return invalid("client/operation", "operation room does not match roomName");
29
+ return ok();
30
+ }
31
+
32
+ function validateSnapshotRequest(message) {
33
+ const baseResult = checkBase(message, "client/snapshot-request");
34
+ if (!baseResult.ok) return baseResult;
35
+ if (message.reason !== undefined && !hasText(message.reason)) return invalid("client/snapshot-request", "reason is invalid");
36
+ return ok();
37
+ }
38
+
39
+ function validateMatterhornStateRequest(message) {
40
+ return checkBase(message, "client/matterhorn-state");
41
+ }
42
+
43
+ const ROOM_INDEX_NGRAM_SUITE = "matterhorn.ngram.hmac-sha256.v1";
44
+ const ROOM_INDEX_TOKEN_RE = /^[A-Za-z0-9_-]{22,96}$/;
45
+
46
+ function validateRoomIndexSearchMessage(message) {
47
+ const baseResult = checkBase(message, "client/room-index-search");
48
+ if (!baseResult.ok) return baseResult;
49
+ if (message.suite !== ROOM_INDEX_NGRAM_SUITE) return invalid("client/room-index-search", "suite is invalid");
50
+ if (!hasText(message.keyId) || message.keyId.length > 80 || !/^[A-Za-z0-9_-]+$/.test(message.keyId)) {
51
+ return invalid("client/room-index-search", "keyId is invalid");
52
+ }
53
+ if (!Array.isArray(message.tokens) || message.tokens.length === 0 || message.tokens.length > 512) {
54
+ return invalid("client/room-index-search", "tokens are invalid");
55
+ }
56
+ const seen = new Set();
57
+ for (const token of message.tokens) {
58
+ if (typeof token !== "string" || !ROOM_INDEX_TOKEN_RE.test(token) || seen.has(token)) {
59
+ return invalid("client/room-index-search", "tokens are invalid");
60
+ }
61
+ seen.add(token);
62
+ }
63
+ if (message.stream !== undefined && !hasText(message.stream)) return invalid("client/room-index-search", "stream is invalid");
64
+ if (message.mode !== undefined && !["all", "any", "threshold"].includes(message.mode)) return invalid("client/room-index-search", "mode is invalid");
65
+ if (message.order !== undefined && !["newest", "score"].includes(message.order)) return invalid("client/room-index-search", "order is invalid");
66
+ if (message.minScore !== undefined) {
67
+ const minScore = Number(message.minScore);
68
+ if (!Number.isFinite(minScore) || minScore < 0 || minScore > 1) return invalid("client/room-index-search", "minScore is invalid");
69
+ }
70
+ if (message.limit !== undefined && (!Number.isInteger(message.limit) || message.limit <= 0 || message.limit > 1000)) {
71
+ return invalid("client/room-index-search", "limit is invalid");
72
+ }
73
+ if (message.offset !== undefined && (!Number.isInteger(message.offset) || message.offset < 0)) {
74
+ return invalid("client/room-index-search", "offset is invalid");
75
+ }
76
+ if (message.requestId !== undefined && !hasText(message.requestId)) return invalid("client/room-index-search", "requestId is invalid");
77
+ return ok();
78
+ }
79
+
80
+ function validatePersonalStateMessage(message) {
81
+ const baseResult = checkBase(message, "client/personal-state");
82
+ if (!baseResult.ok) return baseResult;
83
+ if (message.action !== "subscribe" && message.action !== "publish") return invalid("client/personal-state", "action is invalid");
84
+ if (message.action === "publish") {
85
+ if (!hasText(message.stateId)) return invalid("client/personal-state", "stateId is required");
86
+ if (!Number.isFinite(Number(message.updatedAt))) return invalid("client/personal-state", "updatedAt is invalid");
87
+ if (!isObject(message.payload)) return invalid("client/personal-state", "payload is required");
88
+ }
89
+ return ok();
90
+ }
91
+
92
+ function validatePresenceMessage(message) {
93
+ const baseResult = checkBase(message, "client/presence");
94
+ if (!baseResult.ok) return baseResult;
95
+ if (message.status !== undefined && !["online", "away", "busy", "invisible", "offline"].includes(message.status)) {
96
+ return invalid("client/presence", "status is invalid");
97
+ }
98
+ if (message.activity !== undefined && typeof message.activity !== "string") return invalid("client/presence", "activity is invalid");
99
+ if (message.actor !== undefined && !isObject(message.actor)) return invalid("client/presence", "actor is invalid");
100
+ return validatePresenceEvent(message);
101
+ }
102
+
103
+ function validateEphemeralTokenPayload(payload) {
104
+ if (payload === undefined) return true;
105
+ if (!isObject(payload)) return false;
106
+ return Object.keys(payload).length <= 32;
107
+ }
108
+
109
+ function validateEphemeralTokenMessage(message) {
110
+ const baseResult = checkBase(message, "client/ephemeral-token");
111
+ if (!baseResult.ok) return baseResult;
112
+ if (message.action !== "upsert" && message.action !== "release") return invalid("client/ephemeral-token", "action is invalid");
113
+ if (message.action === "release") {
114
+ if (!hasText(message.tokenId)) return invalid("client/ephemeral-token", "tokenId is required");
115
+ return ok();
116
+ }
117
+ if (!isObject(message.token)) return invalid("client/ephemeral-token", "token is required");
118
+ if (!hasText(message.token.id)) return invalid("client/ephemeral-token", "token.id is required");
119
+ if (!hasText(message.token.kind)) return invalid("client/ephemeral-token", "token.kind is required");
120
+ if (!hasText(message.token.scope)) return invalid("client/ephemeral-token", "token.scope is required");
121
+ if (!hasText(message.token.ownerId)) return invalid("client/ephemeral-token", "token.ownerId is required");
122
+ if (!hasText(message.token.ownerName)) return invalid("client/ephemeral-token", "token.ownerName is required");
123
+ if (!hasText(message.token.clientId)) return invalid("client/ephemeral-token", "token.clientId is required");
124
+ if (!Number.isFinite(Number(message.token.updatedAt))) return invalid("client/ephemeral-token", "token.updatedAt is invalid");
125
+ if (!Number.isFinite(Number(message.token.expiresAt))) return invalid("client/ephemeral-token", "token.expiresAt is invalid");
126
+ if (!validateEphemeralTokenPayload(message.token.payload)) return invalid("client/ephemeral-token", "token.payload is invalid");
127
+ return ok();
128
+ }
129
+
130
+ function validateClientMemberKeyMessage(message) {
131
+ const baseResult = checkBase(message, "client/member-key");
132
+ if (!baseResult.ok) return baseResult;
133
+ if (!isObject(message.claim)) return invalid("client/member-key", "claim is required");
134
+ if (message.claim.kind !== "matterhorn.member-key-claim" && message.claim.type !== "matterhorn/member-key/v1") return invalid("client/member-key", "claim kind is invalid");
135
+ if (message.claim.scheme !== "matterhorn.device-signing.v1") return invalid("client/member-key", "claim scheme is invalid");
136
+ if (message.claim.alg !== "nostr-secp256k1") return invalid("client/member-key", "claim alg is invalid");
137
+ const claimRoom = message.claim.roomName || message.claim.roomId;
138
+ if (claimRoom !== message.roomName) return invalid("client/member-key", "claim room mismatch");
139
+ for (const field of ["memberId", "deviceId", "keyId", "rootPublicKey", "publicKey", "publicKeyFingerprint", "encryptionKeyId", "encryptionPublicKey"]) {
140
+ if (!hasText(message.claim[field])) return invalid("client/member-key", `claim ${field} is required`);
141
+ }
142
+ if (message.claim.encryptionAlg !== "x25519") return invalid("client/member-key", "claim encryptionAlg is invalid");
143
+ if (!Number.isFinite(Number(message.claim.createdAt))) return invalid("client/member-key", "claim createdAt is invalid");
144
+ if (!isObject(message.claim.rootProof) || !hasText(message.claim.rootProof.eventId) || !hasText(message.claim.rootProof.sig)) return invalid("client/member-key", "claim root proof is required");
145
+ if (!isObject(message.claim.proof) || !hasText(message.claim.proof.eventId) || !hasText(message.claim.proof.sig)) return invalid("client/member-key", "claim device proof is required");
146
+ return ok();
147
+ }
148
+
149
+ function validateRoomInfoRequest(message) {
150
+ const baseResult = checkBase(message, "client/room-info", false);
151
+ if (!baseResult.ok) return baseResult;
152
+ if (message.clientId !== undefined && !hasText(message.clientId)) return invalid("client/room-info", "clientId is invalid");
153
+ if (message.cachedFrontend !== undefined) {
154
+ if (!isObject(message.cachedFrontend)) return invalid("client/room-info", "cachedFrontend is invalid");
155
+ if (!hasText(message.cachedFrontend.id)) return invalid("client/room-info", "cachedFrontend.id is invalid");
156
+ if (!hasText(message.cachedFrontend.integrity)) return invalid("client/room-info", "cachedFrontend.integrity is invalid");
157
+ if (message.cachedFrontend.byteLength !== undefined && (!Number.isInteger(message.cachedFrontend.byteLength) || message.cachedFrontend.byteLength < 0)) {
158
+ return invalid("client/room-info", "cachedFrontend.byteLength is invalid");
159
+ }
160
+ }
161
+ return ok();
162
+ }
163
+
164
+ function validateFrontendManifestRequest(message) {
165
+ const baseResult = checkBase(message, "client/frontend-manifest", false);
166
+ if (!baseResult.ok) return baseResult;
167
+ if (message.clientId !== undefined && !hasText(message.clientId)) return invalid("client/frontend-manifest", "clientId is invalid");
168
+ if (message.frontendId !== undefined && !hasText(message.frontendId)) return invalid("client/frontend-manifest", "frontendId is invalid");
169
+ if (message.integrity !== undefined && !hasText(message.integrity)) return invalid("client/frontend-manifest", "integrity is invalid");
170
+ return ok();
171
+ }
172
+
173
+ function validateFrontendChunkRequest(message) {
174
+ const baseResult = checkBase(message, "client/frontend-chunk", false);
175
+ if (!baseResult.ok) return baseResult;
176
+ if (message.clientId !== undefined && !hasText(message.clientId)) return invalid("client/frontend-chunk", "clientId is invalid");
177
+ if (message.frontendId !== undefined && !hasText(message.frontendId)) return invalid("client/frontend-chunk", "frontendId is invalid");
178
+ if (message.integrity !== undefined && !hasText(message.integrity)) return invalid("client/frontend-chunk", "integrity is invalid");
179
+ if (!Number.isInteger(message.offset) || message.offset < 0) return invalid("client/frontend-chunk", "offset is invalid");
180
+ if (message.length !== undefined && (!Number.isInteger(message.length) || message.length <= 0)) return invalid("client/frontend-chunk", "length is invalid");
181
+ return ok();
182
+ }
183
+
184
+ function validateClientMessage(message) {
185
+ if (!isObject(message)) return invalid("client", "message must be an object");
186
+ switch (message.type) {
187
+ case "client/hello": return validateClientHelloMessage(message);
188
+ case "client/room-info": return validateRoomInfoRequest(message);
189
+ case "client/member-key": return validateClientMemberKeyMessage(message);
190
+ case "client/frontend-manifest": return validateFrontendManifestRequest(message);
191
+ case "client/frontend-chunk": return validateFrontendChunkRequest(message);
192
+ case "client/operation": return validateClientOperationMessage(message);
193
+ case "client/snapshot-request": return validateSnapshotRequest(message);
194
+ case "client/matterhorn-state": return validateMatterhornStateRequest(message);
195
+ case "client/room-index-search": return validateRoomIndexSearchMessage(message);
196
+ case "client/personal-state": return validatePersonalStateMessage(message);
197
+ case "client/presence": return validatePresenceMessage(message);
198
+ case "client/ephemeral-token": return validateEphemeralTokenMessage(message);
199
+ case "client/peer-signal": return validateClientPeerSignal(message);
200
+ case "client/push-register": return validateClientPushRegisterMessage(message);
201
+ case "client/push-grant": return validateClientPushGrantMessage(message);
202
+ case "client/push-send": return validateClientPushSendMessage(message);
203
+ default: return invalid("client", `unknown client message type ${JSON.stringify(message.type)} with keys ${Object.keys(message).sort().join(",") || "none"}`);
204
+ }
205
+ }
206
+
207
+ module.exports = {
208
+ validateClientMessage,
209
+ validateClientMemberKeyMessage,
210
+ validateClientOperationMessage,
211
+ validateEphemeralTokenMessage,
212
+ validatePeerSignalFields
213
+ };
@@ -0,0 +1,33 @@
1
+ const { safeValidate, validateHostOperations, validateRelayAddresses } = require("@mh-gg/protocol");
2
+ const { hasText, invalid, isObject, ok, protocolResult } = require("./shared.cjs");
3
+
4
+ function validateHostMessage(message, scope = "host") {
5
+ if (!isObject(message)) return invalid(scope, "message must be an object");
6
+ if (!hasText(message.type)) return invalid(scope, "type is required");
7
+ if (message.protocol !== undefined && message.protocol !== require("@mh-gg/host-config").PROTOCOL) return invalid(scope, "protocol is invalid");
8
+ if (message.type === "host/operations") {
9
+ const result = safeValidate(validateHostOperations, message);
10
+ if (!result.ok) return protocolResult(scope, result);
11
+ }
12
+ return ok();
13
+ }
14
+
15
+ function validateRelayHints(message) {
16
+ if (!isObject(message)) return invalid("relay.hints", "message must be an object");
17
+ if (message.type !== "relay.hints") return invalid("relay.hints", "type is invalid");
18
+ if (!hasText(message.roomName)) return invalid("relay.hints", "roomName is required");
19
+ const result = safeValidate(validateRelayAddresses, message.relayHints || [], "relay.hints.relayHints");
20
+ if (!result.ok) return invalid("relay.hints", "relayHints is invalid");
21
+ return ok();
22
+ }
23
+
24
+ function validateOperationBatch(message) {
25
+ const result = safeValidate(validateHostOperations, message);
26
+ return result.ok ? ok() : protocolResult("host/operations", result);
27
+ }
28
+
29
+ module.exports = {
30
+ validateHostMessage,
31
+ validateOperationBatch,
32
+ validateRelayHints
33
+ };
@@ -0,0 +1,13 @@
1
+ const { validateClientMessage } = require("./client.cjs");
2
+ const { validateHostMessage, validateOperationBatch, validateRelayHints } = require("./host.cjs");
3
+ const { validateRelayEnvelope } = require("./relay.cjs");
4
+ const { INVALID_MESSAGE_CODE } = require("./shared.cjs");
5
+
6
+ module.exports = {
7
+ INVALID_MESSAGE_CODE,
8
+ validateClientMessage,
9
+ validateHostMessage,
10
+ validateOperationBatch,
11
+ validateRelayEnvelope,
12
+ validateRelayHints
13
+ };
@@ -0,0 +1,35 @@
1
+ const { hasText, invalid, isObject, ok, protocolOne } = require("./shared.cjs");
2
+
3
+ function validatePeerSignalFields(message, scope, { relay = false } = {}) {
4
+ if (!isObject(message)) return invalid(scope, "message must be an object");
5
+ if (!hasText(message.roomName)) return invalid(scope, "roomName is required");
6
+ const sourceField = relay ? "sourceClientId" : "clientId";
7
+ if (!hasText(message[sourceField])) return invalid(scope, `${sourceField} is required`);
8
+ if (!hasText(message.targetClientId)) return invalid(scope, "target is required");
9
+ if (relay && message.sourcePeerId !== undefined && !hasText(message.sourcePeerId)) return invalid(scope, "sourcePeerId is invalid");
10
+ if (message.targetPeerId !== undefined && !hasText(message.targetPeerId)) return invalid(scope, "targetPeerId is invalid");
11
+ if (!isObject(message.signal)) return invalid(scope, "signal is required");
12
+ if (!hasText(message.signal.type)) return invalid(scope, "signal.type is required");
13
+ if (!hasText(message.signal.sessionId)) return invalid(scope, "signal.sessionId is required");
14
+ if (message.encryptedSignal !== undefined) {
15
+ if (!isObject(message.encryptedSignal)) return invalid(scope, "encryptedSignal is invalid");
16
+ if (message.encryptedSignal.encrypted !== true) return invalid(scope, "encryptedSignal.encrypted is invalid");
17
+ if (message.encryptedSignal.alg !== "A256GCM") return invalid(scope, "encryptedSignal.alg is invalid");
18
+ if (message.encryptedSignal.kind !== "call.signal") return invalid(scope, "encryptedSignal.kind is invalid");
19
+ if (!hasText(message.encryptedSignal.iv)) return invalid(scope, "encryptedSignal.iv is invalid");
20
+ if (!hasText(message.encryptedSignal.data)) return invalid(scope, "encryptedSignal.data is invalid");
21
+ if (message.auth !== undefined) return invalid(scope, "auth must be encrypted");
22
+ }
23
+ return ok();
24
+ }
25
+
26
+ function validateClientPeerSignal(message) {
27
+ if (message.type !== "client/peer-signal") return invalid("client/peer-signal", "type is invalid");
28
+ if (!protocolOne(message)) return invalid("client/peer-signal", "protocol is invalid");
29
+ return validatePeerSignalFields(message, "client/peer-signal");
30
+ }
31
+
32
+ module.exports = {
33
+ validateClientPeerSignal,
34
+ validatePeerSignalFields
35
+ };