@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 +21 -0
- package/README.md +3 -0
- package/package.json +28 -0
- package/src/constants.cjs +17 -0
- package/src/index.cjs +11 -0
- package/src/inviteGrants.cjs +41 -0
- package/src/ladder.cjs +158 -0
- package/src/ordering.cjs +55 -0
- package/src/receipts.cjs +187 -0
- package/src/resolve/fold.cjs +67 -0
- package/src/resolve/index.cjs +6 -0
- package/src/resolve/operations.cjs +48 -0
- package/src/resolve/policy.cjs +28 -0
- package/src/resolve/voids.cjs +88 -0
- package/src/roles.cjs +53 -0
- package/src/shared.cjs +5 -0
- package/src/state.cjs +146 -0
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
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
|
+
};
|
package/src/ordering.cjs
ADDED
|
@@ -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
|
+
};
|
package/src/receipts.cjs
ADDED
|
@@ -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,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
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
|
+
};
|