@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,66 @@
|
|
|
1
|
+
const {
|
|
2
|
+
CORE_AUTHORITY_GRANT_TYPE,
|
|
3
|
+
CORE_AUTHORITY_REVOKE_TYPE,
|
|
4
|
+
CORE_REVOKE_CREDENTIAL_TYPE
|
|
5
|
+
} = require("@mh-gg/authority");
|
|
6
|
+
const { runtimeError } = require("../errors.cjs");
|
|
7
|
+
const { normalizeRoomRole } = require("../security/roles.cjs");
|
|
8
|
+
const {
|
|
9
|
+
CORE_ACCESS_ROLE_ASSIGN_TYPE,
|
|
10
|
+
CORE_ACCESS_ROLE_DEFINE_TYPE,
|
|
11
|
+
CORE_ACCESS_ROLE_UNASSIGN_TYPE,
|
|
12
|
+
CORE_SCOPE_ROLE_SET_TYPE,
|
|
13
|
+
parseAccessRoleAssignmentPayload,
|
|
14
|
+
parseAccessRoleDefinePayload,
|
|
15
|
+
parseScopeRoleSetPayload
|
|
16
|
+
} = require("../security/scopedRoles.cjs");
|
|
17
|
+
const { parseDirectPayload, isDirectCoreOperation } = require("./directMessages.cjs");
|
|
18
|
+
const { CORE_READ_TAG_SET_TYPE, parseReadTagPayload } = require("./readTags.cjs");
|
|
19
|
+
|
|
20
|
+
function parseCredentialRevokePayload(operation) {
|
|
21
|
+
const credentialId = operation.payload?.credentialId;
|
|
22
|
+
if (typeof credentialId !== "string" || credentialId.length === 0 || credentialId.length > 240) throw runtimeError("SCHEMA_INVALID", "credential.revoke credentialId must be a non-empty string");
|
|
23
|
+
return { credentialId, ...(typeof operation.payload.reason === "string" && operation.payload.reason.length > 0 ? { reason: operation.payload.reason.slice(0, 500) } : {}) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseAuthorityGrantPayload(operation) {
|
|
27
|
+
const target = operation.payload?.target;
|
|
28
|
+
const role = normalizeRoomRole(operation.payload?.role, undefined);
|
|
29
|
+
if (typeof target !== "string" || target.length === 0 || target.length > 240) throw runtimeError("SCHEMA_INVALID", "authority.grant target must be a non-empty string");
|
|
30
|
+
if (!role) throw runtimeError("SCHEMA_INVALID", "authority.grant role is invalid");
|
|
31
|
+
return { target, role, ...(typeof operation.payload.reason === "string" && operation.payload.reason.length > 0 ? { reason: operation.payload.reason.slice(0, 500) } : {}) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseAuthorityRevokePayload(operation) {
|
|
35
|
+
const voidGrantIds = Array.isArray(operation.payload?.voidGrantIds) ? operation.payload.voidGrantIds.filter((id) => typeof id === "string" && id.length > 0).slice(0, 500) : [];
|
|
36
|
+
const scope = operation.payload?.scope || (voidGrantIds.length ? "all-by-target" : "future");
|
|
37
|
+
if (!["future", "from-point", "all-by-target"].includes(scope)) throw runtimeError("SCHEMA_INVALID", "authority.revoke scope is invalid");
|
|
38
|
+
if (voidGrantIds.length === 0 && typeof operation.payload?.target !== "string") throw runtimeError("SCHEMA_INVALID", "authority.revoke requires target or voidGrantIds");
|
|
39
|
+
return {
|
|
40
|
+
...(typeof operation.payload.target === "string" && operation.payload.target.length > 0 ? { target: operation.payload.target } : {}),
|
|
41
|
+
voidGrantIds,
|
|
42
|
+
...(operation.payload.voidFrom !== undefined ? { voidFrom: operation.payload.voidFrom } : {}),
|
|
43
|
+
scope,
|
|
44
|
+
cascade: operation.payload.cascade !== false,
|
|
45
|
+
...(typeof operation.payload.reason === "string" && operation.payload.reason.length > 0 ? { reason: operation.payload.reason.slice(0, 500) } : {})
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseCorePayload(operation) {
|
|
50
|
+
if (operation.type === CORE_REVOKE_CREDENTIAL_TYPE) return parseCredentialRevokePayload(operation);
|
|
51
|
+
if (operation.type === CORE_AUTHORITY_GRANT_TYPE) return parseAuthorityGrantPayload(operation);
|
|
52
|
+
if (operation.type === CORE_AUTHORITY_REVOKE_TYPE) return parseAuthorityRevokePayload(operation);
|
|
53
|
+
if (operation.type === CORE_SCOPE_ROLE_SET_TYPE) return parseScopeRoleSetPayload(operation);
|
|
54
|
+
if (operation.type === CORE_ACCESS_ROLE_DEFINE_TYPE) return parseAccessRoleDefinePayload(operation);
|
|
55
|
+
if (operation.type === CORE_ACCESS_ROLE_ASSIGN_TYPE || operation.type === CORE_ACCESS_ROLE_UNASSIGN_TYPE) return parseAccessRoleAssignmentPayload(operation);
|
|
56
|
+
if (isDirectCoreOperation(operation)) return parseDirectPayload(operation);
|
|
57
|
+
if (operation.type === CORE_READ_TAG_SET_TYPE) return parseReadTagPayload(operation);
|
|
58
|
+
throw runtimeError("UNKNOWN_CORE_OPERATION", `Unknown core operation ${operation.type}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = {
|
|
62
|
+
parseAuthorityGrantPayload,
|
|
63
|
+
parseAuthorityRevokePayload,
|
|
64
|
+
parseCorePayload,
|
|
65
|
+
parseCredentialRevokePayload
|
|
66
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const { clone, pruneMap, rememberBounded } = require("../../shared.cjs");
|
|
2
|
+
const { authorizeCoreOperation } = require("../../security/authorization/coreGate.cjs");
|
|
3
|
+
const { CORE_PLUGIN_ID } = require("@mh-gg/authority");
|
|
4
|
+
const { commitVersion, tombstone } = require("../authorityReplay/state.cjs");
|
|
5
|
+
const { stateWithActorMember } = require("../memberProfiles.cjs");
|
|
6
|
+
const { parseDirectPayload } = require("./payloads.cjs");
|
|
7
|
+
const { applyDirectOperationToRoomState } = require("./state.cjs");
|
|
8
|
+
|
|
9
|
+
async function commitDirectCoreOperation(operation, actor, state, signedOperationForLog) {
|
|
10
|
+
const payload = parseDirectPayload(operation);
|
|
11
|
+
const parsedOperation = { ...operation, payload, actor };
|
|
12
|
+
const updatedAt = parsedOperation.createdAt || this.now();
|
|
13
|
+
const directState = applyDirectOperationToRoomState(state, parsedOperation, actor, updatedAt);
|
|
14
|
+
const memberState = stateWithActorMember(directState, actor, updatedAt);
|
|
15
|
+
const nextRoomState = this.validateRoomState({
|
|
16
|
+
...memberState,
|
|
17
|
+
version: state.version + 1,
|
|
18
|
+
updatedAt,
|
|
19
|
+
seenOperations: rememberBounded(state.seenOperations, parsedOperation.id, this.maxSeenOperations)
|
|
20
|
+
});
|
|
21
|
+
await this.commitStateAndOperation({
|
|
22
|
+
logEntry: { ...signedOperationForLog, payload, committedRoomVersion: nextRoomState.version, committedAt: nextRoomState.updatedAt },
|
|
23
|
+
nextState: nextRoomState,
|
|
24
|
+
expectedPreviousVersion: state.version
|
|
25
|
+
});
|
|
26
|
+
const ack = { ok: true, acceptedOperationId: parsedOperation.id, roomVersion: nextRoomState.version, ...(parsedOperation.ledgerId ? { acceptedLedgerId: parsedOperation.ledgerId, acceptedSnowflakeId: parsedOperation.ledgerId } : {}) };
|
|
27
|
+
this.acks.set(parsedOperation.id, ack);
|
|
28
|
+
pruneMap(this.acks, Math.min(this.maxAcks, this.maxAckCache));
|
|
29
|
+
return clone(ack);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function applyDirectCoreEnvelope(runtime, state, operation) {
|
|
33
|
+
try {
|
|
34
|
+
const rawActor = await runtime.authenticateActor(operation.auth, operation.actor, operation);
|
|
35
|
+
const actor = await authorizeCoreOperation(runtime, { operation, actor: rawActor, state, plugin: { id: CORE_PLUGIN_ID }, roles: ["member"], operationLabel: `${CORE_PLUGIN_ID}.${operation.type}` });
|
|
36
|
+
const payload = parseDirectPayload(operation);
|
|
37
|
+
const updatedAt = operation.createdAt || runtime.now();
|
|
38
|
+
Object.assign(state, stateWithActorMember(applyDirectOperationToRoomState(state, { ...operation, payload }, actor, updatedAt), actor, updatedAt));
|
|
39
|
+
commitVersion(state, operation, runtime);
|
|
40
|
+
return true;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
tombstone(state, operation, "invalid-direct-operation", { code: error?.code, message: error?.message || String(error) });
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
applyDirectCoreEnvelope,
|
|
49
|
+
commitDirectCoreOperation
|
|
50
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const CORE_DIRECT_MESSAGE_PUBLISH_TYPE = "direct.message.publish";
|
|
2
|
+
const CORE_DIRECT_MESSAGE_SEND_TYPE = "direct.message.send";
|
|
3
|
+
const CORE_DM_MESSAGE_TYPE = "dm.message";
|
|
4
|
+
const CORE_MEMBER_KEY_PUBLISH_TYPE = "member.key.publish";
|
|
5
|
+
const CORE_DIRECT_OPERATION_TYPES = Object.freeze([
|
|
6
|
+
CORE_MEMBER_KEY_PUBLISH_TYPE,
|
|
7
|
+
CORE_DIRECT_MESSAGE_PUBLISH_TYPE,
|
|
8
|
+
CORE_DIRECT_MESSAGE_SEND_TYPE,
|
|
9
|
+
CORE_DM_MESSAGE_TYPE
|
|
10
|
+
]);
|
|
11
|
+
const NIP17_PROTOCOL = "nostr.nip17";
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
CORE_DIRECT_MESSAGE_SEND_TYPE,
|
|
15
|
+
CORE_DIRECT_MESSAGE_PUBLISH_TYPE,
|
|
16
|
+
CORE_DIRECT_OPERATION_TYPES,
|
|
17
|
+
CORE_DM_MESSAGE_TYPE,
|
|
18
|
+
CORE_MEMBER_KEY_PUBLISH_TYPE,
|
|
19
|
+
NIP17_PROTOCOL
|
|
20
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { hashCanonical } = require("@mh-gg/base");
|
|
2
|
+
const { runtimeError } = require("../../errors.cjs");
|
|
3
|
+
const { NIP17_PROTOCOL } = require("./constants.cjs");
|
|
4
|
+
|
|
5
|
+
function isPlainObject(value) {
|
|
6
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function requiredString(value, name, max = 240) {
|
|
10
|
+
if (typeof value !== "string") throw runtimeError("SCHEMA_INVALID", `${name} must be a string`);
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (!trimmed) throw runtimeError("SCHEMA_INVALID", `${name} must be a non-empty string`);
|
|
13
|
+
if (trimmed.length > max) throw runtimeError("SCHEMA_INVALID", `${name} is too long`);
|
|
14
|
+
return trimmed;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function optionalString(value, name, max = 240) {
|
|
18
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
19
|
+
return requiredString(value, name, max);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertOnlyPayloadKeys(payload, allowed, operationType) {
|
|
23
|
+
if (!isPlainObject(payload)) throw runtimeError("SCHEMA_INVALID", `${operationType} payload must be an object`);
|
|
24
|
+
const allowedKeys = new Set(allowed);
|
|
25
|
+
for (const key of Object.keys(payload)) {
|
|
26
|
+
if (!allowedKeys.has(key)) throw runtimeError("SCHEMA_INVALID", `${operationType} payload has unknown field ${key}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeDirectUserIds(value) {
|
|
31
|
+
if (!Array.isArray(value)) throw runtimeError("SCHEMA_INVALID", "direct userIds must be an array");
|
|
32
|
+
const userIds = [...new Set(value.map((item) => requiredString(item, "direct userId", 240)))].sort();
|
|
33
|
+
if (userIds.length < 2) throw runtimeError("SCHEMA_INVALID", "direct userIds must include at least two users");
|
|
34
|
+
if (userIds.length > 100) throw runtimeError("SCHEMA_INVALID", "direct userIds supports at most 100 users");
|
|
35
|
+
return userIds;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function directThreadIdFor({ roomId, userIds, topicKey }) {
|
|
39
|
+
const digest = hashCanonical({ kind: "matterhorn.direct.thread.v1", protocol: NIP17_PROTOCOL, roomId, userIds, topicKey: topicKey || null }).slice("sha256-".length, "sha256-".length + 32);
|
|
40
|
+
return `direct_${digest}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function directState(state) {
|
|
44
|
+
return {
|
|
45
|
+
directThreads: isPlainObject(state.directThreads) ? state.directThreads : {},
|
|
46
|
+
directMessages: isPlainObject(state.directMessages) ? state.directMessages : {},
|
|
47
|
+
memberKeys: isPlainObject(state.memberKeys) ? state.memberKeys : {}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
assertOnlyPayloadKeys,
|
|
53
|
+
directState,
|
|
54
|
+
directThreadIdFor,
|
|
55
|
+
isPlainObject,
|
|
56
|
+
normalizeDirectUserIds,
|
|
57
|
+
optionalString,
|
|
58
|
+
requiredString
|
|
59
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const { verifyRoomMemberKeyClaim } = require("@mh-gg/protocol");
|
|
2
|
+
const { runtimeError } = require("../../errors.cjs");
|
|
3
|
+
const { clone } = require("../../shared.cjs");
|
|
4
|
+
const { CORE_DIRECT_OPERATION_TYPES, CORE_MEMBER_KEY_PUBLISH_TYPE } = require("./constants.cjs");
|
|
5
|
+
const {
|
|
6
|
+
assertOnlyPayloadKeys,
|
|
7
|
+
directThreadIdFor,
|
|
8
|
+
isPlainObject,
|
|
9
|
+
normalizeDirectUserIds,
|
|
10
|
+
optionalString,
|
|
11
|
+
requiredString
|
|
12
|
+
} = require("./helpers.cjs");
|
|
13
|
+
const { CORE_PLUGIN_ID } = require("@mh-gg/authority");
|
|
14
|
+
|
|
15
|
+
function parseOpaqueGiftWrap(value, name) {
|
|
16
|
+
if (!isPlainObject(value)) throw runtimeError("SCHEMA_INVALID", `${name} must be an object`);
|
|
17
|
+
const serialized = JSON.stringify(value);
|
|
18
|
+
if (serialized.length > 256000) throw runtimeError("SCHEMA_INVALID", `${name} is too large`);
|
|
19
|
+
return clone(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseGiftWraps(value, userIds, operationType) {
|
|
23
|
+
if (!isPlainObject(value)) throw runtimeError("SCHEMA_INVALID", `${operationType} giftWraps must be an object keyed by userId`);
|
|
24
|
+
const userSet = new Set(userIds);
|
|
25
|
+
const wraps = {};
|
|
26
|
+
for (const [recipientId, rawWrap] of Object.entries(value)) {
|
|
27
|
+
requiredString(recipientId, `${operationType} giftWraps recipientId`, 240);
|
|
28
|
+
if (!userSet.has(recipientId)) throw runtimeError("SCHEMA_INVALID", `${operationType} giftWraps recipientId must be in userIds`);
|
|
29
|
+
wraps[recipientId] = parseOpaqueGiftWrap(rawWrap, `${operationType} giftWraps.${recipientId}`);
|
|
30
|
+
}
|
|
31
|
+
for (const userId of userIds) {
|
|
32
|
+
if (!wraps[userId]) throw runtimeError("SCHEMA_INVALID", `${operationType} giftWraps must include ${userId}`);
|
|
33
|
+
}
|
|
34
|
+
return wraps;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseMemberKeyPublishPayload(operation) {
|
|
38
|
+
assertOnlyPayloadKeys(operation.payload || {}, [], operation.type);
|
|
39
|
+
const verification = verifyRoomMemberKeyClaim(operation.auth?.claim);
|
|
40
|
+
if (!verification.ok) throw runtimeError("SCHEMA_INVALID", verification.error || "member key claim is invalid");
|
|
41
|
+
const claim = verification.claim;
|
|
42
|
+
const claimRoomId = claim.roomName || claim.roomId;
|
|
43
|
+
if (claimRoomId !== operation.roomId) throw runtimeError("ROOM_MISMATCH", "member key claim room must match operation room");
|
|
44
|
+
return { claim: clone(claim) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseDirectMessagePublishPayload(operation) {
|
|
48
|
+
assertOnlyPayloadKeys(operation.payload, ["userIds", "topicKey", "giftWraps"], operation.type);
|
|
49
|
+
const roomId = operation.roomId;
|
|
50
|
+
const userIds = normalizeDirectUserIds(operation.payload?.userIds);
|
|
51
|
+
const topicKey = optionalString(operation.payload?.topicKey, `${operation.type} topicKey`, 240);
|
|
52
|
+
const threadId = directThreadIdFor({ roomId, userIds, topicKey });
|
|
53
|
+
return {
|
|
54
|
+
userIds,
|
|
55
|
+
topicKey,
|
|
56
|
+
threadId,
|
|
57
|
+
giftWraps: parseGiftWraps(operation.payload?.giftWraps, userIds, operation.type)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseDirectPayload(operation) {
|
|
62
|
+
if (operation.type === CORE_MEMBER_KEY_PUBLISH_TYPE) return parseMemberKeyPublishPayload(operation);
|
|
63
|
+
if (CORE_DIRECT_OPERATION_TYPES.includes(operation.type)) return parseDirectMessagePublishPayload(operation);
|
|
64
|
+
throw runtimeError("UNKNOWN_CORE_OPERATION", `Unknown core direct operation ${operation.type}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isDirectCoreOperation(operation) {
|
|
68
|
+
return operation?.pluginId === CORE_PLUGIN_ID && CORE_DIRECT_OPERATION_TYPES.includes(operation.type);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
isDirectCoreOperation,
|
|
73
|
+
parseDirectPayload
|
|
74
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const { hashCanonical } = require("@mh-gg/base");
|
|
2
|
+
const { isSnowflakeId } = require("@mh-gg/protocol");
|
|
3
|
+
const { runtimeError } = require("../../errors.cjs");
|
|
4
|
+
const { clone } = require("../../shared.cjs");
|
|
5
|
+
const { CORE_DIRECT_OPERATION_TYPES, CORE_MEMBER_KEY_PUBLISH_TYPE, NIP17_PROTOCOL } = require("./constants.cjs");
|
|
6
|
+
const { directState, isPlainObject } = require("./helpers.cjs");
|
|
7
|
+
const { readTagForScope } = require("../readTags.cjs");
|
|
8
|
+
|
|
9
|
+
function assertActorInThread(actor, userIds, operationType) {
|
|
10
|
+
if (!actor?.memberId || !userIds.includes(actor.memberId)) throw runtimeError("FORBIDDEN", `${operationType} actor must be in userIds`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertKnownRoomMembers(state, userIds, operationType) {
|
|
14
|
+
const members = isPlainObject(state.members) ? state.members : {};
|
|
15
|
+
if (Object.keys(members).length === 0) return;
|
|
16
|
+
for (const userId of userIds) {
|
|
17
|
+
if (!members[userId]) throw runtimeError("NOT_FOUND", `${operationType} user ${userId} is not a room member`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function publicMemberKeyClaim(claim) {
|
|
22
|
+
return {
|
|
23
|
+
memberId: claim.memberId,
|
|
24
|
+
deviceId: claim.deviceId,
|
|
25
|
+
keyId: claim.keyId,
|
|
26
|
+
publicKey: claim.publicKey,
|
|
27
|
+
publicKeyFingerprint: claim.publicKeyFingerprint,
|
|
28
|
+
...(claim.notificationPublicKey ? {
|
|
29
|
+
notificationAlg: claim.notificationAlg,
|
|
30
|
+
notificationKeyEpoch: claim.notificationKeyEpoch,
|
|
31
|
+
notificationPublicKey: claim.notificationPublicKey
|
|
32
|
+
} : {}),
|
|
33
|
+
createdAt: claim.createdAt
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function applyMemberKeyPublish(state, payload, actor, now) {
|
|
38
|
+
const claim = payload.claim;
|
|
39
|
+
if (claim.memberId !== actor.memberId || claim.deviceId !== actor.deviceId || claim.keyId !== actor.keyId) {
|
|
40
|
+
throw runtimeError("FORBIDDEN", "member.key.publish claim must match the operation signer");
|
|
41
|
+
}
|
|
42
|
+
const current = directState(state);
|
|
43
|
+
const member = current.memberKeys[claim.memberId] || { memberId: claim.memberId, devices: {} };
|
|
44
|
+
return {
|
|
45
|
+
...state,
|
|
46
|
+
memberKeys: {
|
|
47
|
+
...current.memberKeys,
|
|
48
|
+
[claim.memberId]: {
|
|
49
|
+
...member,
|
|
50
|
+
memberId: claim.memberId,
|
|
51
|
+
devices: {
|
|
52
|
+
...(isPlainObject(member.devices) ? member.devices : {}),
|
|
53
|
+
[claim.deviceId]: publicMemberKeyClaim(claim)
|
|
54
|
+
},
|
|
55
|
+
updatedAt: now
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function upsertDirectThread(state, payload, actor, operation, messageId, now) {
|
|
62
|
+
const current = directState(state);
|
|
63
|
+
const existing = current.directThreads[payload.threadId];
|
|
64
|
+
const ledgerId = operation?.ledgerId && isSnowflakeId(operation.ledgerId) ? operation.ledgerId : undefined;
|
|
65
|
+
const thread = existing || {
|
|
66
|
+
id: payload.threadId,
|
|
67
|
+
protocol: NIP17_PROTOCOL,
|
|
68
|
+
userIds: payload.userIds,
|
|
69
|
+
topicKey: payload.topicKey,
|
|
70
|
+
createdBy: actor.memberId,
|
|
71
|
+
createdAt: now
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
...state,
|
|
75
|
+
directThreads: {
|
|
76
|
+
...current.directThreads,
|
|
77
|
+
[payload.threadId]: {
|
|
78
|
+
...thread,
|
|
79
|
+
protocol: NIP17_PROTOCOL,
|
|
80
|
+
userIds: payload.userIds,
|
|
81
|
+
topicKey: payload.topicKey,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
...(ledgerId ? { lastLedgerId: ledgerId } : {}),
|
|
84
|
+
...(messageId ? { lastMessageId: messageId } : {})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function directMessageIdFor(operation) {
|
|
91
|
+
if (operation?.ledgerId && isSnowflakeId(operation.ledgerId)) return `direct_message_${operation.ledgerId}`;
|
|
92
|
+
if (operation?.snowflakeId && isSnowflakeId(operation.snowflakeId)) return `direct_message_${operation.snowflakeId}`;
|
|
93
|
+
const digest = hashCanonical({ kind: "matterhorn.direct.message.v1", operationId: operation.id }).slice("sha256-".length, "sha256-".length + 32);
|
|
94
|
+
return `direct_message_${digest}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function applyDirectMessagePublish(state, payload, actor, operation, now) {
|
|
98
|
+
assertActorInThread(actor, payload.userIds, operation.type);
|
|
99
|
+
assertKnownRoomMembers(state, payload.userIds, operation.type);
|
|
100
|
+
const id = directMessageIdFor(operation);
|
|
101
|
+
const ledgerId = operation?.ledgerId && isSnowflakeId(operation.ledgerId) ? operation.ledgerId : undefined;
|
|
102
|
+
const withThread = upsertDirectThread(state, payload, actor, operation, id, now);
|
|
103
|
+
const current = directState(withThread);
|
|
104
|
+
return {
|
|
105
|
+
...withThread,
|
|
106
|
+
directMessages: {
|
|
107
|
+
...current.directMessages,
|
|
108
|
+
[id]: {
|
|
109
|
+
id,
|
|
110
|
+
protocol: NIP17_PROTOCOL,
|
|
111
|
+
threadId: payload.threadId,
|
|
112
|
+
userIds: payload.userIds,
|
|
113
|
+
topicKey: payload.topicKey,
|
|
114
|
+
authorId: actor.memberId,
|
|
115
|
+
authorDeviceId: actor.deviceId,
|
|
116
|
+
authorKeyId: actor.keyId,
|
|
117
|
+
authorPublicKey: actor.publicKey,
|
|
118
|
+
operationId: operation.id,
|
|
119
|
+
...(ledgerId ? { ledgerId, snowflakeId: ledgerId } : {}),
|
|
120
|
+
createdAt: now,
|
|
121
|
+
giftWraps: payload.giftWraps
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function applyDirectOperationToRoomState(state, operation, actor, now) {
|
|
128
|
+
const payload = operation.payload;
|
|
129
|
+
if (operation.type === CORE_MEMBER_KEY_PUBLISH_TYPE) return applyMemberKeyPublish(state, payload, actor, now);
|
|
130
|
+
if (CORE_DIRECT_OPERATION_TYPES.includes(operation.type)) return applyDirectMessagePublish(state, payload, actor, operation, now);
|
|
131
|
+
throw runtimeError("UNKNOWN_CORE_OPERATION", `Unknown core direct operation ${operation.type}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function publicMessageForActor(message, memberId) {
|
|
135
|
+
const wrap = message.giftWraps?.[memberId];
|
|
136
|
+
if (!wrap) return undefined;
|
|
137
|
+
return {
|
|
138
|
+
...clone(message),
|
|
139
|
+
giftWraps: { [memberId]: clone(wrap) }
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function publicDirectView(state, actor = {}) {
|
|
144
|
+
const current = directState(state);
|
|
145
|
+
const memberId = actor.memberId;
|
|
146
|
+
const visibleThreads = {};
|
|
147
|
+
for (const [id, thread] of Object.entries(current.directThreads)) {
|
|
148
|
+
if (!Array.isArray(thread.userIds) || !thread.userIds.includes(memberId)) continue;
|
|
149
|
+
const item = clone(thread);
|
|
150
|
+
const readTag = readTagForScope(state, memberId, "direct.thread", id);
|
|
151
|
+
if (readTag) item.readTag = readTag;
|
|
152
|
+
visibleThreads[id] = item;
|
|
153
|
+
}
|
|
154
|
+
const visibleThreadIds = new Set(Object.keys(visibleThreads));
|
|
155
|
+
const visibleMessages = {};
|
|
156
|
+
for (const [id, message] of Object.entries(current.directMessages)) {
|
|
157
|
+
if (!visibleThreadIds.has(message.threadId)) continue;
|
|
158
|
+
const view = publicMessageForActor(message, memberId);
|
|
159
|
+
if (view) visibleMessages[id] = view;
|
|
160
|
+
}
|
|
161
|
+
return { threads: visibleThreads, messages: visibleMessages, keys: clone(current.memberKeys) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
applyDirectOperationToRoomState,
|
|
166
|
+
directMessageIdFor,
|
|
167
|
+
publicDirectView
|
|
168
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const { ROOM_STATE_SCHEMA_VERSION } = require("../constants.cjs");
|
|
2
|
+
const { runtimeError, MatterhornRuntimeError } = require("../errors.cjs");
|
|
3
|
+
const { migratePluginState } = require("../plugins/migrations.cjs");
|
|
4
|
+
const { parseWithSchema } = require("../state.cjs");
|
|
5
|
+
const { clone } = require("../shared.cjs");
|
|
6
|
+
const { applyAuthorityProjection, normalizeAuthorityState } = require("@mh-gg/authority");
|
|
7
|
+
const { stateWithOperationLogMembers } = require("./memberProfiles.cjs");
|
|
8
|
+
|
|
9
|
+
function appPackForCurrentRuntime(stateAppPack, roomAppPack) {
|
|
10
|
+
if (!stateAppPack) return clone(roomAppPack);
|
|
11
|
+
if (stateAppPack.id === roomAppPack.id && stateAppPack.hash !== roomAppPack.hash) return clone(roomAppPack);
|
|
12
|
+
return clone(stateAppPack);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function start() {
|
|
16
|
+
let state = await this.store.load();
|
|
17
|
+
if (state) {
|
|
18
|
+
state = await this.migrateExistingState(state);
|
|
19
|
+
if (this.initialAuthority && !state.authority) {
|
|
20
|
+
const projected = applyAuthorityProjection(this.initialAuthority, state.members);
|
|
21
|
+
state = { ...state, authority: projected.authority, members: projected.members, adminIds: projected.adminIds };
|
|
22
|
+
} else if (state.authority) {
|
|
23
|
+
const projected = applyAuthorityProjection(normalizeAuthorityState(state.authority), state.members);
|
|
24
|
+
state = { ...state, authority: projected.authority, members: projected.members, adminIds: projected.adminIds };
|
|
25
|
+
}
|
|
26
|
+
state = await stateWithOperationLogMembers(this, state);
|
|
27
|
+
state = this.validateRoomState(state);
|
|
28
|
+
if (!this.replayBaseState && state.authority && (!this.operationLog?.entries || this.operationLog.entries.length === 0)) this.replayBaseState = clone(state);
|
|
29
|
+
await this.store.save(state);
|
|
30
|
+
if (!this.started) await this.runLifecycleHook("onRoomStart", state);
|
|
31
|
+
if (!this.started) await this.runLifecycleHook("afterStart", state);
|
|
32
|
+
this.started = true;
|
|
33
|
+
return clone(state);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if ((this.productionRuntime || this.relayExecuted) && !this.initialAuthority) {
|
|
37
|
+
throw runtimeError("BOOTSTRAP_AUTHORITY_REQUIRED", "Production and relay-executed empty rooms require initial owner/admin authority");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const createdAt = this.now();
|
|
41
|
+
state = {
|
|
42
|
+
schemaVersion: ROOM_STATE_SCHEMA_VERSION,
|
|
43
|
+
roomId: this.room.id,
|
|
44
|
+
appPack: clone(this.room.appPack),
|
|
45
|
+
version: 0,
|
|
46
|
+
createdAt,
|
|
47
|
+
updatedAt: createdAt,
|
|
48
|
+
members: clone(this.initialMembers || {}),
|
|
49
|
+
revokedCredentialIds: Array.isArray(this.initialRevokedCredentialIds) ? this.initialRevokedCredentialIds.slice(-this.maxRevokedCredentialIds) : [],
|
|
50
|
+
pluginVersions: {},
|
|
51
|
+
plugins: {},
|
|
52
|
+
seenOperations: []
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
for (const plugin of this.pluginList()) {
|
|
56
|
+
const context = this.createContext({ plugin, roomState: state, pluginState: undefined });
|
|
57
|
+
await this.runPluginHook(plugin, "onInstall", state, undefined);
|
|
58
|
+
let pluginState;
|
|
59
|
+
try {
|
|
60
|
+
pluginState = await plugin.createInitialState(context);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw runtimeError("PLUGIN_INITIAL_STATE_FAILED", `${plugin.id}: ${error?.message || error}`);
|
|
63
|
+
}
|
|
64
|
+
state.plugins[plugin.id] = parseWithSchema(plugin.schemas.state, pluginState, `${plugin.id} state`);
|
|
65
|
+
state.pluginVersions[plugin.id] = plugin.version;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (this.initialAuthority) {
|
|
69
|
+
const projected = applyAuthorityProjection(this.initialAuthority, state.members);
|
|
70
|
+
state = { ...state, authority: projected.authority, members: projected.members, adminIds: projected.adminIds };
|
|
71
|
+
}
|
|
72
|
+
state = this.validateRoomState(state);
|
|
73
|
+
if (!this.replayBaseState) this.replayBaseState = clone(state);
|
|
74
|
+
await this.store.save(state);
|
|
75
|
+
await this.runLifecycleHook("onRoomStart", state);
|
|
76
|
+
await this.runLifecycleHook("afterStart", state);
|
|
77
|
+
this.started = true;
|
|
78
|
+
return clone(state);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function migrateExistingState(state) {
|
|
82
|
+
const timestamp = this.now();
|
|
83
|
+
const previous = clone(state);
|
|
84
|
+
const next = {
|
|
85
|
+
schemaVersion: ROOM_STATE_SCHEMA_VERSION,
|
|
86
|
+
seenOperations: [],
|
|
87
|
+
pluginVersions: {},
|
|
88
|
+
plugins: {},
|
|
89
|
+
revokedCredentialIds: [],
|
|
90
|
+
...previous,
|
|
91
|
+
appPack: appPackForCurrentRuntime(previous.appPack, this.room.appPack),
|
|
92
|
+
createdAt: previous.createdAt ?? timestamp,
|
|
93
|
+
updatedAt: previous.updatedAt ?? previous.createdAt ?? timestamp
|
|
94
|
+
};
|
|
95
|
+
for (const plugin of this.pluginList()) {
|
|
96
|
+
const pluginState = next.plugins[plugin.id];
|
|
97
|
+
if (pluginState === undefined) {
|
|
98
|
+
const created = await plugin.createInitialState(this.createContext({ plugin, roomState: next, pluginState: undefined }));
|
|
99
|
+
next.plugins[plugin.id] = parseWithSchema(plugin.schemas.state, created, `${plugin.id} state`);
|
|
100
|
+
} else {
|
|
101
|
+
next.plugins[plugin.id] = await migratePluginState(plugin, next, this.createContext({ plugin, roomState: next, pluginState }));
|
|
102
|
+
}
|
|
103
|
+
next.pluginVersions[plugin.id] = plugin.version;
|
|
104
|
+
}
|
|
105
|
+
next.seenOperations = (Array.isArray(next.seenOperations) ? next.seenOperations : []).slice(-this.maxSeenOperations);
|
|
106
|
+
if (this.initialAuthority && !next.authority) {
|
|
107
|
+
const projected = applyAuthorityProjection(this.initialAuthority, next.members);
|
|
108
|
+
next.authority = projected.authority;
|
|
109
|
+
next.members = projected.members;
|
|
110
|
+
next.adminIds = projected.adminIds;
|
|
111
|
+
} else if (next.authority) {
|
|
112
|
+
const projected = applyAuthorityProjection(normalizeAuthorityState(next.authority), next.members);
|
|
113
|
+
next.authority = projected.authority;
|
|
114
|
+
next.members = projected.members;
|
|
115
|
+
next.adminIds = projected.adminIds;
|
|
116
|
+
}
|
|
117
|
+
return this.validateRoomState(next);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runPluginHook(plugin, name, state, pluginState) {
|
|
121
|
+
const hook = plugin[name];
|
|
122
|
+
if (typeof hook !== "function") return;
|
|
123
|
+
try {
|
|
124
|
+
await hook(this.createContext({ plugin, roomState: state, pluginState }), { roomState: state, pluginState });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof MatterhornRuntimeError) throw error;
|
|
127
|
+
throw runtimeError("LIFECYCLE_HOOK_FAILED", `${plugin.id}.${name} failed: ${error?.message || error}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function runLifecycleHook(name, state, options = {}) {
|
|
132
|
+
for (const plugin of this.pluginList(options)) await this.runPluginHook(plugin, name, state, state.plugins?.[plugin.id]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function stop() {
|
|
136
|
+
const state = await this.store.load();
|
|
137
|
+
try {
|
|
138
|
+
if (state) await this.runLifecycleHook("onRoomStop", this.validateRoomState(state), { reverse: true });
|
|
139
|
+
} finally {
|
|
140
|
+
this.started = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function uninstall() {
|
|
145
|
+
const state = await this.store.load();
|
|
146
|
+
try {
|
|
147
|
+
if (state) await this.runLifecycleHook("onUninstall", this.validateRoomState(state), { reverse: true });
|
|
148
|
+
} finally {
|
|
149
|
+
this.started = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function getState() {
|
|
154
|
+
await this.start();
|
|
155
|
+
return clone(this.validateRoomState(await this.store.load()));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
getState,
|
|
160
|
+
migrateExistingState,
|
|
161
|
+
runLifecycleHook,
|
|
162
|
+
runPluginHook,
|
|
163
|
+
start,
|
|
164
|
+
stop,
|
|
165
|
+
uninstall
|
|
166
|
+
};
|