@mh-gg/host-runtime 0.1.1-alpha.20260613T085325975Z

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +23 -0
  2. package/src/constants.cjs +9 -0
  3. package/src/errors.cjs +39 -0
  4. package/src/host/installAppPack.cjs +35 -0
  5. package/src/host/migrations.cjs +21 -0
  6. package/src/host/runtimeFromPack.cjs +25 -0
  7. package/src/host/startRoomHost.cjs +95 -0
  8. package/src/index.cjs +23 -0
  9. package/src/memory.cjs +81 -0
  10. package/src/plugins/definition.cjs +19 -0
  11. package/src/plugins/install.cjs +90 -0
  12. package/src/plugins/migrations.cjs +27 -0
  13. package/src/plugins/operationDescriptors.cjs +138 -0
  14. package/src/runtime/HostPluginRuntime.cjs +85 -0
  15. package/src/runtime/authorityReplay/applyAuthority.cjs +146 -0
  16. package/src/runtime/authorityReplay/applyContent.cjs +49 -0
  17. package/src/runtime/authorityReplay/index.cjs +70 -0
  18. package/src/runtime/authorityReplay/state.cjs +56 -0
  19. package/src/runtime/context.cjs +65 -0
  20. package/src/runtime/coreOperations.cjs +169 -0
  21. package/src/runtime/corePayloads.cjs +66 -0
  22. package/src/runtime/directMessages/commit.cjs +50 -0
  23. package/src/runtime/directMessages/constants.cjs +20 -0
  24. package/src/runtime/directMessages/helpers.cjs +59 -0
  25. package/src/runtime/directMessages/payloads.cjs +74 -0
  26. package/src/runtime/directMessages/state.cjs +168 -0
  27. package/src/runtime/directMessages.cjs +6 -0
  28. package/src/runtime/lifecycle.cjs +166 -0
  29. package/src/runtime/memberProfiles.cjs +74 -0
  30. package/src/runtime/methods.cjs +10 -0
  31. package/src/runtime/operations.cjs +166 -0
  32. package/src/runtime/queries.cjs +146 -0
  33. package/src/runtime/readTags.cjs +171 -0
  34. package/src/runtime/scopedRoleOperations.cjs +97 -0
  35. package/src/runtime/snowflake.cjs +43 -0
  36. package/src/security/authority/constants.cjs +10 -0
  37. package/src/security/authority/resolve/operations.cjs +7 -0
  38. package/src/security/authority/resolve/policy.cjs +7 -0
  39. package/src/security/authority/resolve/voids.cjs +8 -0
  40. package/src/security/authorization/coreGate.cjs +63 -0
  41. package/src/security/authorization/schemaActions.cjs +75 -0
  42. package/src/security/roleKeys/authenticator.cjs +36 -0
  43. package/src/security/roleKeys/authorities/index.cjs +4 -0
  44. package/src/security/roleKeys/authorities/shapes.cjs +98 -0
  45. package/src/security/roleKeys/authorities/signing.cjs +121 -0
  46. package/src/security/roleKeys/constants.cjs +15 -0
  47. package/src/security/roleKeys/fingerprints.cjs +24 -0
  48. package/src/security/roleKeys/grants.cjs +93 -0
  49. package/src/security/roleKeys/index.cjs +10 -0
  50. package/src/security/roleKeys/roles.cjs +21 -0
  51. package/src/security/roleKeys/signatures.cjs +126 -0
  52. package/src/security/roles.cjs +10 -0
  53. package/src/security/roomDeviceKeys.cjs +41 -0
  54. package/src/security/scopedRoles/access.cjs +123 -0
  55. package/src/security/scopedRoles/constants.cjs +23 -0
  56. package/src/security/scopedRoles/metadata.cjs +39 -0
  57. package/src/security/scopedRoles/normalize.cjs +179 -0
  58. package/src/security/scopedRoles/publicView.cjs +31 -0
  59. package/src/security/scopedRoles/stateOps.cjs +167 -0
  60. package/src/security/scopedRoles.cjs +7 -0
  61. package/src/security/standingAuthority.cjs +76 -0
  62. package/src/shared.cjs +14 -0
  63. package/src/state.cjs +54 -0
  64. package/test/authority-ordering-hardening.test.cjs +101 -0
  65. package/test/authorization-gate.test.cjs +610 -0
  66. package/test/cascading-authority.test.cjs +390 -0
  67. package/test/grant-authority-security.test.cjs +305 -0
  68. package/test/matterhorn-host-runtime.test.cjs +1629 -0
  69. package/test/operation-descriptor-policy.test.cjs +140 -0
  70. package/test/role-key-auth.test.cjs +289 -0
  71. package/test/security-isolation.test.cjs +112 -0
@@ -0,0 +1,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,6 @@
1
+ module.exports = {
2
+ ...require("./directMessages/constants.cjs"),
3
+ ...require("./directMessages/payloads.cjs"),
4
+ ...require("./directMessages/state.cjs"),
5
+ ...require("./directMessages/commit.cjs")
6
+ };
@@ -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
+ };