@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.
- package/package.json +26 -0
- package/src/encryptedRoomRuntime.cjs +641 -0
- package/src/encryptedRoomRuntimeManager.cjs +131 -0
- package/src/index.cjs +152 -0
- package/src/pluginRuntimeHost.cjs +779 -0
- package/test/encryptedRoomRuntime.test.cjs +729 -0
- package/test/operation-role-keys.test.cjs +346 -0
- package/test/plugin-runtime-manager.test.cjs +651 -0
- package/test/relay-runtime.test.cjs +219 -0
|
@@ -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
|
+
};
|