@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,610 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const { manifestHash } = require("@mh-gg/base");
4
+ const {
5
+ HostPluginRuntime,
6
+ CORE_ACCESS_ROLE_ASSIGN_TYPE,
7
+ CORE_ACCESS_ROLE_DEFINE_TYPE,
8
+ CORE_ACCESS_ROLE_UNASSIGN_TYPE,
9
+ CORE_SCOPE_ROLE_SET_TYPE,
10
+ allow,
11
+ createMemoryOperationLog,
12
+ createMemoryRoomStore,
13
+ createAuthorityState,
14
+ createRoomStateStandingAuthority,
15
+ createOperationGrantAuthority,
16
+ createOperationSchemaDescriptor,
17
+ createOperationRoleKeyGrant,
18
+ createRoleKeyAuthenticator,
19
+ createSignedOperationRoleKeyGrant,
20
+ defineHostPlugin,
21
+ deny,
22
+ generateOperationGrantAuthorityKeyPair,
23
+ generateOperationRoleKeyPair,
24
+ normalizeGrantRecord,
25
+ normalizeRevocationRecord,
26
+ normalizeScopedRolesState,
27
+ scopedRoleFromGlobalRole,
28
+ signOperationWithRoleKey
29
+ } = require("../src/index.cjs");
30
+ const { rebuildStateFromAuthorityLog } = require("../src/runtime/authorityReplay/index.cjs");
31
+
32
+ const ROOM_ID = "room_authz_gate";
33
+ const APP_PACK = { id: "com.matterhorn.authz", version: "1.0.0", hash: "sha256-authz", protocolHash: "sha256-protocol" };
34
+ const APP_HASH = APP_PACK.hash || manifestHash(APP_PACK);
35
+ const PLUGIN_ID = "com.matterhorn.authz.plugin";
36
+ const AUDIT_PLUGIN_ID = "com.matterhorn.authz.audit";
37
+
38
+ function schema(parse) { return { parse }; }
39
+
40
+ function authzPlugin(events = []) {
41
+ return defineHostPlugin({
42
+ id: PLUGIN_ID,
43
+ version: "1.0.0",
44
+ operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: PLUGIN_ID, version: "1.0.0", operations: {
45
+ "admin.write": { authorize: { roles: ["admin"] } },
46
+ "member.write": { authorize: { roles: ["member"] } },
47
+ "narrow.write": { authorize: { roles: ["member"] } }
48
+ } }),
49
+ schemas: {
50
+ state: schema((value) => value && typeof value === "object" ? value : { writes: [] }),
51
+ operations: {
52
+ "admin.write": schema((payload) => payload || {}),
53
+ "member.write": schema((payload) => payload || {}),
54
+ "narrow.write": schema((payload) => payload || {})
55
+ }
56
+ },
57
+ createInitialState() { return { writes: [] }; },
58
+ authorize(ctx, op) {
59
+ events.push({ stage: "authorize", type: op.type, role: ctx.actor.role, keyRole: ctx.actor.keyRole, standing: ctx.actor.standing, memberId: ctx.actor.memberId });
60
+ if (op.type === "narrow.write") return deny("plugin narrowed");
61
+ return allow();
62
+ },
63
+ reduce(ctx, state, op) {
64
+ events.push({ stage: "reduce", type: op.type, role: ctx.actor.role, keyRole: ctx.actor.keyRole, standing: ctx.actor.standing, memberId: ctx.actor.memberId });
65
+ return { writes: [...state.writes, { type: op.type, role: ctx.actor.role, keyRole: ctx.actor.keyRole, standing: ctx.actor.standing, memberId: ctx.actor.memberId }] };
66
+ }
67
+ });
68
+ }
69
+
70
+ function auditPlugin(events = []) {
71
+ return defineHostPlugin({
72
+ id: AUDIT_PLUGIN_ID,
73
+ version: "1.0.0",
74
+ operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: AUDIT_PLUGIN_ID, version: "1.0.0", operations: { "audit.capture": { authorize: { roles: ["member"] } } } }),
75
+ schemas: {
76
+ state: schema((value) => value && typeof value === "object" ? value : { captures: [] }),
77
+ operations: { "audit.capture": schema((payload) => payload || {}) }
78
+ },
79
+ createInitialState() { return { captures: [] }; },
80
+ authorize(ctx, op) { events.push({ plugin: AUDIT_PLUGIN_ID, stage: "authorize", role: ctx.actor.role, standing: ctx.actor.standing }); return allow(); },
81
+ reduce(ctx, state, op) { events.push({ plugin: AUDIT_PLUGIN_ID, stage: "reduce", role: ctx.actor.role, standing: ctx.actor.standing }); return { captures: [...state.captures, ctx.actor] }; }
82
+ });
83
+ }
84
+
85
+ function scopedPlugin(events = []) {
86
+ return defineHostPlugin({
87
+ id: "com.matterhorn.authz.scoped",
88
+ version: "1.0.0",
89
+ operationSchemaDescriptor: createOperationSchemaDescriptor({ plugin: "com.matterhorn.authz.scoped", version: "1.0.0", operations: {
90
+ "board.view": { authorize: { roles: ["member"], scope: { action: "view", scopeType: "board", scopeId: "$payload.boardId" } } },
91
+ "board.edit": { authorize: { roles: ["member"], scope: { action: "edit", scopeType: "board", scopeId: "$payload.boardId" } } }
92
+ } }),
93
+ schemas: {
94
+ state: schema((value) => value && typeof value === "object" ? value : { edits: [] }),
95
+ operations: {
96
+ "board.view": schema((payload) => payload || {}),
97
+ "board.edit": schema((payload) => payload || {})
98
+ },
99
+ publicView: schema((view) => view)
100
+ },
101
+ createInitialState() { return { edits: [] }; },
102
+ authorize(ctx, op) {
103
+ events.push({ stage: "authorize", type: op.type, scopedRole: ctx.access.roleForScope("board", op.payload.boardId), canView: ctx.access.canView("board", op.payload.boardId), canEdit: ctx.access.canEdit("board", op.payload.boardId) });
104
+ return allow();
105
+ },
106
+ reduce(_ctx, state, op) {
107
+ return { edits: [...state.edits, op.type] };
108
+ },
109
+ getPublicView(ctx, state) {
110
+ return ctx.access.canView("board", "secret") ? state : { edits: [] };
111
+ }
112
+ });
113
+ }
114
+
115
+ function initialState(overrides = {}) {
116
+ return {
117
+ schemaVersion: 1,
118
+ roomId: ROOM_ID,
119
+ appPack: { id: APP_PACK.id, hash: APP_HASH, protocolHash: APP_PACK.protocolHash },
120
+ version: 0,
121
+ createdAt: 1,
122
+ updatedAt: 1,
123
+ members: {
124
+ alice: { id: "alice", role: "admin" },
125
+ bob: { id: "bob", role: "member" },
126
+ mod: { id: "mod", role: "moderator" },
127
+ demoted: { id: "demoted", role: "member" },
128
+ banned: { id: "banned", role: "member", bannedAt: 900 }
129
+ },
130
+ revokedCredentialIds: [],
131
+ guests: {},
132
+ pluginVersions: {},
133
+ plugins: {},
134
+ seenOperations: [],
135
+ ...overrides
136
+ };
137
+ }
138
+
139
+ function makeKeys() {
140
+ const authorityKey = generateOperationGrantAuthorityKeyPair({ issuer: "room-owner" });
141
+ const authority = createOperationGrantAuthority({
142
+ issuer: authorityKey.issuer,
143
+ roomId: ROOM_ID,
144
+ appPackId: APP_PACK.id,
145
+ appPackHash: APP_HASH,
146
+ publicKeyPem: authorityKey.publicKeyPem,
147
+ issuedAt: 1
148
+ });
149
+ function grantFor(memberId, role, credentialId = `${memberId}_cred`) {
150
+ const key = generateOperationRoleKeyPair({ role });
151
+ const unsigned = createOperationRoleKeyGrant({
152
+ roomId: ROOM_ID,
153
+ appPackId: APP_PACK.id,
154
+ appPackHash: APP_HASH,
155
+ credentialId,
156
+ memberId,
157
+ deviceId: `dev_${memberId}`,
158
+ role,
159
+ publicKeyPem: key.publicKeyPem,
160
+ issuedAt: 2,
161
+ expiresAt: 10_000
162
+ });
163
+ return { key, grant: createSignedOperationRoleKeyGrant(unsigned, { authority, privateKeyPem: authorityKey.privateKeyPem, signedAt: 3 }) };
164
+ }
165
+ const alice = grantFor("alice", "admin");
166
+ const bob = grantFor("bob", "user");
167
+ const mod = grantFor("mod", "moderator");
168
+ const demoted = grantFor("demoted", "admin");
169
+ const banned = grantFor("banned", "user");
170
+ const revoked = grantFor("bob", "user", "revoked_cred");
171
+ return { authority, grants: [alice.grant, bob.grant, mod.grant, demoted.grant, banned.grant, revoked.grant], alice, bob, mod, demoted, banned, revoked };
172
+ }
173
+
174
+ function createRuntime({ keys = makeKeys(), state = initialState(), plugins, standingAuthority } = {}) {
175
+ const events = [];
176
+ const runtime = new HostPluginRuntime({
177
+ room: { id: ROOM_ID, appPack: APP_PACK },
178
+ plugins: plugins || [authzPlugin(events), auditPlugin(events)],
179
+ store: createMemoryRoomStore(state),
180
+ operationLog: createMemoryOperationLog(),
181
+ authenticateActor: createRoleKeyAuthenticator({
182
+ roomId: ROOM_ID,
183
+ appPackId: APP_PACK.id,
184
+ appPackHash: APP_HASH,
185
+ grants: keys.grants,
186
+ grantAuthorities: [keys.authority],
187
+ requireSignedGrants: true,
188
+ now: () => 100
189
+ }),
190
+ ...(standingAuthority ? { standingAuthority } : {})
191
+ });
192
+ return { runtime, events, keys };
193
+ }
194
+
195
+ function unsignedOperation(memberId, type, pluginId = PLUGIN_ID, overrides = {}) {
196
+ return {
197
+ id: overrides.id || `op_${memberId}_${type.replace(/[^a-zA-Z0-9]/g, "_")}`,
198
+ roomId: ROOM_ID,
199
+ appPackId: APP_PACK.id,
200
+ appPackHash: APP_HASH,
201
+ pluginId,
202
+ type,
203
+ actor: { memberId, deviceId: `dev_${memberId}`, role: overrides.claimedRole || "member" },
204
+ seq: overrides.seq || 1,
205
+ createdAt: overrides.createdAt || 1000,
206
+ payload: overrides.payload || {},
207
+ auth: { credentialId: overrides.credentialId || `${memberId}_cred`, signature: "placeholder" }
208
+ };
209
+ }
210
+
211
+ function sign(op, keyPair, grant) {
212
+ return signOperationWithRoleKey(op, { privateKeyPem: keyPair.privateKeyPem, grant, now: () => op.createdAt });
213
+ }
214
+
215
+ async function run(runtime, op) {
216
+ await runtime.start();
217
+ return runtime.handleOperation(op);
218
+ }
219
+
220
+ test("core gate denies a banned member even with a valid unexpired operation grant", async () => {
221
+ const { runtime, keys } = createRuntime();
222
+ const op = sign(unsignedOperation("banned", "member.write"), keys.banned.key, keys.banned.grant);
223
+ const result = await run(runtime, op);
224
+ assert.equal(result.ok, false);
225
+ assert.match(result.reason, /banned|active|standing/i);
226
+ });
227
+
228
+ test("core gate denies a revoked credential without banning the member", async () => {
229
+ const keys = makeKeys();
230
+ const { runtime } = createRuntime({ keys, state: initialState({ revokedCredentialIds: ["revoked_cred"] }) });
231
+ const op = sign(unsignedOperation("bob", "member.write", PLUGIN_ID, { credentialId: "revoked_cred" }), keys.revoked.key, keys.revoked.grant);
232
+ const result = await run(runtime, op);
233
+ assert.equal(result.ok, false);
234
+ assert.match(result.reason, /revoked|active|standing/i);
235
+ });
236
+
237
+ test("effective role is current standing role, not the grant ceiling", async () => {
238
+ const { runtime, keys, events } = createRuntime();
239
+ const deniedAdmin = await run(runtime, sign(unsignedOperation("demoted", "admin.write", PLUGIN_ID, { claimedRole: "admin" }), keys.demoted.key, keys.demoted.grant));
240
+ assert.equal(deniedAdmin.ok, false);
241
+ assert.match(deniedAdmin.reason, /role|admin|Forbidden/i);
242
+
243
+ const allowedMember = await run(runtime, sign(unsignedOperation("demoted", "member.write", PLUGIN_ID, { id: "op_demoted_member", seq: 2, claimedRole: "admin" }), keys.demoted.key, keys.demoted.grant));
244
+ assert.equal(allowedMember.ok, true);
245
+ assert.deepEqual(events.filter((entry) => entry.stage === "reduce").at(-1), {
246
+ stage: "reduce",
247
+ type: "member.write",
248
+ role: "member",
249
+ keyRole: "admin",
250
+ standing: "active",
251
+ memberId: "demoted"
252
+ });
253
+ });
254
+
255
+ test("hand-written plugin authorize cannot widen access beyond declared roles", async () => {
256
+ const { runtime, keys } = createRuntime();
257
+ const op = sign(unsignedOperation("bob", "admin.write", PLUGIN_ID, { claimedRole: "admin" }), keys.bob.key, keys.bob.grant);
258
+ const result = await run(runtime, op);
259
+ assert.equal(result.ok, false);
260
+ assert.match(result.reason, /role|admin|Forbidden/i);
261
+ });
262
+
263
+ test("operation descriptors without authorize.roles fail during plugin registration", () => {
264
+ assert.throws(() => defineHostPlugin({
265
+ id: "com.matterhorn.authz.bad-policy",
266
+ version: "1.0.0",
267
+ operationSchemaDescriptor: createOperationSchemaDescriptor({
268
+ plugin: "com.matterhorn.authz.bad-policy",
269
+ version: "1.0.0",
270
+ operations: { "missing-policy.write": { required: ["value"] } }
271
+ }),
272
+ schemas: {
273
+ state: schema((value) => value && typeof value === "object" ? value : { writes: [] }),
274
+ operations: { "missing-policy.write": schema((payload) => payload || {}) }
275
+ },
276
+ createInitialState() { return { writes: [] }; },
277
+ authorize() { return allow(); },
278
+ reduce(_ctx, state) { return state; }
279
+ }), /authorize\.roles|OPERATION_ROLE_POLICY_MISSING/);
280
+ });
281
+
282
+ test("plugin authorize is narrowing-only", async () => {
283
+ const { runtime, keys } = createRuntime();
284
+ const op = sign(unsignedOperation("bob", "narrow.write", PLUGIN_ID), keys.bob.key, keys.bob.grant);
285
+ const result = await run(runtime, op);
286
+ assert.equal(result.ok, false);
287
+ assert.match(result.reason, /plugin narrowed/);
288
+ });
289
+
290
+ test("standing authority failure denies before plugin code runs", async () => {
291
+ const events = [];
292
+ const keys = makeKeys();
293
+ const runtime = new HostPluginRuntime({
294
+ room: { id: ROOM_ID, appPack: APP_PACK },
295
+ plugins: [authzPlugin(events)],
296
+ store: createMemoryRoomStore(initialState()),
297
+ operationLog: createMemoryOperationLog(),
298
+ authenticateActor: createRoleKeyAuthenticator({ roomId: ROOM_ID, appPackId: APP_PACK.id, appPackHash: APP_HASH, grants: keys.grants, grantAuthorities: [keys.authority], requireSignedGrants: true, now: () => 100 }),
299
+ standingAuthority: { evaluate() { throw new Error("standing db offline"); } }
300
+ });
301
+ const op = sign(unsignedOperation("alice", "admin.write", PLUGIN_ID, { claimedRole: "admin" }), keys.alice.key, keys.alice.grant);
302
+ const result = await run(runtime, op);
303
+ assert.equal(result.ok, false);
304
+ assert.match(result.reason, /standing|offline|unavailable/i);
305
+ assert.deepEqual(events, []);
306
+ });
307
+
308
+ test("ctx.actor carries active standing and effective role across plugins", async () => {
309
+ const { runtime, keys, events } = createRuntime();
310
+ const op = sign(unsignedOperation("bob", "audit.capture", AUDIT_PLUGIN_ID), keys.bob.key, keys.bob.grant);
311
+ const result = await run(runtime, op);
312
+ assert.equal(result.ok, true, result.reason);
313
+ assert.deepEqual(events.filter((entry) => entry.plugin === AUDIT_PLUGIN_ID), [
314
+ { plugin: AUDIT_PLUGIN_ID, stage: "authorize", role: "member", standing: "active" },
315
+ { plugin: AUDIT_PLUGIN_ID, stage: "reduce", role: "member", standing: "active" }
316
+ ]);
317
+ });
318
+
319
+ test("profile-only member records do not make open rooms explicit-membership rooms", async () => {
320
+ const { runtime, keys } = createRuntime({
321
+ state: initialState({
322
+ members: {
323
+ alice: { id: "alice", memberId: "alice", role: "member", displayName: "Alice", profileOnly: true }
324
+ }
325
+ }),
326
+ standingAuthority: createRoomStateStandingAuthority()
327
+ });
328
+ const op = sign(unsignedOperation("bob", "member.write", PLUGIN_ID, {
329
+ id: "op_bob_profile_only_room",
330
+ createdAt: 1001
331
+ }), keys.bob.key, keys.bob.grant);
332
+
333
+ const result = await run(runtime, op);
334
+
335
+ assert.equal(result.ok, true, result.reason);
336
+ const state = await runtime.getState();
337
+ assert.equal(state.members.alice.profileOnly, true);
338
+ assert.equal(state.members.bob, undefined);
339
+ });
340
+
341
+ test("core credential revocation operation kills a specific grant before expiry", async () => {
342
+ const { runtime, keys } = createRuntime();
343
+ const revoke = sign(unsignedOperation("alice", "credential.revoke", "matterhorn.core", {
344
+ id: "op_revoke_bob",
345
+ claimedRole: "admin",
346
+ payload: { credentialId: "bob_cred" }
347
+ }), keys.alice.key, keys.alice.grant);
348
+ assert.equal((await run(runtime, revoke)).ok, true);
349
+
350
+ const bobWrite = sign(unsignedOperation("bob", "member.write", PLUGIN_ID, { id: "op_bob_after_revoke", seq: 2 }), keys.bob.key, keys.bob.grant);
351
+ const result = await runtime.handleOperation(bobWrite);
352
+ assert.equal(result.ok, false);
353
+ assert.match(result.reason, /revoked|active|standing/i);
354
+ });
355
+
356
+ test("core scoped roles gate view and edit before plugin code can widen access", async () => {
357
+ const keys = makeKeys();
358
+ const events = [];
359
+ const runtime = new HostPluginRuntime({
360
+ room: { id: ROOM_ID, appPack: APP_PACK },
361
+ plugins: [scopedPlugin(events)],
362
+ store: createMemoryRoomStore(initialState()),
363
+ operationLog: createMemoryOperationLog(),
364
+ authenticateActor: createRoleKeyAuthenticator({ roomId: ROOM_ID, appPackId: APP_PACK.id, appPackHash: APP_HASH, grants: keys.grants, grantAuthorities: [keys.authority], requireSignedGrants: true, now: () => 100 })
365
+ });
366
+ await runtime.start();
367
+
368
+ const bobEditOpen = sign(unsignedOperation("bob", "board.edit", "com.matterhorn.authz.scoped", { payload: { boardId: "secret" } }), keys.bob.key, keys.bob.grant);
369
+ assert.equal((await runtime.handleOperation(bobEditOpen)).ok, true);
370
+
371
+ const restrict = sign(unsignedOperation("alice", CORE_SCOPE_ROLE_SET_TYPE, "matterhorn.core", {
372
+ id: "op_scope_restrict",
373
+ seq: 2,
374
+ claimedRole: "admin",
375
+ payload: { scopeType: "board", scopeId: "secret", defaultRole: "none" }
376
+ }), keys.alice.key, keys.alice.grant);
377
+ assert.equal((await runtime.handleOperation(restrict)).ok, true);
378
+
379
+ const deniedView = await runtime.handleOperation(sign(unsignedOperation("bob", "board.view", "com.matterhorn.authz.scoped", { id: "op_bob_view_denied", seq: 3, payload: { boardId: "secret" } }), keys.bob.key, keys.bob.grant));
380
+ assert.equal(deniedView.ok, false);
381
+ assert.match(deniedView.reason, /view access/);
382
+
383
+ const defineViewer = sign(unsignedOperation("alice", CORE_ACCESS_ROLE_DEFINE_TYPE, "matterhorn.core", {
384
+ id: "op_access_role_define",
385
+ seq: 4,
386
+ claimedRole: "admin",
387
+ payload: { roleId: "board-viewer", name: "Board viewer", grants: [{ scopeType: "board", scopeId: "secret", role: "viewer" }] }
388
+ }), keys.alice.key, keys.alice.grant);
389
+ assert.equal((await runtime.handleOperation(defineViewer)).ok, true);
390
+ const assignViewer = sign(unsignedOperation("alice", CORE_ACCESS_ROLE_ASSIGN_TYPE, "matterhorn.core", {
391
+ id: "op_access_role_assign",
392
+ seq: 5,
393
+ claimedRole: "admin",
394
+ payload: { target: "bob", roleId: "board-viewer" }
395
+ }), keys.alice.key, keys.alice.grant);
396
+ assert.equal((await runtime.handleOperation(assignViewer)).ok, true);
397
+ const scopedAfterAssignment = (await runtime.getState()).scopedRoles;
398
+ assert.equal(scopedAfterAssignment.roles["board-viewer"].authority.id, defineViewer.id);
399
+ assert.equal(scopedAfterAssignment.roles["board-viewer"].authority.grantedBy, "alice");
400
+ assert.equal(scopedAfterAssignment.assignmentGrants.bob["board-viewer"], assignViewer.id);
401
+ assert.equal(scopedAfterAssignment.grants[assignViewer.id].credentialId, "alice_cred");
402
+ assert.equal(typeof scopedAfterAssignment.grants[assignViewer.id].signature, "string");
403
+
404
+ const allowedView = await runtime.handleOperation(sign(unsignedOperation("bob", "board.view", "com.matterhorn.authz.scoped", { id: "op_bob_view_ok", seq: 6, payload: { boardId: "secret" } }), keys.bob.key, keys.bob.grant));
405
+ assert.equal(allowedView.ok, true);
406
+ const deniedEdit = await runtime.handleOperation(sign(unsignedOperation("bob", "board.edit", "com.matterhorn.authz.scoped", { id: "op_bob_edit_denied", seq: 7, payload: { boardId: "secret" } }), keys.bob.key, keys.bob.grant));
407
+ assert.equal(deniedEdit.ok, false);
408
+ assert.match(deniedEdit.reason, /edit access/);
409
+
410
+ const grantEditor = sign(unsignedOperation("alice", CORE_SCOPE_ROLE_SET_TYPE, "matterhorn.core", {
411
+ id: "op_scope_editor",
412
+ seq: 8,
413
+ claimedRole: "admin",
414
+ payload: { scopeType: "board", scopeId: "secret", target: "bob", role: "editor" }
415
+ }), keys.alice.key, keys.alice.grant);
416
+ assert.equal((await runtime.handleOperation(grantEditor)).ok, true);
417
+ const allowedEdit = await runtime.handleOperation(sign(unsignedOperation("bob", "board.edit", "com.matterhorn.authz.scoped", { id: "op_bob_edit_ok", seq: 9, payload: { boardId: "secret" } }), keys.bob.key, keys.bob.grant));
418
+ assert.equal(allowedEdit.ok, true);
419
+
420
+ const defineChannelRole = sign(unsignedOperation("alice", CORE_ACCESS_ROLE_DEFINE_TYPE, "matterhorn.core", {
421
+ id: "op_channel_role_define",
422
+ seq: 10,
423
+ claimedRole: "admin",
424
+ payload: {
425
+ roleId: "channel-limited",
426
+ name: "Channel limited",
427
+ grants: [
428
+ { scopeType: "voice.channel", scopeId: "d", role: "none" },
429
+ { scopeType: "voice.channel", scopeId: "e", role: "editor" },
430
+ { scopeType: "text.channel", scopeId: "f", role: "viewer" }
431
+ ]
432
+ }
433
+ }), keys.alice.key, keys.alice.grant);
434
+ assert.equal((await runtime.handleOperation(defineChannelRole)).ok, true);
435
+ const assignChannelRole = sign(unsignedOperation("alice", CORE_ACCESS_ROLE_ASSIGN_TYPE, "matterhorn.core", {
436
+ id: "op_channel_role_assign",
437
+ seq: 11,
438
+ claimedRole: "admin",
439
+ payload: { target: "bob", roleId: "channel-limited" }
440
+ }), keys.alice.key, keys.alice.grant);
441
+ assert.equal((await runtime.handleOperation(assignChannelRole)).ok, true);
442
+ const denyTextC = sign(unsignedOperation("alice", CORE_SCOPE_ROLE_SET_TYPE, "matterhorn.core", {
443
+ id: "op_text_c_deny",
444
+ seq: 12,
445
+ claimedRole: "admin",
446
+ payload: { scopeType: "text.channel", scopeId: "c", target: "bob", role: "none" }
447
+ }), keys.alice.key, keys.alice.grant);
448
+ assert.equal((await runtime.handleOperation(denyTextC)).ok, true);
449
+
450
+ const bobView = await runtime.publicView({ memberId: "bob", deviceId: "dev_bob", role: "member" });
451
+ assert.equal(bobView.access.scopes["text.channel:c"].canView, false);
452
+ assert.equal(bobView.access.scopes["voice.channel:d"].canView, false);
453
+ assert.equal(bobView.access.scopes["voice.channel:e"].canEdit, true);
454
+ assert.equal(bobView.access.scopes["text.channel:f"].canView, true);
455
+ assert.equal(bobView.access.scopes["text.channel:f"].canEdit, false);
456
+
457
+ const guestView = await runtime.publicView({ memberId: "guest", deviceId: "dev_guest", role: "guest" });
458
+ assert.deepEqual(guestView.plugins["com.matterhorn.authz.scoped"], { edits: [] });
459
+ assert.equal(guestView.access.scopes["board:secret"].canView, false);
460
+ assert.equal(events.some((entry) => entry.type === "board.edit" && entry.scopedRole === "editor"), true);
461
+ });
462
+
463
+ test("moderators cannot assign compound roles above moderator scope", async () => {
464
+ const keys = makeKeys();
465
+ const { runtime } = createRuntime({ keys, plugins: [scopedPlugin()] });
466
+ await runtime.start();
467
+
468
+ const defineAdminRole = sign(unsignedOperation("alice", CORE_ACCESS_ROLE_DEFINE_TYPE, "matterhorn.core", {
469
+ id: "op_admin_scoped_role_define",
470
+ seq: 2,
471
+ claimedRole: "admin",
472
+ payload: {
473
+ roleId: "board-admin",
474
+ name: "Board admin",
475
+ grants: [{ scopeType: "board", scopeId: "secret", role: "admin" }]
476
+ }
477
+ }), keys.alice.key, keys.alice.grant);
478
+ assert.equal((await runtime.handleOperation(defineAdminRole)).ok, true);
479
+
480
+ const assignAdminRole = sign(unsignedOperation("mod", CORE_ACCESS_ROLE_ASSIGN_TYPE, "matterhorn.core", {
481
+ id: "op_mod_assign_admin_scoped_role",
482
+ seq: 3,
483
+ claimedRole: "moderator",
484
+ payload: { target: "bob", roleId: "board-admin" }
485
+ }), keys.mod.key, keys.mod.grant);
486
+ const result = await runtime.handleOperation(assignAdminRole);
487
+ assert.equal(result.ok, false);
488
+ assert.match(result.reason, /above moderator|Forbidden/i);
489
+ });
490
+
491
+ test("authority replay preserves scoped role operations", async () => {
492
+ const keys = makeKeys();
493
+ const { runtime } = createRuntime({ keys, plugins: [scopedPlugin()] });
494
+ await runtime.start();
495
+
496
+ const ops = [
497
+ sign(unsignedOperation("alice", CORE_SCOPE_ROLE_SET_TYPE, "matterhorn.core", {
498
+ id: "op_replay_scope_restrict",
499
+ seq: 2,
500
+ claimedRole: "admin",
501
+ createdAt: 1002,
502
+ payload: { scopeType: "board", scopeId: "secret", defaultRole: "none" }
503
+ }), keys.alice.key, keys.alice.grant),
504
+ sign(unsignedOperation("alice", CORE_ACCESS_ROLE_DEFINE_TYPE, "matterhorn.core", {
505
+ id: "op_replay_role_define",
506
+ seq: 3,
507
+ claimedRole: "admin",
508
+ createdAt: 1003,
509
+ payload: { roleId: "board-viewer", grants: [{ scopeType: "board", scopeId: "secret", role: "viewer" }] }
510
+ }), keys.alice.key, keys.alice.grant),
511
+ sign(unsignedOperation("alice", CORE_ACCESS_ROLE_ASSIGN_TYPE, "matterhorn.core", {
512
+ id: "op_replay_role_assign",
513
+ seq: 4,
514
+ claimedRole: "admin",
515
+ createdAt: 1004,
516
+ payload: { target: "bob", roleId: "board-viewer" }
517
+ }), keys.alice.key, keys.alice.grant),
518
+ sign(unsignedOperation("alice", CORE_ACCESS_ROLE_UNASSIGN_TYPE, "matterhorn.core", {
519
+ id: "op_replay_role_unassign",
520
+ seq: 5,
521
+ claimedRole: "admin",
522
+ createdAt: 1005,
523
+ payload: { target: "bob", roleId: "board-viewer" }
524
+ }), keys.alice.key, keys.alice.grant)
525
+ ];
526
+
527
+ const rebuilt = await rebuildStateFromAuthorityLog(runtime, initialState({ authority: createAuthorityState({ ownerIds: ["alice"] }) }), ops);
528
+ assert.equal(rebuilt.scopedRoles.scopes["board:secret"].defaultRole, "none");
529
+ assert.equal(rebuilt.scopedRoles.roles["board-viewer"].grants[0].role, "viewer");
530
+ assert.equal(rebuilt.scopedRoles.roles["board-viewer"].authority.id, ops[1].id);
531
+ assert.equal(rebuilt.scopedRoles.assignmentGrants.bob, undefined);
532
+ assert.equal(rebuilt.scopedRoles.revocations[ops[3].id].voidGrantIds[0], ops[2].id);
533
+ assert.equal(rebuilt.scopedRoles.assignments.bob, undefined);
534
+ });
535
+
536
+ test("scoped role metadata normalization preserves signed grant records", () => {
537
+ assert.equal(scopedRoleFromGlobalRole("guest"), "none");
538
+ assert.equal(normalizeGrantRecord(null), undefined);
539
+ assert.equal(normalizeRevocationRecord([]), undefined);
540
+
541
+ assert.deepEqual(normalizeGrantRecord({
542
+ operationId: "op_define",
543
+ actorId: "admin",
544
+ action: "access.role.define",
545
+ scopeType: "board",
546
+ scopeId: "secret",
547
+ defaultRole: "viewer",
548
+ grantedAt: "bad",
549
+ credentialId: "cred_admin",
550
+ signature: "sig_admin"
551
+ }), {
552
+ id: "op_define",
553
+ action: "access.role.define",
554
+ scopeType: "board",
555
+ scopeId: "secret",
556
+ defaultRole: "viewer",
557
+ grantedBy: "admin",
558
+ grantedAt: 0,
559
+ credentialId: "cred_admin",
560
+ signature: "sig_admin"
561
+ });
562
+
563
+ assert.deepEqual(normalizeRevocationRecord({
564
+ operationId: "op_revoke",
565
+ actorId: "admin",
566
+ target: "bob",
567
+ roleId: "board-viewer",
568
+ voidGrantIds: ["op_assign", "", 2],
569
+ revokedAt: "bad",
570
+ credentialId: "cred_admin",
571
+ signature: "sig_admin"
572
+ }), {
573
+ id: "op_revoke",
574
+ action: "revoke",
575
+ target: "bob",
576
+ roleId: "board-viewer",
577
+ voidGrantIds: ["op_assign"],
578
+ revokedBy: "admin",
579
+ revokedAt: 0,
580
+ credentialId: "cred_admin",
581
+ signature: "sig_admin"
582
+ });
583
+
584
+ const normalized = normalizeScopedRolesState({
585
+ roles: {
586
+ "board-viewer": {
587
+ id: "board-viewer",
588
+ grants: [{ scopeType: "board", scopeId: "secret", role: "viewer" }],
589
+ authority: { operationId: "op_define", actorId: "admin" }
590
+ }
591
+ },
592
+ assignments: { bob: ["board-viewer", "missing-role"] },
593
+ grants: {
594
+ op_assign: { operationId: "op_assign", actorId: "admin", target: "bob", roleId: "board-viewer" }
595
+ },
596
+ revocations: {
597
+ op_revoke: { operationId: "op_revoke", actorId: "admin", target: "bob", roleId: "board-viewer", voidGrantIds: ["op_assign"] }
598
+ },
599
+ assignmentGrants: {
600
+ bob: { "board-viewer": "op_assign", missing: "op_missing" },
601
+ broken: null,
602
+ array: []
603
+ }
604
+ });
605
+
606
+ assert.deepEqual(normalized.assignments.bob, ["board-viewer"]);
607
+ assert.equal(normalized.roles["board-viewer"].authority.id, "op_define");
608
+ assert.equal(normalized.assignmentGrants.bob["board-viewer"], "op_assign");
609
+ assert.deepEqual(normalized.revocations.op_revoke.voidGrantIds, ["op_assign"]);
610
+ });