@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.
Files changed (71) hide show
  1. package/package.json +23 -0
  2. package/src/constants.cjs +9 -0
  3. package/src/errors.cjs +39 -0
  4. package/src/host/installAppPack.cjs +35 -0
  5. package/src/host/migrations.cjs +21 -0
  6. package/src/host/runtimeFromPack.cjs +25 -0
  7. package/src/host/startRoomHost.cjs +95 -0
  8. package/src/index.cjs +23 -0
  9. package/src/memory.cjs +81 -0
  10. package/src/plugins/definition.cjs +19 -0
  11. package/src/plugins/install.cjs +90 -0
  12. package/src/plugins/migrations.cjs +27 -0
  13. package/src/plugins/operationDescriptors.cjs +138 -0
  14. package/src/runtime/HostPluginRuntime.cjs +85 -0
  15. package/src/runtime/authorityReplay/applyAuthority.cjs +146 -0
  16. package/src/runtime/authorityReplay/applyContent.cjs +49 -0
  17. package/src/runtime/authorityReplay/index.cjs +70 -0
  18. package/src/runtime/authorityReplay/state.cjs +56 -0
  19. package/src/runtime/context.cjs +65 -0
  20. package/src/runtime/coreOperations.cjs +169 -0
  21. package/src/runtime/corePayloads.cjs +66 -0
  22. package/src/runtime/directMessages/commit.cjs +50 -0
  23. package/src/runtime/directMessages/constants.cjs +20 -0
  24. package/src/runtime/directMessages/helpers.cjs +59 -0
  25. package/src/runtime/directMessages/payloads.cjs +74 -0
  26. package/src/runtime/directMessages/state.cjs +168 -0
  27. package/src/runtime/directMessages.cjs +6 -0
  28. package/src/runtime/lifecycle.cjs +166 -0
  29. package/src/runtime/memberProfiles.cjs +74 -0
  30. package/src/runtime/methods.cjs +10 -0
  31. package/src/runtime/operations.cjs +166 -0
  32. package/src/runtime/queries.cjs +146 -0
  33. package/src/runtime/readTags.cjs +171 -0
  34. package/src/runtime/scopedRoleOperations.cjs +97 -0
  35. package/src/runtime/snowflake.cjs +43 -0
  36. package/src/security/authority/constants.cjs +10 -0
  37. package/src/security/authority/resolve/operations.cjs +7 -0
  38. package/src/security/authority/resolve/policy.cjs +7 -0
  39. package/src/security/authority/resolve/voids.cjs +8 -0
  40. package/src/security/authorization/coreGate.cjs +63 -0
  41. package/src/security/authorization/schemaActions.cjs +75 -0
  42. package/src/security/roleKeys/authenticator.cjs +36 -0
  43. package/src/security/roleKeys/authorities/index.cjs +4 -0
  44. package/src/security/roleKeys/authorities/shapes.cjs +98 -0
  45. package/src/security/roleKeys/authorities/signing.cjs +121 -0
  46. package/src/security/roleKeys/constants.cjs +15 -0
  47. package/src/security/roleKeys/fingerprints.cjs +24 -0
  48. package/src/security/roleKeys/grants.cjs +93 -0
  49. package/src/security/roleKeys/index.cjs +10 -0
  50. package/src/security/roleKeys/roles.cjs +21 -0
  51. package/src/security/roleKeys/signatures.cjs +126 -0
  52. package/src/security/roles.cjs +10 -0
  53. package/src/security/roomDeviceKeys.cjs +41 -0
  54. package/src/security/scopedRoles/access.cjs +123 -0
  55. package/src/security/scopedRoles/constants.cjs +23 -0
  56. package/src/security/scopedRoles/metadata.cjs +39 -0
  57. package/src/security/scopedRoles/normalize.cjs +179 -0
  58. package/src/security/scopedRoles/publicView.cjs +31 -0
  59. package/src/security/scopedRoles/stateOps.cjs +167 -0
  60. package/src/security/scopedRoles.cjs +7 -0
  61. package/src/security/standingAuthority.cjs +76 -0
  62. package/src/shared.cjs +14 -0
  63. package/src/state.cjs +54 -0
  64. package/test/authority-ordering-hardening.test.cjs +101 -0
  65. package/test/authorization-gate.test.cjs +610 -0
  66. package/test/cascading-authority.test.cjs +390 -0
  67. package/test/grant-authority-security.test.cjs +305 -0
  68. package/test/matterhorn-host-runtime.test.cjs +1629 -0
  69. package/test/operation-descriptor-policy.test.cjs +140 -0
  70. package/test/role-key-auth.test.cjs +289 -0
  71. package/test/security-isolation.test.cjs +112 -0
