@mh-gg/protocol 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.
@@ -0,0 +1,86 @@
1
+ const { assertArray, assertNumber, assertOptionalString, assertRecord, assertString } = require("../assertions.cjs");
2
+ const { MAX_PUSH_PAYLOAD_BYTES, RELAY_PUSH } = require("../constants.cjs");
3
+ const { invalid } = require("../errors.cjs");
4
+ const { assertJsonByteLength } = require("./bounds.cjs");
5
+ const { validateCallSignal, validateClientMessage } = require("./client.cjs");
6
+ const { validateHostMessage } = require("./host.cjs");
7
+ const { validateRoomOperation } = require("./operations.cjs");
8
+
9
+ function validateRelayEnvelope(message) {
10
+ const value = assertRecord(message, "relay");
11
+ const type = assertString(value.type, "relay.type");
12
+ if (type === "relay.client") {
13
+ assertString(value.id, "relay.id");
14
+ assertString(value.roomName, "relay.roomName");
15
+ assertString(value.peerId, "relay.peerId");
16
+ const nested = validateClientMessage(value.message);
17
+ if (nested.roomName !== value.roomName) throw invalid("relay.message.roomName", "does not match relay roomName");
18
+ return value;
19
+ }
20
+ if (type === "relay.host") {
21
+ assertString(value.id, "relay.id");
22
+ assertString(value.peerId, "relay.peerId");
23
+ assertOptionalString(value.roomName, "relay.roomName");
24
+ validateHostMessage(value.message, "relay.message");
25
+ return value;
26
+ }
27
+ if (type === "relay.broadcast") {
28
+ assertString(value.id, "relay.id");
29
+ assertString(value.roomName, "relay.roomName");
30
+ validateHostMessage(value.message, "relay.message");
31
+ return value;
32
+ }
33
+ if (type === "relay.client.close") {
34
+ assertOptionalString(value.id, "relay.id");
35
+ assertOptionalString(value.roomName, "relay.roomName");
36
+ assertString(value.peerId, "relay.peerId");
37
+ return value;
38
+ }
39
+
40
+ if (type === "relay.ping" || type === "relay.pong") {
41
+ assertString(value.id, "relay.id");
42
+ assertOptionalString(value.requestId, "relay.requestId");
43
+ assertOptionalString(value.relayAddress, "relay.relayAddress");
44
+ assertOptionalString(value.targetRelayAddress, "relay.targetRelayAddress");
45
+ if (value.sentAt !== undefined) assertNumber(value.sentAt, "relay.sentAt");
46
+ return value;
47
+ }
48
+
49
+ if (type === "relay.matterhorn-operation") {
50
+ assertString(value.id, "relay.id");
51
+ assertString(value.roomName, "relay.roomName");
52
+ validateRoomOperation(value.operation, { roomId: value.roomName });
53
+ if (value.operations !== undefined) {
54
+ assertArray(value.operations, "relay.operations").forEach((operation, index) => {
55
+ validateRoomOperation(operation, { roomId: value.roomName });
56
+ if (operation.roomId !== value.roomName) throw invalid(`relay.operations[${index}].roomId`, "does not match relay roomName");
57
+ });
58
+ }
59
+ if (value.state !== undefined) {
60
+ const state = assertRecord(value.state, "relay.state");
61
+ if (state.roomId !== undefined && state.roomId !== value.roomName) throw invalid("relay.state.roomId", "does not match relay roomName");
62
+ }
63
+ return value;
64
+ }
65
+ if (type === "relay.peer-signal") {
66
+ assertOptionalString(value.id, "relay.id");
67
+ assertString(value.roomName, "relay.roomName");
68
+ assertString(value.sourceClientId, "relay.sourceClientId");
69
+ assertOptionalString(value.sourcePeerId, "relay.sourcePeerId");
70
+ if (value.targetClientId === undefined && value.targetPeerId === undefined) throw invalid("relay.target", "is required");
71
+ assertOptionalString(value.targetClientId, "relay.targetClientId");
72
+ assertOptionalString(value.targetPeerId, "relay.targetPeerId");
73
+ validateCallSignal(value.signal, "relay.signal");
74
+ return value;
75
+ }
76
+ if (type === RELAY_PUSH) {
77
+ assertString(value.id, "relay.id");
78
+ const target = assertRecord(value.target, "relay.target");
79
+ assertString(target.userId, "relay.target.userId");
80
+ assertJsonByteLength(value.payload, "relay.payload", MAX_PUSH_PAYLOAD_BYTES);
81
+ return value;
82
+ }
83
+ throw invalid("relay.type", "is invalid");
84
+ }
85
+
86
+ module.exports = { validateRelayEnvelope };
@@ -0,0 +1,41 @@
1
+ const { MAX_RELAY_ADDRESS_LENGTH } = require("../constants.cjs");
2
+ const { assertArray, assertNumber, assertOptionalString, assertRecord, assertString } = require("../assertions.cjs");
3
+ const { invalid } = require("../errors.cjs");
4
+
5
+ function validateProfile(value, path = "profile") {
6
+ const profile = assertRecord(value, path);
7
+ assertString(profile.id, `${path}.id`);
8
+ assertString(profile.name, `${path}.name`);
9
+ assertString(profile.avatar, `${path}.avatar`);
10
+ assertOptionalString(profile.callPubkey, `${path}.callPubkey`);
11
+ return profile;
12
+ }
13
+
14
+ function validateRelayAddresses(value, path = "relays") {
15
+ return assertArray(value || [], path).map((relay, index) => assertString(relay, `${path}[${index}]`, MAX_RELAY_ADDRESS_LENGTH));
16
+ }
17
+
18
+ function validateOperationKeyPin(value, path = "operationKeyPin") {
19
+ const pin = assertRecord(value, path);
20
+ if (pin.alg !== "ed25519") throw invalid(`${path}.alg`, "is invalid");
21
+ assertString(pin.signer, `${path}.signer`);
22
+ const hash = assertString(pin.publicKeyHash, `${path}.publicKeyHash`);
23
+ if (!hash.startsWith("sha256-")) throw invalid(`${path}.publicKeyHash`, "must be a sha256 hash");
24
+ return pin;
25
+ }
26
+
27
+ function validateOperationSigner(value, path = "operationSigner") {
28
+ const signer = assertRecord(value, path);
29
+ assertString(signer.signer, `${path}.signer`);
30
+ assertString(signer.publicKeyPem, `${path}.publicKeyPem`, 8192);
31
+ if (signer.alg !== undefined && signer.alg !== "ed25519") throw invalid(`${path}.alg`, "is invalid");
32
+ if (signer.createdAt !== undefined) assertNumber(signer.createdAt, `${path}.createdAt`);
33
+ return signer;
34
+ }
35
+
36
+ module.exports = {
37
+ validateOperationKeyPin,
38
+ validateOperationSigner,
39
+ validateProfile,
40
+ validateRelayAddresses
41
+ };
@@ -0,0 +1,421 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const {
4
+ MatterhornProtocolError,
5
+ parseHostOperationBatch,
6
+ parseHostSnapshot,
7
+ parseRoomOperation,
8
+ safeValidate,
9
+ validateOperationKeyPin,
10
+ validateOperationSigner,
11
+ validateClientHello,
12
+ validateClientMessage,
13
+ validateClientMutation,
14
+ validateClientPeerSignal,
15
+ validateClientPushGrant,
16
+ validateClientPushRegister,
17
+ validateClientSnapshotRequest,
18
+ validateHostInfo,
19
+ validateHostOperations,
20
+ validateRelayEnvelope,
21
+ validateRoomOperation,
22
+ ensureOperationIdentity
23
+ } = require("../src/index.cjs");
24
+
25
+ function identified(operation, now = operation.createdAt || 1000) {
26
+ return ensureOperationIdentity(operation, { nodeId: operation.actor?.deviceId || "test", now });
27
+ }
28
+
29
+ test("validates shared client wire messages", () => {
30
+ assert.equal(validateClientHello({
31
+ type: "client/hello",
32
+ protocol: 1,
33
+ roomName: "demo",
34
+ clientId: "alice",
35
+ profile: { id: "alice", name: "Alice", avatar: "✨" },
36
+ relayHints: ["peerjs:relay@wss://peerjs.example"]
37
+ }).clientId, "alice");
38
+
39
+ assert.equal(validateClientMutation({
40
+ type: "client/mutation",
41
+ protocol: 1,
42
+ roomName: "demo",
43
+ mutation: { id: "m1", clientId: "alice", seq: 1, ts: 100, op: "post.add", payload: { body: "hi" } }
44
+ }).mutation.op, "post.add");
45
+ assert.equal(validateClientSnapshotRequest({
46
+ type: "client/snapshot-request",
47
+ protocol: 1,
48
+ roomName: "demo",
49
+ clientId: "alice",
50
+ reason: "gap"
51
+ }).reason, "gap");
52
+ assert.equal(validateClientPeerSignal({
53
+ type: "client/peer-signal",
54
+ protocol: 1,
55
+ roomName: "demo",
56
+ clientId: "alice",
57
+ targetPeerId: "peer-b",
58
+ signal: { type: "call.cancel", sessionId: "s1" }
59
+ }).targetPeerId, "peer-b");
60
+ assert.equal(validateClientMessage({
61
+ type: "client/hello",
62
+ protocol: 1,
63
+ roomName: "demo",
64
+ clientId: "alice"
65
+ }).type, "client/hello");
66
+
67
+ assert.throws(() => validateClientHello({ type: "client/hello", protocol: 2 }), MatterhornProtocolError);
68
+ assert.throws(() => validateClientMessage({ type: "client/unknown" }), /client.type/);
69
+ assert.throws(() => validateClientPeerSignal({ type: "client/peer-signal", protocol: 1, roomName: "demo", clientId: "alice", signal: { type: "call.cancel", sessionId: "s1" } }), /target/);
70
+ assert.equal(safeValidate(validateClientMutation, { type: "client/mutation", protocol: 1 }).ok, false);
71
+ });
72
+
73
+ test("validates push client messages and relay envelopes", () => {
74
+ const subscription = {
75
+ endpoint: "https://push.example/sub",
76
+ keys: { p256dh: "p256dh-key", auth: "auth-key" }
77
+ };
78
+ const grant = {
79
+ kind: "matterhorn.vapid-grant",
80
+ version: 1,
81
+ relayPublicKey: "relay-spki",
82
+ createdAt: 1000,
83
+ wrap: {
84
+ alg: "x25519+hkdf-sha256+aes-256-gcm",
85
+ ephemeralPublicKey: "ephemeral-spki",
86
+ iv: "iv"
87
+ },
88
+ wrappedVapidPrivateKey: "wrapped"
89
+ };
90
+ assert.equal(validateClientPushRegister({
91
+ type: "client/push-register",
92
+ protocol: 1,
93
+ roomName: "room",
94
+ clientId: "device-a",
95
+ userId: "user-a",
96
+ subscription
97
+ }).subscription.endpoint, subscription.endpoint);
98
+ assert.equal(validateClientMessage({
99
+ type: "client/push-grant",
100
+ protocol: 1,
101
+ clientId: "device-a",
102
+ userId: "user-a",
103
+ relayId: "relay-a",
104
+ grant
105
+ }).type, "client/push-grant");
106
+ assert.equal(validateClientPushGrant({
107
+ type: "client/push-grant",
108
+ protocol: 1,
109
+ clientId: "device-a",
110
+ userId: "user-a",
111
+ relayId: "relay-a",
112
+ grant
113
+ }).grant.kind, "matterhorn.vapid-grant");
114
+ assert.equal(validateRelayEnvelope({
115
+ type: "relay.push",
116
+ id: "push-1",
117
+ target: { userId: "user-a" },
118
+ payload: { ciphertext: "abc" }
119
+ }).target.userId, "user-a");
120
+ assert.throws(() => validateClientPushRegister({
121
+ type: "client/push-register",
122
+ protocol: 1,
123
+ roomName: "room",
124
+ clientId: "device-a",
125
+ userId: "user-a",
126
+ subscription: { endpoint: "https://push.example/sub", keys: {} }
127
+ }), /p256dh/);
128
+ assert.throws(() => validateClientPushGrant({
129
+ type: "client/push-grant",
130
+ protocol: 1,
131
+ clientId: "device-a",
132
+ userId: "user-a",
133
+ relayId: "relay-a",
134
+ grant: { ...grant, wrap: { ...grant.wrap, alg: "bad" } }
135
+ }), /wrap.alg/);
136
+ assert.throws(() => validateRelayEnvelope({
137
+ type: "relay.push",
138
+ id: "push-2",
139
+ target: {},
140
+ payload: {}
141
+ }), /target.userId/);
142
+ });
143
+
144
+ test("validates SDK room operations and host operations", () => {
145
+ const op = identified({
146
+ roomId: "room1",
147
+ appPackId: "app",
148
+ appPackHash: "sha256-app",
149
+ pluginId: "plugin",
150
+ type: "thing.create",
151
+ actor: { memberId: "alice", deviceId: "dev", role: "member" },
152
+ seq: 1,
153
+ createdAt: 100,
154
+ payload: {},
155
+ auth: { credentialId: "cred", signature: "sig" }
156
+ });
157
+ assert.equal(validateRoomOperation(op, { roomId: "room1" }).id, op.id);
158
+ assert.throws(() => validateRoomOperation(identified({ ...op, appPackHash: "wrong" }, 100), { appPackHash: "sha256-app" }), /operation.appPackHash/);
159
+ assert.equal(validateHostOperations({ type: "host/operations", protocol: 1, baseVersion: 0, operations: [op] }).operations.length, 1);
160
+
161
+ const legacyOperation = {
162
+ kind: "matterhorn.room-operation",
163
+ version: 1,
164
+ roomName: "event-room",
165
+ baseVersion: 0,
166
+ sequence: 1,
167
+ issuedAt: 100,
168
+ signer: "host",
169
+ role: "admin",
170
+ mutation: { id: "mut1", clientId: "alice", seq: 1, ts: 100, op: "post.add", payload: { body: "hi" } },
171
+ signature: "sig"
172
+ };
173
+ assert.equal(validateHostOperations({ type: "host/operations", protocol: 1, baseVersion: 0, operations: [legacyOperation] }).operations[0].kind, "matterhorn.room-operation");
174
+ assert.throws(
175
+ () => validateHostOperations({ type: "host/operations", protocol: 1, baseVersion: 0, operations: [{ ...legacyOperation, mutation: { ...legacyOperation.mutation, seq: 0 } }] }),
176
+ /mutation.seq/
177
+ );
178
+ });
179
+
180
+ test("validates host info and relay envelopes", () => {
181
+ assert.equal(validateHostInfo({
182
+ kind: "matterhorn.host-info",
183
+ version: "0.1",
184
+ roomId: "room1",
185
+ roomVersion: 2,
186
+ appPack: { id: "app", hash: "sha256-app", protocolHash: "sha256-protocol" },
187
+ plugins: [],
188
+ relays: []
189
+ }).roomVersion, 2);
190
+ assert.equal(validateRelayEnvelope({
191
+ type: "relay.client",
192
+ id: "r1",
193
+ peerId: "peer",
194
+ roomName: "room",
195
+ message: {
196
+ type: "client/hello",
197
+ protocol: 1,
198
+ roomName: "room",
199
+ clientId: "alice"
200
+ }
201
+ }).peerId, "peer");
202
+ assert.equal(validateRelayEnvelope({
203
+ type: "relay.broadcast",
204
+ id: "b1",
205
+ roomName: "room",
206
+ message: { type: "host/state", protocol: 1, state: {} }
207
+ }).roomName, "room");
208
+ assert.equal(validateRelayEnvelope({
209
+ type: "relay.host",
210
+ id: "h1",
211
+ peerId: "host-peer",
212
+ roomName: "room",
213
+ message: { type: "host/state", protocol: 1, state: {} }
214
+ }).peerId, "host-peer");
215
+ assert.equal(validateRelayEnvelope({
216
+ type: "relay.client.close",
217
+ peerId: "client-peer"
218
+ }).peerId, "client-peer");
219
+ assert.equal(validateRelayEnvelope({
220
+ type: "relay.ping",
221
+ id: "ping-1",
222
+ relayAddress: "peerjs:relay-a",
223
+ targetRelayAddress: "peerjs:relay-b",
224
+ sentAt: 1000
225
+ }).id, "ping-1");
226
+ assert.equal(validateRelayEnvelope({
227
+ type: "relay.pong",
228
+ id: "pong-1",
229
+ requestId: "ping-1",
230
+ relayAddress: "peerjs:relay-b",
231
+ sentAt: 1001
232
+ }).requestId, "ping-1");
233
+ assert.equal(validateRelayEnvelope({
234
+ type: "relay.peer-signal",
235
+ roomName: "room",
236
+ sourceClientId: "alice",
237
+ targetPeerId: "peer-b",
238
+ signal: { type: "call.cancel", sessionId: "s1" }
239
+ }).sourceClientId, "alice");
240
+ assert.throws(() => validateRelayEnvelope({
241
+ type: "relay.client",
242
+ id: "r2",
243
+ peerId: "peer",
244
+ roomName: "room",
245
+ message: {
246
+ type: "client/hello",
247
+ protocol: 1,
248
+ roomName: "other",
249
+ clientId: "alice"
250
+ }
251
+ }), /relay.message.roomName/);
252
+ const op = identified({
253
+ roomId: "room2",
254
+ appPackId: "app2",
255
+ appPackHash: "sha256-app2",
256
+ pluginId: "plugin2",
257
+ type: "thing.update",
258
+ actor: { memberId: "bob", deviceId: "dev2", role: "admin" },
259
+ seq: 2,
260
+ createdAt: 200,
261
+ payload: {},
262
+ auth: { credentialId: "cred2", signature: "sig2" }
263
+ });
264
+ assert.equal(validateRelayEnvelope({
265
+ type: "relay.matterhorn-operation",
266
+ id: "relay-op",
267
+ roomName: "room2",
268
+ operation: op,
269
+ operations: [op],
270
+ state: { roomId: "room2" }
271
+ }).operation.id, op.id);
272
+ assert.throws(() => validateRelayEnvelope({
273
+ type: "relay.matterhorn-operation",
274
+ id: "relay-op-bad",
275
+ roomName: "room2",
276
+ operation: op,
277
+ operations: [identified({ ...op, roomId: "other" }, 200)]
278
+ }), /relay.operations|operation.roomId/);
279
+ assert.throws(() => validateRelayEnvelope({ type: "relay.unknown" }), /relay.type/);
280
+ assert.throws(() => validateRelayEnvelope({
281
+ type: "relay.peer-signal",
282
+ roomName: "room",
283
+ sourceClientId: "alice",
284
+ signal: { type: "call.cancel", sessionId: "s1" }
285
+ }), /relay.target/);
286
+ assert.throws(() => validateHostInfo({ kind: "wrong" }), /host-info.kind/);
287
+ });
288
+
289
+ test("validates optional profile, relay, operation, batch, and snapshot branches", () => {
290
+ const op = identified({
291
+ roomId: "room2",
292
+ appPackId: "app2",
293
+ appPackHash: "sha256-app2",
294
+ pluginId: "plugin2",
295
+ type: "thing.update",
296
+ actor: { memberId: "bob", deviceId: "dev2", role: "admin" },
297
+ seq: 2,
298
+ createdAt: 200,
299
+ payload: {},
300
+ auth: { credentialId: "cred2", signature: "sig2" }
301
+ });
302
+ assert.equal(validateClientHello({ type: "client/hello", protocol: 1, roomName: "demo", clientId: "bob" }).clientId, "bob");
303
+ assert.equal(validateClientHello({
304
+ type: "client/hello",
305
+ protocol: 1,
306
+ roomName: "demo",
307
+ clientId: "bob",
308
+ profile: { id: "bob", name: "Bob", avatar: "🧪", callPubkey: "pub" }
309
+ }).profile.callPubkey, "pub");
310
+ assert.throws(() => validateClientHello({ type: "client/hello", protocol: 1, roomName: "demo", clientId: "bob", relayHints: ["x".repeat(2050)] }), /relayHints/);
311
+ assert.throws(() => validateRoomOperation({ ...op, payload: "bad" }), /operation.payload/);
312
+ assert.throws(() => validateRoomOperation(identified({ ...op, roomId: "wrong" }, 200), { roomId: "room2" }), /operation.roomId/);
313
+ assert.throws(() => validateRoomOperation(identified({ ...op, appPackId: "wrong" }, 200), { appPackId: "app2" }), /operation.appPackId/);
314
+ assert.equal(parseRoomOperation(op).id, op.id);
315
+
316
+ const roleKeyOp = identified({
317
+ ...op,
318
+ auth: {
319
+ credentialId: "cred-role",
320
+ signature: "sig-role",
321
+ kind: "matterhorn.operation-role-key-proof",
322
+ alg: "ed25519",
323
+ keyRole: "admin",
324
+ publicKeyFingerprint: "sha256-role",
325
+ issuedAt: 201
326
+ }
327
+ }, 201);
328
+ assert.equal(validateRoomOperation(roleKeyOp).auth.keyRole, "admin");
329
+ assert.throws(() => validateRoomOperation({ ...roleKeyOp, auth: { ...roleKeyOp.auth, issuedAt: "bad" } }), /operation.auth.issuedAt/);
330
+
331
+ assert.equal(parseHostOperationBatch({
332
+ kind: "matterhorn.host-operation-batch",
333
+ roomId: "room2",
334
+ appPackId: "app2",
335
+ appPackHash: "sha256-app2",
336
+ baseVersion: 0,
337
+ headVersion: 1,
338
+ operations: [op]
339
+ }).headVersion, 1);
340
+ assert.throws(() => parseHostOperationBatch({ kind: "bad", roomId: "room2", appPackId: "app2", appPackHash: "sha256-app2", operations: [] }), /host-operation-batch.kind/);
341
+ assert.throws(() => parseHostOperationBatch({ roomId: "room2", appPackId: "app2", appPackHash: "sha256-app2", operations: [identified({ ...op, appPackHash: "wrong" }, 200)] }), /operation.appPackHash/);
342
+
343
+ assert.equal(parseHostSnapshot({
344
+ kind: "matterhorn.host-snapshot",
345
+ roomId: "room2",
346
+ appPackId: "app2",
347
+ appPackHash: "sha256-app2",
348
+ version: 1,
349
+ state: {}
350
+ }).version, 1);
351
+ assert.throws(() => parseHostSnapshot({ kind: "bad", roomId: "room2", appPackId: "app2", appPackHash: "sha256-app2", version: 1, state: {} }), /host-snapshot.kind/);
352
+ });
353
+
354
+ test("safeValidate handles non-Matterhorn errors", () => {
355
+ const result = safeValidate(() => { throw new TypeError("plain failure"); }, {});
356
+ assert.equal(result.ok, false);
357
+ assert.equal(result.code, "invalid-message");
358
+ assert.equal(result.message, "plain failure");
359
+ });
360
+
361
+
362
+ test("validates operation key pins and signer anchors", () => {
363
+ assert.equal(validateOperationKeyPin({ alg: "ed25519", signer: "host_a", publicKeyHash: "sha256-abc" }).signer, "host_a");
364
+ assert.equal(validateOperationSigner({ signer: "host_a", publicKeyPem: "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----", alg: "ed25519", createdAt: 1 }).alg, "ed25519");
365
+ assert.throws(() => validateOperationKeyPin({ alg: "rsa", signer: "host", publicKeyHash: "sha256-x" }), /operationKeyPin.alg/);
366
+ assert.throws(() => validateOperationKeyPin({ alg: "ed25519", signer: "host", publicKeyHash: "not-a-hash" }), /publicKeyHash/);
367
+ assert.throws(() => validateOperationSigner({ signer: "host", publicKeyPem: "pem", alg: "rsa" }), /operationSigner.alg/);
368
+
369
+ const op = identified({
370
+ roomId: "room3",
371
+ appPackId: "app3",
372
+ appPackHash: "sha256-app3",
373
+ pluginId: "plugin3",
374
+ type: "thing.update",
375
+ actor: { memberId: "bob", deviceId: "dev3", role: "admin" },
376
+ seq: 3,
377
+ createdAt: 300,
378
+ payload: {},
379
+ auth: { credentialId: "cred3", signature: "sig3" }
380
+ }, 300);
381
+ assert.equal(validateHostOperations({
382
+ type: "host/operations",
383
+ protocol: 1,
384
+ baseVersion: 0,
385
+ operations: [op],
386
+ operationKeyPin: { alg: "ed25519", signer: "host_a", publicKeyHash: "sha256-abc" }
387
+ }).operationKeyPin.signer, "host_a");
388
+ });
389
+
390
+ test("validates operation trust pins on host operation batches", () => {
391
+ const op = identified({
392
+ roomId: "room-trust",
393
+ appPackId: "app",
394
+ appPackHash: "sha256-app",
395
+ pluginId: "plugin",
396
+ type: "thing.create",
397
+ actor: { memberId: "alice", deviceId: "dev", role: "admin" },
398
+ seq: 1,
399
+ createdAt: 300,
400
+ payload: {},
401
+ auth: { credentialId: "cred", signature: "sig" }
402
+ }, 300);
403
+ const batch = validateHostOperations({
404
+ type: "host/operations",
405
+ protocol: 1,
406
+ baseVersion: 0,
407
+ operations: [op],
408
+ operationTrustPin: {
409
+ kind: "matterhorn.operation-key-pin",
410
+ version: 1,
411
+ roomName: "room-trust",
412
+ signer: "host",
413
+ alg: "ed25519",
414
+ publicKeyPem: "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----",
415
+ fingerprint: "sha256:abc",
416
+ pinnedAt: 300
417
+ }
418
+ });
419
+ assert.equal(batch.operationTrustPin.signer, "host");
420
+ assert.throws(() => validateHostOperations({ ...batch, operationTrustPin: { ...batch.operationTrustPin, alg: "rsa" } }), /operationTrustPin.alg/);
421
+ });
@@ -0,0 +1,87 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const {
5
+ MAX_OPERATION_BYTES,
6
+ MAX_OPERATION_DEPTH,
7
+ ensureOperationIdentity,
8
+ validateRelayEnvelope,
9
+ validateRoomOperation
10
+ } = require("../src/index.cjs");
11
+
12
+ function baseOperation(overrides = {}) {
13
+ return {
14
+ roomId: "room",
15
+ appPackId: "app",
16
+ appPackHash: "sha256-app",
17
+ pluginId: "plugin",
18
+ type: "thing.create",
19
+ actor: { memberId: "alice", deviceId: "dev_alice", role: "admin" },
20
+ seq: 1,
21
+ createdAt: 1000,
22
+ payload: {},
23
+ auth: { credentialId: "cred", signature: "sig" },
24
+ ...overrides
25
+ };
26
+ }
27
+
28
+ function identified(operation = baseOperation(), now = operation.createdAt || 1000) {
29
+ return ensureOperationIdentity(operation, { nodeId: operation.actor?.deviceId || "test", now });
30
+ }
31
+
32
+ function nestedPayload(depth) {
33
+ let value = { leaf: true };
34
+ for (let index = 0; index < depth; index += 1) value = { child: value };
35
+ return value;
36
+ }
37
+
38
+ test("operation validator exposes and applies default byte and depth limits", () => {
39
+ assert.equal(MAX_OPERATION_BYTES, 64 * 1024);
40
+ assert.equal(MAX_OPERATION_DEPTH, 64);
41
+
42
+ const op = identified(baseOperation({ payload: nestedPayload(8) }));
43
+ assert.equal(validateRoomOperation(op, { now: 1000 }).id, op.id);
44
+ });
45
+
46
+ test("operation validator rejects oversized payloads before content hash work", () => {
47
+ let hashCalls = 0;
48
+ const op = { ...baseOperation({ payload: { text: "x".repeat(200) } }), id: "sha256:bad", hlc: "000000000001000:000000:dev_alice" };
49
+ assert.throws(() => validateRoomOperation(op, {
50
+ maxOperationBytes: 128,
51
+ operationContentHash() { hashCalls += 1; return "sha256:never"; }
52
+ }), /operation: is too large/);
53
+ assert.equal(hashCalls, 0);
54
+ });
55
+
56
+ test("operation validator rejects over-deep payloads before content hash work without RangeError", () => {
57
+ let hashCalls = 0;
58
+ const op = { ...baseOperation({ payload: nestedPayload(100_000) }), id: "sha256:bad", hlc: "000000000001000:000000:dev_alice" };
59
+ assert.throws(() => validateRoomOperation(op, {
60
+ operationContentHash() { hashCalls += 1; return "sha256:never"; }
61
+ }), (error) => {
62
+ assert.equal(error instanceof RangeError, false);
63
+ assert.match(error.message, /operation: max depth exceeded/);
64
+ return true;
65
+ });
66
+ assert.equal(hashCalls, 0);
67
+ });
68
+
69
+ test("depth probe is iterative and rejects hostile input before JSON.stringify", () => {
70
+ const op = { ...baseOperation({ payload: nestedPayload(100_000) }), id: "sha256:bad", hlc: "000000000001000:000000:dev_alice" };
71
+ assert.throws(() => validateRoomOperation(op, { maxOperationDepth: 12, validateId: false }), /max depth exceeded/);
72
+ });
73
+
74
+ test("relay room-operation envelopes use the same operation bounds", () => {
75
+ const op = identified(baseOperation({ roomId: "room", payload: { text: "ok" } }));
76
+ assert.equal(validateRelayEnvelope({ type: "relay.matterhorn-operation", id: "relay-op", roomName: "room", operation: op }).operation.id, op.id);
77
+
78
+ const deepOp = { ...baseOperation({ roomId: "room", payload: nestedPayload(100_000) }), id: "sha256:bad", hlc: "000000000001000:000000:dev_alice" };
79
+ assert.throws(() => validateRelayEnvelope({ type: "relay.matterhorn-operation", id: "relay-deep", roomName: "room", operation: deepOp }), /max depth exceeded/);
80
+ });
81
+
82
+ test("a valid operation below relay-sized caps passes the host validator", () => {
83
+ const op = identified(baseOperation({ payload: { text: "x".repeat(1024) } }));
84
+ const bytes = Buffer.byteLength(JSON.stringify(op), "utf8");
85
+ assert.ok(bytes < MAX_OPERATION_BYTES);
86
+ assert.equal(validateRoomOperation(op, { maxOperationBytes: MAX_OPERATION_BYTES, maxOperationDepth: MAX_OPERATION_DEPTH }).id, op.id);
87
+ });