@mh-gg/host-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 +23 -0
- package/src/constants.cjs +9 -0
- package/src/errors.cjs +39 -0
- package/src/host/installAppPack.cjs +35 -0
- package/src/host/migrations.cjs +21 -0
- package/src/host/runtimeFromPack.cjs +25 -0
- package/src/host/startRoomHost.cjs +95 -0
- package/src/index.cjs +23 -0
- package/src/memory.cjs +81 -0
- package/src/plugins/definition.cjs +19 -0
- package/src/plugins/install.cjs +90 -0
- package/src/plugins/migrations.cjs +27 -0
- package/src/plugins/operationDescriptors.cjs +138 -0
- package/src/runtime/HostPluginRuntime.cjs +85 -0
- package/src/runtime/authorityReplay/applyAuthority.cjs +146 -0
- package/src/runtime/authorityReplay/applyContent.cjs +49 -0
- package/src/runtime/authorityReplay/index.cjs +70 -0
- package/src/runtime/authorityReplay/state.cjs +56 -0
- package/src/runtime/context.cjs +65 -0
- package/src/runtime/coreOperations.cjs +169 -0
- package/src/runtime/corePayloads.cjs +66 -0
- package/src/runtime/directMessages/commit.cjs +50 -0
- package/src/runtime/directMessages/constants.cjs +20 -0
- package/src/runtime/directMessages/helpers.cjs +59 -0
- package/src/runtime/directMessages/payloads.cjs +74 -0
- package/src/runtime/directMessages/state.cjs +168 -0
- package/src/runtime/directMessages.cjs +6 -0
- package/src/runtime/lifecycle.cjs +166 -0
- package/src/runtime/memberProfiles.cjs +74 -0
- package/src/runtime/methods.cjs +10 -0
- package/src/runtime/operations.cjs +166 -0
- package/src/runtime/queries.cjs +146 -0
- package/src/runtime/readTags.cjs +171 -0
- package/src/runtime/scopedRoleOperations.cjs +97 -0
- package/src/runtime/snowflake.cjs +43 -0
- package/src/security/authority/constants.cjs +10 -0
- package/src/security/authority/resolve/operations.cjs +7 -0
- package/src/security/authority/resolve/policy.cjs +7 -0
- package/src/security/authority/resolve/voids.cjs +8 -0
- package/src/security/authorization/coreGate.cjs +63 -0
- package/src/security/authorization/schemaActions.cjs +75 -0
- package/src/security/roleKeys/authenticator.cjs +36 -0
- package/src/security/roleKeys/authorities/index.cjs +4 -0
- package/src/security/roleKeys/authorities/shapes.cjs +98 -0
- package/src/security/roleKeys/authorities/signing.cjs +121 -0
- package/src/security/roleKeys/constants.cjs +15 -0
- package/src/security/roleKeys/fingerprints.cjs +24 -0
- package/src/security/roleKeys/grants.cjs +93 -0
- package/src/security/roleKeys/index.cjs +10 -0
- package/src/security/roleKeys/roles.cjs +21 -0
- package/src/security/roleKeys/signatures.cjs +126 -0
- package/src/security/roles.cjs +10 -0
- package/src/security/roomDeviceKeys.cjs +41 -0
- package/src/security/scopedRoles/access.cjs +123 -0
- package/src/security/scopedRoles/constants.cjs +23 -0
- package/src/security/scopedRoles/metadata.cjs +39 -0
- package/src/security/scopedRoles/normalize.cjs +179 -0
- package/src/security/scopedRoles/publicView.cjs +31 -0
- package/src/security/scopedRoles/stateOps.cjs +167 -0
- package/src/security/scopedRoles.cjs +7 -0
- package/src/security/standingAuthority.cjs +76 -0
- package/src/shared.cjs +14 -0
- package/src/state.cjs +54 -0
- package/test/authority-ordering-hardening.test.cjs +101 -0
- package/test/authorization-gate.test.cjs +610 -0
- package/test/cascading-authority.test.cjs +390 -0
- package/test/grant-authority-security.test.cjs +305 -0
- package/test/matterhorn-host-runtime.test.cjs +1629 -0
- package/test/operation-descriptor-policy.test.cjs +140 -0
- package/test/role-key-auth.test.cjs +289 -0
- package/test/security-isolation.test.cjs +112 -0
|
@@ -0,0 +1,1629 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
HostPluginRuntime,
|
|
6
|
+
CORE_DIRECT_MESSAGE_PUBLISH_TYPE,
|
|
7
|
+
CORE_PLUGIN_ID,
|
|
8
|
+
CORE_READ_TAG_SET_TYPE,
|
|
9
|
+
MatterhornRuntimeError,
|
|
10
|
+
RoomStateSchema,
|
|
11
|
+
allow,
|
|
12
|
+
createHostPluginRuntimeFromPack,
|
|
13
|
+
createMemoryOperationLog,
|
|
14
|
+
createMemoryRoomStore,
|
|
15
|
+
createOperationSchemaDescriptor,
|
|
16
|
+
createOperationRoleKeyGrant,
|
|
17
|
+
defineHostPlugin,
|
|
18
|
+
deny,
|
|
19
|
+
generateOperationRoleKeyPair,
|
|
20
|
+
installAppPack,
|
|
21
|
+
installHostPack,
|
|
22
|
+
normalizeRuntimeFailure,
|
|
23
|
+
parseRoomState,
|
|
24
|
+
resolveHostPlugins,
|
|
25
|
+
runMigrations,
|
|
26
|
+
signOperationWithRoleKey,
|
|
27
|
+
startRoomHost
|
|
28
|
+
} = require("../src/index.cjs");
|
|
29
|
+
const { manifestHash } = require("@mh-gg/base");
|
|
30
|
+
const { ensureOperationIdentity, isSnowflakeId, parseHostOperationBatch, parseHostSnapshot } = require("@mh-gg/protocol");
|
|
31
|
+
|
|
32
|
+
const appPack = {
|
|
33
|
+
id: "com.matterhorn.tasks",
|
|
34
|
+
version: "1.0.0",
|
|
35
|
+
hash: "sha256-app",
|
|
36
|
+
protocolHash: "sha256-protocol"
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function schema(parse) {
|
|
40
|
+
return { parse };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rolesPlugin() {
|
|
44
|
+
return {
|
|
45
|
+
id: "@mh-gg/plugin-roles",
|
|
46
|
+
version: "1.0.0",
|
|
47
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: "@mh-gg/plugin-roles", version: "1.0.0", operations: {} }),
|
|
48
|
+
schemas: {
|
|
49
|
+
state: schema((state) => {
|
|
50
|
+
assert.equal(typeof state.permissions, "object");
|
|
51
|
+
return state;
|
|
52
|
+
}),
|
|
53
|
+
operations: {}
|
|
54
|
+
},
|
|
55
|
+
createInitialState() {
|
|
56
|
+
return {
|
|
57
|
+
permissions: {
|
|
58
|
+
admin: ["tasks.task.create", "tasks.task.complete", "tasks.task.archive"],
|
|
59
|
+
member: ["tasks.task.create", "tasks.task.complete"],
|
|
60
|
+
guest: []
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
authorize() {
|
|
65
|
+
return deny("Roles plugin has no direct operations");
|
|
66
|
+
},
|
|
67
|
+
reduce(ctx, state) {
|
|
68
|
+
return state;
|
|
69
|
+
},
|
|
70
|
+
methods: {
|
|
71
|
+
hasPermission(ctx, input) {
|
|
72
|
+
return !!ctx.state.permissions[input.memberId]?.includes(input.permission);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function tasksPlugin(afterCommitCalls = []) {
|
|
79
|
+
return {
|
|
80
|
+
id: "@mh-gg/plugin-tasks",
|
|
81
|
+
version: "1.0.0",
|
|
82
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: "@mh-gg/plugin-tasks", version: "1.0.0", operations: {
|
|
83
|
+
"task.create": { authorize: { roles: ["member"] } },
|
|
84
|
+
"task.complete": { authorize: { roles: ["member"] } }
|
|
85
|
+
} }),
|
|
86
|
+
schemas: {
|
|
87
|
+
state: schema((state) => {
|
|
88
|
+
assert.ok(Array.isArray(state.taskOrder));
|
|
89
|
+
assert.equal(typeof state.tasks, "object");
|
|
90
|
+
return state;
|
|
91
|
+
}),
|
|
92
|
+
operations: {
|
|
93
|
+
"task.create": schema((payload) => {
|
|
94
|
+
assert.equal(typeof payload.title, "string");
|
|
95
|
+
return { title: payload.title.trim() };
|
|
96
|
+
}),
|
|
97
|
+
"task.complete": schema((payload) => {
|
|
98
|
+
assert.equal(typeof payload.taskId, "string");
|
|
99
|
+
return payload;
|
|
100
|
+
})
|
|
101
|
+
},
|
|
102
|
+
publicView: schema((view) => view)
|
|
103
|
+
},
|
|
104
|
+
createInitialState() {
|
|
105
|
+
return { taskOrder: [], tasks: {} };
|
|
106
|
+
},
|
|
107
|
+
async authorize(ctx, op) {
|
|
108
|
+
assert.equal(ctx.plugins.has("@mh-gg/plugin-roles"), true);
|
|
109
|
+
assert.equal(ctx.crypto.verifySignature(), false);
|
|
110
|
+
const allowed = await ctx.plugins.call("@mh-gg/plugin-roles", "hasPermission", {
|
|
111
|
+
memberId: ctx.actor.memberId,
|
|
112
|
+
permission: `tasks.${op.type}`
|
|
113
|
+
});
|
|
114
|
+
return allowed ? allow() : deny("Missing task permission");
|
|
115
|
+
},
|
|
116
|
+
reduce(ctx, state, op) {
|
|
117
|
+
if (op.type === "task.create") {
|
|
118
|
+
const task = {
|
|
119
|
+
id: ctx.crypto.randomId("task"),
|
|
120
|
+
title: op.payload.title,
|
|
121
|
+
done: false,
|
|
122
|
+
createdBy: ctx.actor.memberId,
|
|
123
|
+
createdAt: op.createdAt
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
taskOrder: [...state.taskOrder, task.id],
|
|
127
|
+
tasks: { ...state.tasks, [task.id]: task }
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (op.type === "task.complete") {
|
|
131
|
+
const task = state.tasks[op.payload.taskId];
|
|
132
|
+
return {
|
|
133
|
+
...state,
|
|
134
|
+
tasks: {
|
|
135
|
+
...state.tasks,
|
|
136
|
+
[task.id]: { ...task, done: true, completedAt: op.createdAt }
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return state;
|
|
141
|
+
},
|
|
142
|
+
getPublicView(ctx, state, actor) {
|
|
143
|
+
if (actor.role === "guest") return { count: state.taskOrder.length };
|
|
144
|
+
return state;
|
|
145
|
+
},
|
|
146
|
+
async afterCommit(ctx, event) {
|
|
147
|
+
await ctx.storage.put("lastOperationId", event.op.id);
|
|
148
|
+
await ctx.events.append({ type: "task.afterCommit", operationId: event.op.id });
|
|
149
|
+
afterCommitCalls.push({ operationId: event.op.id, version: ctx.roomState.version });
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function operation(overrides = {}) {
|
|
155
|
+
const op = {
|
|
156
|
+
id: "op_1",
|
|
157
|
+
roomId: "room_tasks",
|
|
158
|
+
appPackId: appPack.id,
|
|
159
|
+
appPackHash: appPack.hash,
|
|
160
|
+
pluginId: "@mh-gg/plugin-tasks",
|
|
161
|
+
type: "task.create",
|
|
162
|
+
actor: {
|
|
163
|
+
memberId: "admin",
|
|
164
|
+
deviceId: "dev_admin",
|
|
165
|
+
role: "admin"
|
|
166
|
+
},
|
|
167
|
+
seq: 1,
|
|
168
|
+
createdAt: 1000,
|
|
169
|
+
payload: {
|
|
170
|
+
title: "Write tests"
|
|
171
|
+
},
|
|
172
|
+
auth: {
|
|
173
|
+
credentialId: "cred_admin",
|
|
174
|
+
signature: "sig"
|
|
175
|
+
},
|
|
176
|
+
...overrides
|
|
177
|
+
};
|
|
178
|
+
return ensureOperationIdentity(op, { nodeId: op.actor?.deviceId || "test", now: op.createdAt });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function repeatedHex(char, length) {
|
|
182
|
+
return char.repeat(length);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function opaqueGiftWrap({ idChar, wrapperPubkeyChar, createdAt = 900 }) {
|
|
186
|
+
return {
|
|
187
|
+
id: repeatedHex(idChar, 64),
|
|
188
|
+
pubkey: repeatedHex(wrapperPubkeyChar, 64),
|
|
189
|
+
created_at: createdAt,
|
|
190
|
+
kind: 1059,
|
|
191
|
+
tags: [["opaque", "true"]],
|
|
192
|
+
content: `nip44-ciphertext-${idChar}`,
|
|
193
|
+
sig: repeatedHex("f", 128)
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function directPublishOperation(overrides = {}) {
|
|
198
|
+
const recipients = overrides.recipients || [
|
|
199
|
+
{ recipientId: "alice", idChar: "1", wrapperPubkeyChar: "4" },
|
|
200
|
+
{ recipientId: "lee", idChar: "2", wrapperPubkeyChar: "5" },
|
|
201
|
+
{ recipientId: "sam", idChar: "3", wrapperPubkeyChar: "6" }
|
|
202
|
+
];
|
|
203
|
+
const giftWraps = {};
|
|
204
|
+
for (const recipient of recipients) giftWraps[recipient.recipientId] = opaqueGiftWrap(recipient);
|
|
205
|
+
return operation({
|
|
206
|
+
id: overrides.id || "op_direct_1",
|
|
207
|
+
pluginId: CORE_PLUGIN_ID,
|
|
208
|
+
type: CORE_DIRECT_MESSAGE_PUBLISH_TYPE,
|
|
209
|
+
actor: overrides.actor || { memberId: "alice", deviceId: "dev_alice", role: "member" },
|
|
210
|
+
payload: {
|
|
211
|
+
userIds: recipients.map((recipient) => recipient.recipientId),
|
|
212
|
+
topicKey: "launch",
|
|
213
|
+
giftWraps,
|
|
214
|
+
...(overrides.payload || {})
|
|
215
|
+
},
|
|
216
|
+
...overrides.operation
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function runtimeWithPlugins(options = {}) {
|
|
221
|
+
const calls = [];
|
|
222
|
+
const store = createMemoryRoomStore();
|
|
223
|
+
const operationLog = createMemoryOperationLog();
|
|
224
|
+
const runtime = new HostPluginRuntime({
|
|
225
|
+
room: {
|
|
226
|
+
id: "room_tasks",
|
|
227
|
+
appPack
|
|
228
|
+
},
|
|
229
|
+
plugins: [rolesPlugin(), tasksPlugin(calls)],
|
|
230
|
+
store,
|
|
231
|
+
operationLog,
|
|
232
|
+
authenticateActor: async (auth, actor) => {
|
|
233
|
+
if (auth.signature !== "sig") throw new Error("bad signature");
|
|
234
|
+
return actor;
|
|
235
|
+
},
|
|
236
|
+
...options
|
|
237
|
+
});
|
|
238
|
+
return { calls, operationLog, runtime, store };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function createAtomicMemoryStore(initialState = null, operationLog = createMemoryOperationLog()) {
|
|
242
|
+
const store = createMemoryRoomStore(initialState);
|
|
243
|
+
store.commitStateAndOperation = async ({ operation: logEntry, nextState, expectedPreviousVersion }) => {
|
|
244
|
+
const current = await store.load();
|
|
245
|
+
const currentVersion = Number.isInteger(current?.version) ? current.version : 0;
|
|
246
|
+
if (Number.isInteger(expectedPreviousVersion) && currentVersion !== expectedPreviousVersion) throw new Error("version conflict");
|
|
247
|
+
if (Number.isInteger(expectedPreviousVersion) && nextState?.version !== expectedPreviousVersion + 1) throw new Error("next version conflict");
|
|
248
|
+
await operationLog.append(logEntry);
|
|
249
|
+
await store.save(nextState);
|
|
250
|
+
};
|
|
251
|
+
return store;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
test("initializes plugin state and commits authorized operations", async () => {
|
|
255
|
+
const { calls, operationLog, runtime, store } = runtimeWithPlugins();
|
|
256
|
+
await runtime.start();
|
|
257
|
+
|
|
258
|
+
const firstOperation = operation();
|
|
259
|
+
const ack = await runtime.handleOperation(firstOperation);
|
|
260
|
+
const state = await store.load();
|
|
261
|
+
const taskId = state.plugins["@mh-gg/plugin-tasks"].taskOrder[0];
|
|
262
|
+
|
|
263
|
+
assert.equal(ack.ok, true);
|
|
264
|
+
assert.equal(ack.acceptedOperationId, firstOperation.id);
|
|
265
|
+
assert.equal(ack.roomVersion, 1);
|
|
266
|
+
assert.match(taskId, /^task_\d+$/);
|
|
267
|
+
assert.equal(isSnowflakeId(operationLog.entries[0].ledgerId), true);
|
|
268
|
+
assert.equal(taskId, `task_${operationLog.entries[0].ledgerId}`);
|
|
269
|
+
assert.equal(ack.acceptedLedgerId, operationLog.entries[0].ledgerId);
|
|
270
|
+
assert.equal(state.plugins["@mh-gg/plugin-tasks"].tasks[taskId].title, "Write tests");
|
|
271
|
+
assert.equal(operationLog.entries[0].id, firstOperation.id);
|
|
272
|
+
assert.deepEqual(calls, [{ operationId: firstOperation.id, version: 1 }]);
|
|
273
|
+
assert.equal(await runtime.pluginStorage("@mh-gg/plugin-tasks").get("lastOperationId"), firstOperation.id);
|
|
274
|
+
await runtime.pluginStorage("@mh-gg/plugin-tasks").delete("lastOperationId");
|
|
275
|
+
assert.equal(await runtime.pluginStorage("@mh-gg/plugin-tasks").get("lastOperationId"), null);
|
|
276
|
+
assert.deepEqual((await runtime.pluginEvents("@mh-gg/plugin-tasks").query({}))[0], {
|
|
277
|
+
type: "task.afterCommit",
|
|
278
|
+
operationId: firstOperation.id
|
|
279
|
+
});
|
|
280
|
+
assert.equal((await runtime.pluginEvents("@mh-gg/plugin-tasks").query({ type: "task.afterCommit" })).length, 1);
|
|
281
|
+
|
|
282
|
+
const duplicate = await runtime.handleOperation(firstOperation);
|
|
283
|
+
assert.deepEqual(duplicate, ack);
|
|
284
|
+
assert.equal(operationLog.entries.length, 1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("operation append failure does not advance room state", async () => {
|
|
288
|
+
const baseStore = createMemoryRoomStore();
|
|
289
|
+
const operationLog = createMemoryOperationLog();
|
|
290
|
+
const failingLog = {
|
|
291
|
+
entries: operationLog.entries,
|
|
292
|
+
append() {
|
|
293
|
+
throw new Error("append failed");
|
|
294
|
+
},
|
|
295
|
+
list: operationLog.list,
|
|
296
|
+
findById: operationLog.findById
|
|
297
|
+
};
|
|
298
|
+
const { runtime } = runtimeWithPlugins({ store: baseStore, operationLog: failingLog });
|
|
299
|
+
await runtime.start();
|
|
300
|
+
const before = await baseStore.load();
|
|
301
|
+
|
|
302
|
+
const ack = await runtime.handleOperation(operation());
|
|
303
|
+
const after = await baseStore.load();
|
|
304
|
+
|
|
305
|
+
assert.equal(ack.ok, false);
|
|
306
|
+
assert.equal(after.version, before.version);
|
|
307
|
+
assert.equal(after.plugins["@mh-gg/plugin-tasks"].taskOrder.length, 0);
|
|
308
|
+
assert.equal(operationLog.entries.length, 0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("state save failure rolls back appended operation log entry", async () => {
|
|
312
|
+
const baseStore = createMemoryRoomStore();
|
|
313
|
+
const operationLog = createMemoryOperationLog();
|
|
314
|
+
let saves = 0;
|
|
315
|
+
const failingStore = {
|
|
316
|
+
load: baseStore.load,
|
|
317
|
+
async save(nextState) {
|
|
318
|
+
saves += 1;
|
|
319
|
+
if (saves > 1) throw new Error("save failed");
|
|
320
|
+
await baseStore.save(nextState);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
const { runtime } = runtimeWithPlugins({ store: failingStore, operationLog });
|
|
324
|
+
await runtime.start();
|
|
325
|
+
const before = await baseStore.load();
|
|
326
|
+
|
|
327
|
+
const ack = await runtime.handleOperation(operation());
|
|
328
|
+
const after = await baseStore.load();
|
|
329
|
+
|
|
330
|
+
assert.equal(ack.ok, false);
|
|
331
|
+
assert.equal(after.version, before.version);
|
|
332
|
+
assert.equal(operationLog.entries.length, 0);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("production and relay-executed runtimes require atomic state and operation commits", () => {
|
|
336
|
+
assert.throws(
|
|
337
|
+
() => runtimeWithPlugins({ productionRuntime: true }),
|
|
338
|
+
(error) => {
|
|
339
|
+
assert.equal(error.code, "ATOMIC_COMMIT_REQUIRED");
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
);
|
|
343
|
+
assert.throws(
|
|
344
|
+
() => runtimeWithPlugins({ relayExecuted: true }),
|
|
345
|
+
(error) => {
|
|
346
|
+
assert.equal(error.code, "ATOMIC_COMMIT_REQUIRED");
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("production startup requires bootstrap authority and keeps custom authenticators strict", async () => {
|
|
353
|
+
const operationLog = createMemoryOperationLog();
|
|
354
|
+
const runtime = new HostPluginRuntime({
|
|
355
|
+
room: { id: "room_tasks", appPack },
|
|
356
|
+
plugins: [rolesPlugin(), tasksPlugin()],
|
|
357
|
+
store: createAtomicMemoryStore(null, operationLog),
|
|
358
|
+
operationLog,
|
|
359
|
+
productionRuntime: true,
|
|
360
|
+
authenticateActor: async (_auth, actor) => actor
|
|
361
|
+
});
|
|
362
|
+
assert.equal(runtime.strictStanding, true);
|
|
363
|
+
await assert.rejects(() => runtime.start(), (error) => {
|
|
364
|
+
assert.equal(error.code, "BOOTSTRAP_AUTHORITY_REQUIRED");
|
|
365
|
+
return true;
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const bootstrapped = new HostPluginRuntime({
|
|
369
|
+
room: { id: "room_tasks", appPack },
|
|
370
|
+
plugins: [rolesPlugin(), tasksPlugin()],
|
|
371
|
+
store: createAtomicMemoryStore(null, createMemoryOperationLog()),
|
|
372
|
+
operationLog: createMemoryOperationLog(),
|
|
373
|
+
productionRuntime: true,
|
|
374
|
+
initialOwners: ["admin"],
|
|
375
|
+
authenticateActor: async (_auth, actor) => actor
|
|
376
|
+
});
|
|
377
|
+
const state = await bootstrapped.start();
|
|
378
|
+
assert.equal(state.adminIds.includes("admin"), true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("atomic commit failures do not partially advance state or operation log", async () => {
|
|
382
|
+
const baseStore = createMemoryRoomStore();
|
|
383
|
+
const operationLog = createMemoryOperationLog();
|
|
384
|
+
const atomicStore = {
|
|
385
|
+
load: baseStore.load,
|
|
386
|
+
async save(nextState) {
|
|
387
|
+
await baseStore.save(nextState);
|
|
388
|
+
},
|
|
389
|
+
async commitStateAndOperation() {
|
|
390
|
+
throw new Error("atomic commit failed");
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
const { runtime } = runtimeWithPlugins({ store: atomicStore, operationLog, productionRuntime: true, initialOwners: ["admin"] });
|
|
394
|
+
await runtime.start();
|
|
395
|
+
const before = await baseStore.load();
|
|
396
|
+
|
|
397
|
+
const ack = await runtime.handleOperation(operation());
|
|
398
|
+
const after = await baseStore.load();
|
|
399
|
+
|
|
400
|
+
assert.equal(ack.ok, false);
|
|
401
|
+
assert.equal(after.version, before.version);
|
|
402
|
+
assert.equal(operationLog.entries.length, 0);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("rejects invalid operations before commit", async () => {
|
|
406
|
+
const { calls, operationLog, runtime, store } = runtimeWithPlugins();
|
|
407
|
+
await runtime.start();
|
|
408
|
+
const before = await store.load();
|
|
409
|
+
|
|
410
|
+
assert.equal((await runtime.handleOperation(operation({ pluginId: "@mh-gg/missing" }))).code, "PLUGIN_NOT_INSTALLED");
|
|
411
|
+
assert.equal((await runtime.handleOperation(operation({ type: "task.unknown" }))).code, "UNKNOWN_OPERATION_TYPE");
|
|
412
|
+
assert.equal((await runtime.handleOperation(operation({
|
|
413
|
+
actor: { memberId: "guest", deviceId: "dev_guest", role: "guest" },
|
|
414
|
+
id: "op_guest"
|
|
415
|
+
}))).code, "FORBIDDEN");
|
|
416
|
+
|
|
417
|
+
assert.deepEqual(await store.load(), before);
|
|
418
|
+
assert.equal(operationLog.entries.length, 0);
|
|
419
|
+
assert.deepEqual(calls, []);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("host pack composition requires schema action tags", async () => {
|
|
423
|
+
const hostPack = taskHostPack({
|
|
424
|
+
hostPack: {
|
|
425
|
+
composition: {
|
|
426
|
+
primaryPlugin: { id: "@mh-gg/plugin-tasks", version: "1.0.0" },
|
|
427
|
+
plugins: [],
|
|
428
|
+
sharedScopes: {},
|
|
429
|
+
views: [],
|
|
430
|
+
actions: [
|
|
431
|
+
{ name: "taskCreate", plugin: "primary", type: "task.create", requiredRole: "admin" }
|
|
432
|
+
]
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
const { runtime } = runtimeWithPlugins({ hostPack });
|
|
437
|
+
await runtime.start();
|
|
438
|
+
|
|
439
|
+
assert.equal((await runtime.handleOperation(operation({ id: "op_schema_missing" }))).code, "SCHEMA_ACTION_REQUIRED");
|
|
440
|
+
assert.equal((await runtime.handleOperation(operation({
|
|
441
|
+
id: "op_schema_mismatch",
|
|
442
|
+
schemaAction: "taskCreate",
|
|
443
|
+
type: "task.complete",
|
|
444
|
+
payload: { taskId: "task_1" }
|
|
445
|
+
}))).code, "SCHEMA_ACTION_MISMATCH");
|
|
446
|
+
assert.equal((await runtime.handleOperation(operation({
|
|
447
|
+
id: "op_schema_member",
|
|
448
|
+
schemaAction: "taskCreate",
|
|
449
|
+
actor: { memberId: "member", deviceId: "dev_member", role: "member" }
|
|
450
|
+
}))).code, "FORBIDDEN");
|
|
451
|
+
|
|
452
|
+
const accepted = await runtime.handleOperation(operation({ id: "op_schema_ok", schemaAction: "taskCreate" }));
|
|
453
|
+
assert.equal(accepted.ok, true);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("rejects room mismatches, missing schemas, and missing plugin methods", async () => {
|
|
457
|
+
const missingSchemaPlugin = {
|
|
458
|
+
...tasksPlugin(),
|
|
459
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({
|
|
460
|
+
plugin: "@mh-gg/plugin-tasks",
|
|
461
|
+
version: "1.0.0",
|
|
462
|
+
operations: {
|
|
463
|
+
"task.create": { authorize: { roles: ["member"] } },
|
|
464
|
+
"task.complete": { authorize: { roles: ["member"] } },
|
|
465
|
+
"task.bad": { authorize: { roles: ["member"] } }
|
|
466
|
+
}
|
|
467
|
+
}),
|
|
468
|
+
schemas: {
|
|
469
|
+
...tasksPlugin().schemas,
|
|
470
|
+
operations: {
|
|
471
|
+
...tasksPlugin().schemas.operations,
|
|
472
|
+
"task.bad": {}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
const runtime = new HostPluginRuntime({
|
|
477
|
+
room: { id: "room_tasks", appPack },
|
|
478
|
+
plugins: [rolesPlugin(), missingSchemaPlugin],
|
|
479
|
+
store: createMemoryRoomStore(),
|
|
480
|
+
operationLog: createMemoryOperationLog(),
|
|
481
|
+
authenticateActor: async (auth, actor) => actor
|
|
482
|
+
});
|
|
483
|
+
await runtime.start();
|
|
484
|
+
|
|
485
|
+
assert.equal((await runtime.handleOperation(operation({ roomId: "other" }))).code, "ROOM_MISMATCH");
|
|
486
|
+
assert.equal((await runtime.handleOperation(operation({ appPackId: "other" }))).code, "APP_PACK_MISMATCH");
|
|
487
|
+
assert.equal((await runtime.handleOperation(operation({ appPackHash: "sha256-other" }))).code, "APP_PACK_HASH_MISMATCH");
|
|
488
|
+
assert.equal((await runtime.handleOperation(operation({ id: "op_bad", type: "task.bad" }))).code, "SCHEMA_MISSING");
|
|
489
|
+
|
|
490
|
+
const methodPlugin = {
|
|
491
|
+
...tasksPlugin(),
|
|
492
|
+
async authorize(ctx) {
|
|
493
|
+
await ctx.plugins.call("@mh-gg/plugin-roles", "missing", {});
|
|
494
|
+
return allow();
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
const methodRuntime = new HostPluginRuntime({
|
|
498
|
+
room: { id: "room_tasks", appPack },
|
|
499
|
+
plugins: [rolesPlugin(), methodPlugin],
|
|
500
|
+
store: createMemoryRoomStore(),
|
|
501
|
+
operationLog: createMemoryOperationLog(),
|
|
502
|
+
authenticateActor: async (auth, actor) => actor
|
|
503
|
+
});
|
|
504
|
+
await methodRuntime.start();
|
|
505
|
+
assert.equal((await methodRuntime.handleOperation(operation())).code, "PLUGIN_METHOD_NOT_FOUND");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("validates runtime construction and capability requirements", async () => {
|
|
509
|
+
assert.throws(() => new HostPluginRuntime(), /room\.id is required/);
|
|
510
|
+
assert.throws(() => new HostPluginRuntime({ room: { id: "room" }, plugins: [rolesPlugin()] }), /room\.appPack id and hash/);
|
|
511
|
+
assert.throws(() => new HostPluginRuntime({ room: { id: "room", appPack }, plugins: [] }), /At least one plugin/);
|
|
512
|
+
assert.throws(() => new HostPluginRuntime({ room: { id: "room", appPack }, plugins: [{}] }), /Plugin id and version/);
|
|
513
|
+
assert.throws(() => new HostPluginRuntime({ room: { id: "room", appPack }, plugins: [rolesPlugin(), rolesPlugin()] }), /Duplicate plugin/);
|
|
514
|
+
assert.equal(defineHostPlugin(rolesPlugin()).id, "@mh-gg/plugin-roles");
|
|
515
|
+
|
|
516
|
+
const capabilityPlugin = {
|
|
517
|
+
...tasksPlugin(),
|
|
518
|
+
authorize(ctx) {
|
|
519
|
+
ctx.capabilities.require("network.fetch");
|
|
520
|
+
return allow();
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
const runtime = new HostPluginRuntime({
|
|
524
|
+
room: { id: "room_tasks", appPack },
|
|
525
|
+
plugins: [rolesPlugin(), capabilityPlugin],
|
|
526
|
+
store: createMemoryRoomStore(),
|
|
527
|
+
operationLog: createMemoryOperationLog(),
|
|
528
|
+
authenticateActor: async (auth, actor) => actor
|
|
529
|
+
});
|
|
530
|
+
await runtime.start();
|
|
531
|
+
assert.equal((await runtime.handleOperation(operation())).code, "CAPABILITY_MISSING");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("rejects operations by default when no actor authenticator is installed", async () => {
|
|
535
|
+
const runtime = new HostPluginRuntime({
|
|
536
|
+
room: { id: "room_tasks", appPack },
|
|
537
|
+
plugins: [rolesPlugin(), tasksPlugin()],
|
|
538
|
+
store: createMemoryRoomStore(),
|
|
539
|
+
operationLog: createMemoryOperationLog()
|
|
540
|
+
});
|
|
541
|
+
await runtime.start();
|
|
542
|
+
const result = await runtime.handleOperation(operation());
|
|
543
|
+
assert.equal(result.code, "AUTHENTICATOR_MISSING");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("serves per-actor public plugin views", async () => {
|
|
547
|
+
const { runtime } = runtimeWithPlugins({
|
|
548
|
+
initialMembers: {
|
|
549
|
+
admin: { id: "admin", memberId: "admin", name: "Admin", role: "admin" },
|
|
550
|
+
member: { id: "member", memberId: "member", name: "Member", role: "member", credentialId: "cred_member" }
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
await runtime.start();
|
|
554
|
+
await runtime.handleOperation(operation());
|
|
555
|
+
|
|
556
|
+
const memberView = await runtime.publicView({ memberId: "member", role: "member" });
|
|
557
|
+
const guestView = await runtime.publicView({ memberId: "guest", role: "guest" });
|
|
558
|
+
|
|
559
|
+
assert.deepEqual(memberView.members.member, { id: "member", memberId: "member", name: "Member", displayName: undefined, role: "member", status: undefined, revokedAt: undefined, bannedAt: undefined });
|
|
560
|
+
assert.equal("credentialId" in memberView.members.member, false);
|
|
561
|
+
assert.equal(memberView.plugins["@mh-gg/plugin-tasks"].taskOrder.length, 1);
|
|
562
|
+
assert.deepEqual(guestView.plugins["@mh-gg/plugin-tasks"], { count: 1 });
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("stores authenticated actor display data in room members", async () => {
|
|
566
|
+
const { runtime, store } = runtimeWithPlugins();
|
|
567
|
+
await runtime.start();
|
|
568
|
+
const first = await runtime.handleOperation(operation({
|
|
569
|
+
id: "op_member_profile_1",
|
|
570
|
+
seq: 1,
|
|
571
|
+
actor: { memberId: "member", deviceId: "dev_member", role: "member", displayName: "Mina", avatar: "M" },
|
|
572
|
+
auth: { credentialId: "cred_member", signature: "sig" }
|
|
573
|
+
}));
|
|
574
|
+
assert.equal(first.ok, true, first.reason);
|
|
575
|
+
|
|
576
|
+
let state = await store.load();
|
|
577
|
+
assert.equal(state.members.member.displayName, "Mina");
|
|
578
|
+
assert.equal(state.members.member.name, "Mina");
|
|
579
|
+
assert.equal(state.members.member.avatar, "M");
|
|
580
|
+
assert.equal(state.members.member.role, "member");
|
|
581
|
+
assert.equal(state.members.member.profileOnly, true);
|
|
582
|
+
|
|
583
|
+
const second = await runtime.handleOperation(operation({
|
|
584
|
+
id: "op_member_profile_2",
|
|
585
|
+
seq: 2,
|
|
586
|
+
createdAt: 1001,
|
|
587
|
+
actor: { memberId: "member", deviceId: "dev_member", role: "member", displayName: "Mina Room", avatar: "R" },
|
|
588
|
+
auth: { credentialId: "cred_member", signature: "sig" }
|
|
589
|
+
}));
|
|
590
|
+
assert.equal(second.ok, true, second.reason);
|
|
591
|
+
|
|
592
|
+
state = await store.load();
|
|
593
|
+
assert.equal(state.members.member.displayName, "Mina Room");
|
|
594
|
+
assert.equal(state.members.member.avatar, "R");
|
|
595
|
+
assert.equal(state.members.member.profileOnly, true);
|
|
596
|
+
const view = await runtime.publicView({ memberId: "admin", role: "admin" });
|
|
597
|
+
assert.equal(view.members.member.displayName, "Mina Room");
|
|
598
|
+
assert.equal(view.members.member.avatar, "R");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("publishes NIP-17 direct messages as actor-filtered gift wraps", async () => {
|
|
602
|
+
const { runtime, store } = runtimeWithPlugins({
|
|
603
|
+
initialMembers: {
|
|
604
|
+
alice: { id: "alice", memberId: "alice", name: "Alice", role: "member" },
|
|
605
|
+
lee: { id: "lee", memberId: "lee", name: "Lee", role: "member" },
|
|
606
|
+
sam: { id: "sam", memberId: "sam", name: "Sam", role: "member" },
|
|
607
|
+
max: { id: "max", memberId: "max", name: "Max", role: "member" }
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
await runtime.start();
|
|
611
|
+
|
|
612
|
+
const publish = directPublishOperation();
|
|
613
|
+
const result = await runtime.handleOperation(publish);
|
|
614
|
+
const state = await store.load();
|
|
615
|
+
const thread = Object.values(state.directThreads)[0];
|
|
616
|
+
const message = Object.values(state.directMessages)[0];
|
|
617
|
+
|
|
618
|
+
assert.equal(result.ok, true);
|
|
619
|
+
assert.equal(thread.protocol, "nostr.nip17");
|
|
620
|
+
assert.deepEqual(thread.userIds, ["alice", "lee", "sam"]);
|
|
621
|
+
assert.equal(thread.topicKey, "launch");
|
|
622
|
+
assert.equal("roomId" in thread, false);
|
|
623
|
+
assert.equal(message.protocol, "nostr.nip17");
|
|
624
|
+
assert.equal("body" in message, false);
|
|
625
|
+
assert.equal("roomId" in message, false);
|
|
626
|
+
assert.deepEqual(Object.keys(message.giftWraps).sort(), ["alice", "lee", "sam"]);
|
|
627
|
+
|
|
628
|
+
const aliceView = await runtime.publicView({ memberId: "alice", role: "member" });
|
|
629
|
+
const leeView = await runtime.publicView({ memberId: "lee", role: "member" });
|
|
630
|
+
const maxView = await runtime.publicView({ memberId: "max", role: "member" });
|
|
631
|
+
const publicMessageId = Object.keys(aliceView.direct.messages)[0];
|
|
632
|
+
|
|
633
|
+
assert.equal(Object.keys(aliceView.direct.threads).length, 1);
|
|
634
|
+
assert.deepEqual(Object.keys(aliceView.direct.messages[publicMessageId].giftWraps), ["alice"]);
|
|
635
|
+
assert.deepEqual(Object.keys(leeView.direct.messages[publicMessageId].giftWraps), ["lee"]);
|
|
636
|
+
assert.deepEqual(maxView.direct, { threads: {}, messages: {}, keys: {} });
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
test("stores direct-message read tags in the reader member state", async () => {
|
|
641
|
+
const { runtime, store } = runtimeWithPlugins({
|
|
642
|
+
initialMembers: {
|
|
643
|
+
alice: { id: "alice", memberId: "alice", name: "Alice", role: "member" },
|
|
644
|
+
lee: { id: "lee", memberId: "lee", name: "Lee", role: "member" },
|
|
645
|
+
sam: { id: "sam", memberId: "sam", name: "Sam", role: "member" }
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
await runtime.start();
|
|
649
|
+
|
|
650
|
+
const publish = directPublishOperation();
|
|
651
|
+
const publishAck = await runtime.handleOperation(publish);
|
|
652
|
+
assert.equal(publishAck.ok, true, publishAck.reason);
|
|
653
|
+
|
|
654
|
+
let state = await store.load();
|
|
655
|
+
const thread = Object.values(state.directThreads)[0];
|
|
656
|
+
const message = Object.values(state.directMessages)[0];
|
|
657
|
+
assert.equal(isSnowflakeId(message.ledgerId), true);
|
|
658
|
+
assert.equal(message.id, `direct_message_${message.ledgerId}`);
|
|
659
|
+
|
|
660
|
+
const read = operation({
|
|
661
|
+
pluginId: CORE_PLUGIN_ID,
|
|
662
|
+
type: CORE_READ_TAG_SET_TYPE,
|
|
663
|
+
actor: { memberId: "alice", deviceId: "dev_alice", role: "member" },
|
|
664
|
+
seq: 2,
|
|
665
|
+
createdAt: 1002,
|
|
666
|
+
payload: {
|
|
667
|
+
scopeType: "direct.thread",
|
|
668
|
+
scopeId: thread.id,
|
|
669
|
+
ledgerId: message.ledgerId,
|
|
670
|
+
messageId: message.id,
|
|
671
|
+
operationId: message.operationId
|
|
672
|
+
},
|
|
673
|
+
auth: { credentialId: "cred_alice", signature: "sig" }
|
|
674
|
+
});
|
|
675
|
+
const readAck = await runtime.handleOperation(read);
|
|
676
|
+
assert.equal(readAck.ok, true, readAck.reason);
|
|
677
|
+
|
|
678
|
+
state = await store.load();
|
|
679
|
+
const key = `direct.thread:${thread.id}`;
|
|
680
|
+
const tag = state.members.alice.readTags[key];
|
|
681
|
+
assert.equal(tag.ledgerId, message.ledgerId);
|
|
682
|
+
assert.equal(tag.messageId, message.id);
|
|
683
|
+
assert.equal(tag.operationId, message.operationId);
|
|
684
|
+
|
|
685
|
+
const aliceView = await runtime.publicView({ memberId: "alice", role: "member" });
|
|
686
|
+
const leeView = await runtime.publicView({ memberId: "lee", role: "member" });
|
|
687
|
+
assert.equal(aliceView.members.alice.readTags[key].ledgerId, message.ledgerId);
|
|
688
|
+
assert.equal(aliceView.direct.threads[thread.id].readTag.ledgerId, message.ledgerId);
|
|
689
|
+
assert.equal(leeView.members.alice.readTags, undefined);
|
|
690
|
+
assert.equal(leeView.direct.threads[thread.id].readTag, undefined);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
test("rejects direct messages that are not complete NIP-17 gift-wrap fanout", async () => {
|
|
695
|
+
const { runtime } = runtimeWithPlugins({
|
|
696
|
+
initialMembers: {
|
|
697
|
+
alice: { id: "alice", memberId: "alice", name: "Alice", role: "member" },
|
|
698
|
+
lee: { id: "lee", memberId: "lee", name: "Lee", role: "member" },
|
|
699
|
+
sam: { id: "sam", memberId: "sam", name: "Sam", role: "member" }
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
await runtime.start();
|
|
703
|
+
|
|
704
|
+
const plaintext = await runtime.handleOperation(directPublishOperation({ id: "op_direct_plaintext", payload: { body: "hello" } }));
|
|
705
|
+
const missingWrap = await runtime.handleOperation(directPublishOperation({
|
|
706
|
+
id: "op_direct_missing_wrap",
|
|
707
|
+
recipients: [
|
|
708
|
+
{ recipientId: "alice", idChar: "1", wrapperPubkeyChar: "4" },
|
|
709
|
+
{ recipientId: "lee", idChar: "2", wrapperPubkeyChar: "5" }
|
|
710
|
+
],
|
|
711
|
+
payload: { userIds: ["alice", "lee", "sam"] }
|
|
712
|
+
}));
|
|
713
|
+
const wrongRecipient = directPublishOperation({
|
|
714
|
+
id: "op_direct_wrong_recipient",
|
|
715
|
+
recipients: [
|
|
716
|
+
{ recipientId: "alice", idChar: "1", wrapperPubkeyChar: "4" },
|
|
717
|
+
{ recipientId: "lee", idChar: "2", wrapperPubkeyChar: "5" }
|
|
718
|
+
],
|
|
719
|
+
payload: {
|
|
720
|
+
giftWraps: {
|
|
721
|
+
alice: opaqueGiftWrap({ idChar: "1", wrapperPubkeyChar: "4" }),
|
|
722
|
+
lee: opaqueGiftWrap({ idChar: "2", wrapperPubkeyChar: "5" }),
|
|
723
|
+
mallory: opaqueGiftWrap({ idChar: "7", wrapperPubkeyChar: "8" })
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
assert.equal(plaintext.code, "SCHEMA_INVALID");
|
|
729
|
+
assert.match(plaintext.reason, /unknown field body/);
|
|
730
|
+
assert.equal(missingWrap.code, "SCHEMA_INVALID");
|
|
731
|
+
assert.match(missingWrap.reason, /must include sam/);
|
|
732
|
+
assert.equal((await runtime.handleOperation(wrongRecipient)).code, "SCHEMA_INVALID");
|
|
733
|
+
const staleReactionAlias = await runtime.handleOperation(directPublishOperation({
|
|
734
|
+
id: "op_direct_stale_reaction",
|
|
735
|
+
operation: { type: "dm.react" }
|
|
736
|
+
}));
|
|
737
|
+
assert.notEqual(staleReactionAlias.ok, true);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("bounds dedupe and ack caches while preserving idempotent recent retries", async () => {
|
|
741
|
+
const { operationLog, runtime, store } = runtimeWithPlugins({ maxSeenOperations: 2, maxAckCache: 1 });
|
|
742
|
+
await runtime.start();
|
|
743
|
+
const opA = operation({ id: "op_a", seq: 1, payload: { title: "A" } });
|
|
744
|
+
const opB = operation({ id: "op_b", seq: 2, payload: { title: "B" } });
|
|
745
|
+
const opC = operation({ id: "op_c", seq: 3, payload: { title: "C" } });
|
|
746
|
+
await runtime.handleOperation(opA);
|
|
747
|
+
const ackB = await runtime.handleOperation(opB);
|
|
748
|
+
await runtime.handleOperation(opC);
|
|
749
|
+
|
|
750
|
+
assert.deepEqual((await store.load()).seenOperations, [opB.id, opC.id]);
|
|
751
|
+
assert.equal(operationLog.entries.length, 3);
|
|
752
|
+
assert.equal(operationLog.entries[2].committedRoomVersion, 3);
|
|
753
|
+
assert.deepEqual(await runtime.handleOperation(opB), {
|
|
754
|
+
...ackB,
|
|
755
|
+
duplicate: true
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("serves operation batches, snapshots, and plugin queries", async () => {
|
|
760
|
+
const queryPlugin = {
|
|
761
|
+
...tasksPlugin(),
|
|
762
|
+
queries: {
|
|
763
|
+
taskCount(ctx, state) {
|
|
764
|
+
return { count: state.taskOrder.length, actorRole: ctx.actor.role };
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
const runtime = new HostPluginRuntime({
|
|
769
|
+
room: { id: "room_tasks", appPack },
|
|
770
|
+
plugins: [rolesPlugin(), queryPlugin],
|
|
771
|
+
store: createMemoryRoomStore(),
|
|
772
|
+
operationLog: createMemoryOperationLog(),
|
|
773
|
+
authenticateActor: async (auth, actor) => actor,
|
|
774
|
+
now: () => 5000
|
|
775
|
+
});
|
|
776
|
+
await runtime.start();
|
|
777
|
+
await runtime.handleOperation(operation());
|
|
778
|
+
|
|
779
|
+
const batch = await runtime.operationBatchSince(0);
|
|
780
|
+
assert.equal(batch.kind, "matterhorn.host-operation-batch");
|
|
781
|
+
assert.equal(batch.appPackId, appPack.id);
|
|
782
|
+
assert.equal(batch.appPackHash, appPack.hash);
|
|
783
|
+
assert.equal(batch.operations.length, 1);
|
|
784
|
+
assert.equal(batch.headVersion, 1);
|
|
785
|
+
assert.equal(parseHostOperationBatch(batch).headVersion, 1);
|
|
786
|
+
const snapshot = await runtime.createSnapshot();
|
|
787
|
+
assert.equal(snapshot.kind, "matterhorn.host-snapshot");
|
|
788
|
+
assert.equal(snapshot.version, 1);
|
|
789
|
+
assert.equal(snapshot.appPackId, appPack.id);
|
|
790
|
+
assert.equal(snapshot.appPackHash, appPack.hash);
|
|
791
|
+
assert.equal(snapshot.createdAt, 5000);
|
|
792
|
+
assert.equal(parseHostSnapshot(snapshot).version, 1);
|
|
793
|
+
assert.deepEqual(await runtime.handleQuery("@mh-gg/plugin-tasks", "taskCount", {}, { memberId: "admin", role: "admin" }), {
|
|
794
|
+
count: 1,
|
|
795
|
+
actorRole: "admin"
|
|
796
|
+
});
|
|
797
|
+
await assert.rejects(() => runtime.handleQuery("@mh-gg/plugin-tasks", "missing", {}, { role: "admin" }), (error) => {
|
|
798
|
+
assert.equal(error instanceof MatterhornRuntimeError, true);
|
|
799
|
+
assert.equal(error.code, "PLUGIN_QUERY_NOT_FOUND");
|
|
800
|
+
return true;
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("validates room state and schema-backed query results without storing query mutations", async () => {
|
|
805
|
+
const queryPlugin = {
|
|
806
|
+
...tasksPlugin(),
|
|
807
|
+
schemas: {
|
|
808
|
+
...tasksPlugin().schemas,
|
|
809
|
+
queries: {
|
|
810
|
+
summary: schema((result) => {
|
|
811
|
+
assert.equal(typeof result.count, "number");
|
|
812
|
+
return { count: result.count, validated: true };
|
|
813
|
+
}),
|
|
814
|
+
broken: schema((result) => {
|
|
815
|
+
assert.equal(typeof result.count, "number");
|
|
816
|
+
return result;
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
queries: {
|
|
821
|
+
summary(ctx, state) {
|
|
822
|
+
state.taskOrder.push("query_mutation");
|
|
823
|
+
ctx.roomState.plugins["@mh-gg/plugin-tasks"].taskOrder.push("context_mutation");
|
|
824
|
+
return { count: state.taskOrder.length };
|
|
825
|
+
},
|
|
826
|
+
broken() {
|
|
827
|
+
return { count: "bad" };
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
const store = createMemoryRoomStore();
|
|
832
|
+
const runtime = new HostPluginRuntime({
|
|
833
|
+
room: { id: "room_tasks", appPack },
|
|
834
|
+
plugins: [queryPlugin],
|
|
835
|
+
store,
|
|
836
|
+
operationLog: createMemoryOperationLog(),
|
|
837
|
+
authenticateActor: async (_auth, actor) => actor
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
const startedState = await runtime.start();
|
|
841
|
+
assert.equal(RoomStateSchema.parse(startedState).roomId, "room_tasks");
|
|
842
|
+
assert.equal(parseRoomState(startedState).appPack.id, appPack.id);
|
|
843
|
+
assert.deepEqual(await runtime.query("@mh-gg/plugin-tasks", "summary", {}, { role: "admin" }), {
|
|
844
|
+
count: 1,
|
|
845
|
+
validated: true
|
|
846
|
+
});
|
|
847
|
+
assert.deepEqual((await store.load()).plugins["@mh-gg/plugin-tasks"].taskOrder, []);
|
|
848
|
+
await assert.rejects(() => runtime.query("@mh-gg/plugin-tasks", "broken", {}, { role: "admin" }), (error) => {
|
|
849
|
+
assert.equal(error.code, "SCHEMA_INVALID");
|
|
850
|
+
return true;
|
|
851
|
+
});
|
|
852
|
+
assert.throws(() => parseRoomState({ ...startedState, version: -1 }), (error) => {
|
|
853
|
+
assert.equal(error.code, "ROOM_STATE_INVALID");
|
|
854
|
+
return true;
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test("isolates plugin reducer failures as operation rejections", async () => {
|
|
859
|
+
const badPlugin = {
|
|
860
|
+
...tasksPlugin(),
|
|
861
|
+
reduce() {
|
|
862
|
+
throw new Error("boom");
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
const runtime = new HostPluginRuntime({
|
|
866
|
+
room: { id: "room_tasks", appPack },
|
|
867
|
+
plugins: [rolesPlugin(), badPlugin],
|
|
868
|
+
store: createMemoryRoomStore(),
|
|
869
|
+
operationLog: createMemoryOperationLog(),
|
|
870
|
+
authenticateActor: async (auth, actor) => actor
|
|
871
|
+
});
|
|
872
|
+
await runtime.start();
|
|
873
|
+
const result = await runtime.handleOperation(operation());
|
|
874
|
+
assert.equal(result.ok, false);
|
|
875
|
+
assert.equal(result.code, "PLUGIN_REDUCE_FAILED");
|
|
876
|
+
assert.match(result.reason, /boom/);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
test("constructs a runtime from pinned app and host pack manifests", async () => {
|
|
880
|
+
const hostPack = {
|
|
881
|
+
kind: "matterhorn.host-pack",
|
|
882
|
+
id: "com.matterhorn.tasks.host",
|
|
883
|
+
appPackId: appPack.id,
|
|
884
|
+
version: "1.0.0",
|
|
885
|
+
plugins: [
|
|
886
|
+
{ id: "@mh-gg/plugin-tasks", version: "1.0.0", source: "workspace:tasks", integrity: "sha256-plugin" }
|
|
887
|
+
],
|
|
888
|
+
compatibility: {
|
|
889
|
+
appProtocolHash: appPack.protocolHash,
|
|
890
|
+
pluginGraphHash: "sha256-graph"
|
|
891
|
+
},
|
|
892
|
+
runtime: { minMatterhornVersion: "0.1.0", sandbox: "process" },
|
|
893
|
+
capabilities: { required: ["room.state"], optional: [] },
|
|
894
|
+
trust: { signatures: [{ publicKey: "rk_pub", signature: "sig" }] }
|
|
895
|
+
};
|
|
896
|
+
const appPackManifest = {
|
|
897
|
+
kind: "matterhorn.app-pack",
|
|
898
|
+
id: appPack.id,
|
|
899
|
+
name: "Tasks",
|
|
900
|
+
version: appPack.version,
|
|
901
|
+
publisher: { id: "com.matterhorn", name: "Matterhorn", publicKey: "rk_pub" },
|
|
902
|
+
matterhornVersion: ">=0.1 <0.2",
|
|
903
|
+
hostPack: { url: "workspace:tasks-host", integrity: manifestHash(hostPack) },
|
|
904
|
+
playerPacks: [
|
|
905
|
+
{ id: "com.matterhorn.tasks.player", name: "Tasks Player", url: "workspace:tasks-player", integrity: "sha256-player" }
|
|
906
|
+
],
|
|
907
|
+
compatibility: {
|
|
908
|
+
appProtocolHash: appPack.protocolHash,
|
|
909
|
+
operationSchemaHash: "sha256-ops",
|
|
910
|
+
stateSchemaHash: "sha256-state"
|
|
911
|
+
},
|
|
912
|
+
capabilities: { required: ["room.state"], optional: [] },
|
|
913
|
+
trust: { createdAt: "2026-05-27T00:00:00Z", signatures: [{ publicKey: "rk_pub", signature: "sig" }] }
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const runtime = createHostPluginRuntimeFromPack({
|
|
917
|
+
appPack: appPackManifest,
|
|
918
|
+
hostPack,
|
|
919
|
+
room: { id: "room_tasks" },
|
|
920
|
+
plugins: [tasksPlugin()],
|
|
921
|
+
authenticateActor: async (auth, actor) => actor
|
|
922
|
+
});
|
|
923
|
+
await runtime.start();
|
|
924
|
+
const state = await runtime.getState();
|
|
925
|
+
assert.equal(state.appPack.id, appPack.id);
|
|
926
|
+
assert.equal(state.appPack.hash, manifestHash(appPackManifest));
|
|
927
|
+
|
|
928
|
+
assert.throws(() => createHostPluginRuntimeFromPack({
|
|
929
|
+
appPack: { ...appPackManifest, hostPack: { ...appPackManifest.hostPack, integrity: "sha256-wrong" } },
|
|
930
|
+
hostPack,
|
|
931
|
+
room: { id: "room_tasks" },
|
|
932
|
+
plugins: [tasksPlugin()]
|
|
933
|
+
}), (error) => {
|
|
934
|
+
assert.equal(error instanceof MatterhornRuntimeError, true);
|
|
935
|
+
assert.equal(error.code, "HOST_PACK_INTEGRITY_MISMATCH");
|
|
936
|
+
return true;
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
test("isolates reducer and afterCommit failures without corrupting room state", async () => {
|
|
941
|
+
const afterCommitLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
942
|
+
const failingReducerPlugin = {
|
|
943
|
+
...tasksPlugin(),
|
|
944
|
+
reduce() {
|
|
945
|
+
throw new Error("boom");
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
const runtime = new HostPluginRuntime({
|
|
949
|
+
room: { id: "room_tasks", appPack },
|
|
950
|
+
plugins: [rolesPlugin(), failingReducerPlugin],
|
|
951
|
+
store: createMemoryRoomStore(),
|
|
952
|
+
operationLog: createMemoryOperationLog(),
|
|
953
|
+
logger: afterCommitLogger,
|
|
954
|
+
authenticateActor: async (auth, actor) => actor
|
|
955
|
+
});
|
|
956
|
+
await runtime.start();
|
|
957
|
+
const before = await runtime.getState();
|
|
958
|
+
const result = await runtime.handleOperation(operation());
|
|
959
|
+
assert.equal(result.ok, false);
|
|
960
|
+
assert.equal(result.code, "PLUGIN_REDUCE_FAILED");
|
|
961
|
+
assert.deepEqual(await runtime.getState(), before);
|
|
962
|
+
|
|
963
|
+
const calls = [];
|
|
964
|
+
const sideEffectRuntime = new HostPluginRuntime({
|
|
965
|
+
room: { id: "room_tasks", appPack },
|
|
966
|
+
plugins: [rolesPlugin(), {
|
|
967
|
+
...tasksPlugin(calls),
|
|
968
|
+
async afterCommit() {
|
|
969
|
+
throw new Error("webhook failed");
|
|
970
|
+
}
|
|
971
|
+
}],
|
|
972
|
+
store: createMemoryRoomStore(),
|
|
973
|
+
operationLog: createMemoryOperationLog(),
|
|
974
|
+
logger: afterCommitLogger,
|
|
975
|
+
authenticateActor: async (auth, actor) => actor
|
|
976
|
+
});
|
|
977
|
+
await sideEffectRuntime.start();
|
|
978
|
+
const ack = await sideEffectRuntime.handleOperation(operation({ id: "op_side" }));
|
|
979
|
+
assert.equal(ack.ok, true);
|
|
980
|
+
assert.equal((await sideEffectRuntime.getState()).version, 1);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
test("bounds seen operations and ack cache", async () => {
|
|
984
|
+
const { runtime, store } = runtimeWithPlugins({ maxSeenOperations: 2, maxAcks: 1 });
|
|
985
|
+
await runtime.start();
|
|
986
|
+
const opA = operation({ id: "op_a", seq: 1 });
|
|
987
|
+
const opB = operation({ id: "op_b", seq: 2 });
|
|
988
|
+
const opC = operation({ id: "op_c", seq: 3 });
|
|
989
|
+
await runtime.handleOperation(opA);
|
|
990
|
+
await runtime.handleOperation(opB);
|
|
991
|
+
await runtime.handleOperation(opC);
|
|
992
|
+
|
|
993
|
+
const state = await store.load();
|
|
994
|
+
assert.deepEqual(state.seenOperations, [opB.id, opC.id]);
|
|
995
|
+
assert.equal(runtime.acks.has(opA.id), false);
|
|
996
|
+
assert.equal(runtime.acks.has(opC.id), true);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
test("migrates plugin state, serves queries, and exposes room info", async () => {
|
|
1000
|
+
const oldState = {
|
|
1001
|
+
schemaVersion: 1,
|
|
1002
|
+
roomId: "room_tasks",
|
|
1003
|
+
appPack,
|
|
1004
|
+
version: 4,
|
|
1005
|
+
createdAt: 1,
|
|
1006
|
+
updatedAt: 2,
|
|
1007
|
+
members: {},
|
|
1008
|
+
pluginVersions: {
|
|
1009
|
+
"@mh-gg/plugin-roles": "1.0.0",
|
|
1010
|
+
"@mh-gg/plugin-tasks": "0.9.0"
|
|
1011
|
+
},
|
|
1012
|
+
plugins: {
|
|
1013
|
+
"@mh-gg/plugin-roles": { permissions: { admin: ["tasks.task.create"] } },
|
|
1014
|
+
"@mh-gg/plugin-tasks": { taskOrder: ["old"], tasks: { old: { id: "old", title: "Old", done: false } } }
|
|
1015
|
+
},
|
|
1016
|
+
seenOperations: ["a", "b", "c"]
|
|
1017
|
+
};
|
|
1018
|
+
const migratedTasks = {
|
|
1019
|
+
...tasksPlugin(),
|
|
1020
|
+
version: "1.0.0",
|
|
1021
|
+
migrations: {
|
|
1022
|
+
"0.9.0->1.0.0"(_ctx, state) {
|
|
1023
|
+
return { ...state, migrated: true };
|
|
1024
|
+
}
|
|
1025
|
+
},
|
|
1026
|
+
schemas: {
|
|
1027
|
+
...tasksPlugin().schemas,
|
|
1028
|
+
state: schema((state) => {
|
|
1029
|
+
assert.ok(Array.isArray(state.taskOrder));
|
|
1030
|
+
assert.equal(typeof state.tasks, "object");
|
|
1031
|
+
assert.equal(state.migrated, true);
|
|
1032
|
+
return state;
|
|
1033
|
+
})
|
|
1034
|
+
},
|
|
1035
|
+
queries: {
|
|
1036
|
+
taskCount(_ctx, state) {
|
|
1037
|
+
return { count: state.taskOrder.length };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
const runtime = new HostPluginRuntime({
|
|
1042
|
+
room: { id: "room_tasks", appPack },
|
|
1043
|
+
plugins: [rolesPlugin(), migratedTasks],
|
|
1044
|
+
store: createMemoryRoomStore(oldState),
|
|
1045
|
+
operationLog: createMemoryOperationLog(),
|
|
1046
|
+
authenticateActor: async (auth, actor) => actor
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
const state = await runtime.start();
|
|
1050
|
+
assert.equal(state.plugins["@mh-gg/plugin-tasks"].migrated, true);
|
|
1051
|
+
assert.equal(state.pluginVersions["@mh-gg/plugin-tasks"], "1.0.0");
|
|
1052
|
+
assert.deepEqual(await runtime.query("@mh-gg/plugin-tasks", "taskCount", {}, { role: "admin" }), { count: 1 });
|
|
1053
|
+
const info = await runtime.roomInfo();
|
|
1054
|
+
assert.equal(info.type, "room/info");
|
|
1055
|
+
assert.equal(info.appPack.id, appPack.id);
|
|
1056
|
+
assert.equal(info.plugins.some((plugin) => plugin.id === "@mh-gg/plugin-tasks"), true);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test("migrates existing state to the current same-app pack hash", async () => {
|
|
1060
|
+
const store = createMemoryRoomStore({
|
|
1061
|
+
schemaVersion: 1,
|
|
1062
|
+
roomId: "room_upgrade",
|
|
1063
|
+
appPack: { ...appPack, hash: "sha256-old-app" },
|
|
1064
|
+
version: 1,
|
|
1065
|
+
createdAt: 1,
|
|
1066
|
+
updatedAt: 2,
|
|
1067
|
+
members: {},
|
|
1068
|
+
revokedCredentialIds: [],
|
|
1069
|
+
pluginVersions: { [tasksPlugin().id]: "1.0.0" },
|
|
1070
|
+
plugins: { [tasksPlugin().id]: { taskOrder: [], tasks: {} } },
|
|
1071
|
+
seenOperations: []
|
|
1072
|
+
});
|
|
1073
|
+
const runtime = new HostPluginRuntime({
|
|
1074
|
+
room: { id: "room_upgrade", appPack },
|
|
1075
|
+
plugins: [tasksPlugin()],
|
|
1076
|
+
store,
|
|
1077
|
+
authenticateActor: async (_auth, opActor) => opActor
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
const state = await runtime.getState();
|
|
1081
|
+
|
|
1082
|
+
assert.equal(state.appPack.id, appPack.id);
|
|
1083
|
+
assert.equal(state.appPack.hash, appPack.hash);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("installs host packs from pinned app packs and plugin registries", () => {
|
|
1087
|
+
const hostPlugin = { ...tasksPlugin(), integrity: "sha256-plugin" };
|
|
1088
|
+
const hostPack = {
|
|
1089
|
+
kind: "matterhorn.host-pack",
|
|
1090
|
+
id: "com.matterhorn.tasks.host",
|
|
1091
|
+
appPackId: appPack.id,
|
|
1092
|
+
version: "1.0.0",
|
|
1093
|
+
plugins: [{ id: hostPlugin.id, version: hostPlugin.version, source: "registry:tasks", integrity: hostPlugin.integrity }],
|
|
1094
|
+
compatibility: { appProtocolHash: appPack.protocolHash, pluginGraphHash: "sha256-plugin-graph" },
|
|
1095
|
+
runtime: { minMatterhornVersion: "0.1.0", sandbox: "process" },
|
|
1096
|
+
capabilities: { required: ["room.state"], optional: [] },
|
|
1097
|
+
trust: { signatures: [{ publicKey: "pub", signature: "sig" }] }
|
|
1098
|
+
};
|
|
1099
|
+
const appPackManifest = {
|
|
1100
|
+
kind: "matterhorn.app-pack",
|
|
1101
|
+
id: appPack.id,
|
|
1102
|
+
name: "Tasks",
|
|
1103
|
+
version: appPack.version,
|
|
1104
|
+
publisher: { id: "pub", name: "Publisher", publicKey: "pub" },
|
|
1105
|
+
matterhornVersion: ">=0.1 <0.2",
|
|
1106
|
+
hostPack: { url: "registry:host", integrity: require("@mh-gg/base").manifestHash(hostPack) },
|
|
1107
|
+
playerPacks: [{ id: "player", name: "Player", url: "registry:player", integrity: "sha256-player" }],
|
|
1108
|
+
compatibility: { appProtocolHash: appPack.protocolHash, operationSchemaHash: "sha256-op", stateSchemaHash: "sha256-state" },
|
|
1109
|
+
capabilities: { required: ["room.state"], optional: [] },
|
|
1110
|
+
trust: { createdAt: "2026-05-27T00:00:00Z", signatures: [{ publicKey: "pub", signature: "sig" }] }
|
|
1111
|
+
};
|
|
1112
|
+
const install = installHostPack({ appPack: appPackManifest, hostPack, pluginRegistry: { [hostPlugin.id]: hostPlugin } });
|
|
1113
|
+
assert.equal(install.plugins[0].id, hostPlugin.id);
|
|
1114
|
+
assert.equal(install.roomAppPack.hash, require("@mh-gg/base").manifestHash(appPackManifest));
|
|
1115
|
+
assert.throws(() => resolveHostPlugins(hostPack, {}), /not available/);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
function taskHostPack(extra = {}) {
|
|
1120
|
+
const pluginRef = {
|
|
1121
|
+
id: "@mh-gg/plugin-tasks",
|
|
1122
|
+
version: "1.0.0",
|
|
1123
|
+
source: "workspace:tasks-plugin",
|
|
1124
|
+
integrity: "sha256-plugin",
|
|
1125
|
+
...(extra.pluginRef || {})
|
|
1126
|
+
};
|
|
1127
|
+
const pack = {
|
|
1128
|
+
kind: "matterhorn.host-pack",
|
|
1129
|
+
id: "com.matterhorn.tasks.host",
|
|
1130
|
+
appPackId: appPack.id,
|
|
1131
|
+
version: "1.0.0",
|
|
1132
|
+
plugins: [pluginRef, ...(extra.plugins || [])],
|
|
1133
|
+
compatibility: {
|
|
1134
|
+
appProtocolHash: appPack.protocolHash,
|
|
1135
|
+
pluginGraphHash: "sha256-plugin-graph",
|
|
1136
|
+
...(extra.compatibility || {})
|
|
1137
|
+
},
|
|
1138
|
+
runtime: { minMatterhornVersion: "0.1.0", sandbox: "process", ...(extra.runtime || {}) },
|
|
1139
|
+
capabilities: { required: ["room.state"], optional: [], ...(extra.capabilities || {}) },
|
|
1140
|
+
trust: { signatures: [{ publicKey: "rk_pub", signature: "sig" }], ...(extra.trust || {}) },
|
|
1141
|
+
...extra.hostPack
|
|
1142
|
+
};
|
|
1143
|
+
return pack;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function taskAppPack(hostPack, extra = {}) {
|
|
1147
|
+
return {
|
|
1148
|
+
kind: "matterhorn.app-pack",
|
|
1149
|
+
id: appPack.id,
|
|
1150
|
+
name: "Tasks",
|
|
1151
|
+
version: appPack.version,
|
|
1152
|
+
publisher: { id: "com.matterhorn", name: "Matterhorn", publicKey: "rk_pub" },
|
|
1153
|
+
matterhornVersion: ">=0.1 <0.2",
|
|
1154
|
+
hostPack: { url: "workspace:tasks-host", integrity: manifestHash(hostPack) },
|
|
1155
|
+
playerPacks: [{ id: "player", name: "Player", url: "workspace:player", integrity: "sha256-player" }],
|
|
1156
|
+
compatibility: {
|
|
1157
|
+
appProtocolHash: appPack.protocolHash,
|
|
1158
|
+
operationSchemaHash: "sha256-ops",
|
|
1159
|
+
stateSchemaHash: "sha256-state"
|
|
1160
|
+
},
|
|
1161
|
+
capabilities: { required: ["room.state"], optional: [] },
|
|
1162
|
+
trust: { createdAt: "2026-05-27T00:00:00Z", signatures: [{ publicKey: "rk_pub", signature: "sig" }] },
|
|
1163
|
+
...extra
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
test("normalizes non-runtime failures into stable rejection payloads", () => {
|
|
1168
|
+
const result = normalizeRuntimeFailure(new Error("plain failure"), "op_plain");
|
|
1169
|
+
assert.deepEqual(result, {
|
|
1170
|
+
ok: false,
|
|
1171
|
+
code: "PLUGIN_FAILURE",
|
|
1172
|
+
reason: "plain failure",
|
|
1173
|
+
operationId: "op_plain"
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
test("rejects bad host plugin graphs before runtime creation", () => {
|
|
1178
|
+
assert.throws(() => resolveHostPlugins(taskHostPack({
|
|
1179
|
+
plugins: [{
|
|
1180
|
+
id: "@mh-gg/plugin-extra",
|
|
1181
|
+
version: "1.0.0",
|
|
1182
|
+
source: "workspace:extra",
|
|
1183
|
+
integrity: "sha256-extra",
|
|
1184
|
+
dependsOn: [{ id: "@mh-gg/plugin-missing", version: "1.0.0" }]
|
|
1185
|
+
}]
|
|
1186
|
+
}), {
|
|
1187
|
+
"@mh-gg/plugin-tasks": tasksPlugin(),
|
|
1188
|
+
"@mh-gg/plugin-extra": { ...tasksPlugin(), id: "@mh-gg/plugin-extra", integrity: "sha256-extra" }
|
|
1189
|
+
}), (error) => {
|
|
1190
|
+
assert.equal(error.code, "PLUGIN_DEPENDENCY_MISSING");
|
|
1191
|
+
return true;
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
assert.throws(() => resolveHostPlugins(taskHostPack({
|
|
1195
|
+
plugins: [{
|
|
1196
|
+
id: "@mh-gg/plugin-extra",
|
|
1197
|
+
version: "1.0.0",
|
|
1198
|
+
source: "workspace:extra",
|
|
1199
|
+
integrity: "sha256-extra",
|
|
1200
|
+
dependsOn: [{ id: "@mh-gg/plugin-tasks", version: "2.0.0" }]
|
|
1201
|
+
}]
|
|
1202
|
+
}), {
|
|
1203
|
+
"@mh-gg/plugin-tasks": tasksPlugin(),
|
|
1204
|
+
"@mh-gg/plugin-extra": { ...tasksPlugin(), id: "@mh-gg/plugin-extra", integrity: "sha256-extra" }
|
|
1205
|
+
}), (error) => {
|
|
1206
|
+
assert.equal(error.code, "PLUGIN_DEPENDENCY_VERSION");
|
|
1207
|
+
return true;
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
assert.throws(() => resolveHostPlugins(taskHostPack({
|
|
1211
|
+
plugins: [{
|
|
1212
|
+
id: "@mh-gg/plugin-extra",
|
|
1213
|
+
version: "1.0.0",
|
|
1214
|
+
source: "workspace:extra",
|
|
1215
|
+
integrity: "sha256-extra",
|
|
1216
|
+
conflictsWith: ["@mh-gg/plugin-tasks"]
|
|
1217
|
+
}]
|
|
1218
|
+
}), {
|
|
1219
|
+
"@mh-gg/plugin-tasks": tasksPlugin(),
|
|
1220
|
+
"@mh-gg/plugin-extra": { ...tasksPlugin(), id: "@mh-gg/plugin-extra", integrity: "sha256-extra" }
|
|
1221
|
+
}), (error) => {
|
|
1222
|
+
assert.equal(error.code, "PLUGIN_CONFLICT");
|
|
1223
|
+
return true;
|
|
1224
|
+
});
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
test("installHostPack rejects app protocol mismatches and plugin integrity mismatches", () => {
|
|
1228
|
+
const hostPack = taskHostPack({ compatibility: { appProtocolHash: "sha256-other-protocol" } });
|
|
1229
|
+
assert.throws(() => installHostPack({ appPack: taskAppPack(hostPack), hostPack, pluginRegistry: { "@mh-gg/plugin-tasks": tasksPlugin() } }), (error) => {
|
|
1230
|
+
assert.equal(error.code, "APP_PROTOCOL_MISMATCH");
|
|
1231
|
+
return true;
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
const goodHostPack = taskHostPack();
|
|
1235
|
+
assert.throws(() => installHostPack({
|
|
1236
|
+
appPack: taskAppPack(goodHostPack),
|
|
1237
|
+
hostPack: goodHostPack,
|
|
1238
|
+
pluginRegistry: { "@mh-gg/plugin-tasks": { ...tasksPlugin(), integrity: "sha256-different" } }
|
|
1239
|
+
}), (error) => {
|
|
1240
|
+
assert.equal(error.code, "PLUGIN_INTEGRITY_MISMATCH");
|
|
1241
|
+
return true;
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test("start lifecycle creates missing migrated plugin state and isolates migration failures", async () => {
|
|
1246
|
+
const store = createMemoryRoomStore({
|
|
1247
|
+
schemaVersion: 1,
|
|
1248
|
+
roomId: "room_tasks",
|
|
1249
|
+
appPack,
|
|
1250
|
+
version: 0,
|
|
1251
|
+
createdAt: 1,
|
|
1252
|
+
updatedAt: 1,
|
|
1253
|
+
members: {},
|
|
1254
|
+
pluginVersions: { "@mh-gg/plugin-roles": "1.0.0" },
|
|
1255
|
+
plugins: { "@mh-gg/plugin-roles": { permissions: {} } },
|
|
1256
|
+
seenOperations: []
|
|
1257
|
+
});
|
|
1258
|
+
const runtime = new HostPluginRuntime({
|
|
1259
|
+
room: { id: "room_tasks", appPack },
|
|
1260
|
+
plugins: [rolesPlugin(), tasksPlugin()],
|
|
1261
|
+
store,
|
|
1262
|
+
operationLog: createMemoryOperationLog(),
|
|
1263
|
+
authenticateActor: async (_auth, actor) => actor
|
|
1264
|
+
});
|
|
1265
|
+
const state = await runtime.start();
|
|
1266
|
+
assert.deepEqual(state.plugins["@mh-gg/plugin-tasks"], { taskOrder: [], tasks: {} });
|
|
1267
|
+
|
|
1268
|
+
const failingMigrationPlugin = {
|
|
1269
|
+
...tasksPlugin(),
|
|
1270
|
+
migrations: {
|
|
1271
|
+
"0.9.0->1.0.0"() {
|
|
1272
|
+
throw new Error("bad migration");
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
const failingStore = createMemoryRoomStore({
|
|
1277
|
+
schemaVersion: 1,
|
|
1278
|
+
roomId: "room_tasks",
|
|
1279
|
+
appPack,
|
|
1280
|
+
version: 0,
|
|
1281
|
+
createdAt: 1,
|
|
1282
|
+
updatedAt: 1,
|
|
1283
|
+
members: {},
|
|
1284
|
+
pluginVersions: { "@mh-gg/plugin-tasks": "0.9.0" },
|
|
1285
|
+
plugins: { "@mh-gg/plugin-tasks": { taskOrder: [], tasks: {} } },
|
|
1286
|
+
seenOperations: []
|
|
1287
|
+
});
|
|
1288
|
+
const failingRuntime = new HostPluginRuntime({
|
|
1289
|
+
room: { id: "room_tasks", appPack },
|
|
1290
|
+
plugins: [failingMigrationPlugin],
|
|
1291
|
+
store: failingStore,
|
|
1292
|
+
operationLog: createMemoryOperationLog(),
|
|
1293
|
+
authenticateActor: async (_auth, actor) => actor
|
|
1294
|
+
});
|
|
1295
|
+
await assert.rejects(() => failingRuntime.start(), (error) => {
|
|
1296
|
+
assert.equal(error.code, "MIGRATION_FAILED");
|
|
1297
|
+
return true;
|
|
1298
|
+
});
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test("start lifecycle does not save or mutate persisted state when migration startup fails", async () => {
|
|
1302
|
+
const persistedState = {
|
|
1303
|
+
schemaVersion: 1,
|
|
1304
|
+
roomId: "room_tasks",
|
|
1305
|
+
appPack: { ...appPack, hash: "sha256-old-app" },
|
|
1306
|
+
version: 3,
|
|
1307
|
+
createdAt: 1,
|
|
1308
|
+
updatedAt: 2,
|
|
1309
|
+
members: {},
|
|
1310
|
+
revokedCredentialIds: [],
|
|
1311
|
+
pluginVersions: { "@mh-gg/plugin-tasks": "1.0.0" },
|
|
1312
|
+
plugins: {
|
|
1313
|
+
"@mh-gg/plugin-tasks": {
|
|
1314
|
+
taskOrder: ["task_existing"],
|
|
1315
|
+
tasks: {
|
|
1316
|
+
task_existing: {
|
|
1317
|
+
id: "task_existing",
|
|
1318
|
+
title: "Keep me",
|
|
1319
|
+
done: false,
|
|
1320
|
+
createdBy: "admin",
|
|
1321
|
+
createdAt: 1
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
},
|
|
1326
|
+
seenOperations: ["op_existing"]
|
|
1327
|
+
};
|
|
1328
|
+
const store = {
|
|
1329
|
+
saveCalls: 0,
|
|
1330
|
+
async load() {
|
|
1331
|
+
return persistedState;
|
|
1332
|
+
},
|
|
1333
|
+
async save(nextState) {
|
|
1334
|
+
this.saveCalls += 1;
|
|
1335
|
+
this.saved = nextState;
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
const failingInstallPlugin = {
|
|
1339
|
+
id: "@mh-gg/plugin-failing-install",
|
|
1340
|
+
version: "1.0.0",
|
|
1341
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: "@mh-gg/plugin-failing-install", version: "1.0.0", operations: {} }),
|
|
1342
|
+
schemas: {
|
|
1343
|
+
state: schema((state) => state),
|
|
1344
|
+
operations: {}
|
|
1345
|
+
},
|
|
1346
|
+
createInitialState(ctx) {
|
|
1347
|
+
ctx.roomState.plugins["@mh-gg/plugin-tasks"].taskOrder.push("mutated-before-failure");
|
|
1348
|
+
throw new Error("extra plugin initial state failed");
|
|
1349
|
+
},
|
|
1350
|
+
authorize() {
|
|
1351
|
+
return deny("no operations");
|
|
1352
|
+
},
|
|
1353
|
+
reduce(ctx, state) {
|
|
1354
|
+
return state;
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
const runtime = new HostPluginRuntime({
|
|
1358
|
+
room: { id: "room_tasks", appPack },
|
|
1359
|
+
plugins: [tasksPlugin(), failingInstallPlugin],
|
|
1360
|
+
store,
|
|
1361
|
+
operationLog: createMemoryOperationLog(),
|
|
1362
|
+
authenticateActor: async (_auth, actor) => actor
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
await assert.rejects(() => runtime.start(), /extra plugin initial state failed/);
|
|
1366
|
+
|
|
1367
|
+
assert.equal(store.saveCalls, 0);
|
|
1368
|
+
assert.equal(store.saved, undefined);
|
|
1369
|
+
assert.deepEqual(persistedState.plugins["@mh-gg/plugin-tasks"].taskOrder, ["task_existing"]);
|
|
1370
|
+
assert.equal(persistedState.plugins["@mh-gg/plugin-failing-install"], undefined);
|
|
1371
|
+
assert.equal(persistedState.pluginVersions["@mh-gg/plugin-failing-install"], undefined);
|
|
1372
|
+
assert.equal(persistedState.appPack.hash, "sha256-old-app");
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
test("lifecycle hooks, snapshots, operation fallbacks, and room info expose plugin packs", async () => {
|
|
1376
|
+
let stopped = false;
|
|
1377
|
+
const playerPack = {
|
|
1378
|
+
kind: "matterhorn.player-pack",
|
|
1379
|
+
id: "com.matterhorn.tasks.player",
|
|
1380
|
+
name: "Tasks Player",
|
|
1381
|
+
version: "1.0.0",
|
|
1382
|
+
publisher: { id: "com.matterhorn", name: "Matterhorn", publicKey: "rk_pub" },
|
|
1383
|
+
entrypoints: { default: "./player.js" },
|
|
1384
|
+
supports: [{ appPackId: appPack.id, appPackRange: ">=1.0.0", appProtocolHash: appPack.protocolHash }],
|
|
1385
|
+
mode: "embedded",
|
|
1386
|
+
trust: { signatures: [{ publicKey: "rk_pub", signature: "sig" }] }
|
|
1387
|
+
};
|
|
1388
|
+
const hostPack = taskHostPack();
|
|
1389
|
+
const fallbackLog = {
|
|
1390
|
+
entries: [],
|
|
1391
|
+
async append(entry) { this.entries.push(entry); return entry; }
|
|
1392
|
+
};
|
|
1393
|
+
const runtime = new HostPluginRuntime({
|
|
1394
|
+
room: { id: "room_tasks", appPack },
|
|
1395
|
+
hostPack,
|
|
1396
|
+
playerPacks: [playerPack],
|
|
1397
|
+
plugins: [rolesPlugin(), { ...tasksPlugin(), onRoomStop() { stopped = true; } }],
|
|
1398
|
+
store: createMemoryRoomStore(),
|
|
1399
|
+
operationLog: fallbackLog,
|
|
1400
|
+
operationPublicKey: "op_pub",
|
|
1401
|
+
authenticateActor: async (_auth, actor) => actor,
|
|
1402
|
+
now: () => 42
|
|
1403
|
+
});
|
|
1404
|
+
await runtime.start();
|
|
1405
|
+
await runtime.handleOperation(operation());
|
|
1406
|
+
assert.equal((await runtime.operations()).length, 1);
|
|
1407
|
+
const batch = await runtime.operationBatchSince(0);
|
|
1408
|
+
assert.equal(batch.operations.length, 1);
|
|
1409
|
+
const snapshot = await runtime.createSnapshot();
|
|
1410
|
+
assert.equal(snapshot.createdAt, 42);
|
|
1411
|
+
const info = await runtime.roomInfo();
|
|
1412
|
+
assert.equal(info.hostPack.id, hostPack.id);
|
|
1413
|
+
assert.equal(info.playerRecommendations[0].id, playerPack.id);
|
|
1414
|
+
assert.equal(info.operationPublicKey, "op_pub");
|
|
1415
|
+
await runtime.stop();
|
|
1416
|
+
assert.equal(stopped, true);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
test("runs stop and uninstall lifecycle hooks in reverse order and reports failing plugins", async () => {
|
|
1420
|
+
const calls = [];
|
|
1421
|
+
function lifecyclePlugin(id, hooks = {}) {
|
|
1422
|
+
return {
|
|
1423
|
+
id,
|
|
1424
|
+
version: "1.0.0",
|
|
1425
|
+
operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: id, version: "1.0.0", operations: {} }),
|
|
1426
|
+
schemas: {
|
|
1427
|
+
state: schema((state) => {
|
|
1428
|
+
assert.equal(typeof state.ready, "boolean");
|
|
1429
|
+
return state;
|
|
1430
|
+
}),
|
|
1431
|
+
operations: {}
|
|
1432
|
+
},
|
|
1433
|
+
createInitialState() {
|
|
1434
|
+
return { ready: true };
|
|
1435
|
+
},
|
|
1436
|
+
authorize() {
|
|
1437
|
+
return deny();
|
|
1438
|
+
},
|
|
1439
|
+
reduce(_ctx, state) {
|
|
1440
|
+
return state;
|
|
1441
|
+
},
|
|
1442
|
+
onInstall: hooks.onInstall || (() => calls.push(`${id}:install`)),
|
|
1443
|
+
onRoomStart: hooks.onRoomStart || (() => calls.push(`${id}:start`)),
|
|
1444
|
+
afterStart: hooks.afterStart || (() => calls.push(`${id}:afterStart`)),
|
|
1445
|
+
onRoomStop: hooks.onRoomStop || (() => calls.push(`${id}:stop`)),
|
|
1446
|
+
onUninstall: hooks.onUninstall || (() => calls.push(`${id}:uninstall`))
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const runtime = new HostPluginRuntime({
|
|
1451
|
+
room: { id: "room_tasks", appPack },
|
|
1452
|
+
plugins: [lifecyclePlugin("base"), lifecyclePlugin("child")],
|
|
1453
|
+
store: createMemoryRoomStore(),
|
|
1454
|
+
operationLog: createMemoryOperationLog(),
|
|
1455
|
+
authenticateActor: async (_auth, actor) => actor
|
|
1456
|
+
});
|
|
1457
|
+
await runtime.start();
|
|
1458
|
+
await runtime.stop();
|
|
1459
|
+
await runtime.uninstall();
|
|
1460
|
+
assert.deepEqual(calls, [
|
|
1461
|
+
"base:install",
|
|
1462
|
+
"child:install",
|
|
1463
|
+
"base:start",
|
|
1464
|
+
"child:start",
|
|
1465
|
+
"base:afterStart",
|
|
1466
|
+
"child:afterStart",
|
|
1467
|
+
"child:stop",
|
|
1468
|
+
"base:stop",
|
|
1469
|
+
"child:uninstall",
|
|
1470
|
+
"base:uninstall"
|
|
1471
|
+
]);
|
|
1472
|
+
|
|
1473
|
+
const failingRuntime = new HostPluginRuntime({
|
|
1474
|
+
room: { id: "room_tasks", appPack },
|
|
1475
|
+
plugins: [lifecyclePlugin("ok"), lifecyclePlugin("bad", {
|
|
1476
|
+
onRoomStop() {
|
|
1477
|
+
throw new Error("stop failed");
|
|
1478
|
+
}
|
|
1479
|
+
})],
|
|
1480
|
+
store: createMemoryRoomStore(),
|
|
1481
|
+
operationLog: createMemoryOperationLog(),
|
|
1482
|
+
authenticateActor: async (_auth, actor) => actor
|
|
1483
|
+
});
|
|
1484
|
+
await failingRuntime.start();
|
|
1485
|
+
await assert.rejects(() => failingRuntime.stop(), (error) => {
|
|
1486
|
+
assert.equal(error.code, "LIFECYCLE_HOOK_FAILED");
|
|
1487
|
+
assert.match(error.message, /bad\.onRoomStop failed/);
|
|
1488
|
+
return true;
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
test("installAppPack and startRoomHost wire workspace manifests into a runnable host", async () => {
|
|
1493
|
+
const hostPack = taskHostPack();
|
|
1494
|
+
const appManifest = taskAppPack(hostPack);
|
|
1495
|
+
const graphSafeTasks = { ...tasksPlugin(), stateSchemaHash: "sha256-state", operationSchemaHash: "sha256-ops" };
|
|
1496
|
+
const installed = await installAppPack({ url: "workspace:tasks-app", integrity: manifestHash(appManifest) }, {
|
|
1497
|
+
workspace: {
|
|
1498
|
+
"workspace:tasks-app": appManifest,
|
|
1499
|
+
"tasks-app": appManifest,
|
|
1500
|
+
"workspace:tasks-host": hostPack,
|
|
1501
|
+
"tasks-host": hostPack
|
|
1502
|
+
},
|
|
1503
|
+
pluginRegistry: { "@mh-gg/plugin-tasks": graphSafeTasks },
|
|
1504
|
+
skipPluginGraphHash: true
|
|
1505
|
+
});
|
|
1506
|
+
assert.equal(installed.plugins[0].id, "@mh-gg/plugin-tasks");
|
|
1507
|
+
|
|
1508
|
+
const calls = [];
|
|
1509
|
+
const started = await startRoomHost({
|
|
1510
|
+
installed,
|
|
1511
|
+
roomName: "room_tasks",
|
|
1512
|
+
authenticateActor: async (_auth, actor) => actor,
|
|
1513
|
+
store: createMemoryRoomStore(),
|
|
1514
|
+
relay: { async registerRoom(input) { calls.push(input); } },
|
|
1515
|
+
createInviteUrl: ({ room }) => `matterhorn://custom/${room.id}`
|
|
1516
|
+
});
|
|
1517
|
+
assert.equal(started.room.id, "room_tasks");
|
|
1518
|
+
assert.equal(started.inviteUrl, "matterhorn://custom/room_tasks");
|
|
1519
|
+
assert.equal(calls[0].room.id, "room_tasks");
|
|
1520
|
+
assert.equal(calls[0].appPack.id, appManifest.id);
|
|
1521
|
+
assert.equal(calls[0].hostPack.id, hostPack.id);
|
|
1522
|
+
assert.equal(calls[0].plugins[0].id, "@mh-gg/plugin-tasks");
|
|
1523
|
+
assert.equal(calls[0].state.roomId, "room_tasks");
|
|
1524
|
+
assert.deepEqual(calls[0].operations, []);
|
|
1525
|
+
assert.equal((await started.runtime.getState()).roomId, "room_tasks");
|
|
1526
|
+
|
|
1527
|
+
await assert.rejects(() => startRoomHost({ installed }), (error) => {
|
|
1528
|
+
assert.equal(error.code, "INVALID_ROOM");
|
|
1529
|
+
return true;
|
|
1530
|
+
});
|
|
1531
|
+
await assert.rejects(() => startRoomHost({
|
|
1532
|
+
installed,
|
|
1533
|
+
roomName: "room_tasks",
|
|
1534
|
+
relay: { async registerRoom() { throw new Error("relay down"); } }
|
|
1535
|
+
}), (error) => {
|
|
1536
|
+
assert.equal(error.code, "RELAY_REGISTRATION_FAILED");
|
|
1537
|
+
return true;
|
|
1538
|
+
});
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
test("startRoomHost production role-key grants reject unsigned grants by default", async () => {
|
|
1542
|
+
const hostPack = taskHostPack();
|
|
1543
|
+
const appManifest = taskAppPack(hostPack);
|
|
1544
|
+
const installed = installHostPack({
|
|
1545
|
+
appPack: appManifest,
|
|
1546
|
+
hostPack,
|
|
1547
|
+
pluginRegistry: { "@mh-gg/plugin-tasks": tasksPlugin() },
|
|
1548
|
+
skipPluginGraphHash: true
|
|
1549
|
+
});
|
|
1550
|
+
const operationLog = createMemoryOperationLog();
|
|
1551
|
+
const roleKey = generateOperationRoleKeyPair({ role: "admin" });
|
|
1552
|
+
const grant = createOperationRoleKeyGrant({
|
|
1553
|
+
roomId: "room_tasks",
|
|
1554
|
+
appPackId: installed.roomAppPack.id,
|
|
1555
|
+
appPackHash: installed.roomAppPack.hash,
|
|
1556
|
+
credentialId: "cred_prod_unsigned",
|
|
1557
|
+
memberId: "admin",
|
|
1558
|
+
deviceId: "dev_admin",
|
|
1559
|
+
role: "admin",
|
|
1560
|
+
publicKeyPem: roleKey.publicKeyPem,
|
|
1561
|
+
issuedAt: 1
|
|
1562
|
+
});
|
|
1563
|
+
const started = await startRoomHost({
|
|
1564
|
+
installed,
|
|
1565
|
+
roomName: "room_tasks",
|
|
1566
|
+
productionRuntime: true,
|
|
1567
|
+
initialOwners: ["admin"],
|
|
1568
|
+
store: createAtomicMemoryStore(null, operationLog),
|
|
1569
|
+
operationLog,
|
|
1570
|
+
operationRoleKeyGrants: [grant]
|
|
1571
|
+
});
|
|
1572
|
+
const signed = signOperationWithRoleKey(operation({
|
|
1573
|
+
id: "op_unsigned_prod_host",
|
|
1574
|
+
appPackId: installed.roomAppPack.id,
|
|
1575
|
+
appPackHash: installed.roomAppPack.hash,
|
|
1576
|
+
auth: { credentialId: grant.credentialId },
|
|
1577
|
+
actor: { memberId: "admin", deviceId: "dev_admin", role: "admin" }
|
|
1578
|
+
}), {
|
|
1579
|
+
grant,
|
|
1580
|
+
privateKeyPem: roleKey.privateKeyPem,
|
|
1581
|
+
issuedAt: 1000
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
const result = await started.runtime.handleOperation(signed);
|
|
1585
|
+
|
|
1586
|
+
assert.equal(result.ok, false);
|
|
1587
|
+
assert.match(result.reason, /signed|authority/i);
|
|
1588
|
+
assert.equal(operationLog.entries.length, 0);
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
test("runMigrations reports unchanged stores and rejects unparseable initial state", async () => {
|
|
1592
|
+
const hostPack = taskHostPack();
|
|
1593
|
+
const installed = installHostPack({ appPack: taskAppPack(hostPack), hostPack, pluginRegistry: { "@mh-gg/plugin-tasks": tasksPlugin() } });
|
|
1594
|
+
assert.deepEqual(await runMigrations(createMemoryRoomStore(), installed), { ok: true, migrated: [] });
|
|
1595
|
+
const oldHashStore = createMemoryRoomStore({
|
|
1596
|
+
schemaVersion: 1,
|
|
1597
|
+
roomId: "room_tasks",
|
|
1598
|
+
appPack: { ...installed.roomAppPack, hash: "sha256-old-task-schema" },
|
|
1599
|
+
version: 1,
|
|
1600
|
+
createdAt: 1,
|
|
1601
|
+
updatedAt: 2,
|
|
1602
|
+
members: {},
|
|
1603
|
+
revokedCredentialIds: [],
|
|
1604
|
+
pluginVersions: { "@mh-gg/plugin-tasks": "1.0.0" },
|
|
1605
|
+
plugins: { "@mh-gg/plugin-tasks": { taskOrder: [], tasks: {} } },
|
|
1606
|
+
seenOperations: []
|
|
1607
|
+
});
|
|
1608
|
+
const migrated = await runMigrations(oldHashStore, installed, { room: { id: "room_tasks", appPack: installed.roomAppPack } });
|
|
1609
|
+
assert.equal(migrated.state.appPack.hash, installed.roomAppPack.hash);
|
|
1610
|
+
assert.equal((await oldHashStore.load()).appPack.hash, installed.roomAppPack.hash);
|
|
1611
|
+
|
|
1612
|
+
const badPlugin = {
|
|
1613
|
+
...tasksPlugin(),
|
|
1614
|
+
createInitialState() {
|
|
1615
|
+
return null;
|
|
1616
|
+
}
|
|
1617
|
+
};
|
|
1618
|
+
const runtime = new HostPluginRuntime({
|
|
1619
|
+
room: { id: "room_tasks", appPack },
|
|
1620
|
+
plugins: [badPlugin],
|
|
1621
|
+
store: createMemoryRoomStore(),
|
|
1622
|
+
operationLog: createMemoryOperationLog(),
|
|
1623
|
+
authenticateActor: async (_auth, actor) => actor
|
|
1624
|
+
});
|
|
1625
|
+
await assert.rejects(() => runtime.start(), (error) => {
|
|
1626
|
+
assert.equal(error.code, "SCHEMA_INVALID");
|
|
1627
|
+
return true;
|
|
1628
|
+
});
|
|
1629
|
+
});
|