@@ -0,0 +1,140 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const {
5
+ HostPluginRuntime,
6
+ allow,
7
+ createMemoryOperationLog,
8
+ createMemoryRoomStore,
9
+ createOperationSchemaDescriptor,
10
+ defineHostPlugin,
11
+ declaredRolesForOperation,
12
+ operationDescriptor
13
+ } = require("..");
14
+ const { ensureOperationIdentity } = require("@mh-gg/protocol");
15
+
16
+ const ROOM = {
17
+ id: "room_policy",
18
+ appPack: { id: "app.policy", hash: "sha256-policy" }
19
+ };
20
+
21
+ function stateSchema() {
22
+ return { parse(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : { items: [] }; } };
23
+ }
24
+
25
+ function payloadSchema() {
26
+ return { parse(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } };
27
+ }
28
+
29
+ function validPlugin(overrides = {}) {
30
+ return defineHostPlugin({
31
+ id: overrides.id || "policy.plugin",
32
+ version: "1.0.0",
33
+ schemas: {
34
+ state: stateSchema(),
35
+ operations: overrides.operations || { "thing.create": payloadSchema() }
36
+ },
37
+ operationSchemaDescriptor: overrides.operationSchemaDescriptor || createOperationSchemaDescriptor({
38
+ plugin: overrides.id || "policy.plugin",
39
+ version: "1.0.0",
40
+ operations: {
41
+ "thing.create": { authorize: { roles: ["member"] } }
42
+ }
43
+ }),
44
+ createInitialState() { return { items: [] }; },
45
+ authorize() { return allow(); },
46
+ reduce(_ctx, state) { return { ...state, items: [...(state.items || []), "ok"] }; }
47
+ });
48
+ }
49
+
50
+ function runtimeFor(plugin) {
51
+ return new HostPluginRuntime({
52
+ room: ROOM,
53
+ plugins: [plugin],
54
+ store: createMemoryRoomStore(),
55
+ operationLog: createMemoryOperationLog(),
56
+ authenticateActor: async (_auth, actor) => actor
57
+ });
58
+ }
59
+
60
+ function operation(type = "thing.create", overrides = {}) {
61
+ const op = {
62
+ id: `op_${type.replace(/[^a-z0-9]+/gi, "_")}_${overrides.seq || 1}`,
63
+ roomId: ROOM.id,
64
+ appPackId: ROOM.appPack.id,
65
+ appPackHash: ROOM.appPack.hash,
66
+ pluginId: "policy.plugin",
67
+ type,
68
+ actor: { memberId: "member", deviceId: "dev", role: "member" },
69
+ seq: overrides.seq || 1,
70
+ createdAt: 1000 + (overrides.seq || 1),
71
+ payload: {},
72
+ auth: { credentialId: "cred", signature: "sig" },
73
+ ...overrides
74
+ };
75
+ return ensureOperationIdentity(op, { nodeId: op.actor.deviceId, now: op.createdAt });
76
+ }
77
+
78
+ test("valid plugins expose one canonical operation descriptor shape", () => {
79
+ const plugin = validPlugin();
80
+ assert.deepEqual(operationDescriptor(plugin, "thing.create"), { authorize: { roles: ["member"] } });
81
+ assert.deepEqual(declaredRolesForOperation(plugin, "thing.create"), ["member"]);
82
+ });
83
+
84
+ test("plugin registration rejects operations without canonical authorize.roles", () => {
85
+ assert.throws(() => validPlugin({
86
+ operationSchemaDescriptor: createOperationSchemaDescriptor({
87
+ plugin: "policy.plugin",
88
+ version: "1.0.0",
89
+ operations: { "thing.create": { required: ["title"] } }
90
+ })
91
+ }), /authorize\.roles|OPERATION_ROLE_POLICY_MISSING/);
92
+ });
93
+
94
+ test("plugin registration rejects legacy flat operation descriptors", () => {
95
+ assert.throws(() => validPlugin({
96
+ operationSchemaDescriptor: { "thing.create": { authorize: { roles: ["member"] } } }
97
+ }), /operationSchemaDescriptor\.operations|canonical/i);
98
+ });
99
+
100
+ test("plugin registration rejects alternate descriptor shapes instead of falling back", () => {
101
+ assert.throws(() => defineHostPlugin({
102
+ id: "policy.plugin",
103
+ version: "1.0.0",
104
+ operationPolicyDescriptor: { "thing.create": { authorize: { roles: ["member"] } } },
105
+ schemas: { state: stateSchema(), operations: { "thing.create": payloadSchema() } },
106
+ createInitialState() { return { items: [] }; },
107
+ authorize() { return allow(); },
108
+ reduce(_ctx, state) { return state; }
109
+ }), /operationSchemaDescriptor|canonical/i);
110
+ });
111
+
112
+ test("canonical descriptor entries must match schemas.operations exactly", () => {
113
+ assert.throws(() => validPlugin({
114
+ operationSchemaDescriptor: createOperationSchemaDescriptor({
115
+ plugin: "policy.plugin",
116
+ version: "1.0.0",
117
+ operations: {
118
+ "thing.create": { authorize: { roles: ["member"] } },
119
+ "thing.extra": { authorize: { roles: ["member"] } }
120
+ }
121
+ })
122
+ }), /thing\.extra|schemas\.operations/);
123
+
124
+ assert.throws(() => validPlugin({
125
+ operations: { "thing.create": payloadSchema(), "thing.delete": payloadSchema() },
126
+ operationSchemaDescriptor: createOperationSchemaDescriptor({
127
+ plugin: "policy.plugin",
128
+ version: "1.0.0",
129
+ operations: { "thing.create": { authorize: { roles: ["member"] } } }
130
+ })
131
+ }), /thing\.delete|authorize\.roles/);
132
+ });
133
+
134
+ test("unknown operation attacks produce routine unknown-operation denial, not missing policy", async () => {
135
+ const runtime = runtimeFor(validPlugin());
136
+ const result = await runtime.handleOperation(operation("thing.unknown"));
137
+ assert.equal(result.ok, false);
138
+ assert.equal(result.code, "UNKNOWN_OPERATION_TYPE");
139
+ assert.doesNotMatch(result.reason, /authorize\.roles|role policy/i);
140
+ });
@@ -0,0 +1,289 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { manifestHash } = require("@mh-gg/base");
5
+ const {
6
+ HostPluginRuntime,
7
+ createMemoryOperationLog,
8
+ createMemoryRoomStore,
9
+ createOperationRoleKeyGrant,
10
+ createRoleKeyAuthenticator,
11
+ generateOperationRoleKeyPair,
12
+ publicOperationRoleKeyGrant,
13
+ signOperationWithRoleKey,
14
+ verifyOperationRoleKey,
15
+ operationKeyRoleToActorRole
16
+ } = require("../src/index.cjs");
17
+ const { kanbanAppPack, kanbanHostPlugin, KANBAN_PLUGIN_ID } = require("../../../examples/kanban/src/sdk-app.mjs");
18
+
19
+ const ROOM_ID = "room_role_keys";
20
+ const APP_HASH = manifestHash(kanbanAppPack);
21
+
22
+ function actor(role = "member", memberId = "member") {
23
+ return { memberId, deviceId: `dev_${memberId}`, role, displayName: memberId };
24
+ }
25
+
26
+ function operation(overrides = {}) {
27
+ return {
28
+ id: overrides.id || "op_1",
29
+ roomId: ROOM_ID,
30
+ appPackId: kanbanAppPack.id,
31
+ appPackHash: APP_HASH,
32
+ pluginId: overrides.pluginId || KANBAN_PLUGIN_ID,
33
+ type: overrides.type || "list.create",
34
+ actor: overrides.actor || actor("admin", "admin"),
35
+ seq: overrides.seq || 1,
36
+ createdAt: overrides.createdAt || 1000,
37
+ payload: overrides.payload || { title: "Backlog" },
38
+ auth: { credentialId: overrides.credentialId || "cred_placeholder", signature: "placeholder" },
39
+ ...overrides
40
+ };
41
+ }
42
+
43
+ function signedOperation(op, key, grant, overrides = {}) {
44
+ return signOperationWithRoleKey({ ...op, auth: { credentialId: grant.credentialId } }, {
45
+ privateKeyPem: key.privateKeyPem,
46
+ grant,
47
+ now: () => op.createdAt || 1000,
48
+ ...overrides
49
+ });
50
+ }
51
+
52
+ function initialMembersFromGrants(grants) {
53
+ return Object.fromEntries(grants.filter((grant) => grant.memberId).map((grant) => [grant.memberId, { id: grant.memberId, role: operationKeyRoleToActorRole(grant.role), credentialId: grant.credentialId, deviceId: grant.deviceId }]));
54
+ }
55
+
56
+ async function startRuntime(grants, now = () => 5000) {
57
+ const runtime = new HostPluginRuntime({
58
+ room: {
59
+ id: ROOM_ID,
60
+ appPack: {
61
+ id: kanbanAppPack.id,
62
+ version: kanbanAppPack.version,
63
+ hash: APP_HASH,
64
+ protocolHash: kanbanAppPack.compatibility.appProtocolHash
65
+ }
66
+ },
67
+ plugins: [kanbanHostPlugin],
68
+ store: createMemoryRoomStore(),
69
+ operationLog: createMemoryOperationLog(),
70
+ initialMembers: initialMembersFromGrants(grants),
71
+ authenticateActor: createRoleKeyAuthenticator({
72
+ roomId: ROOM_ID,
73
+ appPackId: kanbanAppPack.id,
74
+ appPackHash: APP_HASH,
75
+ grants,
76
+ now
77
+ })
78
+ });
79
+ await runtime.start();
80
+ return runtime;
81
+ }
82
+
83
+ test("role-key authenticator prevents a user key from claiming admin operations", async () => {
84
+ const adminKey = generateOperationRoleKeyPair({ role: "admin" });
85
+ const userKey = generateOperationRoleKeyPair({ role: "user" });
86
+ const adminGrant = createOperationRoleKeyGrant({
87
+ roomId: ROOM_ID,
88
+ appPackId: kanbanAppPack.id,
89
+ appPackHash: APP_HASH,
90
+ credentialId: "admin_cred",
91
+ memberId: "alice",
92
+ deviceId: "dev_alice",
93
+ role: "admin",
94
+ publicKeyPem: adminKey.publicKeyPem,
95
+ issuedAt: 100
96
+ });
97
+ const userGrant = createOperationRoleKeyGrant({
98
+ roomId: ROOM_ID,
99
+ appPackId: kanbanAppPack.id,
100
+ appPackHash: APP_HASH,
101
+ credentialId: "user_cred",
102
+ memberId: "bob",
103
+ deviceId: "dev_bob",
104
+ role: "user",
105
+ publicKeyPem: userKey.publicKeyPem,
106
+ issuedAt: 100
107
+ });
108
+ const runtime = await startRuntime([adminGrant, userGrant]);
109
+
110
+ const forgedAdmin = signedOperation(operation({
111
+ id: "op_forged_admin",
112
+ actor: actor("admin", "bob")
113
+ }), userKey, userGrant);
114
+ const rejected = await runtime.handleOperation(forgedAdmin);
115
+ assert.equal(rejected.ok, false);
116
+ assert.match(rejected.reason, /Admins only|Forbidden|Operation is not allowed|requires admin/);
117
+
118
+ const adminList = signedOperation(operation({ id: "op_admin_list", actor: actor("admin", "alice") }), adminKey, adminGrant);
119
+ assert.equal((await runtime.handleOperation(adminList)).ok, true);
120
+
121
+ const state = await runtime.getState();
122
+ const listId = state.plugins[KANBAN_PLUGIN_ID].lists[0].id;
123
+ const userCard = signedOperation(operation({
124
+ id: "op_user_card",
125
+ seq: 2,
126
+ type: "card.create",
127
+ actor: actor("admin", "bob"),
128
+ payload: { listId, title: "User can write member operation" },
129
+ createdAt: 1001
130
+ }), userKey, userGrant);
131
+ assert.equal((await runtime.handleOperation(userCard)).ok, true);
132
+
133
+ const finalState = await runtime.getState();
134
+ const cardId = finalState.plugins[KANBAN_PLUGIN_ID].lists[0].cardIds[0];
135
+ assert.equal(finalState.plugins[KANBAN_PLUGIN_ID].cards[cardId].title, "User can write member operation");
136
+ });
137
+
138
+ test("role-key signatures reject tampering, wrong room, and expired grants", async () => {
139
+ const key = generateOperationRoleKeyPair({ role: "admin" });
140
+ const grant = createOperationRoleKeyGrant({
141
+ roomId: ROOM_ID,
142
+ appPackId: kanbanAppPack.id,
143
+ appPackHash: APP_HASH,
144
+ credentialId: "admin_cred",
145
+ memberId: "alice",
146
+ role: "admin",
147
+ publicKeyPem: key.publicKeyPem,
148
+ issuedAt: 100,
149
+ expiresAt: 10000
150
+ });
151
+ const signed = signedOperation(operation({ id: "op_valid", actor: actor("admin", "alice") }), key, grant);
152
+ assert.equal(verifyOperationRoleKey(signed, [grant], { now: 5000 }).ok, true);
153
+
154
+ const tampered = { ...signed, payload: { title: "Changed after signing" } };
155
+ assert.equal(verifyOperationRoleKey(tampered, [grant], { now: 5000 }).ok, false);
156
+
157
+ const wrongRoom = signedOperation(operation({ id: "op_wrong_room", roomId: "other", actor: actor("admin", "alice") }), key, grant);
158
+ assert.equal(verifyOperationRoleKey(wrongRoom, [grant], { now: 5000 }).ok, false);
159
+
160
+ assert.equal(verifyOperationRoleKey(signed, [grant], { now: 11000 }).ok, false);
161
+ });
162
+
163
+ test("public role-key grants never expose private key material", () => {
164
+ const key = generateOperationRoleKeyPair({ role: "admin" });
165
+ const grant = createOperationRoleKeyGrant({
166
+ roomId: ROOM_ID,
167
+ appPackId: kanbanAppPack.id,
168
+ appPackHash: APP_HASH,
169
+ credentialId: "admin_cred",
170
+ memberId: "alice",
171
+ role: "admin",
172
+ publicKeyPem: key.publicKeyPem
173
+ });
174
+ const publicGrant = publicOperationRoleKeyGrant({ ...grant, privateKeyPem: key.privateKeyPem });
175
+ assert.equal(publicGrant.privateKeyPem, undefined);
176
+ assert.equal(publicGrant.publicKeyPem.includes("PUBLIC KEY"), true);
177
+ });
178
+
179
+ test("role-key helpers reject malformed grants and scoped permissions", () => {
180
+ const key = generateOperationRoleKeyPair({ role: "member" });
181
+ assert.equal(key.role, "user");
182
+ assert.throws(() => createOperationRoleKeyGrant({}), /publicKeyPem/);
183
+ assert.throws(() => createOperationRoleKeyGrant({ publicKeyPem: key.publicKeyPem, role: "guest", credentialId: "c", roomId: ROOM_ID, appPackId: kanbanAppPack.id }), /role/);
184
+ assert.throws(() => createOperationRoleKeyGrant({ publicKeyPem: key.publicKeyPem, role: "user", credentialId: "c", roomId: ROOM_ID, appPackId: kanbanAppPack.id, allowedPluginIds: [""] }), /allowedPluginIds/);
185
+
186
+ const grant = createOperationRoleKeyGrant({
187
+ roomId: ROOM_ID,
188
+ appPackId: kanbanAppPack.id,
189
+ appPackHash: APP_HASH,
190
+ credentialId: "user_scoped",
191
+ memberId: "bob",
192
+ deviceId: "dev_bob",
193
+ role: "user",
194
+ publicKeyPem: key.publicKeyPem,
195
+ allowedPluginIds: [KANBAN_PLUGIN_ID],
196
+ allowedOperations: ["card.create"],
197
+ issuedAt: 100
198
+ });
199
+ const base = operation({
200
+ id: "op_scoped",
201
+ type: "card.create",
202
+ actor: actor("member", "bob"),
203
+ payload: { listId: "list_1", title: "Card" },
204
+ createdAt: 1000
205
+ });
206
+ const signed = signedOperation(base, key, grant);
207
+ assert.equal(verifyOperationRoleKey(signed, [grant], { now: 5000 }).ok, true);
208
+ assert.match(verifyOperationRoleKey({ ...signed, pluginId: "other.plugin" }, [grant], { now: 5000 }).error, /plugin/);
209
+ assert.match(verifyOperationRoleKey({ ...signed, type: "list.create" }, [grant], { now: 5000 }).error, /operation/);
210
+ assert.match(verifyOperationRoleKey({ ...signed, actor: { ...signed.actor, deviceId: "other" } }, [grant], { now: 5000 }).error, /device/);
211
+ });
212
+
213
+ test("role-key verifier covers auth metadata and grant collection forms", () => {
214
+ const key = generateOperationRoleKeyPair({ role: "admin" });
215
+ const grant = createOperationRoleKeyGrant({
216
+ roomId: ROOM_ID,
217
+ appPackId: kanbanAppPack.id,
218
+ appPackHash: APP_HASH,
219
+ credentialId: "admin_map",
220
+ memberId: "alice",
221
+ role: "admin",
222
+ publicKeyPem: key.publicKeyPem,
223
+ issuedAt: 100
224
+ });
225
+ const signed = signedOperation(operation({ id: "op_map", actor: actor("admin", "alice") }), key, grant);
226
+ assert.equal(verifyOperationRoleKey(signed, new Map([[grant.credentialId, grant]]), { now: 5000 }).ok, true);
227
+ assert.equal(verifyOperationRoleKey(signed, { [grant.credentialId]: grant }, { now: 5000 }).ok, true);
228
+ assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, kind: "wrong" } }, [grant], { now: 5000 }).error, /kind/);
229
+ assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, version: 2 } }, [grant], { now: 5000 }).error, /version/);
230
+ assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, alg: "rsa" } }, [grant], { now: 5000 }).error, /algorithm/);
231
+ assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, keyRole: "user" } }, [grant], { now: 5000 }).error, /role/);
232
+ assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, publicKeyFingerprint: "sha256-nope" } }, [grant], { now: 5000 }).error, /fingerprint/);
233
+ assert.match(verifyOperationRoleKey({ ...signed, auth: { ...signed.auth, issuedAt: 1 } }, [grant], { now: 5000 }).error, /predates/);
234
+ assert.match(verifyOperationRoleKey({ ...signed, auth: { credentialId: grant.credentialId } }, [grant], { now: 5000 }).error, /signature/);
235
+ assert.match(verifyOperationRoleKey(null, [grant]).error, /Operation is required/);
236
+ assert.match(verifyOperationRoleKey({ ...signed, auth: null }, [grant]).error, /auth/);
237
+ });
238
+
239
+
240
+ test("role-key helpers cover canonicalization and public grant collection edge cases", () => {
241
+ const {
242
+ canonicalJson,
243
+ isOperationRoleKeyGrant,
244
+ operationKeyRoleToActorRole,
245
+ publicOperationRoleKeyGrants,
246
+ roleKeyFingerprint
247
+ } = require("../src/index.cjs");
248
+ assert.throws(() => canonicalJson(Symbol("bad")), /Unsupported value/);
249
+ assert.equal(operationKeyRoleToActorRole("guest"), "guest");
250
+ assert.deepEqual(publicOperationRoleKeyGrants(undefined), []);
251
+
252
+ const key = generateOperationRoleKeyPair({ role: "admin" });
253
+ const grant = createOperationRoleKeyGrant({
254
+ roomId: ROOM_ID,
255
+ appPackId: kanbanAppPack.id,
256
+ credentialId: "admin_publics",
257
+ role: "admin",
258
+ publicKeyPem: key.publicKeyPem
259
+ });
260
+ assert.equal(publicOperationRoleKeyGrants(grant)[0].credentialId, "admin_publics");
261
+ assert.equal(publicOperationRoleKeyGrants(new Map([[grant.credentialId, grant]])).length, 1);
262
+
263
+ const invalidKeyGrant = {
264
+ ...grant,
265
+ credentialId: "invalid_key",
266
+ publicKeyPem: "not a public key",
267
+ publicKeyFingerprint: roleKeyFingerprint("not a public key")
268
+ };
269
+ assert.equal(isOperationRoleKeyGrant(invalidKeyGrant), true);
270
+ const signed = signedOperation(operation({ id: "op_invalid_key", actor: actor("admin", "admin") }), key, invalidKeyGrant);
271
+ assert.match(verifyOperationRoleKey(signed, [invalidKeyGrant], { now: 5000 }).error, /decoder|PEM|key|unsupported|asn1|Failed/i);
272
+ });
273
+
274
+ test("signing role-key operations accepts operations without existing auth", () => {
275
+ const key = generateOperationRoleKeyPair({ role: "admin" });
276
+ const grant = createOperationRoleKeyGrant({
277
+ roomId: ROOM_ID,
278
+ appPackId: kanbanAppPack.id,
279
+ appPackHash: APP_HASH,
280
+ credentialId: "admin_no_auth",
281
+ role: "admin",
282
+ publicKeyPem: key.publicKeyPem,
283
+ issuedAt: 1
284
+ });
285
+ const signed = signOperationWithRoleKey({ ...operation({ id: "op_no_auth" }), auth: undefined }, { privateKeyPem: key.privateKeyPem, grant, issuedAt: 10 });
286
+ assert.equal(signed.auth.credentialId, "admin_no_auth");
287
+ assert.equal(verifyOperationRoleKey(signed, [grant], { now: 20 }).ok, true);
288
+ assert.throws(() => signOperationWithRoleKey(operation(), { privateKeyPem: key.privateKeyPem, grant: { bad: true } }), /valid operation role-key grant/);
289
+ });
@@ -0,0 +1,112 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const {
5
+ HostPluginRuntime,
6
+ allow,
7
+ createOperationSchemaDescriptor,
8
+ defineHostPlugin,
9
+ createMemoryOperationLog,
10
+ createMemoryRoomStore
11
+ } = require("../src/index.cjs");
12
+ const { ensureOperationIdentity } = require("@mh-gg/protocol");
13
+
14
+ function stateSchema() {
15
+ return { parse(value) { return JSON.parse(JSON.stringify(value)); } };
16
+ }
17
+
18
+ function makeOp(type, pluginId, id = type) {
19
+ const op = {
20
+ id,
21
+ roomId: "room_security",
22
+ appPackId: "app.security",
23
+ appPackHash: "sha256-app-security",
24
+ pluginId,
25
+ type,
26
+ actor: { memberId: "alice", deviceId: "dev_alice", role: "admin" },
27
+ seq: 1,
28
+ createdAt: 1000,
29
+ payload: {},
30
+ auth: { credentialId: "cred", signature: "sig" }
31
+ };
32
+ return ensureOperationIdentity(op, { nodeId: op.actor.deviceId, now: op.createdAt });
33
+ }
34
+
35
+ function victimPlugin() {
36
+ return defineHostPlugin({
37
+ id: "com.matterhorn.security.victim",
38
+ version: "1.0.0",
39
+ operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: "com.matterhorn.security.victim", version: "1.0.0", operations: { noop: { authorize: { roles: ["member"] } } } }),
40
+ schemas: {
41
+ state: stateSchema(),
42
+ operations: { noop: { parse: (value) => value || {} } }
43
+ },
44
+ async createInitialState() { return { secret: "safe", methodTouched: false }; },
45
+ authorize() { return allow(); },
46
+ async reduce(_ctx, state) { return state; },
47
+ methods: {
48
+ mutateState(ctx) {
49
+ ctx.state.secret = "method-pwned";
50
+ ctx.state.methodTouched = true;
51
+ return { ok: true };
52
+ }
53
+ }
54
+ });
55
+ }
56
+
57
+ function attackerPlugin({ viaMethod = false } = {}) {
58
+ return defineHostPlugin({
59
+ id: "com.matterhorn.security.attacker",
60
+ version: "1.0.0",
61
+ operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: "com.matterhorn.security.attacker", version: "1.0.0", operations: { attack: { authorize: { roles: ["member"] } } } }),
62
+ schemas: {
63
+ state: stateSchema(),
64
+ operations: { attack: { parse: (value) => value || {} } }
65
+ },
66
+ async createInitialState() { return { attacks: 0 }; },
67
+ authorize() { return allow(); },
68
+ async reduce(ctx, state) {
69
+ if (viaMethod) {
70
+ await ctx.plugins.call("com.matterhorn.security.victim", "mutateState", {});
71
+ } else {
72
+ ctx.roomState.plugins["com.matterhorn.security.victim"].secret = "context-pwned";
73
+ }
74
+ return { ...state, attacks: state.attacks + 1 };
75
+ }
76
+ });
77
+ }
78
+
79
+ async function startRuntime(plugins) {
80
+ const runtime = new HostPluginRuntime({
81
+ room: { id: "room_security", appPack: { id: "app.security", hash: "sha256-app-security" } },
82
+ plugins,
83
+ store: createMemoryRoomStore(),
84
+ operationLog: createMemoryOperationLog(),
85
+ authenticateActor: async (_auth, actor) => actor,
86
+ now: () => 1000
87
+ });
88
+ await runtime.start();
89
+ return runtime;
90
+ }
91
+
92
+ test("plugin reducers cannot mutate sibling plugin state through their context", async () => {
93
+ const runtime = await startRuntime([victimPlugin(), attackerPlugin()]);
94
+
95
+ const result = await runtime.handleOperation(makeOp("attack", "com.matterhorn.security.attacker"));
96
+ const state = await runtime.getState();
97
+
98
+ assert.equal(result.ok, true);
99
+ assert.equal(state.plugins["com.matterhorn.security.attacker"].attacks, 1);
100
+ assert.equal(state.plugins["com.matterhorn.security.victim"].secret, "safe");
101
+ });
102
+
103
+ test("plugin method calls receive isolated target state and cannot mutate persisted plugin state", async () => {
104
+ const runtime = await startRuntime([victimPlugin(), attackerPlugin({ viaMethod: true })]);
105
+
106
+ const result = await runtime.handleOperation(makeOp("attack", "com.matterhorn.security.attacker", "op_method_attack"));
107
+ const state = await runtime.getState();
108
+
109
+ assert.equal(result.ok, true);
110
+ assert.equal(state.plugins["com.matterhorn.security.attacker"].attacks, 1);
111
+ assert.deepEqual(state.plugins["com.matterhorn.security.victim"], { secret: "safe", methodTouched: false });
112
+ });