@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,651 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const { manifestHash } = require("@mh-gg/base");
|
|
5
|
+
const { createMemoryOperationLog, createMemoryRoomStore } = require("@mh-gg/host-runtime");
|
|
6
|
+
const { createRelayPluginRuntimeManager } = require("../src/index.cjs");
|
|
7
|
+
const { kanbanAppPack, kanbanHostPlugins, kanbanHostPlugin, KANBAN_PLUGIN_ID } = require("../../../examples/kanban/src/sdk-app.mjs");
|
|
8
|
+
const { COMMENTS_PLUGIN_ID } = require("@mh-gg/base-plugins");
|
|
9
|
+
const { ensureOperationIdentity } = require("@mh-gg/protocol");
|
|
10
|
+
|
|
11
|
+
function actor(role = "admin", memberId = "admin") {
|
|
12
|
+
return { memberId, deviceId: `dev_${memberId}`, role, displayName: memberId };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function operation(overrides = {}) {
|
|
16
|
+
const op = {
|
|
17
|
+
clientOperationId: overrides.id || "op_1",
|
|
18
|
+
roomId: "room_resilient_board",
|
|
19
|
+
appPackId: kanbanAppPack.id,
|
|
20
|
+
appPackHash: manifestHash(kanbanAppPack),
|
|
21
|
+
pluginId: overrides.pluginId || KANBAN_PLUGIN_ID,
|
|
22
|
+
type: overrides.type || "list.create",
|
|
23
|
+
actor: overrides.actor || actor(),
|
|
24
|
+
seq: overrides.seq || 1,
|
|
25
|
+
createdAt: overrides.createdAt || 1000,
|
|
26
|
+
payload: overrides.payload || { title: "Backlog" },
|
|
27
|
+
auth: { credentialId: "cred", signature: "sig" },
|
|
28
|
+
...overrides
|
|
29
|
+
};
|
|
30
|
+
delete op.id;
|
|
31
|
+
return ensureOperationIdentity(op, { now: op.createdAt, nodeId: op.actor?.deviceId || "dev_admin" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function enable(manager, extra = {}) {
|
|
35
|
+
return manager.enableRoomComposition({
|
|
36
|
+
roomName: "room_resilient_board",
|
|
37
|
+
appPack: kanbanAppPack,
|
|
38
|
+
plugins: kanbanHostPlugins,
|
|
39
|
+
authenticateActor: async (_auth, opActor) => opActor,
|
|
40
|
+
now: () => 1700000000000,
|
|
41
|
+
...extra
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("relay plugin runtime manager enables every plugin in the composition set", async () => {
|
|
46
|
+
const manager = createRelayPluginRuntimeManager();
|
|
47
|
+
await enable(manager);
|
|
48
|
+
|
|
49
|
+
assert.equal(manager.hasRoom("room_resilient_board"), true);
|
|
50
|
+
assert.deepEqual(manager.enabledPlugins("room_resilient_board").map((plugin) => plugin.id), kanbanHostPlugins.map((plugin) => plugin.id));
|
|
51
|
+
assert.ok(manager.enabledPlugins("room_resilient_board").some((plugin) => plugin.id === COMMENTS_PLUGIN_ID), "shared comments plugin should be enabled too");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("relay plugin runtime manager exposes partial capabilities and preserves missing plugin state", async () => {
|
|
55
|
+
const source = createRelayPluginRuntimeManager();
|
|
56
|
+
await enable(source);
|
|
57
|
+
const fullSnapshot = await source.snapshot("room_resilient_board");
|
|
58
|
+
const partialPlugins = kanbanHostPlugins.filter((plugin) => plugin.id !== COMMENTS_PLUGIN_ID);
|
|
59
|
+
const partial = createRelayPluginRuntimeManager();
|
|
60
|
+
await partial.enableRoomComposition({
|
|
61
|
+
roomName: "room_resilient_board",
|
|
62
|
+
appPack: kanbanAppPack,
|
|
63
|
+
plugins: partialPlugins,
|
|
64
|
+
expectedPlugins: kanbanHostPlugins,
|
|
65
|
+
authenticateActor: async (_auth, opActor) => opActor,
|
|
66
|
+
state: fullSnapshot.state,
|
|
67
|
+
now: () => 1700000000000
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const capabilities = partial.runtimeCapabilities("room_resilient_board");
|
|
71
|
+
assert.equal(capabilities.mode, "partial");
|
|
72
|
+
assert.equal(capabilities.unavailablePlugins.some((plugin) => plugin.id === COMMENTS_PLUGIN_ID), true);
|
|
73
|
+
assert.equal(capabilities.unavailableOperations.some((item) => item.pluginId === COMMENTS_PLUGIN_ID && item.type === "comments.add"), true);
|
|
74
|
+
assert.equal(partial.roomApp("room_resilient_board").runtimeCapabilities.mode, "partial");
|
|
75
|
+
assert.ok((await partial.snapshot("room_resilient_board")).state.plugins[COMMENTS_PLUGIN_ID], "missing plugin state should remain opaque");
|
|
76
|
+
|
|
77
|
+
const available = await partial.handleClientOperation({
|
|
78
|
+
roomName: "room_resilient_board",
|
|
79
|
+
peerId: "peer",
|
|
80
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: operation({ id: "partial_available", seq: 1 }) },
|
|
81
|
+
sendToClient: () => {},
|
|
82
|
+
broadcastToRoom: () => {}
|
|
83
|
+
});
|
|
84
|
+
assert.equal(available.ok, true);
|
|
85
|
+
|
|
86
|
+
const sent = [];
|
|
87
|
+
const missing = await partial.handleClientOperation({
|
|
88
|
+
roomName: "room_resilient_board",
|
|
89
|
+
peerId: "peer",
|
|
90
|
+
message: {
|
|
91
|
+
type: "client/operation",
|
|
92
|
+
protocol: 1,
|
|
93
|
+
roomName: "room_resilient_board",
|
|
94
|
+
clientId: "alice",
|
|
95
|
+
operation: operation({
|
|
96
|
+
id: "partial_missing_comments",
|
|
97
|
+
seq: 2,
|
|
98
|
+
pluginId: COMMENTS_PLUGIN_ID,
|
|
99
|
+
type: "comments.add",
|
|
100
|
+
payload: { scopeType: "card", scopeId: "card-1", body: "Needs discussion" }
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
sendToClient: (peerId, message) => sent.push({ peerId, message }),
|
|
104
|
+
broadcastToRoom: () => {}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.equal(missing.ok, false);
|
|
108
|
+
assert.equal(missing.code, "PLUGIN_UNAVAILABLE");
|
|
109
|
+
assert.equal(missing.retryableWhenCapabilityAvailable, true);
|
|
110
|
+
assert.equal(sent[0].message.result.code, "PLUGIN_UNAVAILABLE");
|
|
111
|
+
assert.equal(sent[0].message.result.retryableWhenCapabilityAvailable, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("relay plugin runtime manager logs authoritative runtime decisions", async () => {
|
|
115
|
+
const logs = [];
|
|
116
|
+
const logger = {
|
|
117
|
+
info: (message, fields) => logs.push({ level: "info", message, fields }),
|
|
118
|
+
warn: (message, fields) => logs.push({ level: "warn", message, fields })
|
|
119
|
+
};
|
|
120
|
+
const manager = createRelayPluginRuntimeManager({ logger });
|
|
121
|
+
await enable(manager);
|
|
122
|
+
|
|
123
|
+
const acceptedOperation = operation({ id: "logged_accept" });
|
|
124
|
+
const accepted = await manager.handleClientOperation({
|
|
125
|
+
roomName: "room_resilient_board",
|
|
126
|
+
peerId: "peer",
|
|
127
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", operation: acceptedOperation },
|
|
128
|
+
sendToClient: () => {},
|
|
129
|
+
broadcastToRoom: () => {}
|
|
130
|
+
});
|
|
131
|
+
assert.equal(accepted.ok, true);
|
|
132
|
+
|
|
133
|
+
const rejected = await manager.handleRelayOperation({
|
|
134
|
+
roomName: "room_resilient_board",
|
|
135
|
+
operation: { nope: true }
|
|
136
|
+
});
|
|
137
|
+
assert.equal(rejected.ok, false);
|
|
138
|
+
|
|
139
|
+
assert.equal(logs.some((entry) =>
|
|
140
|
+
entry.level === "info" &&
|
|
141
|
+
entry.message.includes("relay runtime accepted") &&
|
|
142
|
+
entry.fields.roomName === "room_resilient_board" &&
|
|
143
|
+
entry.fields.operationId === acceptedOperation.id
|
|
144
|
+
), true);
|
|
145
|
+
assert.equal(logs.some((entry) =>
|
|
146
|
+
entry.level === "warn" &&
|
|
147
|
+
entry.message.includes("relay runtime rejected") &&
|
|
148
|
+
entry.fields.roomName === "room_resilient_board" &&
|
|
149
|
+
entry.fields.relayDirection === "relay"
|
|
150
|
+
), true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("relay plugin runtime manager refreshes room app metadata", async () => {
|
|
154
|
+
const manager = createRelayPluginRuntimeManager();
|
|
155
|
+
await enable(manager, {
|
|
156
|
+
roomApp: { appPack: { id: kanbanAppPack.id, hash: "sha256-stale" }, frontends: [{ id: "player", integrity: "sha256-old" }] }
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
assert.equal(manager.roomApp("room_resilient_board").frontends[0].integrity, "sha256-old");
|
|
160
|
+
assert.equal(manager.roomApp("room_resilient_board").appPack.hash, manifestHash(kanbanAppPack));
|
|
161
|
+
assert.equal(manager.updateRoomApp("room_resilient_board", { frontends: [{ id: "player", dynamic: true, integrity: "sha256-new" }] }), true);
|
|
162
|
+
assert.equal(manager.roomApp("room_resilient_board").frontends[0].dynamic, true);
|
|
163
|
+
assert.equal(manager.roomApp("room_resilient_board").frontends[0].integrity, "sha256-new");
|
|
164
|
+
assert.equal(manager.updateRoomApp("missing", { frontends: [] }), false);
|
|
165
|
+
|
|
166
|
+
await enable(manager, {
|
|
167
|
+
roomApp: { frontends: [{ id: "player", integrity: "sha256-reregistered" }] }
|
|
168
|
+
});
|
|
169
|
+
assert.equal(manager.roomApp("room_resilient_board").frontends[0].integrity, "sha256-reregistered");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("relay plugin runtime manager does not retain failed plugin starts", async () => {
|
|
173
|
+
const manager = createRelayPluginRuntimeManager();
|
|
174
|
+
await assert.rejects(() => manager.enableRoomComposition({
|
|
175
|
+
roomName: "room_bad_start",
|
|
176
|
+
appPack: kanbanAppPack,
|
|
177
|
+
plugins: [{ id: KANBAN_PLUGIN_ID, version: "0.0.0" }],
|
|
178
|
+
authenticateActor: async (_auth, opActor) => opActor
|
|
179
|
+
}), /createInitialState is required/);
|
|
180
|
+
|
|
181
|
+
assert.equal(manager.hasRoom("room_bad_start"), false);
|
|
182
|
+
|
|
183
|
+
await manager.enableRoomComposition({
|
|
184
|
+
roomName: "room_bad_start",
|
|
185
|
+
appPack: kanbanAppPack,
|
|
186
|
+
plugins: kanbanHostPlugins,
|
|
187
|
+
authenticateActor: async (_auth, opActor) => opActor
|
|
188
|
+
});
|
|
189
|
+
assert.equal(manager.hasRoom("room_bad_start"), true);
|
|
190
|
+
assert.deepEqual(manager.enabledPlugins("room_bad_start").map((plugin) => plugin.id), kanbanHostPlugins.map((plugin) => plugin.id));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("relay plugin runtime manager repairs stale non-executable room entries", async () => {
|
|
194
|
+
const manager = createRelayPluginRuntimeManager();
|
|
195
|
+
const appPack = {
|
|
196
|
+
id: kanbanAppPack.id,
|
|
197
|
+
version: kanbanAppPack.version,
|
|
198
|
+
hash: manifestHash(kanbanAppPack),
|
|
199
|
+
protocolHash: kanbanAppPack.compatibility.appProtocolHash
|
|
200
|
+
};
|
|
201
|
+
const room = { id: "room_stale_bad_start", appPack };
|
|
202
|
+
manager.rooms.set(room.id, {
|
|
203
|
+
roomName: room.id,
|
|
204
|
+
room,
|
|
205
|
+
hostPack: null,
|
|
206
|
+
plugins: [{ id: KANBAN_PLUGIN_ID, version: "0.0.0" }],
|
|
207
|
+
store: createMemoryRoomStore(),
|
|
208
|
+
operationLog: createMemoryOperationLog(),
|
|
209
|
+
roomApp: {},
|
|
210
|
+
runtime: { authenticateActor: async (_auth, opActor) => opActor, capabilities: [] }
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await manager.enableRoomComposition({
|
|
214
|
+
roomName: room.id,
|
|
215
|
+
appPack: kanbanAppPack,
|
|
216
|
+
plugins: kanbanHostPlugins,
|
|
217
|
+
authenticateActor: async (_auth, opActor) => opActor
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
assert.equal(manager.hasRoom(room.id), true);
|
|
221
|
+
assert.deepEqual(manager.enabledPlugins(room.id).map((plugin) => plugin.id), kanbanHostPlugins.map((plugin) => plugin.id));
|
|
222
|
+
assert.equal((await manager.snapshot(room.id)).state.appPack.hash, appPack.hash);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("relay plugin runtime manager upgrades same-app hash without dropping state", async () => {
|
|
226
|
+
const manager = createRelayPluginRuntimeManager();
|
|
227
|
+
await enable(manager);
|
|
228
|
+
await manager.handleClientOperation({
|
|
229
|
+
roomName: "room_resilient_board",
|
|
230
|
+
peerId: "peer",
|
|
231
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", operation: operation({ id: "before_upgrade" }) },
|
|
232
|
+
sendToClient: () => {}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const upgradedAppPack = { ...kanbanAppPack, hash: "sha256-upgraded-kanban" };
|
|
236
|
+
await enable(manager, { appPack: upgradedAppPack });
|
|
237
|
+
const upgraded = await manager.snapshot("room_resilient_board");
|
|
238
|
+
|
|
239
|
+
assert.equal(upgraded.room.appPack.hash, upgradedAppPack.hash);
|
|
240
|
+
assert.equal(upgraded.state.appPack.hash, upgradedAppPack.hash);
|
|
241
|
+
assert.equal(upgraded.state.version, 1);
|
|
242
|
+
assert.equal(Object.keys(upgraded.state.plugins[KANBAN_PLUGIN_ID].lists).length, 1);
|
|
243
|
+
|
|
244
|
+
const afterOperation = operation({ id: "after_upgrade", seq: 2, appPackHash: upgradedAppPack.hash, payload: { title: "Later" } });
|
|
245
|
+
const after = await manager.handleClientOperation({
|
|
246
|
+
roomName: "room_resilient_board",
|
|
247
|
+
peerId: "peer",
|
|
248
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", operation: afterOperation },
|
|
249
|
+
sendToClient: () => {},
|
|
250
|
+
broadcastToRoom: async (roomName, createMessage) => {
|
|
251
|
+
assert.equal(roomName, "room_resilient_board");
|
|
252
|
+
assert.equal((await createMessage()).acceptedOperationId, afterOperation.id);
|
|
253
|
+
assert.equal((await createMessage(actor("member", "viewer"))).state.roomId, "room_resilient_board");
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
assert.equal(after.ok, true);
|
|
258
|
+
assert.equal((await manager.snapshot("room_resilient_board")).state.version, 2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("relay plugin runtime manager rolls back failed same-app runtime swaps", async () => {
|
|
262
|
+
const manager = createRelayPluginRuntimeManager();
|
|
263
|
+
await enable(manager);
|
|
264
|
+
await manager.handleClientOperation({
|
|
265
|
+
roomName: "room_resilient_board",
|
|
266
|
+
peerId: "peer",
|
|
267
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", operation: operation({ id: "before_failed_swap" }) },
|
|
268
|
+
sendToClient: () => {}
|
|
269
|
+
});
|
|
270
|
+
const before = await manager.snapshot("room_resilient_board");
|
|
271
|
+
const failingPlugins = kanbanHostPlugins.map((plugin) => plugin.id === KANBAN_PLUGIN_ID
|
|
272
|
+
? {
|
|
273
|
+
...plugin,
|
|
274
|
+
schemas: {
|
|
275
|
+
...plugin.schemas,
|
|
276
|
+
state: {
|
|
277
|
+
parse() {
|
|
278
|
+
throw new Error("schema upgrade rejected");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
: plugin);
|
|
284
|
+
|
|
285
|
+
await assert.rejects(() => enable(manager, {
|
|
286
|
+
appPack: { ...kanbanAppPack, hash: "sha256-bad-upgrade" },
|
|
287
|
+
plugins: failingPlugins
|
|
288
|
+
}), /schema upgrade rejected/);
|
|
289
|
+
const after = await manager.snapshot("room_resilient_board");
|
|
290
|
+
|
|
291
|
+
assert.deepEqual(after.state, before.state);
|
|
292
|
+
assert.equal(after.room.appPack.hash, before.room.appPack.hash);
|
|
293
|
+
assert.deepEqual(manager.enabledPlugins("room_resilient_board").map((plugin) => plugin.id), kanbanHostPlugins.map((plugin) => plugin.id));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("relay plugin runtime manager applies same-app historical operations during catch-up", async () => {
|
|
297
|
+
const manager = createRelayPluginRuntimeManager();
|
|
298
|
+
await enable(manager);
|
|
299
|
+
const oldHashOperation = operation({
|
|
300
|
+
id: "op_old_hash_catchup",
|
|
301
|
+
appPackHash: "sha256-older-kanban-schema",
|
|
302
|
+
payload: { title: "Catch-up state" }
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const result = await manager.handleRelayOperation({
|
|
306
|
+
type: "relay.matterhorn-operation",
|
|
307
|
+
roomName: "room_resilient_board",
|
|
308
|
+
operation: oldHashOperation
|
|
309
|
+
});
|
|
310
|
+
const snapshot = await manager.snapshot("room_resilient_board");
|
|
311
|
+
|
|
312
|
+
assert.equal(result.ok, true, result.reason);
|
|
313
|
+
assert.equal(snapshot.state.plugins[KANBAN_PLUGIN_ID].lists[0].title, "Catch-up state");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("relay plugin runtime manager ignores relay operations for another app", async () => {
|
|
317
|
+
const warnings = [];
|
|
318
|
+
const logger = {
|
|
319
|
+
debug() {},
|
|
320
|
+
info() {},
|
|
321
|
+
warn(...args) { warnings.push(args); },
|
|
322
|
+
error() {}
|
|
323
|
+
};
|
|
324
|
+
const manager = createRelayPluginRuntimeManager({ logger });
|
|
325
|
+
await enable(manager);
|
|
326
|
+
const wrongAppOperation = operation({
|
|
327
|
+
id: "op_wrong_app_catchup",
|
|
328
|
+
appPackId: "com.matterhorn.other"
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const result = await manager.handleRelayOperation({
|
|
332
|
+
type: "relay.matterhorn-operation",
|
|
333
|
+
roomName: "room_resilient_board",
|
|
334
|
+
operation: wrongAppOperation
|
|
335
|
+
});
|
|
336
|
+
const snapshot = await manager.snapshot("room_resilient_board");
|
|
337
|
+
|
|
338
|
+
assert.equal(result.ok, false);
|
|
339
|
+
assert.equal(result.ignored, true);
|
|
340
|
+
assert.equal(result.code, "APP_PACK_MISMATCH");
|
|
341
|
+
assert.equal(result.reason, "app-mismatch");
|
|
342
|
+
assert.equal(snapshot.state.version, 0);
|
|
343
|
+
assert.equal(warnings.length, 1);
|
|
344
|
+
assert.match(warnings[0][0], /relay runtime rejected/);
|
|
345
|
+
assert.equal(warnings[0][1].reason, "app-mismatch");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("relay plugin runtimes keep a room running and converge when a peer relay returns", async () => {
|
|
349
|
+
const sentFromA = [];
|
|
350
|
+
const sentFromB = [];
|
|
351
|
+
const relayA = createRelayPluginRuntimeManager({ relayAddress: "relay-a", sendRelayEnvelope: (message) => sentFromA.push(message) });
|
|
352
|
+
const relayB = createRelayPluginRuntimeManager({ relayAddress: "relay-b", sendRelayEnvelope: (message) => sentFromB.push(message) });
|
|
353
|
+
await enable(relayA);
|
|
354
|
+
await enable(relayB);
|
|
355
|
+
|
|
356
|
+
const result1 = await relayA.handleClientOperation({
|
|
357
|
+
roomName: "room_resilient_board",
|
|
358
|
+
peerId: "alice-peer",
|
|
359
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: operation({ id: "op_list", seq: 1 }) },
|
|
360
|
+
sendToClient: () => {},
|
|
361
|
+
broadcastToRoom: () => {}
|
|
362
|
+
});
|
|
363
|
+
assert.equal(result1.ok, true);
|
|
364
|
+
assert.equal(sentFromA[0].type, "relay.matterhorn-operation");
|
|
365
|
+
|
|
366
|
+
const replayOnB = await relayB.handleRelayOperation(sentFromA[0]);
|
|
367
|
+
assert.equal(replayOnB.ok, true);
|
|
368
|
+
assert.equal((await relayB.snapshot("room_resilient_board")).state.plugins[KANBAN_PLUGIN_ID].lists[0].title, "Backlog");
|
|
369
|
+
|
|
370
|
+
const backlogId = (await relayB.snapshot("room_resilient_board")).state.plugins[KANBAN_PLUGIN_ID].lists[0].id;
|
|
371
|
+
const result2 = await relayB.handleClientOperation({
|
|
372
|
+
roomName: "room_resilient_board",
|
|
373
|
+
peerId: "bob-peer",
|
|
374
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "bob", operation: operation({ id: "op_card", seq: 2, type: "card.create", payload: { listId: backlogId, title: "Continue while relay A is down" }, actor: actor("member", "bob"), createdAt: 1001 }) },
|
|
375
|
+
sendToClient: () => {},
|
|
376
|
+
broadcastToRoom: () => {}
|
|
377
|
+
});
|
|
378
|
+
assert.equal(result2.ok, true);
|
|
379
|
+
|
|
380
|
+
const snapshotWhileADown = await relayB.snapshot("room_resilient_board");
|
|
381
|
+
assert.equal(snapshotWhileADown.state.version, 2);
|
|
382
|
+
assert.equal(snapshotWhileADown.state.plugins[KANBAN_PLUGIN_ID].lists[0].cardIds.length, 1);
|
|
383
|
+
|
|
384
|
+
const staleA = await relayA.snapshot("room_resilient_board");
|
|
385
|
+
assert.equal(staleA.state.version, 1);
|
|
386
|
+
const synced = await relayA.syncSnapshot("room_resilient_board", snapshotWhileADown.state);
|
|
387
|
+
assert.equal(synced.updated, true);
|
|
388
|
+
assert.equal((await relayA.snapshot("room_resilient_board")).state.version, 2);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("relay plugin runtimes reject host re-registration snapshots that are older than relay state", async () => {
|
|
392
|
+
const manager = createRelayPluginRuntimeManager();
|
|
393
|
+
await enable(manager);
|
|
394
|
+
await manager.handleClientOperation({
|
|
395
|
+
roomName: "room_resilient_board",
|
|
396
|
+
peerId: "peer",
|
|
397
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: operation({ id: "op_list", seq: 1 }) },
|
|
398
|
+
sendToClient: () => {},
|
|
399
|
+
broadcastToRoom: () => {}
|
|
400
|
+
});
|
|
401
|
+
const fresh = await manager.snapshot("room_resilient_board");
|
|
402
|
+
const stale = { ...fresh.state, version: 0, plugins: {} };
|
|
403
|
+
|
|
404
|
+
const result = await manager.syncSnapshot("room_resilient_board", stale);
|
|
405
|
+
assert.equal(result.updated, false);
|
|
406
|
+
assert.equal((await manager.snapshot("room_resilient_board")).state.version, 1);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("relay plugin runtimes reject destructive newer snapshots without operation proof", async () => {
|
|
410
|
+
const manager = createRelayPluginRuntimeManager();
|
|
411
|
+
await enable(manager);
|
|
412
|
+
await manager.handleClientOperation({
|
|
413
|
+
roomName: "room_resilient_board",
|
|
414
|
+
peerId: "peer",
|
|
415
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: operation({ id: "op_list_before_destructive", seq: 1 }) },
|
|
416
|
+
sendToClient: () => {},
|
|
417
|
+
broadcastToRoom: () => {}
|
|
418
|
+
});
|
|
419
|
+
const fresh = await manager.snapshot("room_resilient_board");
|
|
420
|
+
const destructive = {
|
|
421
|
+
...fresh.state,
|
|
422
|
+
version: fresh.state.version + 1,
|
|
423
|
+
updatedAt: fresh.state.updatedAt + 1,
|
|
424
|
+
plugins: {
|
|
425
|
+
...fresh.state.plugins,
|
|
426
|
+
[KANBAN_PLUGIN_ID]: { lists: [], cards: {}, activity: [] }
|
|
427
|
+
},
|
|
428
|
+
seenOperations: []
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const result = await manager.syncSnapshot("room_resilient_board", destructive);
|
|
432
|
+
const after = await manager.snapshot("room_resilient_board");
|
|
433
|
+
|
|
434
|
+
assert.equal(result.ok, false);
|
|
435
|
+
assert.equal(result.reason, "snapshot-operation-proof-required");
|
|
436
|
+
assert.equal(after.state.version, fresh.state.version);
|
|
437
|
+
assert.equal(after.state.plugins[KANBAN_PLUGIN_ID].lists[0].title, "Backlog");
|
|
438
|
+
assert.deepEqual(after.state.seenOperations, fresh.state.seenOperations);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("relay plugin runtime manager pulls newer same-app snapshot state across hash changes", async () => {
|
|
442
|
+
const host = createRelayPluginRuntimeManager();
|
|
443
|
+
await enable(host);
|
|
444
|
+
await host.handleClientOperation({
|
|
445
|
+
roomName: "room_resilient_board",
|
|
446
|
+
peerId: "peer",
|
|
447
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: operation({ id: "op_host_state", seq: 1, payload: { title: "Host snapshot state" } }) },
|
|
448
|
+
sendToClient: () => {},
|
|
449
|
+
broadcastToRoom: () => {}
|
|
450
|
+
});
|
|
451
|
+
const hostSnapshot = await host.snapshot("room_resilient_board");
|
|
452
|
+
const relay = createRelayPluginRuntimeManager();
|
|
453
|
+
await enable(relay);
|
|
454
|
+
|
|
455
|
+
const synced = await relay.syncSnapshot("room_resilient_board", {
|
|
456
|
+
...hostSnapshot.state,
|
|
457
|
+
appPack: { ...hostSnapshot.state.appPack, hash: "sha256-older-kanban-schema" }
|
|
458
|
+
});
|
|
459
|
+
const relaySnapshot = await relay.snapshot("room_resilient_board");
|
|
460
|
+
|
|
461
|
+
assert.equal(synced.updated, true);
|
|
462
|
+
assert.equal(relaySnapshot.state.appPack.hash, manifestHash(kanbanAppPack));
|
|
463
|
+
assert.equal(relaySnapshot.state.plugins[KANBAN_PLUGIN_ID].lists[0].title, "Host snapshot state");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("relay plugin runtime manager reuses existing rooms and trusts newer registration snapshots", async () => {
|
|
467
|
+
const source = createRelayPluginRuntimeManager();
|
|
468
|
+
await enable(source);
|
|
469
|
+
await source.handleClientOperation({
|
|
470
|
+
roomName: "room_resilient_board",
|
|
471
|
+
peerId: "peer",
|
|
472
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: operation({ id: "op_existing_list", seq: 1 }) },
|
|
473
|
+
sendToClient: () => {},
|
|
474
|
+
broadcastToRoom: () => {}
|
|
475
|
+
});
|
|
476
|
+
const newerSnapshot = await source.snapshot("room_resilient_board");
|
|
477
|
+
|
|
478
|
+
const manager = createRelayPluginRuntimeManager();
|
|
479
|
+
const existing = await enable(manager);
|
|
480
|
+
const reused = await manager.enableRoomComposition({
|
|
481
|
+
roomName: "room_resilient_board",
|
|
482
|
+
appPack: kanbanAppPack,
|
|
483
|
+
plugins: kanbanHostPlugins,
|
|
484
|
+
authenticateActor: async (_auth, opActor) => opActor
|
|
485
|
+
});
|
|
486
|
+
assert.equal(reused, existing);
|
|
487
|
+
assert.equal((await manager.snapshot("room_resilient_board")).state.version, 0);
|
|
488
|
+
|
|
489
|
+
const synced = await manager.enableRoomComposition({
|
|
490
|
+
roomName: "room_resilient_board",
|
|
491
|
+
appPack: kanbanAppPack,
|
|
492
|
+
plugins: kanbanHostPlugins,
|
|
493
|
+
authenticateActor: async (_auth, opActor) => opActor,
|
|
494
|
+
state: newerSnapshot.state
|
|
495
|
+
});
|
|
496
|
+
assert.equal(synced, existing);
|
|
497
|
+
assert.equal((await manager.snapshot("room_resilient_board")).state.version, 1);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("relay plugin runtime manager exposes stable error paths and timestamp freshness", async () => {
|
|
501
|
+
const { isNewerRoomState } = require("../src/index.cjs");
|
|
502
|
+
assert.equal(isNewerRoomState({ version: 1, updatedAt: 20 }, { version: 1, updatedAt: 10 }), true);
|
|
503
|
+
assert.equal(isNewerRoomState({ version: 1, updatedAt: 5 }, { version: 2, updatedAt: 1 }), false);
|
|
504
|
+
|
|
505
|
+
const manager = createRelayPluginRuntimeManager();
|
|
506
|
+
await assert.rejects(() => manager.enableRoomComposition({ plugins: kanbanHostPlugins }), /roomName/);
|
|
507
|
+
await assert.rejects(() => manager.enableRoomComposition({ roomName: "missing_plugins", appPack: kanbanAppPack, plugins: [] }), /at least one plugin/);
|
|
508
|
+
assert.deepEqual(manager.enabledPlugins("missing"), []);
|
|
509
|
+
assert.equal(manager.roomApp("missing"), undefined);
|
|
510
|
+
assert.equal(await manager.snapshot("missing"), undefined);
|
|
511
|
+
assert.deepEqual(await manager.syncSnapshot("missing", {}), { ok: false, updated: false, reason: "room-not-enabled" });
|
|
512
|
+
assert.deepEqual(manager.runtimeCapabilities("missing"), { mode: "unavailable", availablePlugins: [], unavailablePlugins: [], availableOperations: [], unavailableOperations: [] });
|
|
513
|
+
|
|
514
|
+
const sent = [];
|
|
515
|
+
const rejected = await manager.handleClientOperation({
|
|
516
|
+
roomName: "missing",
|
|
517
|
+
peerId: "peer",
|
|
518
|
+
message: {},
|
|
519
|
+
sendToClient: (peerId, message) => sent.push({ peerId, message })
|
|
520
|
+
});
|
|
521
|
+
assert.equal(rejected.ok, false);
|
|
522
|
+
assert.equal(sent[0].message.code, "room-runtime-unavailable");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("relay plugin runtime manager rejects bad operations and invalid snapshots without corrupting state", async () => {
|
|
526
|
+
const manager = createRelayPluginRuntimeManager({ authenticateActor: async (_auth, opActor) => opActor });
|
|
527
|
+
await enable(manager);
|
|
528
|
+
const state = (await manager.snapshot("room_resilient_board")).state;
|
|
529
|
+
|
|
530
|
+
assert.equal((await manager.syncSnapshot("room_resilient_board", null)).reason, "snapshot-required");
|
|
531
|
+
assert.equal((await manager.syncSnapshot("room_resilient_board", { ...state, roomId: "other" })).reason, "room-mismatch");
|
|
532
|
+
assert.equal((await manager.syncSnapshot("room_resilient_board", { ...state, appPack: { ...state.appPack, id: "other-app" } })).reason, "app-mismatch");
|
|
533
|
+
const sameAppHash = await manager.syncSnapshot("room_resilient_board", {
|
|
534
|
+
...state,
|
|
535
|
+
appPack: { ...state.appPack, hash: "sha256-older-schema" },
|
|
536
|
+
version: state.version + 1,
|
|
537
|
+
updatedAt: state.updatedAt + 1
|
|
538
|
+
});
|
|
539
|
+
assert.equal(sameAppHash.updated, true);
|
|
540
|
+
assert.equal((await manager.snapshot("room_resilient_board")).state.appPack.hash, manifestHash(kanbanAppPack));
|
|
541
|
+
const current = (await manager.snapshot("room_resilient_board")).state;
|
|
542
|
+
const invalidMigratedSnapshot = await manager.syncSnapshot("room_resilient_board", {
|
|
543
|
+
...current,
|
|
544
|
+
version: current.version + 1,
|
|
545
|
+
updatedAt: current.updatedAt + 1,
|
|
546
|
+
pluginVersions: {
|
|
547
|
+
...current.pluginVersions,
|
|
548
|
+
[KANBAN_PLUGIN_ID]: "0.9.0"
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
assert.equal(invalidMigratedSnapshot.ok, false);
|
|
552
|
+
assert.match(invalidMigratedSnapshot.reason, /No migration/);
|
|
553
|
+
|
|
554
|
+
const sent = [];
|
|
555
|
+
const bad = await manager.handleClientOperation({
|
|
556
|
+
roomName: "room_resilient_board",
|
|
557
|
+
peerId: "peer",
|
|
558
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: { nope: true } },
|
|
559
|
+
sendToClient: (peerId, message) => sent.push({ peerId, message })
|
|
560
|
+
});
|
|
561
|
+
assert.equal(bad.ok, false);
|
|
562
|
+
assert.equal(sent[0].message.code, "bad-operation");
|
|
563
|
+
|
|
564
|
+
const relayBad = await manager.handleRelayOperation({ roomName: "room_resilient_board", operation: { nope: true } });
|
|
565
|
+
assert.equal(relayBad.ok, false);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("relay plugin runtime manager can be created from installed metadata and refuses missing authenticators", async () => {
|
|
569
|
+
const manager = createRelayPluginRuntimeManager();
|
|
570
|
+
await manager.enableRoomComposition({
|
|
571
|
+
room: { id: "room_installed", appPack: { id: kanbanAppPack.id, version: kanbanAppPack.version, hash: manifestHash(kanbanAppPack), protocolHash: kanbanAppPack.compatibility.appProtocolHash } },
|
|
572
|
+
hostPack: { id: "host", version: "1.0.0", hash: "sha256-host", compatibility: { pluginGraphHash: "sha256-plugins" }, capabilities: { required: [] } },
|
|
573
|
+
installed: { plugins: kanbanHostPlugins }
|
|
574
|
+
});
|
|
575
|
+
assert.equal(manager.roomApp("room_installed").hostPack.id, "host");
|
|
576
|
+
const rejected = await manager.handleClientOperation({
|
|
577
|
+
roomName: "room_installed",
|
|
578
|
+
peerId: "peer",
|
|
579
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_installed", clientId: "alice", operation: operation({ roomId: "room_installed" }) },
|
|
580
|
+
sendToClient: () => {}
|
|
581
|
+
});
|
|
582
|
+
assert.equal(rejected.ok, false);
|
|
583
|
+
assert.match(rejected.reason, /authenticateActor/);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("relay plugin runtime manager handles expectedPlugins with operation descriptors", async () => {
|
|
587
|
+
const manager = createRelayPluginRuntimeManager();
|
|
588
|
+
await manager.enableRoomComposition({
|
|
589
|
+
roomName: "room_descriptor_test",
|
|
590
|
+
appPack: kanbanAppPack,
|
|
591
|
+
plugins: kanbanHostPlugins,
|
|
592
|
+
expectedPlugins: [
|
|
593
|
+
{ id: KANBAN_PLUGIN_ID, version: "1.0.0", operations: ["list.create", "card.create"] }
|
|
594
|
+
],
|
|
595
|
+
authenticateActor: async (_auth, opActor) => opActor
|
|
596
|
+
});
|
|
597
|
+
assert.equal(manager.hasRoom("room_descriptor_test"), true);
|
|
598
|
+
const capabilities = manager.runtimeCapabilities("room_descriptor_test");
|
|
599
|
+
assert.equal(capabilities.mode, "full");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("relay plugin runtime manager rejects invalid device-signed operations", async () => {
|
|
603
|
+
const manager = createRelayPluginRuntimeManager();
|
|
604
|
+
await enable(manager);
|
|
605
|
+
const sent = [];
|
|
606
|
+
const badDeviceOp = operation({
|
|
607
|
+
id: "bad_device_op",
|
|
608
|
+
seq: 99,
|
|
609
|
+
auth: {
|
|
610
|
+
scheme: "matterhorn.device-signing.v1",
|
|
611
|
+
keyId: "bad",
|
|
612
|
+
memberId: "bad",
|
|
613
|
+
deviceId: "bad",
|
|
614
|
+
rootPublicKey: "bad",
|
|
615
|
+
publicKey: "bad",
|
|
616
|
+
publicKeyFingerprint: "bad",
|
|
617
|
+
claim: { roomName: "room_resilient_board", roomId: "room_resilient_board", memberId: "bad", deviceId: "bad", keyId: "bad", rootPublicKey: "bad", publicKey: "bad", publicKeyFingerprint: "bad" },
|
|
618
|
+
signature: "00"
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
const result = await manager.handleClientOperation({
|
|
622
|
+
roomName: "room_resilient_board",
|
|
623
|
+
peerId: "peer",
|
|
624
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: badDeviceOp },
|
|
625
|
+
sendToClient: (peerId, message) => sent.push({ peerId, message })
|
|
626
|
+
});
|
|
627
|
+
assert.equal(result.ok, false);
|
|
628
|
+
assert.equal(sent[0].message.code, "bad-operation");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("relay plugin runtime manager detects destructive snapshots with meaningful primitive data", async () => {
|
|
632
|
+
const manager = createRelayPluginRuntimeManager();
|
|
633
|
+
await enable(manager);
|
|
634
|
+
await manager.handleClientOperation({
|
|
635
|
+
roomName: "room_resilient_board",
|
|
636
|
+
peerId: "peer",
|
|
637
|
+
message: { type: "client/operation", protocol: 1, roomName: "room_resilient_board", clientId: "alice", operation: operation({ id: "op_list", seq: 1 }) },
|
|
638
|
+
sendToClient: () => {},
|
|
639
|
+
broadcastToRoom: () => {}
|
|
640
|
+
});
|
|
641
|
+
const current = (await manager.snapshot("room_resilient_board")).state;
|
|
642
|
+
const destructive = {
|
|
643
|
+
...current,
|
|
644
|
+
version: current.version + 1,
|
|
645
|
+
updatedAt: current.updatedAt + 1,
|
|
646
|
+
plugins: {}
|
|
647
|
+
};
|
|
648
|
+
const result = await manager.syncSnapshot("room_resilient_board", destructive);
|
|
649
|
+
assert.equal(result.ok, false);
|
|
650
|
+
assert.equal(result.reason, "snapshot-operation-proof-required");
|
|
651
|
+
});
|