@mh-gg/authority 0.1.1-alpha.20260626T104441232Z

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matterhorn contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @mh-gg/authority
2
+
3
+ Shared room authority fold, policy, and validity ladder for host-optional encrypted rooms.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@mh-gg/authority",
3
+ "version": "0.1.1-alpha.20260626T104441232Z",
4
+ "description": "Shared room authority fold, policy, and validity ladder for host-optional encrypted rooms.",
5
+ "type": "commonjs",
6
+ "main": "src/index.cjs",
7
+ "exports": {
8
+ ".": "./src/index.cjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=22.12"
12
+ },
13
+ "dependencies": {
14
+ "nostr-tools": "^2.23.5",
15
+ "@mh-gg/protocol": "^0.1.1-alpha.20260626T104441232Z",
16
+ "@mh-gg/event": "^0.1.1-alpha.20260626T104441232Z"
17
+ },
18
+ "license": "MIT",
19
+ "files": [
20
+ "src",
21
+ "README.md",
22
+ "package.json"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test test/*.test.cjs",
26
+ "coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
27
+ }
28
+ }
@@ -0,0 +1,17 @@
1
+ const AUTHORITY_STATE_KIND = "matterhorn.authority-chain";
2
+ const AUTHORITY_STATE_VERSION = 1;
3
+ const CORE_PLUGIN_ID = "matterhorn.core";
4
+ const CORE_AUTHORITY_GRANT_TYPE = "authority.grant";
5
+ const CORE_AUTHORITY_REVOKE_TYPE = "authority.revoke";
6
+ const CORE_REVOKE_CREDENTIAL_TYPE = "credential.revoke";
7
+ const AUTHORITY_SCOPES = Object.freeze(["future", "from-point", "all-by-target"]);
8
+
9
+ module.exports = {
10
+ AUTHORITY_SCOPES,
11
+ AUTHORITY_STATE_KIND,
12
+ AUTHORITY_STATE_VERSION,
13
+ CORE_AUTHORITY_GRANT_TYPE,
14
+ CORE_AUTHORITY_REVOKE_TYPE,
15
+ CORE_PLUGIN_ID,
16
+ CORE_REVOKE_CREDENTIAL_TYPE
17
+ };
package/src/index.cjs ADDED
@@ -0,0 +1,11 @@
1
+ module.exports = {
2
+ ...require("./constants.cjs"),
3
+ ...require("./roles.cjs"),
4
+ ...require("./shared.cjs"),
5
+ ...require("./state.cjs"),
6
+ ...require("./ordering.cjs"),
7
+ ...require("./resolve/index.cjs"),
8
+ ...require("./receipts.cjs"),
9
+ ...require("./inviteGrants.cjs"),
10
+ ...require("./ladder.cjs")
11
+ };
@@ -0,0 +1,41 @@
1
+ const {
2
+ INVITE_GRANT_ALG,
3
+ INVITE_GRANT_SIGNATURE_KIND,
4
+ INVITE_GRANT_TYPE,
5
+ INVITE_GRANT_VERSION,
6
+ INVITE_PROOF_VERSION,
7
+ INVITE_REVOKE_TYPE,
8
+ createInviteProof,
9
+ inviteProofMessage,
10
+ lengthPrefixedField,
11
+ normalizeInviteGrant,
12
+ normalizeInviteRevocation,
13
+ publicInviteGrant,
14
+ signInviteGrant,
15
+ unsignedInviteGrant,
16
+ validateInviteGrant,
17
+ validateInviteRevocation,
18
+ verifyInviteProof,
19
+ verifySignedInviteGrant
20
+ } = require("@mh-gg/protocol");
21
+
22
+ module.exports = {
23
+ INVITE_GRANT_ALG,
24
+ INVITE_GRANT_SIGNATURE_KIND,
25
+ INVITE_GRANT_TYPE,
26
+ INVITE_GRANT_VERSION,
27
+ INVITE_PROOF_VERSION,
28
+ INVITE_REVOKE_TYPE,
29
+ createInviteProof,
30
+ inviteProofMessage,
31
+ lengthPrefixedField,
32
+ normalizeInviteGrant,
33
+ normalizeInviteRevocation,
34
+ publicInviteGrant,
35
+ signInviteGrant,
36
+ unsignedInviteGrant,
37
+ validateInviteGrant,
38
+ validateInviteRevocation,
39
+ verifyInviteProof,
40
+ verifySignedInviteGrant
41
+ };
package/src/ladder.cjs ADDED
@@ -0,0 +1,158 @@
1
+ const { verifyEvent } = require("nostr-tools/pure");
2
+ const { normalizeRoomRole, roleMeetsRequirement } = require("./roles.cjs");
3
+
4
+ const TIERS = Object.freeze({
5
+ SIGNATURE_VALID: 1,
6
+ MEMBER_VALID: 2,
7
+ ADMISSIBLE: 3,
8
+ ROOM_VALID: 4
9
+ });
10
+
11
+ function isSignatureValid(nostrEvent) {
12
+ if (!nostrEvent || typeof nostrEvent !== "object") return false;
13
+ try {
14
+ return verifyEvent(nostrEvent) === true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function isMemberValid(nostrEvent, { grantChainValidator }) {
21
+ if (!isSignatureValid(nostrEvent)) return false;
22
+ if (typeof grantChainValidator !== "function") return false;
23
+ try {
24
+ return grantChainValidator(nostrEvent) === true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function eventHeader(nostrEvent) {
31
+ return nostrEvent?._header || nostrEvent?.header || {};
32
+ }
33
+
34
+ function callBoolean(name, fn, event, context) {
35
+ if (typeof fn !== "function") return true;
36
+ try {
37
+ return fn(event, context) === true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function verifyAdmissibilitySignature(nostrEvent, context) {
44
+ if (typeof context.verifySignature === "function") return context.verifySignature(nostrEvent, context) === true;
45
+ return isSignatureValid(nostrEvent);
46
+ }
47
+
48
+ function validateGrantChain(nostrEvent, context) {
49
+ if (typeof context.grantChainValidator === "function") return context.grantChainValidator(nostrEvent, context) === true;
50
+ if (typeof context.validateGrantChain === "function") return context.validateGrantChain(nostrEvent, context) === true;
51
+ return true;
52
+ }
53
+
54
+ function hasActiveEpoch(epoch, activeEpochIds) {
55
+ if (!epoch) return true;
56
+ if (!activeEpochIds) return false;
57
+ if (activeEpochIds instanceof Set) return activeEpochIds.has(epoch);
58
+ if (Array.isArray(activeEpochIds)) return activeEpochIds.includes(epoch);
59
+ if (typeof activeEpochIds === "object") return activeEpochIds[epoch] === true;
60
+ return false;
61
+ }
62
+
63
+ function credentialMemberId(header, credentials, credentialRequired) {
64
+ if (!header.credentialId) return credentialRequired ? { ok: false, reason: "missing-credential" } : { ok: true, memberId: header.memberId };
65
+ const credential = credentials?.[header.credentialId];
66
+ if (!credential) return { ok: false, reason: "missing-credential" };
67
+ if (credential.revokedAt) return { ok: false, reason: "revoked-credential" };
68
+ if (credential.active === false) return { ok: false, reason: "missing-credential" };
69
+ return { ok: true, memberId: credential.memberId || header.memberId };
70
+ }
71
+
72
+ function resolveProjectionMembers(authorityProjection) {
73
+ if (!authorityProjection || typeof authorityProjection !== "object" || Array.isArray(authorityProjection)) return undefined;
74
+ return authorityProjection.members;
75
+ }
76
+
77
+ function isActiveMember(member) {
78
+ if (!member) return false;
79
+ if (member.revokedAt) return false;
80
+ if (member.tombstoned) return false;
81
+ if (member.active === false) return false;
82
+ return true;
83
+ }
84
+
85
+ function ensureMemberActive(memberId, members) {
86
+ const member = members[memberId];
87
+ if (!member) return { ok: false, reason: "missing-member" };
88
+ if (member.active === false) return { ok: false, reason: "inactive-member" };
89
+ if (member.revokedAt || member.tombstoned) return { ok: false, reason: "revoked-member" };
90
+ return { ok: true, member };
91
+ }
92
+
93
+ function isMissingEpoch(entry, activeEpochIds) {
94
+ return Boolean(entry?.epoch && !hasActiveEpoch(entry.epoch, activeEpochIds));
95
+ }
96
+
97
+ function evaluateAdmissibility(nostrEvent, context = {}) {
98
+ const {
99
+ requiredRole,
100
+ activeEpochIds = new Set(),
101
+ authorityProjection,
102
+ credentialRequired = true,
103
+ rateLimiter,
104
+ sizeOk = true
105
+ } = context;
106
+
107
+ if (!sizeOk) return { ok: false, reason: "invalid-size" };
108
+ if (!verifyAdmissibilitySignature(nostrEvent, context)) return { ok: false, reason: "invalid-signature" };
109
+ if (!validateGrantChain(nostrEvent, context)) return { ok: false, reason: "invalid-member-chain" };
110
+
111
+ const members = resolveProjectionMembers(authorityProjection);
112
+ if (!members) return { ok: false, reason: "missing-member" };
113
+
114
+ const header = eventHeader(nostrEvent);
115
+ const credential = credentialMemberId(header, authorityProjection.credentials, credentialRequired);
116
+ if (!credential.ok) return credential;
117
+ const memberId = credential.memberId;
118
+ if (!memberId) return { ok: false, reason: "missing-member" };
119
+
120
+ const memberResult = ensureMemberActive(memberId, members);
121
+ if (!memberResult.ok) return memberResult;
122
+ const member = memberResult.member;
123
+ if (isMissingEpoch(header, activeEpochIds)) return { ok: false, reason: "inactive-epoch" };
124
+
125
+ const role = normalizeRoomRole(member.role, undefined);
126
+ if (!role) return { ok: false, reason: "missing-member" };
127
+ if (requiredRole && !roleMeetsRequirement(role, requiredRole)) return { ok: false, reason: "insufficient-role" };
128
+ if (!callBoolean("rateLimiter", rateLimiter, nostrEvent, context)) return { ok: false, reason: "rate-limited" };
129
+
130
+ return { ok: true, role, memberId };
131
+ }
132
+
133
+ function isAdmissible(nostrEvent, context = {}) {
134
+ return evaluateAdmissibility(nostrEvent, context).ok;
135
+ }
136
+
137
+ function isRoomValid(operation, { authorityState, tombstones = {} }) {
138
+ if (!operation || !operation.id) return false;
139
+ return !tombstones[operation.id];
140
+ }
141
+
142
+ function validityTier(nostrEvent, operation, context) {
143
+ if (!isSignatureValid(nostrEvent)) return { tier: 0, ok: false, reason: "invalid-signature" };
144
+ if (!isMemberValid(nostrEvent, context)) return { tier: TIERS.SIGNATURE_VALID, ok: false, reason: "invalid-member-chain" };
145
+ if (!isAdmissible(nostrEvent, context)) return { tier: TIERS.MEMBER_VALID, ok: false, reason: "not-admissible" };
146
+ if (!isRoomValid(operation, context)) return { tier: TIERS.ADMISSIBLE, ok: false, reason: "not-room-valid" };
147
+ return { tier: TIERS.ROOM_VALID, ok: true };
148
+ }
149
+
150
+ module.exports = {
151
+ TIERS,
152
+ evaluateAdmissibility,
153
+ isAdmissible,
154
+ isMemberValid,
155
+ isRoomValid,
156
+ isSignatureValid,
157
+ validityTier
158
+ };
@@ -0,0 +1,55 @@
1
+ const { codePointCompare, operationContentHash, parseHlc, validateHlc } = require("@mh-gg/protocol");
2
+
3
+ function operationCreatedAt(operation) {
4
+ const createdAt = Number(operation?.createdAt ?? operation?.issuedAt ?? operation?.at ?? 0);
5
+ return Number.isFinite(createdAt) ? createdAt : 0;
6
+ }
7
+
8
+ function operationHlc(operation) {
9
+ const hlc = operation?.hlc ?? operation?.clock ?? operation?.causal?.hlc;
10
+ return typeof hlc === "string" ? hlc : "";
11
+ }
12
+
13
+ function requireOperationHlc(operation, options = {}) {
14
+ const validation = validateHlc(operationHlc(operation), options);
15
+ if (!validation.ok) throw new Error(validation.error);
16
+ return validation.parsed.value;
17
+ }
18
+
19
+ function operationIdHash(operation) {
20
+ if (typeof operation?.id === "string" && operation.id.length > 0) return operation.id;
21
+ return operationContentHash(operation || {});
22
+ }
23
+
24
+ function operationSortKey(operation) {
25
+ const hlc = operationHlc(operation);
26
+ const validation = validateHlc(hlc, { now: Number.MAX_SAFE_INTEGER, maxFutureSkewMs: Number.MAX_SAFE_INTEGER });
27
+ return [validation.ok ? validation.parsed.value : `~invalid:${operationIdHash(operation)}`, operationIdHash(operation)];
28
+ }
29
+
30
+ function compareOperations(left, right) {
31
+ const [leftHlc, leftId] = operationSortKey(left);
32
+ const [rightHlc, rightId] = operationSortKey(right);
33
+ const hlcOrder = codePointCompare(leftHlc, rightHlc);
34
+ if (hlcOrder !== 0) return hlcOrder;
35
+ return codePointCompare(leftId, rightId);
36
+ }
37
+
38
+ function sortOperations(operations = []) {
39
+ return operations.slice().sort(compareOperations);
40
+ }
41
+
42
+ function hasValidOperationHlc(operation, options = {}) {
43
+ return Boolean(parseHlc(operationHlc(operation)) && validateHlc(operationHlc(operation), options).ok);
44
+ }
45
+
46
+ module.exports = {
47
+ compareOperations,
48
+ hasValidOperationHlc,
49
+ operationCreatedAt,
50
+ operationHlc,
51
+ operationIdHash,
52
+ operationSortKey,
53
+ requireOperationHlc,
54
+ sortOperations
55
+ };
@@ -0,0 +1,187 @@
1
+ const { canonicalJson } = require("@mh-gg/event/canonicalJson");
2
+ const crypto = require("node:crypto");
3
+
4
+ const RECEIPT_KIND = "matterhorn.receipt-attestation";
5
+ const RECEIPT_VERSION = 1;
6
+ const INVITE_REVOCATION_PROPAGATION_BOUND_MS = 3_600_000;
7
+ const INVITE_REVOCATION_RACE_ACCEPT = "accept";
8
+ const INVITE_REVOCATION_RACE_PENDING = "pending";
9
+ const INVITE_REVOCATION_RACE_REJECT = "reject";
10
+ const INVITE_REVOCATION_RACE_TOMBSTONE_REASON = "revoked-invite-race";
11
+
12
+ function receiptId(relayId, eventId) {
13
+ return `${relayId}:${eventId}`;
14
+ }
15
+
16
+ function createReceiptAttestation({ relayId, eventId, roomId, seenAt = Date.now() }) {
17
+ if (!relayId || !eventId || !roomId) throw new Error("relayId, eventId, and roomId are required");
18
+ return {
19
+ kind: RECEIPT_KIND,
20
+ version: RECEIPT_VERSION,
21
+ relayId,
22
+ eventId,
23
+ roomId,
24
+ seenAt
25
+ };
26
+ }
27
+
28
+ function receiptAttestationHash(attestation) {
29
+ return crypto.createHash("sha256").update(canonicalJson(attestation)).digest("hex");
30
+ }
31
+
32
+ function isReceiptAttestation(value) {
33
+ return (
34
+ value &&
35
+ typeof value === "object" &&
36
+ value.kind === RECEIPT_KIND &&
37
+ value.version === RECEIPT_VERSION &&
38
+ typeof value.relayId === "string" &&
39
+ typeof value.eventId === "string" &&
40
+ typeof value.roomId === "string" &&
41
+ Number.isFinite(value.seenAt)
42
+ );
43
+ }
44
+
45
+ function earliestAttestation(attestations = []) {
46
+ let earliest = undefined;
47
+ for (const a of attestations) {
48
+ if (!isReceiptAttestation(a)) continue;
49
+ if (!earliest || a.seenAt < earliest.seenAt) earliest = a;
50
+ }
51
+ return earliest;
52
+ }
53
+
54
+ function attestationsForEvent(attestationsById = {}, eventId) {
55
+ const out = [];
56
+ for (const att of Object.values(attestationsById)) {
57
+ if (att.eventId === eventId) out.push(att);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function backdatedFoldWindowMs(deltaMs = 3_600_000) {
63
+ return Math.max(0, Number(deltaMs) || 0);
64
+ }
65
+
66
+ function inviteIdFromJoinOperation(operation = {}) {
67
+ return (
68
+ operation.inviteId ||
69
+ operation.auth?.inviteId ||
70
+ operation.auth?.id ||
71
+ operation.join?.inviteId ||
72
+ operation.externalCommit?.inviteId ||
73
+ operation.payload?.inviteId ||
74
+ operation.payload?.auth?.inviteId ||
75
+ operation.payload?.auth?.id ||
76
+ operation.payload?.join?.inviteId ||
77
+ operation.payload?.externalCommit?.inviteId
78
+ );
79
+ }
80
+
81
+ function eventIdOf(value = {}) {
82
+ return value.eventId || value.id;
83
+ }
84
+
85
+ function receiptForEvent(attestations, eventId) {
86
+ if (!eventId) return undefined;
87
+ return earliestAttestation(attestationsForEvent(attestations, eventId));
88
+ }
89
+
90
+ function classifyInviteRevocationRace({ joinOperation, revocationEvent, attestations, deltaMs = INVITE_REVOCATION_PROPAGATION_BOUND_MS } = {}) {
91
+ const inviteId = inviteIdFromJoinOperation(joinOperation);
92
+ if (!inviteId || !revocationEvent?.inviteId || inviteId !== revocationEvent.inviteId) {
93
+ return { decision: INVITE_REVOCATION_RACE_ACCEPT, reason: "invite-not-revoked" };
94
+ }
95
+
96
+ const joinReceipt = receiptForEvent(attestations, eventIdOf(joinOperation));
97
+ const revocationReceipt = receiptForEvent(attestations, eventIdOf(revocationEvent));
98
+ if (!joinReceipt || !revocationReceipt) {
99
+ return {
100
+ decision: INVITE_REVOCATION_RACE_PENDING,
101
+ reason: "missing-receipt",
102
+ inviteId,
103
+ joinReceipt,
104
+ revocationReceipt
105
+ };
106
+ }
107
+
108
+ const window = backdatedFoldWindowMs(deltaMs);
109
+ if (joinReceipt.seenAt > revocationReceipt.seenAt + window) {
110
+ return {
111
+ decision: INVITE_REVOCATION_RACE_REJECT,
112
+ reason: INVITE_REVOCATION_RACE_TOMBSTONE_REASON,
113
+ inviteId,
114
+ joinReceipt,
115
+ revocationReceipt,
116
+ deltaMs: window
117
+ };
118
+ }
119
+
120
+ return {
121
+ decision: INVITE_REVOCATION_RACE_ACCEPT,
122
+ reason: "within-propagation-bound",
123
+ inviteId,
124
+ joinReceipt,
125
+ revocationReceipt,
126
+ deltaMs: window
127
+ };
128
+ }
129
+
130
+ function shouldRejectInviteJoinAfterRevocation(input) {
131
+ return classifyInviteRevocationRace(input).decision === INVITE_REVOCATION_RACE_REJECT;
132
+ }
133
+
134
+ function tombstoneInviteRevocationRaces({ operations = [], revocations = [], attestations, deltaMs = INVITE_REVOCATION_PROPAGATION_BOUND_MS } = {}) {
135
+ const tombstones = {};
136
+ for (const operation of operations) {
137
+ for (const revocationEvent of revocations) {
138
+ const result = classifyInviteRevocationRace({ joinOperation: operation, revocationEvent, attestations, deltaMs });
139
+ if (result.decision !== INVITE_REVOCATION_RACE_REJECT) continue;
140
+ tombstones[eventIdOf(operation)] = {
141
+ reason: INVITE_REVOCATION_RACE_TOMBSTONE_REASON,
142
+ inviteId: result.inviteId,
143
+ revocationId: eventIdOf(revocationEvent),
144
+ joinReceiptSeenAt: result.joinReceipt.seenAt,
145
+ revocationReceiptSeenAt: result.revocationReceipt.seenAt,
146
+ deltaMs: result.deltaMs
147
+ };
148
+ break;
149
+ }
150
+ }
151
+ return tombstones;
152
+ }
153
+
154
+ function isBackdatedOperation({ operation, revocationHlc, attestations, deltaMs }) {
155
+ const opHlc = operation?.hlc;
156
+ if (!opHlc || !revocationHlc) return false;
157
+ const revokeHlc = typeof revocationHlc === "string" ? revocationHlc : revocationHlc.hlc;
158
+ if (!revokeHlc || String(opHlc).localeCompare(String(revokeHlc)) >= 0) return false;
159
+ const opReceipt = earliestAttestation(attestationsForEvent(attestations, operation.eventId || operation.id));
160
+ const revocationReceipt = earliestAttestation(attestationsForEvent(attestations, revocationHlc.eventId || revocationHlc.id));
161
+ if (!opReceipt || !revocationReceipt) return false;
162
+ const window = backdatedFoldWindowMs(deltaMs);
163
+ // Operation HLC predates revocation, but receipt is after revocation receipt + window.
164
+ const opReceiptTooLate = opReceipt.seenAt > revocationReceipt.seenAt + window;
165
+ return opReceiptTooLate;
166
+ }
167
+
168
+ module.exports = {
169
+ INVITE_REVOCATION_PROPAGATION_BOUND_MS,
170
+ INVITE_REVOCATION_RACE_ACCEPT,
171
+ INVITE_REVOCATION_RACE_PENDING,
172
+ INVITE_REVOCATION_RACE_REJECT,
173
+ INVITE_REVOCATION_RACE_TOMBSTONE_REASON,
174
+ RECEIPT_KIND,
175
+ RECEIPT_VERSION,
176
+ backdatedFoldWindowMs,
177
+ classifyInviteRevocationRace,
178
+ createReceiptAttestation,
179
+ earliestAttestation,
180
+ inviteIdFromJoinOperation,
181
+ isBackdatedOperation,
182
+ isReceiptAttestation,
183
+ receiptAttestationHash,
184
+ receiptId,
185
+ shouldRejectInviteJoinAfterRevocation,
186
+ tombstoneInviteRevocationRaces
187
+ };
@@ -0,0 +1,67 @@
1
+ const { applyAuthorityProjection, createAuthorityState, normalizeAuthorityState, roleFromAuthority } = require("../state.cjs");
2
+ const { hasValidOperationHlc, sortOperations } = require("../ordering.cjs");
3
+ const { grantFromOperation, isAuthorityGrantOperation, isAuthorityRevokeOperation, revocationFromOperation } = require("./operations.cjs");
4
+ const { canSetRole, projectionFor } = require("./policy.cjs");
5
+ const { computeVoidedGrantIds } = require("./voids.cjs");
6
+
7
+ function tombstone(tombstones, id, reason, details = {}) {
8
+ if (!id || tombstones[id]) return;
9
+ tombstones[id] = { reason, ...details };
10
+ }
11
+ function authoritySeed(baseAuthority) {
12
+ return createAuthorityState({
13
+ ownerIds: baseAuthority.genesis?.ownerIds || [],
14
+ grants: baseAuthority.grants || [],
15
+ revocations: baseAuthority.revocations || [],
16
+ tombstones: baseAuthority.tombstones || {},
17
+ createdAt: baseAuthority.genesis?.createdAt || 0
18
+ });
19
+ }
20
+
21
+ function addBaseGrants(nextAuthority, voidedGrantIds, tombstones) {
22
+ const grants = [];
23
+ for (const baseGrant of nextAuthority.grants || []) {
24
+ if (voidedGrantIds.has(baseGrant.id)) tombstone(tombstones, baseGrant.id, "voided-by-revocation", { source: "base" });
25
+ else grants.push(baseGrant);
26
+ }
27
+ return grants;
28
+ }
29
+
30
+ function applyGrantOperation(operation, context) {
31
+ const { nextAuthority, grants, tombstones, voidedGrantIds } = context;
32
+ if (!hasValidOperationHlc(operation)) return tombstone(tombstones, operation.id, "invalid-authority-hlc");
33
+ const grant = grantFromOperation(operation);
34
+ if (!grant) return tombstone(tombstones, operation.id, "invalid-authority-grant");
35
+ if (voidedGrantIds.has(grant.id)) return tombstone(tombstones, grant.id, "voided-by-revocation");
36
+ const current = projectionFor({ ...nextAuthority, grants });
37
+ const granterRole = current[grant.granter] || roleFromAuthority(nextAuthority, grant.granter);
38
+ const targetRole = current[grant.target] || roleFromAuthority(nextAuthority, grant.target);
39
+ if (canSetRole(granterRole, grant.role, targetRole)) return grants.push(grant);
40
+ tombstone(tombstones, grant.id, "unauthorized-authority-grant", { granter: grant.granter, target: grant.target, role: grant.role });
41
+ }
42
+
43
+ function applyRevocationOperation(operation, context) {
44
+ const { revocations, tombstones, validRevocationIds } = context;
45
+ if (!hasValidOperationHlc(operation)) return tombstone(tombstones, operation.id, "invalid-authority-hlc");
46
+ const revocation = revocationFromOperation(operation);
47
+ if (!revocation) return tombstone(tombstones, operation.id, "invalid-authority-revocation");
48
+ if (validRevocationIds.has(revocation.id)) return revocations.push(revocation);
49
+ tombstone(tombstones, revocation.id, "unauthorized-authority-revocation", { revoker: revocation.revoker });
50
+ }
51
+
52
+ function resolveAuthorityOperations({ authority: authorityInput, operations = [] } = {}) {
53
+ const baseAuthority = normalizeAuthorityState(authorityInput) || createAuthorityState({ ownerIds: [] });
54
+ const sorted = sortOperations(operations);
55
+ const { voidedGrantIds, validRevocationIds } = computeVoidedGrantIds(baseAuthority, sorted);
56
+ const nextAuthority = authoritySeed(baseAuthority);
57
+ const tombstones = { ...(nextAuthority.tombstones || {}) };
58
+ const grants = addBaseGrants(nextAuthority, voidedGrantIds, tombstones);
59
+ const revocations = [...(nextAuthority.revocations || [])];
60
+ const context = { nextAuthority, grants, revocations, tombstones, voidedGrantIds, validRevocationIds };
61
+ for (const operation of sorted) {
62
+ if (isAuthorityGrantOperation(operation)) applyGrantOperation(operation, context);
63
+ else if (isAuthorityRevokeOperation(operation)) applyRevocationOperation(operation, context);
64
+ }
65
+ return applyAuthorityProjection({ ...nextAuthority, grants, revocations, tombstones }).authority;
66
+ }
67
+ module.exports = { resolveAuthorityOperations };
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ ...require("./operations.cjs"),
3
+ ...require("./policy.cjs"),
4
+ ...require("./voids.cjs"),
5
+ ...require("./fold.cjs")
6
+ };
@@ -0,0 +1,48 @@
1
+ const { CORE_AUTHORITY_GRANT_TYPE, CORE_AUTHORITY_REVOKE_TYPE, CORE_PLUGIN_ID } = require("../constants.cjs");
2
+ const { normalizeGrant, normalizeRevocation } = require("../state.cjs");
3
+ const { operationCreatedAt } = require("../ordering.cjs");
4
+
5
+ function isAuthorityGrantOperation(operation) {
6
+ return operation?.pluginId === CORE_PLUGIN_ID && operation?.type === CORE_AUTHORITY_GRANT_TYPE;
7
+ }
8
+ function isAuthorityRevokeOperation(operation) {
9
+ return operation?.pluginId === CORE_PLUGIN_ID && operation?.type === CORE_AUTHORITY_REVOKE_TYPE;
10
+ }
11
+ function actorId(operation) {
12
+ return operation?.actor?.memberId;
13
+ }
14
+ function grantFromOperation(operation) {
15
+ if (!isAuthorityGrantOperation(operation)) return undefined;
16
+ return normalizeGrant({
17
+ id: operation.id,
18
+ granter: actorId(operation),
19
+ target: operation.payload?.target,
20
+ role: operation.payload?.role,
21
+ createdAt: operationCreatedAt(operation),
22
+ hlc: operation.hlc,
23
+ reason: operation.payload?.reason,
24
+ source: "operation",
25
+ credentialId: operation.auth?.credentialId,
26
+ signature: operation.auth?.signature,
27
+ signedAt: operation.auth?.issuedAt || operation.auth?.createdAt
28
+ });
29
+ }
30
+ function revocationFromOperation(operation) {
31
+ if (!isAuthorityRevokeOperation(operation)) return undefined;
32
+ return normalizeRevocation({
33
+ id: operation.id,
34
+ revoker: actorId(operation),
35
+ target: operation.payload?.target,
36
+ voidGrantIds: operation.payload?.voidGrantIds,
37
+ voidFrom: operation.payload?.voidFrom,
38
+ scope: operation.payload?.scope,
39
+ cascade: operation.payload?.cascade,
40
+ createdAt: operationCreatedAt(operation),
41
+ hlc: operation.hlc,
42
+ reason: operation.payload?.reason,
43
+ credentialId: operation.auth?.credentialId,
44
+ signature: operation.auth?.signature,
45
+ signedAt: operation.auth?.issuedAt || operation.auth?.createdAt
46
+ });
47
+ }
48
+ module.exports = { grantFromOperation, isAuthorityGrantOperation, isAuthorityRevokeOperation, revocationFromOperation };
@@ -0,0 +1,28 @@
1
+ const { normalizeRoomRole, roleMeetsRequirement, roleRank } = require("../roles.cjs");
2
+ const { applyAuthorityProjection } = require("../state.cjs");
3
+
4
+ function projectionFor(authority, grants = []) {
5
+ return applyAuthorityProjection({ ...authority, grants }).authority.derivedRoles || {};
6
+ }
7
+ function roleInProjection(authority, grants, memberId) {
8
+ if (!memberId) return undefined;
9
+ return projectionFor(authority, grants)[memberId];
10
+ }
11
+ function canSetRole(granterRole, targetRole, targetCurrentRole) {
12
+ const granter = normalizeRoomRole(granterRole, undefined);
13
+ const target = normalizeRoomRole(targetRole, undefined);
14
+ const currentTarget = normalizeRoomRole(targetCurrentRole, undefined);
15
+ if (!granter || !target) return false;
16
+ if (granter === "owner") return true;
17
+ if (target === "owner" || currentTarget === "owner") return false;
18
+ return roleMeetsRequirement(granter, "admin") && roleRank(target) <= roleRank("admin");
19
+ }
20
+ function canVoidGrant(revokerRole, grant) {
21
+ const role = normalizeRoomRole(revokerRole, undefined);
22
+ if (!role || !grant) return false;
23
+ const grantRole = normalizeRoomRole(grant.role, undefined);
24
+ if (role === "owner") return true;
25
+ if (grantRole === "owner") return false;
26
+ return roleMeetsRequirement(role, "admin") && roleRank(grantRole) <= roleRank("admin");
27
+ }
28
+ module.exports = { canSetRole, canVoidGrant, projectionFor, roleInProjection };
@@ -0,0 +1,88 @@
1
+ const { roleMeetsRequirement } = require("../roles.cjs");
2
+ const { createAuthorityState, normalizeAuthorityState } = require("../state.cjs");
3
+ const { hasValidOperationHlc, operationHlc, sortOperations } = require("../ordering.cjs");
4
+ const { codePointCompare, parseHlc } = require("@mh-gg/protocol");
5
+ const { grantFromOperation, isAuthorityGrantOperation, isAuthorityRevokeOperation, revocationFromOperation } = require("./operations.cjs");
6
+ const { canSetRole, canVoidGrant, roleInProjection } = require("./policy.cjs");
7
+
8
+ function grantRecordMap(authority, operations = []) {
9
+ const records = new Map();
10
+ for (const grant of authority?.grants || []) records.set(grant.id, { ...grant, source: grant.source || "base" });
11
+ for (const operation of operations) {
12
+ if (!hasValidOperationHlc(operation)) continue;
13
+ const grant = grantFromOperation(operation);
14
+ if (grant) records.set(grant.id, grant);
15
+ }
16
+ return records;
17
+ }
18
+
19
+ function grantIsAtOrAfterHlc(grant, cutHlc) {
20
+ if (!parseHlc(cutHlc)) return true;
21
+ const grantHlc = grant?.hlc || "";
22
+ if (!parseHlc(grantHlc)) return false;
23
+ return codePointCompare(grantHlc, cutHlc) >= 0;
24
+ }
25
+
26
+ function grantsMatchingRevocation(revocation, grantsById) {
27
+ if (!revocation) return [];
28
+ const out = new Map((revocation.voidGrantIds || []).map((id) => grantsById.get(id)).filter(Boolean).map((grant) => [grant.id, grant]));
29
+ if (revocation.target && (revocation.scope === "from-point" || revocation.scope === "all-by-target")) {
30
+ for (const grant of grantsById.values()) {
31
+ if (grant.granter !== revocation.target) continue;
32
+ if (revocation.scope === "from-point" && revocation.voidFrom !== undefined && !grantIsAtOrAfterHlc(grant, revocation.voidFrom)) continue;
33
+ out.set(grant.id, grant);
34
+ }
35
+ }
36
+ return [...out.values()];
37
+ }
38
+
39
+ function recomputeAppliedGrants(authority, processedGrants, voidedGrantIds) {
40
+ return [...(authority.grants || []), ...processedGrants].filter((grant) => !voidedGrantIds.has(grant.id));
41
+ }
42
+
43
+ function processGrantForVoids(operation, context) {
44
+ const { authority, processedGrants, voidedGrantIds } = context;
45
+ if (!hasValidOperationHlc(operation)) return;
46
+ const grant = grantFromOperation(operation);
47
+ if (!grant || voidedGrantIds.has(grant.id)) return;
48
+ const applied = recomputeAppliedGrants(authority, processedGrants, voidedGrantIds);
49
+ const granterRole = roleInProjection(authority, applied, grant.granter);
50
+ const targetRole = roleInProjection(authority, applied, grant.target);
51
+ if (canSetRole(granterRole, grant.role, targetRole)) processedGrants.push(grant);
52
+ }
53
+
54
+ function processRevocationForVoids(operation, context) {
55
+ const { authority, grantsById, processedGrants, validRevocationIds, voidedGrantIds } = context;
56
+ if (!hasValidOperationHlc(operation)) return;
57
+ const revocation = revocationFromOperation(operation);
58
+ if (!revocation) return;
59
+ const applied = recomputeAppliedGrants(authority, processedGrants, voidedGrantIds);
60
+ const revokerRole = roleInProjection(authority, applied, revocation.revoker);
61
+ const matches = grantsMatchingRevocation(revocation, grantsById);
62
+ if (matches.length === 0) {
63
+ if (roleMeetsRequirement(revokerRole, "admin")) validRevocationIds.add(revocation.id);
64
+ return;
65
+ }
66
+ if (!matches.every((grant) => canVoidGrant(revokerRole, grant))) return;
67
+ validRevocationIds.add(revocation.id);
68
+ for (const grant of matches) voidedGrantIds.add(grant.id);
69
+ }
70
+
71
+ function computeVoidedGrantIds(authorityInput, operations = []) {
72
+ const authority = normalizeAuthorityState(authorityInput) || createAuthorityState({ ownerIds: [] });
73
+ const sorted = sortOperations(operations);
74
+ const context = {
75
+ authority,
76
+ grantsById: grantRecordMap(authority, sorted),
77
+ voidedGrantIds: new Set(),
78
+ validRevocationIds: new Set(),
79
+ processedGrants: []
80
+ };
81
+ for (const operation of sorted) {
82
+ if (isAuthorityGrantOperation(operation)) processGrantForVoids(operation, context);
83
+ else if (isAuthorityRevokeOperation(operation)) processRevocationForVoids(operation, context);
84
+ }
85
+ return { voidedGrantIds: context.voidedGrantIds, validRevocationIds: context.validRevocationIds };
86
+ }
87
+
88
+ module.exports = { computeVoidedGrantIds, grantIsAtOrAfterHlc, grantRecordMap, grantsMatchingRevocation, recomputeAppliedGrants };
package/src/roles.cjs ADDED
@@ -0,0 +1,53 @@
1
+ const ROLE_RANKS = Object.freeze({
2
+ guest: 0,
3
+ member: 1,
4
+ user: 1,
5
+ facilitator: 1,
6
+ moderator: 2,
7
+ admin: 3,
8
+ owner: 4
9
+ });
10
+
11
+ function normalizeRoomRole(role, fallback) {
12
+ const missingFallback = arguments.length < 2;
13
+ const fallbackRole = missingFallback ? "guest" : fallback;
14
+ if (role === "user") return "member";
15
+ if (role === "owner" || role === "admin" || role === "moderator" || role === "facilitator" || role === "member" || role === "guest") return role;
16
+ return fallbackRole;
17
+ }
18
+
19
+ function roleRank(role, options = {}) {
20
+ const normalized = normalizeRoomRole(role, options.strict ? undefined : "guest");
21
+ if (!normalized) return undefined;
22
+ return ROLE_RANKS[normalized] ?? ROLE_RANKS.guest;
23
+ }
24
+
25
+ function minRoleByRank(roles = []) {
26
+ const normalized = (Array.isArray(roles) ? roles : [roles]).map((role) => normalizeRoomRole(role)).filter(Boolean);
27
+ if (normalized.length === 0) return undefined;
28
+ return normalized.reduce((lowest, role) => roleRank(role) < roleRank(lowest) ? role : lowest, normalized[0]);
29
+ }
30
+
31
+ function roleMeetsRequirement(actorRole, requiredRole) {
32
+ if (normalizeRoomRole(requiredRole, undefined) === "facilitator") {
33
+ const actor = normalizeRoomRole(actorRole, undefined);
34
+ return actor === "facilitator" || actor === "moderator" || actor === "admin" || actor === "owner";
35
+ }
36
+ return roleRank(actorRole) >= roleRank(requiredRole);
37
+ }
38
+
39
+ function effectiveRoleFor({ keyRole, stateRole, actorRole } = {}) {
40
+ const ceiling = normalizeRoomRole(keyRole, undefined);
41
+ const current = normalizeRoomRole(stateRole || actorRole || ceiling || "guest");
42
+ if (!ceiling) return current;
43
+ return roleRank(current) <= roleRank(ceiling) ? current : ceiling;
44
+ }
45
+
46
+ module.exports = {
47
+ ROLE_RANKS,
48
+ effectiveRoleFor,
49
+ minRoleByRank,
50
+ normalizeRoomRole,
51
+ roleMeetsRequirement,
52
+ roleRank
53
+ };
package/src/shared.cjs ADDED
@@ -0,0 +1,5 @@
1
+ function clone(value) {
2
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
3
+ }
4
+
5
+ module.exports = { clone };
package/src/state.cjs ADDED
@@ -0,0 +1,146 @@
1
+ const { normalizeRoomRole, roleRank } = require("./roles.cjs");
2
+ const { clone } = require("./shared.cjs");
3
+ const { AUTHORITY_STATE_KIND, AUTHORITY_STATE_VERSION } = require("./constants.cjs");
4
+
5
+ function uniqStrings(values = []) {
6
+ return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === "string" && value.length > 0))];
7
+ }
8
+
9
+ function normalizeGrant(input = {}) {
10
+ const id = String(input.id || "");
11
+ const granter = String(input.granter || input.actor?.memberId || input.revoker || "");
12
+ const target = String(input.target || input.memberId || "");
13
+ const role = normalizeRoomRole(input.role, undefined);
14
+ if (!id || !granter || !target || !role) return undefined;
15
+ return {
16
+ id,
17
+ granter,
18
+ target,
19
+ role,
20
+ createdAt: Number.isFinite(Number(input.createdAt)) ? Number(input.createdAt) : 0,
21
+ ...(input.hlc ? { hlc: input.hlc } : {}),
22
+ ...(input.reason ? { reason: String(input.reason) } : {}),
23
+ ...(input.source ? { source: String(input.source) } : {}),
24
+ ...(typeof input.credentialId === "string" && input.credentialId.length > 0 ? { credentialId: input.credentialId } : {}),
25
+ ...(typeof input.signature === "string" && input.signature.length > 0 ? { signature: input.signature } : {}),
26
+ ...(Number.isFinite(Number(input.signedAt)) ? { signedAt: Number(input.signedAt) } : {})
27
+ };
28
+ }
29
+
30
+ function normalizeRevocation(input = {}) {
31
+ const id = String(input.id || "");
32
+ const revoker = String(input.revoker || input.actor?.memberId || "");
33
+ if (!id || !revoker) return undefined;
34
+ const voidGrantIds = uniqStrings(input.voidGrantIds);
35
+ return {
36
+ id,
37
+ revoker,
38
+ target: typeof input.target === "string" && input.target.length > 0 ? input.target : undefined,
39
+ voidGrantIds,
40
+ voidFrom: input.voidFrom ?? undefined,
41
+ scope: input.scope || (voidGrantIds.length ? "all-by-target" : "future"),
42
+ cascade: input.cascade !== false,
43
+ createdAt: Number.isFinite(Number(input.createdAt)) ? Number(input.createdAt) : 0,
44
+ ...(input.hlc ? { hlc: input.hlc } : {}),
45
+ ...(input.reason ? { reason: String(input.reason) } : {}),
46
+ ...(typeof input.credentialId === "string" && input.credentialId.length > 0 ? { credentialId: input.credentialId } : {}),
47
+ ...(typeof input.signature === "string" && input.signature.length > 0 ? { signature: input.signature } : {}),
48
+ ...(Number.isFinite(Number(input.signedAt)) ? { signedAt: Number(input.signedAt) } : {})
49
+ };
50
+ }
51
+
52
+ function createAuthorityState({ ownerIds = [], grants = [], revocations = [], tombstones = {}, createdAt = Date.now() } = {}) {
53
+ const normalizedOwners = uniqStrings(ownerIds);
54
+ const state = {
55
+ kind: AUTHORITY_STATE_KIND,
56
+ version: AUTHORITY_STATE_VERSION,
57
+ genesis: {
58
+ ownerIds: normalizedOwners,
59
+ createdAt
60
+ },
61
+ grants: grants.map(normalizeGrant).filter(Boolean),
62
+ revocations: revocations.map(normalizeRevocation).filter(Boolean),
63
+ tombstones: { ...(tombstones || {}) },
64
+ derivedRoles: {}
65
+ };
66
+ return applyAuthorityProjection(state).authority;
67
+ }
68
+
69
+ function normalizeAuthorityState(authority) {
70
+ if (!authority || typeof authority !== "object") return undefined;
71
+ const ownerIds = uniqStrings(authority.genesis?.ownerIds || authority.ownerIds || []);
72
+ if (ownerIds.length === 0) return undefined;
73
+ return applyAuthorityProjection({
74
+ kind: authority.kind || AUTHORITY_STATE_KIND,
75
+ version: authority.version || AUTHORITY_STATE_VERSION,
76
+ genesis: {
77
+ ...(authority.genesis || {}),
78
+ ownerIds,
79
+ createdAt: authority.genesis?.createdAt || authority.createdAt || 0
80
+ },
81
+ grants: (Array.isArray(authority.grants) ? authority.grants : []).map(normalizeGrant).filter(Boolean),
82
+ revocations: (Array.isArray(authority.revocations) ? authority.revocations : []).map(normalizeRevocation).filter(Boolean),
83
+ tombstones: { ...(authority.tombstones || {}) },
84
+ derivedRoles: { ...(authority.derivedRoles || {}) }
85
+ }).authority;
86
+ }
87
+
88
+ function authorityEnabled(roomState) {
89
+ return Boolean(normalizeAuthorityState(roomState?.authority));
90
+ }
91
+
92
+ function roleFromAuthority(authority, memberId) {
93
+ const normalized = normalizeAuthorityState(authority);
94
+ if (!normalized || typeof memberId !== "string" || memberId.length === 0) return undefined;
95
+ if (normalized.derivedRoles?.[memberId]) return normalized.derivedRoles[memberId];
96
+ if ((normalized.genesis?.ownerIds || []).includes(memberId)) return "owner";
97
+ return undefined;
98
+ }
99
+
100
+ function adminIdsFromDerivedRoles(roles = {}) {
101
+ return Object.entries(roles)
102
+ .filter(([, role]) => roleRank(role) >= roleRank("admin"))
103
+ .map(([memberId]) => memberId)
104
+ .sort();
105
+ }
106
+
107
+ function applyAuthorityProjection(authorityInput = {}, membersInput = {}) {
108
+ const authority = clone(authorityInput);
109
+ const roles = {};
110
+ const owners = uniqStrings(authority.genesis?.ownerIds || []);
111
+ for (const ownerId of owners) roles[ownerId] = "owner";
112
+ for (const grant of authority.grants || []) {
113
+ if (!grant?.target || !grant?.role) continue;
114
+ if (grant.role === "guest") delete roles[grant.target];
115
+ else roles[grant.target] = normalizeRoomRole(grant.role);
116
+ }
117
+ authority.derivedRoles = roles;
118
+ const members = { ...(membersInput || {}) };
119
+ for (const [memberId, role] of Object.entries(roles)) {
120
+ const member = {
121
+ ...(members[memberId] || {}),
122
+ id: members[memberId]?.id || memberId,
123
+ role
124
+ };
125
+ delete member.profileOnly;
126
+ members[memberId] = member;
127
+ }
128
+ // Downgrade members whose previous role projection disappeared, while retaining identity metadata.
129
+ for (const [memberId, member] of Object.entries(members)) {
130
+ if (roles[memberId]) continue;
131
+ if (member?.role && roleRank(member.role) > roleRank("member")) members[memberId] = { ...member, role: "member" };
132
+ }
133
+ return { authority, members, adminIds: adminIdsFromDerivedRoles(roles) };
134
+ }
135
+
136
+ module.exports = {
137
+ adminIdsFromDerivedRoles,
138
+ applyAuthorityProjection,
139
+ authorityEnabled,
140
+ createAuthorityState,
141
+ normalizeAuthorityState,
142
+ normalizeGrant,
143
+ normalizeRevocation,
144
+ roleFromAuthority,
145
+ uniqStrings
146
+ };