@mh-gg/host-runtime 0.1.1-alpha.20260613T085325975Z
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +23 -0
- package/src/constants.cjs +9 -0
- package/src/errors.cjs +39 -0
- package/src/host/installAppPack.cjs +35 -0
- package/src/host/migrations.cjs +21 -0
- package/src/host/runtimeFromPack.cjs +25 -0
- package/src/host/startRoomHost.cjs +95 -0
- package/src/index.cjs +23 -0
- package/src/memory.cjs +81 -0
- package/src/plugins/definition.cjs +19 -0
- package/src/plugins/install.cjs +90 -0
- package/src/plugins/migrations.cjs +27 -0
- package/src/plugins/operationDescriptors.cjs +138 -0
- package/src/runtime/HostPluginRuntime.cjs +85 -0
- package/src/runtime/authorityReplay/applyAuthority.cjs +146 -0
- package/src/runtime/authorityReplay/applyContent.cjs +49 -0
- package/src/runtime/authorityReplay/index.cjs +70 -0
- package/src/runtime/authorityReplay/state.cjs +56 -0
- package/src/runtime/context.cjs +65 -0
- package/src/runtime/coreOperations.cjs +169 -0
- package/src/runtime/corePayloads.cjs +66 -0
- package/src/runtime/directMessages/commit.cjs +50 -0
- package/src/runtime/directMessages/constants.cjs +20 -0
- package/src/runtime/directMessages/helpers.cjs +59 -0
- package/src/runtime/directMessages/payloads.cjs +74 -0
- package/src/runtime/directMessages/state.cjs +168 -0
- package/src/runtime/directMessages.cjs +6 -0
- package/src/runtime/lifecycle.cjs +166 -0
- package/src/runtime/memberProfiles.cjs +74 -0
- package/src/runtime/methods.cjs +10 -0
- package/src/runtime/operations.cjs +166 -0
- package/src/runtime/queries.cjs +146 -0
- package/src/runtime/readTags.cjs +171 -0
- package/src/runtime/scopedRoleOperations.cjs +97 -0
- package/src/runtime/snowflake.cjs +43 -0
- package/src/security/authority/constants.cjs +10 -0
- package/src/security/authority/resolve/operations.cjs +7 -0
- package/src/security/authority/resolve/policy.cjs +7 -0
- package/src/security/authority/resolve/voids.cjs +8 -0
- package/src/security/authorization/coreGate.cjs +63 -0
- package/src/security/authorization/schemaActions.cjs +75 -0
- package/src/security/roleKeys/authenticator.cjs +36 -0
- package/src/security/roleKeys/authorities/index.cjs +4 -0
- package/src/security/roleKeys/authorities/shapes.cjs +98 -0
- package/src/security/roleKeys/authorities/signing.cjs +121 -0
- package/src/security/roleKeys/constants.cjs +15 -0
- package/src/security/roleKeys/fingerprints.cjs +24 -0
- package/src/security/roleKeys/grants.cjs +93 -0
- package/src/security/roleKeys/index.cjs +10 -0
- package/src/security/roleKeys/roles.cjs +21 -0
- package/src/security/roleKeys/signatures.cjs +126 -0
- package/src/security/roles.cjs +10 -0
- package/src/security/roomDeviceKeys.cjs +41 -0
- package/src/security/scopedRoles/access.cjs +123 -0
- package/src/security/scopedRoles/constants.cjs +23 -0
- package/src/security/scopedRoles/metadata.cjs +39 -0
- package/src/security/scopedRoles/normalize.cjs +179 -0
- package/src/security/scopedRoles/publicView.cjs +31 -0
- package/src/security/scopedRoles/stateOps.cjs +167 -0
- package/src/security/scopedRoles.cjs +7 -0
- package/src/security/standingAuthority.cjs +76 -0
- package/src/shared.cjs +14 -0
- package/src/state.cjs +54 -0
- package/test/authority-ordering-hardening.test.cjs +101 -0
- package/test/authorization-gate.test.cjs +610 -0
- package/test/cascading-authority.test.cjs +390 -0
- package/test/grant-authority-security.test.cjs +305 -0
- package/test/matterhorn-host-runtime.test.cjs +1629 -0
- package/test/operation-descriptor-policy.test.cjs +140 -0
- package/test/role-key-auth.test.cjs +289 -0
- package/test/security-isolation.test.cjs +112 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const { clone } = require("../../shared.cjs");
|
|
3
|
+
const { OPERATION_ROLE_KEY_PROOF_KIND, OPERATION_ROLE_KEY_VERSION, ROLE_ALG } = require("./constants.cjs");
|
|
4
|
+
const { canonicalJson } = require("@mh-gg/event/canonicalJson");
|
|
5
|
+
const { ensureOperationIdentity } = require("@mh-gg/protocol");
|
|
6
|
+
const { ensureText, grantArray, isOperationRoleKeyGrant, publicOperationRoleKeyGrant } = require("./grants.cjs");
|
|
7
|
+
const { verifyOperationRoleKeyGrant } = require("./authorities/index.cjs");
|
|
8
|
+
const { normalizeOperationKeyRole, operationKeyRoleToActorRole } = require("./roles.cjs");
|
|
9
|
+
|
|
10
|
+
function unsignedOperationForRoleKey(operation) {
|
|
11
|
+
const next = clone(operation);
|
|
12
|
+
if (!next.auth || typeof next.auth !== "object") next.auth = {};
|
|
13
|
+
delete next.ledgerId;
|
|
14
|
+
delete next.snowflakeId;
|
|
15
|
+
delete next.committedRoomVersion;
|
|
16
|
+
delete next.committedAt;
|
|
17
|
+
delete next.receivedAt;
|
|
18
|
+
delete next.relayId;
|
|
19
|
+
delete next.auth.signature;
|
|
20
|
+
delete next.auth.eventId;
|
|
21
|
+
delete next.auth.signatureEventId;
|
|
22
|
+
delete next.auth.signedAt;
|
|
23
|
+
return next;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function operationPayloadForRoleKey(operation) {
|
|
27
|
+
return Buffer.from(canonicalJson(unsignedOperationForRoleKey(operation)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function signOperationWithRoleKey(operation, options = {}) {
|
|
31
|
+
const grant = options.grant;
|
|
32
|
+
if (!isOperationRoleKeyGrant(grant)) throw new Error("A valid operation role-key grant is required");
|
|
33
|
+
const privateKeyPem = ensureText(options.privateKeyPem || options.privateKey, "privateKeyPem");
|
|
34
|
+
const next = clone(operation);
|
|
35
|
+
next.auth = {
|
|
36
|
+
...(next.auth || {}),
|
|
37
|
+
kind: OPERATION_ROLE_KEY_PROOF_KIND,
|
|
38
|
+
version: OPERATION_ROLE_KEY_VERSION,
|
|
39
|
+
alg: ROLE_ALG,
|
|
40
|
+
credentialId: grant.credentialId,
|
|
41
|
+
keyRole: grant.role,
|
|
42
|
+
publicKeyFingerprint: grant.publicKeyFingerprint,
|
|
43
|
+
issuedAt: Number.isFinite(options.issuedAt) ? options.issuedAt : (typeof options.now === "function" ? options.now() : Date.now())
|
|
44
|
+
};
|
|
45
|
+
delete next.auth.signature;
|
|
46
|
+
delete next.auth.eventId;
|
|
47
|
+
delete next.auth.signatureEventId;
|
|
48
|
+
delete next.auth.signedAt;
|
|
49
|
+
const withIdentity = ensureOperationIdentity(next, {
|
|
50
|
+
nodeId: next.actor?.deviceId || next.actor?.memberId || grant.deviceId || grant.memberId || "role-key",
|
|
51
|
+
now: Number.isFinite(options.issuedAt) ? options.issuedAt : (typeof options.now === "function" ? options.now() : Date.now()),
|
|
52
|
+
lastSeenHlc: options.lastSeenHlc
|
|
53
|
+
});
|
|
54
|
+
withIdentity.auth.signature = crypto.sign(null, operationPayloadForRoleKey(withIdentity), privateKeyPem).toString("base64url");
|
|
55
|
+
return withIdentity;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findGrantForOperation(operation, grants) {
|
|
59
|
+
const auth = operation?.auth || {};
|
|
60
|
+
return grantArray(grants).find((grant) => {
|
|
61
|
+
if (!isOperationRoleKeyGrant(grant)) return false;
|
|
62
|
+
if (auth.credentialId && grant.credentialId === auth.credentialId) return true;
|
|
63
|
+
if (auth.publicKeyFingerprint && grant.publicKeyFingerprint === auth.publicKeyFingerprint) return true;
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function grantAllowsOperation(grant, operation, options = {}) {
|
|
69
|
+
const auth = operation.auth || {};
|
|
70
|
+
if (!isOperationRoleKeyGrant(grant, options)) return "Operation role key grant is invalid.";
|
|
71
|
+
if (auth.kind !== undefined && auth.kind !== OPERATION_ROLE_KEY_PROOF_KIND) return "Operation role-key proof kind is invalid.";
|
|
72
|
+
if (auth.version !== undefined && auth.version !== OPERATION_ROLE_KEY_VERSION) return "Operation role-key proof version is invalid.";
|
|
73
|
+
if (auth.alg !== undefined && auth.alg !== ROLE_ALG) return "Operation role-key proof algorithm is invalid.";
|
|
74
|
+
if (auth.keyRole !== undefined && normalizeOperationKeyRole(auth.keyRole) !== grant.role) return "Operation role-key role mismatch.";
|
|
75
|
+
if (auth.publicKeyFingerprint !== undefined && auth.publicKeyFingerprint !== grant.publicKeyFingerprint) return "Operation role-key fingerprint mismatch.";
|
|
76
|
+
if (operation.roomId !== grant.roomId) return "Operation role key is for a different room.";
|
|
77
|
+
if (operation.appPackId !== grant.appPackId) return "Operation role key is for a different app pack.";
|
|
78
|
+
if (grant.appPackHash && operation.appPackHash !== grant.appPackHash) return "Operation role key is for a different app pack hash.";
|
|
79
|
+
if (grant.memberId && operation.actor?.memberId !== grant.memberId) return "Operation role key member mismatch.";
|
|
80
|
+
if (grant.deviceId && operation.actor?.deviceId !== grant.deviceId) return "Operation role key device mismatch.";
|
|
81
|
+
if (grant.allowedPluginIds && !grant.allowedPluginIds.includes(operation.pluginId)) return "Operation role key is not valid for this plugin.";
|
|
82
|
+
if (grant.allowedOperations && !grant.allowedOperations.includes(operation.type)) return "Operation role key is not valid for this operation.";
|
|
83
|
+
const now = Number.isFinite(options.now) ? options.now : (typeof options.now === "function" ? options.now() : Date.now());
|
|
84
|
+
if (grant.expiresAt !== undefined && now > grant.expiresAt) return "Operation role key has expired.";
|
|
85
|
+
if (Number.isFinite(auth.issuedAt) && auth.issuedAt < grant.issuedAt) return "Operation role-key proof predates grant.";
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function verifyOperationRoleKey(operation, grants, options = {}) {
|
|
90
|
+
if (!operation || typeof operation !== "object" || Array.isArray(operation)) return { ok: false, error: "Operation is required." };
|
|
91
|
+
if (!operation.auth || typeof operation.auth !== "object" || Array.isArray(operation.auth)) return { ok: false, error: "Operation auth is required." };
|
|
92
|
+
if (typeof operation.auth.signature !== "string" || operation.auth.signature.length === 0) return { ok: false, error: "Operation role-key signature is required." };
|
|
93
|
+
const grant = findGrantForOperation(operation, grants);
|
|
94
|
+
if (!grant) return { ok: false, error: "Operation role key is unknown." };
|
|
95
|
+
const grantVerification = verifyOperationRoleKeyGrant(grant, options.grantAuthorities || options.operationGrantAuthorities || options.authorities, options);
|
|
96
|
+
if (!grantVerification.ok) return { ok: false, error: grantVerification.error };
|
|
97
|
+
const grantError = grantAllowsOperation(grant, operation, options);
|
|
98
|
+
if (grantError) return { ok: false, error: grantError };
|
|
99
|
+
try {
|
|
100
|
+
const ok = crypto.verify(null, operationPayloadForRoleKey(operation), grant.publicKeyPem, Buffer.from(operation.auth.signature, "base64url"));
|
|
101
|
+
if (!ok) return { ok: false, error: "Operation role-key signature is invalid." };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return { ok: false, error: error?.message || "Operation role-key signature verification failed." };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
grant: publicOperationRoleKeyGrant(grant),
|
|
108
|
+
actor: {
|
|
109
|
+
...(operation.actor || {}),
|
|
110
|
+
memberId: grant.memberId || operation.actor?.memberId,
|
|
111
|
+
deviceId: grant.deviceId || operation.actor?.deviceId,
|
|
112
|
+
role: operationKeyRoleToActorRole(grant.role),
|
|
113
|
+
keyRole: grant.role,
|
|
114
|
+
credentialId: grant.credentialId
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
findGrantForOperation,
|
|
121
|
+
grantAllowsOperation,
|
|
122
|
+
operationPayloadForRoleKey,
|
|
123
|
+
signOperationWithRoleKey,
|
|
124
|
+
unsignedOperationForRoleKey,
|
|
125
|
+
verifyOperationRoleKey
|
|
126
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const authority = require("@mh-gg/authority");
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
ROLE_RANKS: authority.ROLE_RANKS,
|
|
5
|
+
effectiveRoleFor: authority.effectiveRoleFor,
|
|
6
|
+
minRoleByRank: authority.minRoleByRank,
|
|
7
|
+
normalizeRoomRole: authority.normalizeRoomRole,
|
|
8
|
+
roleMeetsRequirement: authority.roleMeetsRequirement,
|
|
9
|
+
roleRank: authority.roleRank
|
|
10
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { verifyRoomDeviceSignedOperation } = require("@mh-gg/protocol");
|
|
2
|
+
const { runtimeError } = require("../errors.cjs");
|
|
3
|
+
|
|
4
|
+
function isRoomDeviceSignedOperation(operation) {
|
|
5
|
+
return operation?.auth?.scheme === "matterhorn.device-signing.v1";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function actorFromRoomDeviceSignature(operation) {
|
|
9
|
+
const verification = verifyRoomDeviceSignedOperation(operation, operation?.auth?.claim);
|
|
10
|
+
if (!verification.ok) {
|
|
11
|
+
throw runtimeError("INVALID_ROOM_DEVICE_SIGNATURE", verification.error || "Room device signature is invalid");
|
|
12
|
+
}
|
|
13
|
+
const auth = verification.auth;
|
|
14
|
+
const actor = operation.actor || {};
|
|
15
|
+
return {
|
|
16
|
+
...actor,
|
|
17
|
+
memberId: auth.memberId,
|
|
18
|
+
deviceId: auth.deviceId,
|
|
19
|
+
credentialId: auth.credentialId,
|
|
20
|
+
keyId: auth.keyId,
|
|
21
|
+
rootPublicKey: auth.rootPublicKey,
|
|
22
|
+
publicKey: auth.publicKey,
|
|
23
|
+
publicKeyFingerprint: auth.publicKeyFingerprint
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createRoomDeviceKeyAuthenticator() {
|
|
28
|
+
return async function authenticateRoomDeviceKey(authOrOperation, _actor, operation) {
|
|
29
|
+
const signedOperation = operation || authOrOperation;
|
|
30
|
+
if (!isRoomDeviceSignedOperation(signedOperation)) {
|
|
31
|
+
throw runtimeError("ROOM_DEVICE_SIGNATURE_REQUIRED", "Operation is not signed with a Matterhorn room-device key");
|
|
32
|
+
}
|
|
33
|
+
return actorFromRoomDeviceSignature(signedOperation);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
actorFromRoomDeviceSignature,
|
|
39
|
+
createRoomDeviceKeyAuthenticator,
|
|
40
|
+
isRoomDeviceSignedOperation
|
|
41
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const { runtimeError } = require("../../errors.cjs");
|
|
2
|
+
const { actorWithStanding } = require("../standingAuthority.cjs");
|
|
3
|
+
const {
|
|
4
|
+
normalizeScopedRole,
|
|
5
|
+
normalizeScopedRolesState,
|
|
6
|
+
optionalBoundedString,
|
|
7
|
+
scopedRoleFromGlobalRole,
|
|
8
|
+
scopedRoleMeets,
|
|
9
|
+
scopedRoleRank,
|
|
10
|
+
scopeKey
|
|
11
|
+
} = require("./normalize.cjs");
|
|
12
|
+
|
|
13
|
+
function grantSpecificity(grant, scopeType, scopeId) {
|
|
14
|
+
if (grant.scopeType === scopeType && grant.scopeId === scopeId) return 3;
|
|
15
|
+
if (grant.scopeType === scopeType && grant.scopeId === "*") return 2;
|
|
16
|
+
if (grant.scopeType === "*" && grant.scopeId === "*") return 1;
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function compoundRoleForActor(scopedState, actor = {}, scopeType, scopeId) {
|
|
21
|
+
const roleIds = actor.memberId ? scopedState.assignments?.[actor.memberId] || [] : [];
|
|
22
|
+
let selectedRole;
|
|
23
|
+
let selectedSpecificity = 0;
|
|
24
|
+
for (const roleId of roleIds) {
|
|
25
|
+
const compoundRole = scopedState.roles?.[roleId];
|
|
26
|
+
for (const grant of compoundRole?.grants || []) {
|
|
27
|
+
const specificity = grantSpecificity(grant, scopeType, scopeId);
|
|
28
|
+
if (specificity === 0) continue;
|
|
29
|
+
if (specificity > selectedSpecificity || (specificity === selectedSpecificity && scopedRoleRank(grant.role) > scopedRoleRank(selectedRole))) {
|
|
30
|
+
selectedRole = grant.role;
|
|
31
|
+
selectedSpecificity = specificity;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return selectedRole;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scopedRoleForActor(roomState, actor = {}, scopeType, scopeId) {
|
|
39
|
+
if (!actor || actor.standing && actor.standing !== "active") return "none";
|
|
40
|
+
const globalRole = scopedRoleFromGlobalRole(actor.role);
|
|
41
|
+
if (scopedRoleRank(globalRole) >= scopedRoleRank("admin")) return globalRole;
|
|
42
|
+
const scopedState = normalizeScopedRolesState(roomState?.scopedRoles);
|
|
43
|
+
const entry = scopedState.scopes[scopeKey(scopeType, scopeId)];
|
|
44
|
+
const compoundRole = compoundRoleForActor(scopedState, actor, scopeType, scopeId);
|
|
45
|
+
if (!entry) return compoundRole || globalRole;
|
|
46
|
+
const explicitRole = actor.memberId ? normalizeScopedRole(entry.roles?.[actor.memberId], undefined) : undefined;
|
|
47
|
+
if (explicitRole) return explicitRole;
|
|
48
|
+
return compoundRole || normalizeScopedRole(entry.defaultRole, "none");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function canAccessScope(roomState, actor, scopeType, scopeId, requiredRole) {
|
|
52
|
+
return scopedRoleMeets(scopedRoleForActor(roomState, actor, scopeType, scopeId), requiredRole);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function canViewScope(roomState, actor, scopeType, scopeId) {
|
|
56
|
+
return canAccessScope(roomState, actor, scopeType, scopeId, "viewer");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function canEditScope(roomState, actor, scopeType, scopeId) {
|
|
60
|
+
return canAccessScope(roomState, actor, scopeType, scopeId, "editor");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function evaluateScopeValue(value, { operation, actor, roomState, plugin }) {
|
|
64
|
+
if (typeof value !== "string") return value;
|
|
65
|
+
if (value === "$app.id") return operation?.appPackId;
|
|
66
|
+
if (value === "$room.id") return roomState?.roomId || operation?.roomId;
|
|
67
|
+
if (value === "$plugin.id") return plugin?.id || operation?.pluginId;
|
|
68
|
+
if (value.startsWith("$payload.")) return operation?.payload?.[value.slice("$payload.".length)];
|
|
69
|
+
if (value.startsWith("$actor.")) return actor?.[value.slice("$actor.".length)];
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeScopePolicy(policy, ctx) {
|
|
74
|
+
const scopeType = evaluateScopeValue(policy.scopeType || policy.type, ctx);
|
|
75
|
+
const scopeId = evaluateScopeValue(policy.scopeId || policy.id, ctx);
|
|
76
|
+
return {
|
|
77
|
+
scopeType: optionalBoundedString(scopeType, "scopeType", 80),
|
|
78
|
+
scopeId: optionalBoundedString(scopeId, "scopeId", 180),
|
|
79
|
+
action: policy.action === "view" ? "view" : "edit"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function operationScopePolicies(authorize = {}) {
|
|
84
|
+
const policy = authorize.scope || authorize.scopes;
|
|
85
|
+
if (!policy) return [];
|
|
86
|
+
return Array.isArray(policy) ? policy : [policy];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function assertOperationScopeAccess({ roomState, actor, operation, plugin, authorize = {}, operationLabel }) {
|
|
90
|
+
const ctx = { roomState, actor, operation, plugin };
|
|
91
|
+
for (const rawPolicy of operationScopePolicies(authorize)) {
|
|
92
|
+
const policy = normalizeScopePolicy(rawPolicy || {}, ctx);
|
|
93
|
+
if (!policy.scopeType || !policy.scopeId) {
|
|
94
|
+
throw runtimeError("FORBIDDEN", `${operationLabel || `${plugin.id}.${operation.type}`} requires scoped ${policy.action} access`);
|
|
95
|
+
}
|
|
96
|
+
const requiredRole = policy.action === "view" ? "viewer" : "editor";
|
|
97
|
+
if (!canAccessScope(roomState, actor, policy.scopeType, policy.scopeId, requiredRole)) {
|
|
98
|
+
throw runtimeError("FORBIDDEN", `${operationLabel || `${plugin.id}.${operation.type}`} requires ${policy.action} access to ${policy.scopeType}:${policy.scopeId}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function effectiveActorForRoom(runtime, { actor = {}, state, operation } = {}) {
|
|
104
|
+
const standing = await runtime.standingAuthority.evaluate({
|
|
105
|
+
memberId: actor?.memberId || operation?.actor?.memberId,
|
|
106
|
+
deviceId: actor?.deviceId || operation?.actor?.deviceId,
|
|
107
|
+
credentialId: actor?.credentialId || operation?.auth?.credentialId,
|
|
108
|
+
actor,
|
|
109
|
+
operation,
|
|
110
|
+
roomState: state,
|
|
111
|
+
now: operation?.createdAt || runtime.now()
|
|
112
|
+
});
|
|
113
|
+
return actorWithStanding({ ...(operation?.actor || {}), ...actor, credentialId: actor?.credentialId || operation?.auth?.credentialId }, standing);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
assertOperationScopeAccess,
|
|
118
|
+
canAccessScope,
|
|
119
|
+
canEditScope,
|
|
120
|
+
canViewScope,
|
|
121
|
+
effectiveActorForRoom,
|
|
122
|
+
scopedRoleForActor
|
|
123
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const CORE_SCOPE_ROLE_SET_TYPE = "scope.role.set";
|
|
2
|
+
const CORE_ACCESS_ROLE_DEFINE_TYPE = "access.role.define";
|
|
3
|
+
const CORE_ACCESS_ROLE_ASSIGN_TYPE = "access.role.assign";
|
|
4
|
+
const CORE_ACCESS_ROLE_UNASSIGN_TYPE = "access.role.unassign";
|
|
5
|
+
const SCOPED_ROLES_STATE_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
const SCOPED_ROLE_RANKS = Object.freeze({
|
|
8
|
+
none: -1,
|
|
9
|
+
viewer: 0,
|
|
10
|
+
editor: 1,
|
|
11
|
+
moderator: 2,
|
|
12
|
+
admin: 3,
|
|
13
|
+
owner: 4
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
CORE_ACCESS_ROLE_ASSIGN_TYPE,
|
|
18
|
+
CORE_ACCESS_ROLE_DEFINE_TYPE,
|
|
19
|
+
CORE_ACCESS_ROLE_UNASSIGN_TYPE,
|
|
20
|
+
CORE_SCOPE_ROLE_SET_TYPE,
|
|
21
|
+
SCOPED_ROLE_RANKS,
|
|
22
|
+
SCOPED_ROLES_STATE_VERSION
|
|
23
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const { normalizeGrantRecord, normalizeRevocationRecord } = require("./normalize.cjs");
|
|
2
|
+
|
|
3
|
+
function grantMetadata(operation, actor = {}, payload = {}, updatedAt = Date.now(), action = "grant") {
|
|
4
|
+
return normalizeGrantRecord({
|
|
5
|
+
id: operation?.id || `${action}_${updatedAt}`,
|
|
6
|
+
action,
|
|
7
|
+
target: payload.target,
|
|
8
|
+
roleId: payload.roleId,
|
|
9
|
+
scopeType: payload.scopeType,
|
|
10
|
+
scopeId: payload.scopeId,
|
|
11
|
+
role: payload.role,
|
|
12
|
+
defaultRole: payload.defaultRole,
|
|
13
|
+
grantedBy: actor.memberId,
|
|
14
|
+
grantedAt: updatedAt,
|
|
15
|
+
grantHlc: operation?.hlc,
|
|
16
|
+
credentialId: operation?.auth?.credentialId,
|
|
17
|
+
signature: operation?.auth?.signature
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function revocationMetadata(operation, actor = {}, payload = {}, updatedAt = Date.now(), voidGrantIds = []) {
|
|
22
|
+
return normalizeRevocationRecord({
|
|
23
|
+
id: operation?.id || `revoke_${updatedAt}`,
|
|
24
|
+
action: "access.role.unassign",
|
|
25
|
+
target: payload.target,
|
|
26
|
+
roleId: payload.roleId,
|
|
27
|
+
voidGrantIds,
|
|
28
|
+
revokedBy: actor.memberId,
|
|
29
|
+
revokedAt: updatedAt,
|
|
30
|
+
revocationHlc: operation?.hlc,
|
|
31
|
+
credentialId: operation?.auth?.credentialId,
|
|
32
|
+
signature: operation?.auth?.signature
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
grantMetadata,
|
|
38
|
+
revocationMetadata
|
|
39
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const { runtimeError } = require("../../errors.cjs");
|
|
2
|
+
const { roleRank } = require("../roles.cjs");
|
|
3
|
+
const { SCOPED_ROLE_RANKS, SCOPED_ROLES_STATE_VERSION } = require("./constants.cjs");
|
|
4
|
+
|
|
5
|
+
function boundedString(value, name, max = 240) {
|
|
6
|
+
if (typeof value !== "string" || value.trim() === "") throw runtimeError("SCHEMA_INVALID", `${name} must be a non-empty string`);
|
|
7
|
+
const trimmed = value.trim();
|
|
8
|
+
if (trimmed.length > max) throw runtimeError("SCHEMA_INVALID", `${name} is too long`);
|
|
9
|
+
return trimmed;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function optionalBoundedString(value, name, max = 240) {
|
|
13
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
14
|
+
return boundedString(value, name, max);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function scopeKey(scopeType, scopeId) {
|
|
18
|
+
return `${encodeURIComponent(scopeType)}:${encodeURIComponent(scopeId)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeScopedRole(role, fallback = undefined) {
|
|
22
|
+
if (role === "none") return "none";
|
|
23
|
+
if (role === "view" || role === "viewer" || role === "read" || role === "reader") return "viewer";
|
|
24
|
+
if (role === "edit" || role === "editor" || role === "member" || role === "user") return "editor";
|
|
25
|
+
if (role === "moderator" || role === "admin" || role === "owner") return role;
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scopedRoleRank(role) {
|
|
30
|
+
return SCOPED_ROLE_RANKS[normalizeScopedRole(role, "none")] ?? SCOPED_ROLE_RANKS.none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scopedRoleMeets(role, requiredRole) {
|
|
34
|
+
return scopedRoleRank(role) >= scopedRoleRank(requiredRole);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scopedRoleFromGlobalRole(role) {
|
|
38
|
+
if (roleRank(role) >= roleRank("owner")) return "owner";
|
|
39
|
+
if (roleRank(role) >= roleRank("admin")) return "admin";
|
|
40
|
+
if (roleRank(role) >= roleRank("moderator")) return "moderator";
|
|
41
|
+
if (roleRank(role) >= roleRank("member")) return "editor";
|
|
42
|
+
return "none";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeScopeEntry(value = {}) {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
47
|
+
const scopeType = optionalBoundedString(value.scopeType, "scopeType", 80);
|
|
48
|
+
const scopeId = optionalBoundedString(value.scopeId, "scopeId", 180);
|
|
49
|
+
if (!scopeType || !scopeId) return undefined;
|
|
50
|
+
const roles = {};
|
|
51
|
+
for (const [memberId, role] of Object.entries(value.roles || {})) {
|
|
52
|
+
const normalized = normalizeScopedRole(role, undefined);
|
|
53
|
+
if (memberId && normalized) roles[memberId] = normalized;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
scopeType,
|
|
57
|
+
scopeId,
|
|
58
|
+
defaultRole: normalizeScopedRole(value.defaultRole, "editor"),
|
|
59
|
+
roles,
|
|
60
|
+
...(Number.isFinite(Number(value.updatedAt)) ? { updatedAt: Number(value.updatedAt) } : {}),
|
|
61
|
+
...(optionalBoundedString(value.updatedBy, "updatedBy", 240) ? { updatedBy: value.updatedBy } : {})
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeGrantRecord(input = {}) {
|
|
66
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
|
|
67
|
+
const id = optionalBoundedString(input.id || input.operationId, "grantId", 240);
|
|
68
|
+
const grantedBy = optionalBoundedString(input.grantedBy || input.actorId, "grantedBy", 240);
|
|
69
|
+
if (!id || !grantedBy) return undefined;
|
|
70
|
+
const role = normalizeScopedRole(input.role, undefined);
|
|
71
|
+
const defaultRole = normalizeScopedRole(input.defaultRole, undefined);
|
|
72
|
+
return {
|
|
73
|
+
id,
|
|
74
|
+
action: optionalBoundedString(input.action, "grant action", 120) || "grant",
|
|
75
|
+
...(optionalBoundedString(input.target, "target", 240) ? { target: input.target } : {}),
|
|
76
|
+
...(optionalBoundedString(input.roleId, "roleId", 120) ? { roleId: input.roleId } : {}),
|
|
77
|
+
...(optionalBoundedString(input.scopeType, "scopeType", 80) ? { scopeType: input.scopeType } : {}),
|
|
78
|
+
...(optionalBoundedString(input.scopeId, "scopeId", 180) ? { scopeId: input.scopeId } : {}),
|
|
79
|
+
...(role ? { role } : {}),
|
|
80
|
+
...(defaultRole ? { defaultRole } : {}),
|
|
81
|
+
grantedBy,
|
|
82
|
+
grantedAt: Number.isFinite(Number(input.grantedAt)) ? Number(input.grantedAt) : 0,
|
|
83
|
+
...(optionalBoundedString(input.grantHlc, "grantHlc", 240) ? { grantHlc: input.grantHlc } : {}),
|
|
84
|
+
...(optionalBoundedString(input.credentialId, "credentialId", 240) ? { credentialId: input.credentialId } : {}),
|
|
85
|
+
...(optionalBoundedString(input.signature, "signature", 2048) ? { signature: input.signature } : {})
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeRevocationRecord(input = {}) {
|
|
90
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
|
|
91
|
+
const id = optionalBoundedString(input.id || input.operationId, "revocationId", 240);
|
|
92
|
+
const revokedBy = optionalBoundedString(input.revokedBy || input.actorId, "revokedBy", 240);
|
|
93
|
+
if (!id || !revokedBy) return undefined;
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
action: optionalBoundedString(input.action, "revocation action", 120) || "revoke",
|
|
97
|
+
...(optionalBoundedString(input.target, "target", 240) ? { target: input.target } : {}),
|
|
98
|
+
...(optionalBoundedString(input.roleId, "roleId", 120) ? { roleId: input.roleId } : {}),
|
|
99
|
+
voidGrantIds: (Array.isArray(input.voidGrantIds) ? input.voidGrantIds : []).filter((item) => typeof item === "string" && item.length > 0).slice(0, 500),
|
|
100
|
+
revokedBy,
|
|
101
|
+
revokedAt: Number.isFinite(Number(input.revokedAt)) ? Number(input.revokedAt) : 0,
|
|
102
|
+
...(optionalBoundedString(input.revocationHlc, "revocationHlc", 240) ? { revocationHlc: input.revocationHlc } : {}),
|
|
103
|
+
...(optionalBoundedString(input.credentialId, "credentialId", 240) ? { credentialId: input.credentialId } : {}),
|
|
104
|
+
...(optionalBoundedString(input.signature, "signature", 2048) ? { signature: input.signature } : {})
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeRoleGrant(input = {}, index = 0) {
|
|
109
|
+
const scopeType = optionalBoundedString(input.scopeType, `grants[${index}].scopeType`, 80);
|
|
110
|
+
const scopeId = optionalBoundedString(input.scopeId, `grants[${index}].scopeId`, 180);
|
|
111
|
+
const role = normalizeScopedRole(input.role, undefined);
|
|
112
|
+
if (!scopeType || !scopeId || !role) return undefined;
|
|
113
|
+
return { scopeType, scopeId, role };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeCompoundRole(input = {}) {
|
|
117
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
|
|
118
|
+
const id = optionalBoundedString(input.id || input.roleId, "roleId", 120);
|
|
119
|
+
if (!id) return undefined;
|
|
120
|
+
const grants = (Array.isArray(input.grants) ? input.grants : []).map(normalizeRoleGrant).filter(Boolean);
|
|
121
|
+
return {
|
|
122
|
+
id,
|
|
123
|
+
name: optionalBoundedString(input.name, "role name", 120) || id,
|
|
124
|
+
grants,
|
|
125
|
+
...(normalizeGrantRecord(input.authority) ? { authority: normalizeGrantRecord(input.authority) } : {}),
|
|
126
|
+
...(Number.isFinite(Number(input.updatedAt)) ? { updatedAt: Number(input.updatedAt) } : {}),
|
|
127
|
+
...(optionalBoundedString(input.updatedBy, "updatedBy", 240) ? { updatedBy: input.updatedBy } : {})
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeScopedRolesState(value = {}) {
|
|
132
|
+
const scopes = {};
|
|
133
|
+
const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
134
|
+
for (const entry of Object.values(input.scopes || {})) {
|
|
135
|
+
const normalized = normalizeScopeEntry(entry);
|
|
136
|
+
if (normalized) scopes[scopeKey(normalized.scopeType, normalized.scopeId)] = normalized;
|
|
137
|
+
}
|
|
138
|
+
const roles = {};
|
|
139
|
+
for (const role of Object.values(input.roles || {})) {
|
|
140
|
+
const normalized = normalizeCompoundRole(role);
|
|
141
|
+
if (normalized) roles[normalized.id] = normalized;
|
|
142
|
+
}
|
|
143
|
+
const assignments = {};
|
|
144
|
+
for (const [memberId, roleIds] of Object.entries(input.assignments || {})) {
|
|
145
|
+
const ids = (Array.isArray(roleIds) ? roleIds : []).filter((id) => typeof id === "string" && roles[id]);
|
|
146
|
+
if (memberId && ids.length) assignments[memberId] = [...new Set(ids)].sort();
|
|
147
|
+
}
|
|
148
|
+
const grants = {};
|
|
149
|
+
for (const grant of Object.values(input.grants || {})) {
|
|
150
|
+
const normalized = normalizeGrantRecord(grant);
|
|
151
|
+
if (normalized) grants[normalized.id] = normalized;
|
|
152
|
+
}
|
|
153
|
+
const revocations = {};
|
|
154
|
+
for (const revocation of Object.values(input.revocations || {})) {
|
|
155
|
+
const normalized = normalizeRevocationRecord(revocation);
|
|
156
|
+
if (normalized) revocations[normalized.id] = normalized;
|
|
157
|
+
}
|
|
158
|
+
const assignmentGrants = {};
|
|
159
|
+
for (const [target, byRole] of Object.entries(input.assignmentGrants || {})) {
|
|
160
|
+
if (!target || !byRole || typeof byRole !== "object" || Array.isArray(byRole)) continue;
|
|
161
|
+
const entries = Object.entries(byRole).filter(([roleId, grantId]) => roles[roleId] && typeof grantId === "string" && grants[grantId]);
|
|
162
|
+
if (entries.length) assignmentGrants[target] = Object.fromEntries(entries);
|
|
163
|
+
}
|
|
164
|
+
return { version: SCOPED_ROLES_STATE_VERSION, scopes, roles, assignments, grants, revocations, assignmentGrants };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
boundedString,
|
|
169
|
+
normalizeGrantRecord,
|
|
170
|
+
normalizeRoleGrant,
|
|
171
|
+
normalizeRevocationRecord,
|
|
172
|
+
normalizeScopedRole,
|
|
173
|
+
normalizeScopedRolesState,
|
|
174
|
+
optionalBoundedString,
|
|
175
|
+
scopedRoleFromGlobalRole,
|
|
176
|
+
scopedRoleMeets,
|
|
177
|
+
scopedRoleRank,
|
|
178
|
+
scopeKey
|
|
179
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { normalizeScopedRolesState, scopedRoleMeets, scopeKey } = require("./normalize.cjs");
|
|
2
|
+
const { scopedRoleForActor } = require("./access.cjs");
|
|
3
|
+
const { SCOPED_ROLES_STATE_VERSION } = require("./constants.cjs");
|
|
4
|
+
|
|
5
|
+
function publicScopedRolesView(roomState, actor = {}) {
|
|
6
|
+
const state = normalizeScopedRolesState(roomState?.scopedRoles);
|
|
7
|
+
const visibleScopeKeys = new Set(Object.keys(state.scopes));
|
|
8
|
+
for (const role of Object.values(state.roles)) {
|
|
9
|
+
for (const grant of role.grants || []) {
|
|
10
|
+
if (grant.scopeType !== "*" && grant.scopeId !== "*") visibleScopeKeys.add(scopeKey(grant.scopeType, grant.scopeId));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const scopes = {};
|
|
14
|
+
for (const key of visibleScopeKeys) {
|
|
15
|
+
const entry = state.scopes[key] || {};
|
|
16
|
+
const [encodedScopeType, encodedScopeId] = key.split(":");
|
|
17
|
+
const scopeType = entry.scopeType || decodeURIComponent(encodedScopeType || "");
|
|
18
|
+
const scopeId = entry.scopeId || decodeURIComponent(encodedScopeId || "");
|
|
19
|
+
const role = scopedRoleForActor(roomState, actor, scopeType, scopeId);
|
|
20
|
+
scopes[key] = {
|
|
21
|
+
scopeType,
|
|
22
|
+
scopeId,
|
|
23
|
+
role,
|
|
24
|
+
canView: scopedRoleMeets(role, "viewer"),
|
|
25
|
+
canEdit: scopedRoleMeets(role, "editor")
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { version: SCOPED_ROLES_STATE_VERSION, scopes };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { publicScopedRolesView };
|