@mh-gg/relay-runtime 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,779 @@
1
+ const { manifestHash } = require("@mh-gg/base");
2
+ const { canonicalJson, createRoleKeyAuthenticator, operationKeyRoleToActorRole, publicOperationGrantAuthorities, publicOperationRoleKeyGrants, verifyOperationRoleKeyGrant } = require("@mh-gg/host-runtime");
3
+ const {
4
+ HostPluginRuntime,
5
+ createMemoryOperationLog,
6
+ createMemoryRoomStore
7
+ } = require("@mh-gg/host-runtime");
8
+ const { safeValidate, validateRoomOperation, verifyRoomDeviceSignedOperation } = require("@mh-gg/protocol");
9
+
10
+ function clone(value) {
11
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
12
+ }
13
+
14
+ function roomStateVersion(state) {
15
+ return Number.isInteger(state?.version) ? state.version : -1;
16
+ }
17
+
18
+ function roomStateUpdatedAt(state) {
19
+ return Number(state?.updatedAt || state?.createdAt || 0);
20
+ }
21
+
22
+ function isNewerRoomState(candidate, current) {
23
+ const candidateVersion = roomStateVersion(candidate);
24
+ const currentVersion = roomStateVersion(current);
25
+ if (candidateVersion !== currentVersion) return candidateVersion > currentVersion;
26
+ return roomStateUpdatedAt(candidate) > roomStateUpdatedAt(current);
27
+ }
28
+
29
+ function appPackForRoom(input) {
30
+ const appPack = input.room?.appPack || input.appPack || input.installed?.roomAppPack || input.installed?.appPack;
31
+ if (!appPack?.id) throw new Error("Relay room composition requires an app pack");
32
+ return {
33
+ id: appPack.id,
34
+ version: appPack.version,
35
+ hash: appPack.hash || manifestHash(appPack),
36
+ protocolHash: appPack.protocolHash || appPack.compatibility?.appProtocolHash,
37
+ ...(appPack.matterhorn ? { matterhorn: clone(appPack.matterhorn) } : {}),
38
+ ...(appPack.metadata ? { metadata: clone(appPack.metadata) } : {})
39
+ };
40
+ }
41
+
42
+ function roomForComposition(input) {
43
+ const roomName = input.roomName || input.room?.id || input.roomId;
44
+ if (typeof roomName !== "string" || roomName.length === 0) throw new Error("Relay room composition requires roomName");
45
+ return {
46
+ ...(input.room || {}),
47
+ id: roomName,
48
+ appPack: appPackForRoom(input),
49
+ hostPack: input.room?.hostPack || (input.hostPack ? {
50
+ id: input.hostPack.id,
51
+ version: input.hostPack.version,
52
+ hash: input.hostPack.hash || manifestHash(input.hostPack),
53
+ pluginGraphHash: input.hostPack.compatibility?.pluginGraphHash
54
+ } : undefined)
55
+ };
56
+ }
57
+
58
+ function appPackChanged(current, next) {
59
+ return Boolean(current?.id && next?.id && current.id === next.id && current.hash !== next.hash);
60
+ }
61
+
62
+ function pluginsCanRun(plugins = []) {
63
+ return plugins.every((plugin) =>
64
+ plugin &&
65
+ typeof plugin.createInitialState === "function" &&
66
+ typeof plugin.authorize === "function" &&
67
+ typeof plugin.reduce === "function"
68
+ );
69
+ }
70
+
71
+ function pluginListForComposition(input) {
72
+ const plugins = input.plugins || input.installed?.plugins;
73
+ if (!Array.isArray(plugins) || plugins.length === 0) throw new Error("Relay room composition requires at least one plugin");
74
+ return plugins;
75
+ }
76
+
77
+ function pluginRuntimeDescriptor(plugin) {
78
+ if (!plugin?.id) return undefined;
79
+ return {
80
+ id: plugin.id,
81
+ version: plugin.version,
82
+ operations: Object.keys(plugin.schemas?.operations || {}).sort(),
83
+ operationSchemaDescriptor: clone(plugin.operationSchemaDescriptor)
84
+ };
85
+ }
86
+
87
+ function pluginDescriptorsForComposition(input, plugins) {
88
+ const declared = input.expectedPlugins || input.operationDescriptorPlugins || input.installed?.expectedPlugins;
89
+ const source = Array.isArray(declared) && declared.length > 0 ? declared : plugins;
90
+ return source
91
+ .map((plugin) => {
92
+ if (plugin?.operations && plugin.id) {
93
+ return {
94
+ id: plugin.id,
95
+ version: plugin.version,
96
+ operations: plugin.operations.slice().sort(),
97
+ operationSchemaDescriptor: clone(plugin.operationSchemaDescriptor)
98
+ };
99
+ }
100
+ return pluginRuntimeDescriptor(plugin);
101
+ })
102
+ .filter(Boolean);
103
+ }
104
+
105
+ function defaultLogger() {
106
+ return { debug() {}, info() {}, warn() {}, error() {} };
107
+ }
108
+
109
+ function operationRoleKeyGrantsForInput(input = {}, options = {}) {
110
+ return input.operationRoleKeyGrants || input.operationKeyGrants || input.installed?.operationRoleKeyGrants || options.operationRoleKeyGrants || options.operationKeyGrants || undefined;
111
+ }
112
+
113
+ function operationGrantAuthoritiesForInput(input = {}, options = {}) {
114
+ return input.operationGrantAuthorities || input.grantAuthorities || input.installed?.operationGrantAuthorities || options.operationGrantAuthorities || options.grantAuthorities || undefined;
115
+ }
116
+
117
+ function allowUnsignedOperationRoleKeyGrants(input = {}, options = {}) {
118
+ return input.allowUnsignedOperationRoleKeyGrants === true || input.allowUnsignedRoleKeyGrants === true || options.allowUnsignedOperationRoleKeyGrants === true || options.allowUnsignedRoleKeyGrants === true;
119
+ }
120
+
121
+ function allowHistoricalAppPackHashes(input = {}, options = {}) {
122
+ if (input.allowHistoricalAppPackHashes !== undefined) return input.allowHistoricalAppPackHashes === true;
123
+ return options.allowHistoricalAppPackHashes !== false;
124
+ }
125
+
126
+ function grantArray(grants) {
127
+ if (!grants) return [];
128
+ if (Array.isArray(grants)) return grants;
129
+ if (grants instanceof Map) return [...grants.values()];
130
+ if (grants.kind === "matterhorn.operation-role-key-grant") return [grants];
131
+ return Object.values(grants);
132
+ }
133
+
134
+ function initialMembersFromGrants(grants = []) {
135
+ const members = {};
136
+ for (const grant of grantArray(grants)) {
137
+ if (!grant?.memberId) continue;
138
+ members[grant.memberId] = {
139
+ ...(members[grant.memberId] || {}),
140
+ id: grant.memberId,
141
+ role: operationKeyRoleToActorRole(grant.role),
142
+ credentialId: grant.credentialId,
143
+ deviceId: grant.deviceId
144
+ };
145
+ }
146
+ return members;
147
+ }
148
+
149
+ function validateRelayOperationRoleKeyGrants(input = {}, options = {}) {
150
+ const grants = operationRoleKeyGrantsForInput(input, options);
151
+ if (!grants) return { grants: undefined, authorities: operationGrantAuthoritiesForInput(input, options), requireSignedGrants: false };
152
+ const authorities = operationGrantAuthoritiesForInput(input, options);
153
+ const allowUnsigned = allowUnsignedOperationRoleKeyGrants(input, options);
154
+ const requireSignedGrants = !allowUnsigned;
155
+ if (requireSignedGrants && (!authorities || grantArray(authorities).length === 0)) {
156
+ throw new Error("Relay operation role-key grants require at least one trusted grant authority. Set allowUnsignedOperationRoleKeyGrants only for local development fixtures.");
157
+ }
158
+ const currentAppPackHash = input.appPack?.hash || (input.appPack ? manifestHash(input.appPack) : undefined) || input.room?.appPack?.hash || input.installed?.roomAppPack?.hash || input.installed?.appPack?.hash;
159
+ const allowHistoricalHashes = allowHistoricalAppPackHashes(input, options);
160
+ for (const grant of grantArray(grants)) {
161
+ const verified = verifyOperationRoleKeyGrant(grant, authorities, {
162
+ roomId: input.roomName || input.room?.id || input.roomId,
163
+ appPackId: input.appPack?.id || input.room?.appPack?.id || input.installed?.roomAppPack?.id || input.installed?.appPack?.id,
164
+ appPackHash: allowHistoricalHashes && grant?.appPackHash ? grant.appPackHash : currentAppPackHash,
165
+ now: input.now || options.now,
166
+ requireSignedGrants
167
+ });
168
+ if (!verified.ok) throw new Error(`Relay operation role-key grant is not trusted: ${verified.error}`);
169
+ }
170
+ return { grants, authorities, requireSignedGrants };
171
+ }
172
+
173
+ function authenticatorForRoom(input = {}, room, options = {}) {
174
+ if (input.authenticateActor) return input.authenticateActor;
175
+ if (options.authenticateActor) return options.authenticateActor;
176
+ const grantTrust = validateRelayOperationRoleKeyGrants(input, options);
177
+ const grants = grantTrust.grants;
178
+ if (grants) {
179
+ return createRoleKeyAuthenticator({
180
+ roomId: room.id,
181
+ appPackId: room.appPack.id,
182
+ appPackHash: room.appPack.hash,
183
+ grants,
184
+ grantAuthorities: grantTrust.authorities,
185
+ requireSignedGrants: grantTrust.requireSignedGrants,
186
+ acceptOperationAppPackHash: allowHistoricalAppPackHashes(input, options),
187
+ now: input.now || options.now
188
+ });
189
+ }
190
+ return async () => {
191
+ throw new Error("Relay plugin runtime authenticateActor is required");
192
+ };
193
+ }
194
+
195
+ function runtimeForEntry({
196
+ input,
197
+ room,
198
+ plugins,
199
+ store,
200
+ operationLog,
201
+ authenticateActor,
202
+ capabilities,
203
+ grantTrust,
204
+ logger,
205
+ now,
206
+ options
207
+ }) {
208
+ return new HostPluginRuntime({
209
+ room,
210
+ hostPack: input.hostPack || input.installed?.hostPack || null,
211
+ playerPacks: input.playerPacks || input.installed?.playerPacks || [],
212
+ store,
213
+ operationLog,
214
+ plugins,
215
+ capabilities,
216
+ authenticateActor,
217
+ now: input.now || now,
218
+ logger,
219
+ allowHistoricalAppPackHashes: allowHistoricalAppPackHashes(input, options),
220
+ initialMembers: input.initialMembers || initialMembersFromGrants(grantTrust?.grants),
221
+ relayExecuted: options.productionRuntime === true || input.productionRuntime === true,
222
+ devUnsafeTrustActorRole: input.devUnsafeTrustActorRole === true || options.devUnsafeTrustActorRole === true,
223
+ strictStanding: input.strictStanding === undefined ? options.strictStanding : input.strictStanding
224
+ });
225
+ }
226
+
227
+ function normalizeOperationMessage(message) {
228
+ const operation = message?.operation || message;
229
+ const result = safeValidate(validateRoomOperation, operation);
230
+ if (!result.ok) {
231
+ return {
232
+ ok: false,
233
+ error: result.message || "Operation is invalid"
234
+ };
235
+ }
236
+ if (operation?.auth?.scheme === "matterhorn.device-signing.v1") {
237
+ const signature = verifyRoomDeviceSignedOperation(operation, operation.auth.claim);
238
+ if (!signature.ok) return { ok: false, error: signature.error || "Operation room-device signature is invalid" };
239
+ }
240
+ return { ok: true, operation };
241
+ }
242
+
243
+ function createHostMessage(type, roomName, body = {}) {
244
+ return {
245
+ type,
246
+ protocol: 1,
247
+ roomName,
248
+ ...body
249
+ };
250
+ }
251
+
252
+ function logRuntimeDecision(logger, status, context = {}) {
253
+ const payload = {
254
+ roomName: context.roomName,
255
+ operationId: context.operationId,
256
+ pluginId: context.pluginId,
257
+ operationType: context.operationType,
258
+ reason: context.reason,
259
+ relayDirection: context.relayDirection
260
+ };
261
+ const message = `relay runtime ${status}: ${payload.roomName || "unknown-room"} ${payload.operationId || "unknown-operation"}`;
262
+ if (status === "accepted") logger.info?.(message, payload);
263
+ else logger.warn?.(message, payload);
264
+ }
265
+
266
+ function operationCapability(pluginId, operationType) {
267
+ return { pluginId, type: operationType };
268
+ }
269
+
270
+ function runtimeCapabilityDocument(value) {
271
+ if (!value) {
272
+ return {
273
+ mode: "unavailable",
274
+ availablePlugins: [],
275
+ unavailablePlugins: [],
276
+ availableOperations: [],
277
+ unavailableOperations: []
278
+ };
279
+ }
280
+ const availablePluginIds = new Set(value.plugins.map((plugin) => plugin.id));
281
+ const descriptors = Array.isArray(value.pluginDescriptors) ? value.pluginDescriptors : value.plugins.map(pluginRuntimeDescriptor).filter(Boolean);
282
+ const availablePlugins = value.plugins.map((plugin) => ({ id: plugin.id, version: plugin.version }));
283
+ const unavailablePlugins = descriptors
284
+ .filter((plugin) => !availablePluginIds.has(plugin.id))
285
+ .map((plugin) => ({ id: plugin.id, version: plugin.version }));
286
+ const availableOperations = [];
287
+ const unavailableOperations = [];
288
+ for (const descriptor of descriptors) {
289
+ const target = availablePluginIds.has(descriptor.id) ? availableOperations : unavailableOperations;
290
+ for (const type of descriptor.operations || []) target.push(operationCapability(descriptor.id, type));
291
+ }
292
+ return {
293
+ mode: unavailablePlugins.length > 0 ? "partial" : "full",
294
+ availablePlugins,
295
+ unavailablePlugins,
296
+ availableOperations,
297
+ unavailableOperations
298
+ };
299
+ }
300
+
301
+ function resultWithCapabilityFailure(value, operation, result) {
302
+ if (!result || result.ok !== false) return result;
303
+ const missingPlugin = result.code === "PLUGIN_NOT_INSTALLED";
304
+ const unavailable = runtimeCapabilityDocument(value).unavailableOperations
305
+ .some((item) => item.pluginId === operation?.pluginId && item.type === operation?.type);
306
+ if (!missingPlugin && !unavailable) return result;
307
+ return {
308
+ ...result,
309
+ code: "PLUGIN_UNAVAILABLE",
310
+ reason: `Operation ${operation.pluginId}.${operation.type} is not available on this relay.`,
311
+ retryableWhenCapabilityAvailable: true
312
+ };
313
+ }
314
+
315
+
316
+ function normalizeSnapshotInput(incoming) {
317
+ if (incoming && typeof incoming === "object" && incoming.state && typeof incoming.state === "object") {
318
+ return { state: incoming.state, operations: Array.isArray(incoming.operations) ? incoming.operations : undefined };
319
+ }
320
+ return { state: incoming, operations: undefined };
321
+ }
322
+
323
+ function comparableRoomState(state) {
324
+ if (!state || typeof state !== "object") return state;
325
+ return {
326
+ roomId: state.roomId,
327
+ appPack: state.appPack,
328
+ version: state.version,
329
+ members: state.members,
330
+ pluginVersions: state.pluginVersions,
331
+ plugins: state.plugins,
332
+ seenOperations: state.seenOperations
333
+ };
334
+ }
335
+
336
+ function sameRoomState(left, right) {
337
+ return canonicalJson(comparableRoomState(left)) === canonicalJson(comparableRoomState(right));
338
+ }
339
+
340
+ function plainObject(value) {
341
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
342
+ }
343
+
344
+ function hasMeaningfulData(value) {
345
+ if (value === null || value === undefined) return false;
346
+ if (Array.isArray(value)) return value.some(hasMeaningfulData);
347
+ if (typeof value === "object") return Object.values(value).some(hasMeaningfulData);
348
+ if (typeof value === "string") return value.length > 0;
349
+ return true;
350
+ }
351
+
352
+ function dropsMeaningfulObjectEntries(candidate, current) {
353
+ const candidateObject = plainObject(candidate);
354
+ for (const [key, value] of Object.entries(plainObject(current))) {
355
+ if (!hasMeaningfulData(value)) continue;
356
+ if (!Object.prototype.hasOwnProperty.call(candidateObject, key)) return true;
357
+ if (!hasMeaningfulData(candidateObject[key])) return true;
358
+ }
359
+ return false;
360
+ }
361
+
362
+ function isDestructiveRoomState(candidate, current) {
363
+ if (!current || typeof current !== "object") return false;
364
+ if (dropsMeaningfulObjectEntries(candidate?.plugins, current.plugins)) return true;
365
+ if (dropsMeaningfulObjectEntries(candidate?.members, current.members)) return true;
366
+ const currentSeen = Array.isArray(current.seenOperations) ? current.seenOperations : [];
367
+ const candidateSeen = Array.isArray(candidate?.seenOperations) ? candidate.seenOperations : [];
368
+ return currentSeen.length > 0 && candidateSeen.length < currentSeen.length;
369
+ }
370
+
371
+ function createTransactionalRoomStore(targetStore, initialState) {
372
+ let committed = false;
373
+ let staged = clone(initialState);
374
+ return {
375
+ async load() {
376
+ return committed ? await targetStore.load() : clone(staged);
377
+ },
378
+ async save(nextState) {
379
+ if (committed) {
380
+ await targetStore.save(clone(nextState));
381
+ return;
382
+ }
383
+ staged = clone(nextState);
384
+ },
385
+ async commit() {
386
+ await targetStore.save(clone(staged));
387
+ committed = true;
388
+ return clone(staged);
389
+ }
390
+ };
391
+ }
392
+
393
+
394
+ function operationForEnvelopeCompare(operation) {
395
+ const next = clone(operation);
396
+ delete next.ledgerId;
397
+ delete next.snowflakeId;
398
+ delete next.committedRoomVersion;
399
+ delete next.committedAt;
400
+ delete next.receivedAt;
401
+ delete next.relayId;
402
+ return next;
403
+ }
404
+
405
+ function operationsContainEnvelopeOperation(operations, operation) {
406
+ if (!Array.isArray(operations)) return false;
407
+ const expected = canonicalJson(operationForEnvelopeCompare(operation));
408
+ return operations.some((candidate) => canonicalJson(operationForEnvelopeCompare(candidate)) === expected);
409
+ }
410
+
411
+ async function verifySnapshotWithOperations(value, incomingState, operations = []) {
412
+ if (!Array.isArray(operations) || operations.length === 0) {
413
+ return { ok: false, reason: "snapshot-operation-proof-required" };
414
+ }
415
+ const baselineState = await value.runtime.getState();
416
+ const replayRuntime = new HostPluginRuntime({
417
+ room: value.room,
418
+ hostPack: value.hostPack,
419
+ playerPacks: [],
420
+ store: createMemoryRoomStore(baselineState),
421
+ operationLog: createMemoryOperationLog(),
422
+ plugins: value.plugins,
423
+ capabilities: [...value.runtime.capabilities],
424
+ authenticateActor: value.runtime.authenticateActor,
425
+ initialMembers: baselineState.members || {},
426
+ initialRevokedCredentialIds: baselineState.revokedCredentialIds || [],
427
+ strictStanding: value.runtime.strictStanding,
428
+ allowHistoricalAppPackHashes: true,
429
+ now: () => incomingState.createdAt || incomingState.updatedAt || Date.now(),
430
+ logger: value.runtime.logger
431
+ });
432
+ await replayRuntime.start();
433
+ for (const operation of operations) {
434
+ const result = await replayRuntime.handleOperation(operation);
435
+ if (!result.ok) return { ok: false, reason: `snapshot-operation-proof-invalid: ${result.reason || result.code || "operation rejected"}` };
436
+ }
437
+ const replayed = await replayRuntime.getState();
438
+ if (!sameRoomState(replayed, incomingState)) return { ok: false, reason: "snapshot-operation-proof-mismatch" };
439
+ return { ok: true, state: replayed };
440
+ }
441
+
442
+ function createRelayPluginRuntimeManager(options = {}) {
443
+ const rooms = new Map();
444
+ const logger = options.logger || defaultLogger();
445
+ const relayAddress = options.relayAddress || "relay-local";
446
+ const sendRelayEnvelope = options.sendRelayEnvelope || (() => 0);
447
+ const now = options.now || (() => Date.now());
448
+ let messageSeq = 0;
449
+
450
+ function nextMessageId(type = "relay.matterhorn") {
451
+ messageSeq += 1;
452
+ return `${relayAddress}:${type}:${now()}:${messageSeq}`;
453
+ }
454
+
455
+ function entry(roomName) {
456
+ return rooms.get(roomName);
457
+ }
458
+
459
+ async function startEntry(entryValue) {
460
+ await entryValue.runtime.start();
461
+ return entryValue;
462
+ }
463
+
464
+ async function replaceEntryRuntime(existing, input, room, plugins, grantTrust) {
465
+ const previousState = await existing.store.load();
466
+ const stagedStore = createTransactionalRoomStore(existing.store, previousState);
467
+ const authenticateActor = (input.authenticateActor || options.authenticateActor || operationRoleKeyGrantsForInput(input, options))
468
+ ? authenticatorForRoom(input, room, options)
469
+ : existing.runtime.authenticateActor;
470
+ const runtime = runtimeForEntry({
471
+ input,
472
+ room,
473
+ plugins,
474
+ store: stagedStore,
475
+ operationLog: existing.operationLog,
476
+ authenticateActor,
477
+ capabilities: input.capabilities || input.hostPack?.capabilities?.required || input.installed?.hostPack?.capabilities?.required || [...existing.runtime.capabilities],
478
+ grantTrust,
479
+ logger,
480
+ now,
481
+ options
482
+ });
483
+ await runtime.start();
484
+ await stagedStore.commit();
485
+ existing.room = room;
486
+ existing.hostPack = input.hostPack || input.installed?.hostPack || null;
487
+ existing.plugins = plugins;
488
+ existing.pluginDescriptors = pluginDescriptorsForComposition(input, plugins);
489
+ existing.operationRoleKeyGrants = publicOperationRoleKeyGrants(grantTrust.grants || []);
490
+ existing.operationGrantAuthorities = publicOperationGrantAuthorities(grantTrust.authorities || []);
491
+ existing.requireSignedOperationRoleKeyGrants = grantTrust.requireSignedGrants;
492
+ existing.runtime = runtime;
493
+ return existing;
494
+ }
495
+
496
+ async function enableRoomComposition(input = {}) {
497
+ const room = roomForComposition(input);
498
+ const roomName = room.id;
499
+ const plugins = pluginListForComposition(input);
500
+ const existing = rooms.get(roomName);
501
+ if (existing) {
502
+ if (input.roomApp) existing.roomApp = clone(input.roomApp);
503
+ existing.pluginDescriptors = pluginDescriptorsForComposition(input, plugins);
504
+ if (appPackChanged(existing.room.appPack, room.appPack) || !pluginsCanRun(existing.plugins)) {
505
+ const grantTrust = validateRelayOperationRoleKeyGrants(input, options);
506
+ await replaceEntryRuntime(existing, input, room, plugins, grantTrust);
507
+ }
508
+ const incomingState = input.state || input.snapshot;
509
+ if (incomingState) await syncSnapshot(roomName, incomingState, { trusted: true });
510
+ return existing;
511
+ }
512
+
513
+ const grantTrust = validateRelayOperationRoleKeyGrants(input, options);
514
+ const store = input.store || createMemoryRoomStore(input.state || input.snapshot || null);
515
+ const operationLog = input.operationLog || createMemoryOperationLog(input.operations || []);
516
+ const runtime = runtimeForEntry({
517
+ input,
518
+ room,
519
+ store,
520
+ operationLog,
521
+ plugins,
522
+ authenticateActor: authenticatorForRoom(input, room, options),
523
+ capabilities: input.capabilities || input.hostPack?.capabilities?.required || input.installed?.hostPack?.capabilities?.required || [],
524
+ grantTrust,
525
+ logger,
526
+ now,
527
+ options
528
+ });
529
+ const created = {
530
+ roomName,
531
+ room,
532
+ hostPack: input.hostPack || input.installed?.hostPack || null,
533
+ plugins,
534
+ pluginDescriptors: pluginDescriptorsForComposition(input, plugins),
535
+ runtime,
536
+ store,
537
+ operationLog,
538
+ roomApp: clone(input.roomApp || {}),
539
+ operationRoleKeyGrants: publicOperationRoleKeyGrants(grantTrust.grants || []),
540
+ operationGrantAuthorities: publicOperationGrantAuthorities(grantTrust.authorities || []),
541
+ requireSignedOperationRoleKeyGrants: grantTrust.requireSignedGrants,
542
+ relayAddress,
543
+ enabledAt: now()
544
+ };
545
+ await startEntry(created);
546
+ rooms.set(roomName, created);
547
+ return created;
548
+ }
549
+
550
+ function hasRoom(roomName) {
551
+ return rooms.has(roomName);
552
+ }
553
+
554
+ function enabledPlugins(roomName) {
555
+ const value = entry(roomName);
556
+ return value ? value.plugins.map((plugin) => ({ id: plugin.id, version: plugin.version, meta: clone(plugin.meta) })) : [];
557
+ }
558
+
559
+ function roomApp(roomName) {
560
+ const value = entry(roomName);
561
+ if (!value) return undefined;
562
+ const appPack = {
563
+ id: value.room.appPack.id,
564
+ version: value.room.appPack.version,
565
+ hash: value.room.appPack.hash,
566
+ protocolHash: value.room.appPack.protocolHash,
567
+ ...(value.room.appPack.matterhorn ? { matterhorn: clone(value.room.appPack.matterhorn) } : {}),
568
+ ...(value.room.appPack.metadata ? { metadata: clone(value.room.appPack.metadata) } : {})
569
+ };
570
+ const extras = clone(value.roomApp || {});
571
+ const roomAppPack = extras.appPack ? { ...extras.appPack, ...appPack } : appPack;
572
+ return {
573
+ ...extras,
574
+ id: value.room.appPack.id,
575
+ version: value.room.appPack.version,
576
+ hash: value.room.appPack.hash,
577
+ protocolHash: value.room.appPack.protocolHash,
578
+ appPack: roomAppPack,
579
+ hostPack: value.room.hostPack || extras.hostPack,
580
+ plugins: enabledPlugins(roomName),
581
+ operationAuth: {
582
+ kind: "matterhorn.operation-role-keyring",
583
+ version: 1,
584
+ grants: publicOperationRoleKeyGrants(value.operationRoleKeyGrants || []),
585
+ authorities: publicOperationGrantAuthorities(value.operationGrantAuthorities || []),
586
+ requireSignedGrants: value.requireSignedOperationRoleKeyGrants === true
587
+ },
588
+ runtimeCapabilities: runtimeCapabilityDocument(value)
589
+ };
590
+ }
591
+
592
+ function runtimeCapabilities(roomName) {
593
+ return runtimeCapabilityDocument(entry(roomName));
594
+ }
595
+
596
+ function updateRoomApp(roomName, nextRoomApp) {
597
+ const value = entry(roomName);
598
+ if (!value) return false;
599
+ value.roomApp = clone(nextRoomApp || {});
600
+ return true;
601
+ }
602
+
603
+ async function snapshot(roomName, actor) {
604
+ const value = entry(roomName);
605
+ if (!value) return undefined;
606
+ const state = actor ? await value.runtime.publicView(actor) : await value.runtime.getState();
607
+ return {
608
+ roomName,
609
+ room: clone(value.room),
610
+ roomApp: roomApp(roomName),
611
+ state,
612
+ operations: typeof value.operationLog.list === "function" ? await value.operationLog.list() : clone(value.operationLog.entries || [])
613
+ };
614
+ }
615
+
616
+ async function syncSnapshot(roomName, incomingSnapshot, options = {}) {
617
+ const value = entry(roomName);
618
+ if (!value) return { ok: false, updated: false, reason: "room-not-enabled" };
619
+ const { state: rawIncomingState, operations } = normalizeSnapshotInput(incomingSnapshot);
620
+ let incomingState = rawIncomingState;
621
+ if (!incomingState || typeof incomingState !== "object") return { ok: false, updated: false, reason: "snapshot-required" };
622
+ if (incomingState.roomId !== value.room.id) return { ok: false, updated: false, reason: "room-mismatch" };
623
+ if (incomingState.appPack?.id !== value.room.appPack.id) return { ok: false, updated: false, reason: "app-mismatch" };
624
+ if (incomingState.appPack?.hash !== value.room.appPack.hash) incomingState = { ...clone(incomingState), appPack: clone(value.room.appPack) };
625
+ try {
626
+ incomingState = await value.runtime.migrateExistingState(incomingState);
627
+ } catch (error) {
628
+ return { ok: false, updated: false, reason: error?.message || "snapshot-invalid" };
629
+ }
630
+ const current = await value.runtime.getState();
631
+ if (!isNewerRoomState(incomingState, current)) return { ok: true, updated: false, state: current };
632
+ const destructive = isDestructiveRoomState(incomingState, current);
633
+ const requiresProof = destructive || options.requireOperationProof === true || (value.requireSignedOperationRoleKeyGrants === true && options.trusted !== true);
634
+ if (requiresProof) {
635
+ const verified = await verifySnapshotWithOperations(value, incomingState, operations);
636
+ if (!verified.ok) return { ok: false, updated: false, reason: verified.reason };
637
+ }
638
+ await value.store.save(clone(incomingState));
639
+ if (Array.isArray(operations) && Array.isArray(value.operationLog?.entries)) {
640
+ value.operationLog.entries.splice(0, value.operationLog.entries.length, ...operations.map(clone));
641
+ await value.operationLog.persist?.();
642
+ }
643
+ return { ok: true, updated: true, state: await value.runtime.getState() };
644
+ }
645
+
646
+ async function handleClientOperation({ roomName, peerId, message, sendToClient, broadcastToRoom, broadcastToMesh = sendRelayEnvelope } = {}) {
647
+ const value = entry(roomName || message?.roomName);
648
+ if (!value) {
649
+ sendToClient?.(peerId, createHostMessage("host/error", roomName || message?.roomName, {
650
+ code: "room-runtime-unavailable",
651
+ message: "The relay has not enabled this room composition."
652
+ }));
653
+ logRuntimeDecision(logger, "rejected", {
654
+ roomName: roomName || message?.roomName,
655
+ operationId: message?.operation?.id,
656
+ reason: "room-runtime-unavailable",
657
+ relayDirection: "client"
658
+ });
659
+ return { ok: false, reason: "room-runtime-unavailable" };
660
+ }
661
+ const parsed = normalizeOperationMessage(message);
662
+ if (!parsed.ok) {
663
+ sendToClient?.(peerId, createHostMessage("host/error", value.roomName, {
664
+ code: "bad-operation",
665
+ message: parsed.error
666
+ }));
667
+ logRuntimeDecision(logger, "rejected", {
668
+ roomName: value.roomName,
669
+ operationId: message?.operation?.id,
670
+ reason: parsed.error,
671
+ relayDirection: "client"
672
+ });
673
+ return { ok: false, reason: parsed.error };
674
+ }
675
+ const result = resultWithCapabilityFailure(value, parsed.operation, await value.runtime.handleOperation(parsed.operation));
676
+ logRuntimeDecision(logger, result.ok ? "accepted" : "rejected", {
677
+ roomName: value.roomName,
678
+ operationId: parsed.operation.id,
679
+ pluginId: parsed.operation.pluginId,
680
+ operationType: parsed.operation.type,
681
+ reason: result.reason || result.code,
682
+ relayDirection: "client"
683
+ });
684
+ const state = await value.runtime.getState();
685
+ const actorState = await value.runtime.publicView(parsed.operation.actor);
686
+ const resultMessage = createHostMessage("host/operation-result", value.roomName, {
687
+ operationId: parsed.operation.id,
688
+ result,
689
+ state: actorState
690
+ });
691
+ sendToClient?.(peerId, resultMessage);
692
+ if (result.ok) {
693
+ const operations = typeof value.operationLog.list === "function" ? await value.operationLog.list() : [];
694
+ await broadcastToRoom?.(value.roomName, async (actor) => createHostMessage("host/matterhorn-state", value.roomName, {
695
+ acceptedOperationId: parsed.operation.id,
696
+ state: actor ? await value.runtime.publicView(actor) : state
697
+ }));
698
+ broadcastToMesh?.({
699
+ type: "relay.matterhorn-operation",
700
+ id: nextMessageId("relay.matterhorn-operation"),
701
+ roomName: value.roomName,
702
+ operation: parsed.operation,
703
+ state,
704
+ operations,
705
+ originRelay: relayAddress
706
+ });
707
+ }
708
+ return { ...result, state };
709
+ }
710
+
711
+ async function handleRelayOperation(message = {}) {
712
+ const value = entry(message.roomName);
713
+ if (!value) {
714
+ logRuntimeDecision(logger, "rejected", {
715
+ roomName: message.roomName,
716
+ operationId: message.operation?.id,
717
+ reason: "room-runtime-unavailable",
718
+ relayDirection: "relay"
719
+ });
720
+ return { ok: false, reason: "room-runtime-unavailable" };
721
+ }
722
+ const parsed = normalizeOperationMessage(message.operation);
723
+ if (!parsed.ok) {
724
+ logRuntimeDecision(logger, "rejected", {
725
+ roomName: message.roomName,
726
+ operationId: message.operation?.id,
727
+ reason: parsed.error,
728
+ relayDirection: "relay"
729
+ });
730
+ return { ok: false, reason: parsed.error };
731
+ }
732
+ if (parsed.operation.appPackId !== value.room.appPack.id) {
733
+ logRuntimeDecision(logger, "rejected", {
734
+ roomName: message.roomName,
735
+ operationId: parsed.operation.id,
736
+ pluginId: parsed.operation.pluginId,
737
+ operationType: parsed.operation.type,
738
+ reason: "app-mismatch",
739
+ relayDirection: "relay"
740
+ });
741
+ return { ok: false, ignored: true, code: "APP_PACK_MISMATCH", reason: "app-mismatch", state: await value.runtime.getState() };
742
+ }
743
+ const result = resultWithCapabilityFailure(value, parsed.operation, await value.runtime.handleOperation(parsed.operation));
744
+ logRuntimeDecision(logger, result.ok ? "accepted" : "rejected", {
745
+ roomName: message.roomName,
746
+ operationId: parsed.operation.id,
747
+ pluginId: parsed.operation.pluginId,
748
+ operationType: parsed.operation.type,
749
+ reason: result.reason || result.code,
750
+ relayDirection: "relay"
751
+ });
752
+ if (result.ok) return { ...result, state: await value.runtime.getState() };
753
+ if (message.state && Array.isArray(message.operations) && operationsContainEnvelopeOperation(message.operations, parsed.operation)) {
754
+ const synced = await syncSnapshot(message.roomName, { state: message.state, operations: message.operations });
755
+ if (synced.ok && synced.updated) return { ok: true, synced: true, acceptedOperationId: parsed.operation.id, state: await value.runtime.getState() };
756
+ }
757
+ return { ...result, state: await value.runtime.getState() };
758
+ }
759
+
760
+ return {
761
+ enableRoomComposition,
762
+ enabledPlugins,
763
+ get: entry,
764
+ handleClientOperation,
765
+ handleRelayOperation,
766
+ hasRoom,
767
+ roomApp,
768
+ rooms,
769
+ runtimeCapabilities,
770
+ snapshot,
771
+ syncSnapshot,
772
+ updateRoomApp
773
+ };
774
+ }
775
+
776
+ module.exports = {
777
+ createRelayPluginRuntimeManager,
778
+ isNewerRoomState
779
+ };