diff --git a/contracts/0.2.0/chatroom-slim.js b/contracts/0.2.0/chatroom-slim.js index 09522c22b4..be7d324437 100644 --- a/contracts/0.2.0/chatroom-slim.js +++ b/contracts/0.2.0/chatroom-slim.js @@ -7497,24 +7497,23 @@ ${this.getErrorInfo()}`; }, "gi.contracts/chatroom/leave": { validate: objectOf({ - username: optional(string), - member: string + username: string, + showKickedBy: optional(string) }), process({ data, meta, hash, id, contractID }, { state }) { - const { member } = data; - const isKicked = data.username && member !== data.username; - if (!state.onlyRenderMessage && !state.users[member]) { - console.warn(`Can not leave the chatroom ${contractID} which ${member} is not part of`); - return; + const { username, showKickedBy } = data; + const isKicked = showKickedBy && username !== showKickedBy; + if (!state.onlyRenderMessage && !state.users[username]) { + throw new Error(`Can not leave the chatroom ${contractID} which ${username} is not part of`); } - import_common.Vue.delete(state.users, member); + import_common.Vue.delete(state.users, username); if (!state.onlyRenderMessage || state.attributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { return; } - const notificationType = !isKicked ? MESSAGE_NOTIFICATIONS.LEAVE_MEMBER : MESSAGE_NOTIFICATIONS.KICK_MEMBER; - const notificationData = createNotificationData(notificationType, isKicked ? { username: member } : {}); + const notificationType = isKicked ? MESSAGE_NOTIFICATIONS.KICK_MEMBER : MESSAGE_NOTIFICATIONS.LEAVE_MEMBER; + const notificationData = createNotificationData(notificationType, isKicked ? { username } : {}); const newMessage = createMessage({ - meta: isKicked ? meta : { ...meta, username: member }, + meta: { ...meta, username: showKickedBy || username }, hash, id, data: notificationData, @@ -7523,7 +7522,7 @@ ${this.getErrorInfo()}`; state.messages.push(newMessage); }, sideEffect({ data, hash, contractID, meta }, { state }) { - if (data.member === (0, import_sbp6.default)("state/vuex/state").loggedIn.username) { + if (data.username === (0, import_sbp6.default)("state/vuex/state").loggedIn.username) { if ((0, import_sbp6.default)("chelonia/contract/isSyncing", contractID)) { return; } @@ -7532,13 +7531,13 @@ ${this.getErrorInfo()}`; emitMessageEvent({ contractID, hash }); setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }); if (state.attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE) { - (0, import_sbp6.default)("gi.contracts/chatroom/rotateKeys", contractID, state); + (0, import_sbp6.default)("gi.contracts/chatroom/rotateKeys", contractID); } } const rootGetters = (0, import_sbp6.default)("state/vuex/getters"); - const userID = rootGetters.ourContactProfiles[data.member]?.contractID; + const userID = rootGetters.ourContactProfiles[data.username]?.contractID; if (userID) { - (0, import_sbp6.default)("gi.contracts/chatroom/removeForeignKeys", contractID, userID, state); + (0, import_sbp6.default)("gi.contracts/chatroom/removeForeignKeys", contractID, userID); } } }, @@ -7831,7 +7830,8 @@ ${this.getErrorInfo()}`; } }, methods: { - "gi.contracts/chatroom/rotateKeys": (contractID, state) => { + "gi.contracts/chatroom/rotateKeys": (contractID) => { + const state = (0, import_sbp6.default)("state/vuex/state")[contractID]; if (!state._volatile) import_common.Vue.set(state, "_volatile", /* @__PURE__ */ Object.create(null)); if (!state._volatile.pendingKeyRevocations) @@ -7844,7 +7844,8 @@ ${this.getErrorInfo()}`; console.warn(`rotateKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e); }); }, - "gi.contracts/chatroom/removeForeignKeys": (contractID, userID, state) => { + "gi.contracts/chatroom/removeForeignKeys": (contractID, userID) => { + const state = (0, import_sbp6.default)("state/vuex/state")[contractID]; const keyIds = findForeignKeysByContractID(state, userID); if (!keyIds?.length) return; diff --git a/contracts/0.2.0/chatroom.0.2.0.manifest.json b/contracts/0.2.0/chatroom.0.2.0.manifest.json index 11509a6301..bb9305957f 100644 --- a/contracts/0.2.0/chatroom.0.2.0.manifest.json +++ b/contracts/0.2.0/chatroom.0.2.0.manifest.json @@ -1 +1 @@ -{"head":{"manifestVersion":"1.0.0"},"body":"{\"version\":\"0.2.0\",\"contract\":{\"hash\":\"21XWnNMSuCrnYWd5k4rfgiER52edE4n4ZsMuMxxgLB8tEXRfpQ\",\"file\":\"chatroom.js\"},\"authors\":[{\"cipher\":\"algo\",\"key\":\"\"},{\"cipher\":\"algo\",\"key\":\"\"}],\"contractSlim\":{\"file\":\"chatroom-slim.js\",\"hash\":\"21XWnNTNdo9ejZSdvNfCFHnh3kQPqNTXesNScNcXaezGTfJfYF\"}}","signature":{"key":"","signature":""}} \ No newline at end of file +{"head":{"manifestVersion":"1.0.0"},"body":"{\"version\":\"0.2.0\",\"contract\":{\"hash\":\"21XWnNG5UBeuaGnHekrzgQ9BdokCkeJE6qsP1KJJWsA99q9GV2\",\"file\":\"chatroom.js\"},\"authors\":[{\"cipher\":\"algo\",\"key\":\"\"},{\"cipher\":\"algo\",\"key\":\"\"}],\"contractSlim\":{\"file\":\"chatroom-slim.js\",\"hash\":\"21XWnNWRjGzDQsYD1inHB9DLY2HT1gimz6TrNgjVXdTKkK87tL\"}}","signature":{"key":"","signature":""}} \ No newline at end of file diff --git a/contracts/0.2.0/chatroom.js b/contracts/0.2.0/chatroom.js index ed1df256d5..d9767e0756 100644 --- a/contracts/0.2.0/chatroom.js +++ b/contracts/0.2.0/chatroom.js @@ -16591,24 +16591,23 @@ ${this.getErrorInfo()}`; }, "gi.contracts/chatroom/leave": { validate: objectOf({ - username: optional(string), - member: string + username: string, + showKickedBy: optional(string) }), process({ data, meta, hash: hash2, id, contractID }, { state }) { - const { member } = data; - const isKicked = data.username && member !== data.username; - if (!state.onlyRenderMessage && !state.users[member]) { - console.warn(`Can not leave the chatroom ${contractID} which ${member} is not part of`); - return; + const { username, showKickedBy } = data; + const isKicked = showKickedBy && username !== showKickedBy; + if (!state.onlyRenderMessage && !state.users[username]) { + throw new Error(`Can not leave the chatroom ${contractID} which ${username} is not part of`); } - vue_esm_default.delete(state.users, member); + vue_esm_default.delete(state.users, username); if (!state.onlyRenderMessage || state.attributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { return; } - const notificationType = !isKicked ? MESSAGE_NOTIFICATIONS.LEAVE_MEMBER : MESSAGE_NOTIFICATIONS.KICK_MEMBER; - const notificationData = createNotificationData(notificationType, isKicked ? { username: member } : {}); + const notificationType = isKicked ? MESSAGE_NOTIFICATIONS.KICK_MEMBER : MESSAGE_NOTIFICATIONS.LEAVE_MEMBER; + const notificationData = createNotificationData(notificationType, isKicked ? { username } : {}); const newMessage = createMessage({ - meta: isKicked ? meta : { ...meta, username: member }, + meta: { ...meta, username: showKickedBy || username }, hash: hash2, id, data: notificationData, @@ -16617,7 +16616,7 @@ ${this.getErrorInfo()}`; state.messages.push(newMessage); }, sideEffect({ data, hash: hash2, contractID, meta }, { state }) { - if (data.member === (0, import_sbp7.default)("state/vuex/state").loggedIn.username) { + if (data.username === (0, import_sbp7.default)("state/vuex/state").loggedIn.username) { if ((0, import_sbp7.default)("chelonia/contract/isSyncing", contractID)) { return; } @@ -16626,13 +16625,13 @@ ${this.getErrorInfo()}`; emitMessageEvent({ contractID, hash: hash2 }); setReadUntilWhileJoining({ contractID, hash: hash2, createdDate: meta.createdDate }); if (state.attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE) { - (0, import_sbp7.default)("gi.contracts/chatroom/rotateKeys", contractID, state); + (0, import_sbp7.default)("gi.contracts/chatroom/rotateKeys", contractID); } } const rootGetters = (0, import_sbp7.default)("state/vuex/getters"); - const userID = rootGetters.ourContactProfiles[data.member]?.contractID; + const userID = rootGetters.ourContactProfiles[data.username]?.contractID; if (userID) { - (0, import_sbp7.default)("gi.contracts/chatroom/removeForeignKeys", contractID, userID, state); + (0, import_sbp7.default)("gi.contracts/chatroom/removeForeignKeys", contractID, userID); } } }, @@ -16925,7 +16924,8 @@ ${this.getErrorInfo()}`; } }, methods: { - "gi.contracts/chatroom/rotateKeys": (contractID, state) => { + "gi.contracts/chatroom/rotateKeys": (contractID) => { + const state = (0, import_sbp7.default)("state/vuex/state")[contractID]; if (!state._volatile) vue_esm_default.set(state, "_volatile", /* @__PURE__ */ Object.create(null)); if (!state._volatile.pendingKeyRevocations) @@ -16938,7 +16938,8 @@ ${this.getErrorInfo()}`; console.warn(`rotateKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e); }); }, - "gi.contracts/chatroom/removeForeignKeys": (contractID, userID, state) => { + "gi.contracts/chatroom/removeForeignKeys": (contractID, userID) => { + const state = (0, import_sbp7.default)("state/vuex/state")[contractID]; const keyIds = findForeignKeysByContractID(state, userID); if (!keyIds?.length) return; diff --git a/contracts/0.2.0/group-slim.js b/contracts/0.2.0/group-slim.js index 22c7cbcb25..c2ec0264b4 100644 --- a/contracts/0.2.0/group-slim.js +++ b/contracts/0.2.0/group-slim.js @@ -7311,8 +7311,8 @@ ${this.getErrorInfo()}`; }; const message = { data: messageData, meta, contractID }; (0, import_sbp.default)("gi.contracts/group/removeMember/process", message, state); - (0, import_sbp.default)("gi.contracts/group/pushSideEffect", contractID, ["gi.contracts/group/removeMember/sideEffect", message]); archiveProposal({ state, proposalHash, proposal, contractID }); + (0, import_sbp.default)("gi.contracts/group/pushSideEffect", contractID, ["gi.contracts/group/removeMember/sideEffect", message]); }, [VOTE_AGAINST]: voteAgainst }, @@ -8398,7 +8398,7 @@ ${this.getErrorInfo()}`; process({ data, meta, contractID }, { state, getters }) { memberLeaves({ username: data.member, dateLeft: meta.createdDate }, { contractID, meta, state, getters }); }, - async sideEffect({ data, meta, contractID }, { state, getters }) { + sideEffect({ data, meta, contractID }, { state, getters }) { const rootState = (0, import_sbp6.default)("state/vuex/state"); const rootGetters = (0, import_sbp6.default)("state/vuex/getters"); const contracts = rootState.contracts || {}; @@ -8407,25 +8407,30 @@ ${this.getErrorInfo()}`; if ((0, import_sbp6.default)("okTurtles.data/get", "JOINING_GROUP-" + contractID)) { return; } - await (0, import_sbp6.default)("gi.contracts/group/removeArchivedProposals", contractID); - await (0, import_sbp6.default)("gi.contracts/group/removeArchivedPayments", contractID); - const groupIdToSwitch = Object.keys(contracts).filter((cID) => contracts[cID].type === "gi.contracts/group" && cID !== contractID).sort((cID1, cID2) => rootState[cID1].profiles?.[username] ? -1 : 1)[0] || null; - (0, import_sbp6.default)("state/vuex/commit", "setCurrentChatRoomId", {}); - (0, import_sbp6.default)("state/vuex/commit", "setCurrentGroupId", groupIdToSwitch); - (0, import_sbp6.default)("chelonia/contract/remove", contractID).catch((e) => { - console.error(`sideEffect(removeMember): ${e.name} thrown by /remove ${contractID}:`, e); - }); - (0, import_sbp6.default)("chelonia/queueInvocation", contractID, ["gi.actions/identity/saveOurLoginState"]).then(function() { - const router = (0, import_sbp6.default)("controller/router"); - const switchFrom = router.currentRoute.path; - const switchTo = groupIdToSwitch ? "/dashboard" : "/"; - if (switchFrom !== "/join" && switchFrom !== switchTo) { - router.push({ path: switchTo }).catch(console.warn); + (0, import_sbp6.default)("chelonia/queueInvocation", contractID, async () => { + if (rootState[contractID]?.profiles?.[username]?.status === PROFILE_STATUS.REMOVED) { + await (0, import_sbp6.default)("gi.contracts/group/removeArchivedProposals", contractID); + await (0, import_sbp6.default)("gi.contracts/group/removeArchivedPayments", contractID); + const groupIdToSwitch = Object.keys(contracts).filter((cID) => contracts[cID].type === "gi.contracts/group" && cID !== contractID).sort((cID1, cID2) => rootState[cID1].profiles?.[username] ? -1 : 1)[0] || null; + (0, import_sbp6.default)("state/vuex/commit", "setCurrentChatRoomId", {}); + (0, import_sbp6.default)("state/vuex/commit", "setCurrentGroupId", groupIdToSwitch); + await (0, import_sbp6.default)("chelonia/contract/remove", contractID).catch((e) => { + console.error(`sideEffect(removeMember): ${e.name} thrown by /remove ${contractID}:`, e); + }); + await (0, import_sbp6.default)("gi.actions/identity/saveOurLoginState").then(function() { + const router = (0, import_sbp6.default)("controller/router"); + const switchFrom = router.currentRoute.path; + const switchTo = groupIdToSwitch ? "/dashboard" : "/"; + if (switchFrom !== "/join" && switchFrom !== switchTo) { + router.push({ path: switchTo }).catch(console.warn); + } + }).catch((e) => { + console.error(`sideEffect(removeMember): ${e.name} thrown during queueEvent to ${contractID} by saveOurLoginState:`, e); + }); + await (0, import_sbp6.default)("gi.contracts/group/revokeGroupKeyAndRotateOurPEK", contractID, true).catch((e) => { + console.error(`sideEffect(removeMember): ${e.name} thrown during revokeGroupKeyAndRotateOurPEK to ${contractID}:`, e); + }); } - }).catch((e) => { - console.error(`sideEffect(removeMember): ${e.name} thrown during queueEvent to ${contractID} by saveOurLoginState:`, e); - }).then(() => (0, import_sbp6.default)("gi.contracts/group/revokeGroupKeyAndRotateOurPEK", contractID, true)).catch((e) => { - console.error(`sideEffect(removeMember): ${e.name} thrown during revokeGroupKeyAndRotateOurPEK to ${contractID}:`, e); }); for (const notification of rootGetters.notificationsByGroup(contractID)) { (0, import_sbp6.default)("state/vuex/commit", REMOVE_NOTIFICATION, notification); @@ -8439,7 +8444,7 @@ ${this.getErrorInfo()}`; groupID: contractID, username: memberRemovedThemselves ? meta.username : data.member }); - (0, import_sbp6.default)("gi.contracts/group/rotateKeys", contractID, state).then(() => { + (0, import_sbp6.default)("gi.contracts/group/rotateKeys", contractID).then(() => { return (0, import_sbp6.default)("gi.contracts/group/revokeGroupKeyAndRotateOurPEK", contractID, false); }).catch((e) => { console.error("Error rotating group keys or our PEK", e); @@ -8447,7 +8452,7 @@ ${this.getErrorInfo()}`; const rootGetters2 = (0, import_sbp6.default)("state/vuex/getters"); const userID = rootGetters2.ourContactProfiles[data.member]?.contractID; if (userID) { - (0, import_sbp6.default)("gi.contracts/group/removeForeignKeys", contractID, userID, state); + (0, import_sbp6.default)("gi.contracts/group/removeForeignKeys", contractID, userID); } } } @@ -8652,30 +8657,15 @@ ${this.getErrorInfo()}`; "gi.contracts/group/leaveChatRoom": { validate: objectOf({ chatRoomID: string, - member: string, - leavingGroup: boolean + username: string }), process({ data, meta }, { state }) { - import_common3.Vue.set(state.chatRooms[data.chatRoomID], "users", state.chatRooms[data.chatRoomID].users.filter((u) => u !== data.member)); - }, - async sideEffect({ meta, data, contractID }, { state }) { - const rootState = (0, import_sbp6.default)("state/vuex/state"); - if (meta.username === rootState.loggedIn.username && !(0, import_sbp6.default)("okTurtles.data/get", "JOINING_GROUP-" + contractID)) { - const sendingData = data.leavingGroup ? { member: data.member } : { member: data.member, username: meta.username }; - await (0, import_sbp6.default)("gi.actions/chatroom/leave", { - contractID: data.chatRoomID, - data: sendingData, - ...data.leavingGroup && { - signingKeyId: (0, import_sbp6.default)("chelonia/contract/currentKeyIdByName", state, "csk"), - innerSigningContractID: null - } - }); - } + import_common3.Vue.set(state.chatRooms[data.chatRoomID], "users", state.chatRooms[data.chatRoomID].users.filter((u) => u !== data.username)); } }, "gi.contracts/group/joinChatRoom": { - validate: objectMaybeOf({ - username: string, + validate: objectOf({ + username: optional(string), chatRoomID: string }), process({ data, meta }, { state }) { @@ -8686,9 +8676,8 @@ ${this.getErrorInfo()}`; const rootState = (0, import_sbp6.default)("state/vuex/state"); const username = data.username || meta.username; if (username === rootState.loggedIn.username) { - if (!(0, import_sbp6.default)("okTurtles.data/get", "JOINING_GROUP-" + contractID) || (0, import_sbp6.default)("okTurtles.data/get", "JOINING_GROUP_CHAT")) { + if (!(0, import_sbp6.default)("okTurtles.data/get", "JOINING_GROUP-" + contractID)) { await (0, import_sbp6.default)("chelonia/contract/sync", data.chatRoomID); - (0, import_sbp6.default)("okTurtles.data/set", "JOINING_GROUP_CHAT", false); } } } @@ -8868,7 +8857,8 @@ ${this.getErrorInfo()}`; }); } }, - "gi.contracts/group/rotateKeys": (contractID, state) => { + "gi.contracts/group/rotateKeys": (contractID) => { + const state = (0, import_sbp6.default)("state/vuex/state")[contractID]; if (!state._volatile) import_common3.Vue.set(state, "_volatile", /* @__PURE__ */ Object.create(null)); if (!state._volatile.pendingKeyRevocations) @@ -8877,14 +8867,16 @@ ${this.getErrorInfo()}`; const CEKid = findKeyIdByName(state, "cek"); import_common3.Vue.set(state._volatile.pendingKeyRevocations, CSKid, true); import_common3.Vue.set(state._volatile.pendingKeyRevocations, CEKid, true); - return (0, import_sbp6.default)("chelonia/queueInvocation", contractID, ["gi.actions/out/rotateKeys", contractID, "gi.contracts/group", "pending", "gi.actions/group/shareNewKeys"]).catch((e) => { - console.warn(`rotateKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e); + return (0, import_sbp6.default)("gi.actions/out/rotateKeys", contractID, "gi.contracts/group", "pending", "gi.actions/group/shareNewKeys").catch((e) => { + console.warn(`rotateKeys: ${e.name} thrown:`, e); }); }, "gi.contracts/group/revokeGroupKeyAndRotateOurPEK": (groupContractID, disconnectGroup) => { const rootState = (0, import_sbp6.default)("state/vuex/state"); const { identityContractID } = rootState.loggedIn; const state = rootState[identityContractID]; + if (!state._volatile) + import_common3.Vue.set(state, "_volatile", /* @__PURE__ */ Object.create(null)); if (!state._volatile.pendingKeyRevocations) import_common3.Vue.set(state._volatile, "pendingKeyRevocations", /* @__PURE__ */ Object.create(null)); const CSKid = findKeyIdByName(state, "csk"); @@ -8914,7 +8906,8 @@ ${this.getErrorInfo()}`; console.error(`revokeGroupKeyAndRotateOurPEK: ${e.name} thrown during queueEvent to ${identityContractID}:`, e); }); }, - "gi.contracts/group/removeForeignKeys": (contractID, userID, state) => { + "gi.contracts/group/removeForeignKeys": (contractID, userID) => { + const state = (0, import_sbp6.default)("state/vuex/state")[contractID]; const keyIds = findForeignKeysByContractID(state, userID); if (!keyIds?.length) return; @@ -8922,13 +8915,13 @@ ${this.getErrorInfo()}`; const CEKid = findKeyIdByName(state, "cek"); if (!CEKid) throw new Error("Missing encryption key"); - (0, import_sbp6.default)("chelonia/queueInvocation", contractID, ["chelonia/out/keyDel", { + return (0, import_sbp6.default)("chelonia/out/keyDel", { contractID, contractName: "gi.contracts/group", data: keyIds, signingKeyId: CSKid - }]).catch((e) => { - console.warn(`removeForeignKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e); + }).catch((e) => { + console.warn(`removeForeignKeys: ${e.name} error thrown:`, e); }); } } diff --git a/contracts/0.2.0/group.0.2.0.manifest.json b/contracts/0.2.0/group.0.2.0.manifest.json index 891513feca..2836af4123 100644 --- a/contracts/0.2.0/group.0.2.0.manifest.json +++ b/contracts/0.2.0/group.0.2.0.manifest.json @@ -1 +1 @@ -{"head":{"manifestVersion":"1.0.0"},"body":"{\"version\":\"0.2.0\",\"contract\":{\"hash\":\"21XWnNFFQqoQ2fFncUoEPYCnKyaPuZKVYBxRdR6nY9MypMVyQu\",\"file\":\"group.js\"},\"authors\":[{\"cipher\":\"algo\",\"key\":\"\"},{\"cipher\":\"algo\",\"key\":\"\"}],\"contractSlim\":{\"file\":\"group-slim.js\",\"hash\":\"21XWnNUobHkRALuMvw7jNjkr3o1sWc4YJufJhEg1wDo1ECuy8L\"}}","signature":{"key":"","signature":""}} \ No newline at end of file +{"head":{"manifestVersion":"1.0.0"},"body":"{\"version\":\"0.2.0\",\"contract\":{\"hash\":\"21XWnNJQiq1F5YcEzzJZWSyz6fFCMZ7fUxdaapLjV1o2mdCasw\",\"file\":\"group.js\"},\"authors\":[{\"cipher\":\"algo\",\"key\":\"\"},{\"cipher\":\"algo\",\"key\":\"\"}],\"contractSlim\":{\"file\":\"group-slim.js\",\"hash\":\"21XWnNPuCM2WqU9aYLAz66gtVjXXFosMnaqhYFCJ5wqWEGT2yq\"}}","signature":{"key":"","signature":""}} \ No newline at end of file diff --git a/contracts/0.2.0/group.js b/contracts/0.2.0/group.js index db17da0d8b..2d7a002cb3 100644 --- a/contracts/0.2.0/group.js +++ b/contracts/0.2.0/group.js @@ -15977,6 +15977,7 @@ var errors_exports = {}; __export(errors_exports, { GIErrorIgnoreAndBan: () => GIErrorIgnoreAndBan, + GIErrorMissingSigningKeyError: () => GIErrorMissingSigningKeyError, GIErrorUIRuntimeError: () => GIErrorUIRuntimeError }); var GIErrorIgnoreAndBan = class extends Error { @@ -15997,6 +15998,15 @@ } } }; + var GIErrorMissingSigningKeyError = class extends Error { + constructor(...params) { + super(...params); + this.name = "GIErrorMissingSigningKeyError"; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } + }; // frontend/model/contracts/misc/flowTyper.js var EMPTY_VALUE = Symbol("@@empty"); @@ -16481,8 +16491,8 @@ ${this.getErrorInfo()}`; }; const message = { data: messageData, meta, contractID }; (0, import_sbp2.default)("gi.contracts/group/removeMember/process", message, state); - (0, import_sbp2.default)("gi.contracts/group/pushSideEffect", contractID, ["gi.contracts/group/removeMember/sideEffect", message]); archiveProposal({ state, proposalHash, proposal, contractID }); + (0, import_sbp2.default)("gi.contracts/group/pushSideEffect", contractID, ["gi.contracts/group/removeMember/sideEffect", message]); }, [VOTE_AGAINST]: voteAgainst }, @@ -17519,7 +17529,7 @@ ${this.getErrorInfo()}`; process({ data, meta, contractID }, { state, getters }) { memberLeaves({ username: data.member, dateLeft: meta.createdDate }, { contractID, meta, state, getters }); }, - async sideEffect({ data, meta, contractID }, { state, getters }) { + sideEffect({ data, meta, contractID }, { state, getters }) { const rootState = (0, import_sbp7.default)("state/vuex/state"); const rootGetters = (0, import_sbp7.default)("state/vuex/getters"); const contracts = rootState.contracts || {}; @@ -17528,25 +17538,30 @@ ${this.getErrorInfo()}`; if ((0, import_sbp7.default)("okTurtles.data/get", "JOINING_GROUP-" + contractID)) { return; } - await (0, import_sbp7.default)("gi.contracts/group/removeArchivedProposals", contractID); - await (0, import_sbp7.default)("gi.contracts/group/removeArchivedPayments", contractID); - const groupIdToSwitch = Object.keys(contracts).filter((cID) => contracts[cID].type === "gi.contracts/group" && cID !== contractID).sort((cID1, cID2) => rootState[cID1].profiles?.[username] ? -1 : 1)[0] || null; - (0, import_sbp7.default)("state/vuex/commit", "setCurrentChatRoomId", {}); - (0, import_sbp7.default)("state/vuex/commit", "setCurrentGroupId", groupIdToSwitch); - (0, import_sbp7.default)("chelonia/contract/remove", contractID).catch((e) => { - console.error(`sideEffect(removeMember): ${e.name} thrown by /remove ${contractID}:`, e); - }); - (0, import_sbp7.default)("chelonia/queueInvocation", contractID, ["gi.actions/identity/saveOurLoginState"]).then(function() { - const router = (0, import_sbp7.default)("controller/router"); - const switchFrom = router.currentRoute.path; - const switchTo = groupIdToSwitch ? "/dashboard" : "/"; - if (switchFrom !== "/join" && switchFrom !== switchTo) { - router.push({ path: switchTo }).catch(console.warn); + (0, import_sbp7.default)("chelonia/queueInvocation", contractID, async () => { + if (rootState[contractID]?.profiles?.[username]?.status === PROFILE_STATUS.REMOVED) { + await (0, import_sbp7.default)("gi.contracts/group/removeArchivedProposals", contractID); + await (0, import_sbp7.default)("gi.contracts/group/removeArchivedPayments", contractID); + const groupIdToSwitch = Object.keys(contracts).filter((cID) => contracts[cID].type === "gi.contracts/group" && cID !== contractID).sort((cID1, cID2) => rootState[cID1].profiles?.[username] ? -1 : 1)[0] || null; + (0, import_sbp7.default)("state/vuex/commit", "setCurrentChatRoomId", {}); + (0, import_sbp7.default)("state/vuex/commit", "setCurrentGroupId", groupIdToSwitch); + await (0, import_sbp7.default)("chelonia/contract/remove", contractID).catch((e) => { + console.error(`sideEffect(removeMember): ${e.name} thrown by /remove ${contractID}:`, e); + }); + await (0, import_sbp7.default)("gi.actions/identity/saveOurLoginState").then(function() { + const router = (0, import_sbp7.default)("controller/router"); + const switchFrom = router.currentRoute.path; + const switchTo = groupIdToSwitch ? "/dashboard" : "/"; + if (switchFrom !== "/join" && switchFrom !== switchTo) { + router.push({ path: switchTo }).catch(console.warn); + } + }).catch((e) => { + console.error(`sideEffect(removeMember): ${e.name} thrown during queueEvent to ${contractID} by saveOurLoginState:`, e); + }); + await (0, import_sbp7.default)("gi.contracts/group/revokeGroupKeyAndRotateOurPEK", contractID, true).catch((e) => { + console.error(`sideEffect(removeMember): ${e.name} thrown during revokeGroupKeyAndRotateOurPEK to ${contractID}:`, e); + }); } - }).catch((e) => { - console.error(`sideEffect(removeMember): ${e.name} thrown during queueEvent to ${contractID} by saveOurLoginState:`, e); - }).then(() => (0, import_sbp7.default)("gi.contracts/group/revokeGroupKeyAndRotateOurPEK", contractID, true)).catch((e) => { - console.error(`sideEffect(removeMember): ${e.name} thrown during revokeGroupKeyAndRotateOurPEK to ${contractID}:`, e); }); for (const notification of rootGetters.notificationsByGroup(contractID)) { (0, import_sbp7.default)("state/vuex/commit", REMOVE_NOTIFICATION, notification); @@ -17560,7 +17575,7 @@ ${this.getErrorInfo()}`; groupID: contractID, username: memberRemovedThemselves ? meta.username : data.member }); - (0, import_sbp7.default)("gi.contracts/group/rotateKeys", contractID, state).then(() => { + (0, import_sbp7.default)("gi.contracts/group/rotateKeys", contractID).then(() => { return (0, import_sbp7.default)("gi.contracts/group/revokeGroupKeyAndRotateOurPEK", contractID, false); }).catch((e) => { console.error("Error rotating group keys or our PEK", e); @@ -17568,7 +17583,7 @@ ${this.getErrorInfo()}`; const rootGetters2 = (0, import_sbp7.default)("state/vuex/getters"); const userID = rootGetters2.ourContactProfiles[data.member]?.contractID; if (userID) { - (0, import_sbp7.default)("gi.contracts/group/removeForeignKeys", contractID, userID, state); + (0, import_sbp7.default)("gi.contracts/group/removeForeignKeys", contractID, userID); } } } @@ -17773,30 +17788,15 @@ ${this.getErrorInfo()}`; "gi.contracts/group/leaveChatRoom": { validate: objectOf({ chatRoomID: string, - member: string, - leavingGroup: boolean + username: string }), process({ data, meta }, { state }) { - vue_esm_default.set(state.chatRooms[data.chatRoomID], "users", state.chatRooms[data.chatRoomID].users.filter((u) => u !== data.member)); - }, - async sideEffect({ meta, data, contractID }, { state }) { - const rootState = (0, import_sbp7.default)("state/vuex/state"); - if (meta.username === rootState.loggedIn.username && !(0, import_sbp7.default)("okTurtles.data/get", "JOINING_GROUP-" + contractID)) { - const sendingData = data.leavingGroup ? { member: data.member } : { member: data.member, username: meta.username }; - await (0, import_sbp7.default)("gi.actions/chatroom/leave", { - contractID: data.chatRoomID, - data: sendingData, - ...data.leavingGroup && { - signingKeyId: (0, import_sbp7.default)("chelonia/contract/currentKeyIdByName", state, "csk"), - innerSigningContractID: null - } - }); - } + vue_esm_default.set(state.chatRooms[data.chatRoomID], "users", state.chatRooms[data.chatRoomID].users.filter((u) => u !== data.username)); } }, "gi.contracts/group/joinChatRoom": { - validate: objectMaybeOf({ - username: string, + validate: objectOf({ + username: optional(string), chatRoomID: string }), process({ data, meta }, { state }) { @@ -17807,9 +17807,8 @@ ${this.getErrorInfo()}`; const rootState = (0, import_sbp7.default)("state/vuex/state"); const username = data.username || meta.username; if (username === rootState.loggedIn.username) { - if (!(0, import_sbp7.default)("okTurtles.data/get", "JOINING_GROUP-" + contractID) || (0, import_sbp7.default)("okTurtles.data/get", "JOINING_GROUP_CHAT")) { + if (!(0, import_sbp7.default)("okTurtles.data/get", "JOINING_GROUP-" + contractID)) { await (0, import_sbp7.default)("chelonia/contract/sync", data.chatRoomID); - (0, import_sbp7.default)("okTurtles.data/set", "JOINING_GROUP_CHAT", false); } } } @@ -17989,7 +17988,8 @@ ${this.getErrorInfo()}`; }); } }, - "gi.contracts/group/rotateKeys": (contractID, state) => { + "gi.contracts/group/rotateKeys": (contractID) => { + const state = (0, import_sbp7.default)("state/vuex/state")[contractID]; if (!state._volatile) vue_esm_default.set(state, "_volatile", /* @__PURE__ */ Object.create(null)); if (!state._volatile.pendingKeyRevocations) @@ -17998,14 +17998,16 @@ ${this.getErrorInfo()}`; const CEKid = findKeyIdByName(state, "cek"); vue_esm_default.set(state._volatile.pendingKeyRevocations, CSKid, true); vue_esm_default.set(state._volatile.pendingKeyRevocations, CEKid, true); - return (0, import_sbp7.default)("chelonia/queueInvocation", contractID, ["gi.actions/out/rotateKeys", contractID, "gi.contracts/group", "pending", "gi.actions/group/shareNewKeys"]).catch((e) => { - console.warn(`rotateKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e); + return (0, import_sbp7.default)("gi.actions/out/rotateKeys", contractID, "gi.contracts/group", "pending", "gi.actions/group/shareNewKeys").catch((e) => { + console.warn(`rotateKeys: ${e.name} thrown:`, e); }); }, "gi.contracts/group/revokeGroupKeyAndRotateOurPEK": (groupContractID, disconnectGroup) => { const rootState = (0, import_sbp7.default)("state/vuex/state"); const { identityContractID } = rootState.loggedIn; const state = rootState[identityContractID]; + if (!state._volatile) + vue_esm_default.set(state, "_volatile", /* @__PURE__ */ Object.create(null)); if (!state._volatile.pendingKeyRevocations) vue_esm_default.set(state._volatile, "pendingKeyRevocations", /* @__PURE__ */ Object.create(null)); const CSKid = findKeyIdByName(state, "csk"); @@ -18035,7 +18037,8 @@ ${this.getErrorInfo()}`; console.error(`revokeGroupKeyAndRotateOurPEK: ${e.name} thrown during queueEvent to ${identityContractID}:`, e); }); }, - "gi.contracts/group/removeForeignKeys": (contractID, userID, state) => { + "gi.contracts/group/removeForeignKeys": (contractID, userID) => { + const state = (0, import_sbp7.default)("state/vuex/state")[contractID]; const keyIds = findForeignKeysByContractID(state, userID); if (!keyIds?.length) return; @@ -18043,13 +18046,13 @@ ${this.getErrorInfo()}`; const CEKid = findKeyIdByName(state, "cek"); if (!CEKid) throw new Error("Missing encryption key"); - (0, import_sbp7.default)("chelonia/queueInvocation", contractID, ["chelonia/out/keyDel", { + return (0, import_sbp7.default)("chelonia/out/keyDel", { contractID, contractName: "gi.contracts/group", data: keyIds, signingKeyId: CSKid - }]).catch((e) => { - console.warn(`removeForeignKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e); + }).catch((e) => { + console.warn(`removeForeignKeys: ${e.name} error thrown:`, e); }); } } diff --git a/contracts/0.2.0/identity-slim.js b/contracts/0.2.0/identity-slim.js index 16cd2d3a28..ad18a82589 100644 --- a/contracts/0.2.0/identity-slim.js +++ b/contracts/0.2.0/identity-slim.js @@ -312,6 +312,9 @@ ${this.getErrorInfo()}`; }, sideEffect({ contractID }) { if (contractID === (0, import_sbp.default)("state/vuex/getters").ourIdentityContractId) { + if ((0, import_sbp.default)("chelonia/contract/isSyncing", contractID)) { + return; + } (0, import_sbp.default)("chelonia/queueInvocation", contractID, ["gi.actions/identity/updateLoginStateUponLogin"]).catch((e) => { (0, import_sbp.default)("gi.notifications/emit", "ERROR", { message: (0, import_common.L)("Failed to join groups we're part of on another device. Not catastrophic, but could lead to problems. {errName}: '{errMsg}'", { diff --git a/contracts/0.2.0/identity.0.2.0.manifest.json b/contracts/0.2.0/identity.0.2.0.manifest.json index 0e6f169e10..9c640354d1 100644 --- a/contracts/0.2.0/identity.0.2.0.manifest.json +++ b/contracts/0.2.0/identity.0.2.0.manifest.json @@ -1 +1 @@ -{"head":{"manifestVersion":"1.0.0"},"body":"{\"version\":\"0.2.0\",\"contract\":{\"hash\":\"21XWnNTMkdh4kSPqYSBg7w2vEbVu6K8VVsTHhmmk7ASyeywTF1\",\"file\":\"identity.js\"},\"authors\":[{\"cipher\":\"algo\",\"key\":\"\"},{\"cipher\":\"algo\",\"key\":\"\"}],\"contractSlim\":{\"file\":\"identity-slim.js\",\"hash\":\"21XWnNX2cByjzY8L5pxyuBxemFAoWcThiHXkX88WdMQqCi5eBY\"}}","signature":{"key":"","signature":""}} \ No newline at end of file +{"head":{"manifestVersion":"1.0.0"},"body":"{\"version\":\"0.2.0\",\"contract\":{\"hash\":\"21XWnNNbRaENeLUornX7BY1jMttSSYCEwWNPXVqz7e6Zpuq7aE\",\"file\":\"identity.js\"},\"authors\":[{\"cipher\":\"algo\",\"key\":\"\"},{\"cipher\":\"algo\",\"key\":\"\"}],\"contractSlim\":{\"file\":\"identity-slim.js\",\"hash\":\"21XWnNFzZoo6QZcmqtBa2ST32NSEs93aPN6aiFsLrWReKPed4A\"}}","signature":{"key":"","signature":""}} \ No newline at end of file diff --git a/contracts/0.2.0/identity.js b/contracts/0.2.0/identity.js index d88a9a51d7..b5bf98356c 100644 --- a/contracts/0.2.0/identity.js +++ b/contracts/0.2.0/identity.js @@ -9409,6 +9409,9 @@ ${this.getErrorInfo()}`; }, sideEffect({ contractID }) { if (contractID === (0, import_sbp2.default)("state/vuex/getters").ourIdentityContractId) { + if ((0, import_sbp2.default)("chelonia/contract/isSyncing", contractID)) { + return; + } (0, import_sbp2.default)("chelonia/queueInvocation", contractID, ["gi.actions/identity/updateLoginStateUponLogin"]).catch((e) => { (0, import_sbp2.default)("gi.notifications/emit", "ERROR", { message: L("Failed to join groups we're part of on another device. Not catastrophic, but could lead to problems. {errName}: '{errMsg}'", { diff --git a/frontend/common/errors.js b/frontend/common/errors.js index 6365a7b579..20a16c35ef 100644 --- a/frontend/common/errors.js +++ b/frontend/common/errors.js @@ -23,3 +23,14 @@ export class GIErrorUIRuntimeError extends Error { } } } + +export class GIErrorMissingSigningKeyError extends Error { + constructor (...params: any[]) { + super(...params) + // this.name = this.constructor.name + this.name = 'GIErrorMissingSigningKeyError' // string literal so minifier doesn't overwrite + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor) + } + } +} diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 6cdd253880..2f9735cfe5 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -36,18 +36,19 @@ import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keygen, keyId, ser import type { GIActionParams } from './types.js' import { encryptedAction } from './utils.js' -export async function leaveAllChatRooms (groupContractID: string, member: string) { - // let user leaves all the chatrooms before leaving group +export async function leaveAllChatRooms (groupContractID: string, username: string) { + // NOTE: user should be left from all the chatrooms before leaving group const rootState = sbp('state/vuex/state') const chatRooms = rootState[groupContractID].chatRooms const chatRoomIDsToLeave = Object.keys(chatRooms) - .filter(cID => chatRooms[cID].users.includes(member)) + .filter(cID => chatRooms[cID].users.includes(username)) try { for (const chatRoomID of chatRoomIDsToLeave) { await sbp('gi.actions/group/leaveChatRoom', { contractID: groupContractID, - data: { chatRoomID, member, leavingGroup: true } + data: { chatRoomID, username }, + options: { leavingGroup: true } }) } } catch (e) { @@ -314,8 +315,7 @@ export default (sbp('sbp/selectors/register', { // secret keys to be shared with us, (b) ready to call the inviteAccept // action if we haven't done so yet (because we were previously waiting for // the keys), or (c) already a member and ready to interact with the group. - 'gi.actions/group/join': async function (params: $Exact & { options?: { skipUsableKeysCheck?: boolean; skipInviteAccept?: boolean } }) { - sbp('okTurtles.data/set', 'JOINING_GROUP-' + params.contractID, true) + 'gi.actions/group/join': async function (params: $Exact & { options?: { skipUsableKeysCheck?: boolean } }) { try { const rootState = sbp('state/vuex/state') const username = rootState.loggedIn.username @@ -323,8 +323,16 @@ export default (sbp('sbp/selectors/register', { console.log('@@@@@@@@ AT join for ' + params.contractID) + const isJoiningGroup = !!params.originatingContractID + isJoiningGroup && sbp('okTurtles.data/set', 'JOINING_GROUP-' + params.contractID, true) await sbp('chelonia/contract/sync', params.contractID) - if (rootState.contracts[params.contractID]?.type !== 'gi.contracts/group') { + await sbp('chelonia/contract/wait', params.contractID) + isJoiningGroup && sbp('okTurtles.data/set', 'JOINING_GROUP-' + params.contractID, false) + + if (!rootState.contracts[params.contractID]) { + // NOTE: already kicked from the group by someone else + return + } else if (rootState.contracts[params.contractID]?.type !== 'gi.contracts/group') { throw Error(`Contract ${params.contractID} is not a group`) } @@ -341,7 +349,7 @@ export default (sbp('sbp/selectors/register', { // params.originatingContractID is set, it means that we're joining // through an invite link, and we must send a key request to complete // the joining process. - const sendKeyRequest = (!hasSecretKeys && params.originatingContractID) + const sendKeyRequest = (!hasSecretKeys && isJoiningGroup) // If we are expecting to receive keys, set up an event listener // We are expecting to receive keys if: @@ -520,8 +528,6 @@ export default (sbp('sbp/selectors/register', { console.info('found unsynced identity contracts to sync:', missingIDs) await sbp('chelonia/contract/sync', missingIDs) } - - sbp('okTurtles.data/set', 'JOINING_GROUP-' + params.contractID, false) // We have already sent a key request that hasn't been answered. We cannot // do much at this point, so we do nothing. // This could happen, for example, after logging in if we still haven't @@ -536,7 +542,7 @@ export default (sbp('sbp/selectors/register', { saveLoginState('joining', params.contractID) } }, - 'gi.actions/group/joinAndSwitch': async function (params: $Exact & { options?: { skipUsableKeysCheck?: boolean; skipInviteAccept: boolean } }) { + 'gi.actions/group/joinAndSwitch': async function (params: $Exact & { options?: { skipUsableKeysCheck?: boolean } }) { await sbp('gi.actions/group/join', params) // after joining, we can set the current group sbp('gi.actions/group/switch', params.contractID) @@ -685,17 +691,6 @@ export default (sbp('sbp/selectors/register', { } }) - if (username === me) { - // 'JOINING_GROUP_CHAT' is necessary to identify the joining chatroom action is NEW or OLD - // Users join the chatroom thru group making group actions - // But when user joins the group, he needs to ignore all the actions about chatroom - // Because the user is joining group, not joining chatroom - // and he is going to make a new action to join 'General' chatroom AGAIN - // While joining group, we don't set this flag because Joining chatroom actions are all OLD ones, which need to be ignored - // Joining 'General' chatroom is one of the steps to join group - // So setting 'JOINING_GROUP_CHAT' can not be out of the 'JOINING_GROUP' scope - sbp('okTurtles.data/set', 'JOINING_GROUP_CHAT', true) - } await sendMessage({ ...omit(params, ['options', 'action', 'hooks']), hooks: { @@ -705,6 +700,43 @@ export default (sbp('sbp/selectors/register', { }) return message }), + ...encryptedAction('gi.actions/group/leaveChatRoom', L('Failed to leave chat channel.'), async function (sendMessage, params) { + const rootState = sbp('state/vuex/state') + let chatRoomPayload = omit(params.data, ['chatRoomID']) + // NOTE: showKickedBy is in the options field because it's only needed for chatroom contract + if (params.options?.showKickedBy) { + chatRoomPayload = { + ...chatRoomPayload, + showKickedBy: params.options.showKickedBy + } + } + + await sbp('gi.actions/chatroom/leave', { + contractID: params.data.chatRoomID, + data: chatRoomPayload, + // When a group is being left, we want to also leave chatrooms, + // including private chatrooms. Since the user issuing the action + // may not be a member of the chatroom, we use the group's CSK + // unconditionally in this situation, which should be a key in the + // chatroom (either the CSK or the groupKey) + ...(params.options?.leavingGroup && { + signingKeyId: sbp('chelonia/contract/currentKeyIdByName', rootState[params.contractID], 'csk'), + innerSigningContractID: null + }), + hooks: { + prepublish: params.hooks?.prepublish, + postpublish: null + } + }) + + return await sendMessage({ + ...omit(params, ['options', 'action', 'hooks']), + hooks: { + prepublish: null, + postpublish: params.hooks?.postpublish + } + }) + }), 'gi.actions/group/addAndJoinChatRoom': async function (params: GIActionParams) { const message = await sbp('gi.actions/group/addChatRoom', { ...omit(params, ['options', 'hooks']), @@ -958,7 +990,6 @@ export default (sbp('sbp/selectors/register', { sbp('okTurtles.events/emit', OPEN_MODAL, 'AddMembers') } }, - ...encryptedAction('gi.actions/group/leaveChatRoom', L('Failed to leave chat channel.')), ...encryptedAction('gi.actions/group/deleteChatRoom', L('Failed to delete chat channel.')), ...encryptedAction('gi.actions/group/invite', L('Failed to create invite.')), ...encryptedAction('gi.actions/group/inviteAccept', L('Failed to accept invite.')), diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index b68810ed64..ee51f66e7c 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -3,7 +3,8 @@ import { GIErrorUIRuntimeError, L, LError } from '@common/common.js' import { CHATROOM_PRIVACY_LEVEL, - CHATROOM_TYPES + CHATROOM_TYPES, + PROFILE_STATUS } from '@model/contracts/shared/constants.js' import { difference, omit, pickWhere, uniq } from '@model/contracts/shared/giLodash.js' import sbp from '@sbp/sbp' @@ -294,7 +295,7 @@ export default (sbp('sbp/selectors/register', { console.info('synchronizing login state:', { groupsJoined }) for (const contractID of groupsJoined) { try { - await sbp('gi.actions/group/join', { contractID, options: { skipInviteAccept: true } }) + await sbp('gi.actions/group/join', { contractID }) } catch (e) { console.error(`updateLoginStateUponLogin: ${e.name} attempting to join group ${contractID}`, e) if (state.contracts[contractID] || state[contractID]) { @@ -373,6 +374,9 @@ export default (sbp('sbp/selectors/register', { const groupsToRejoin = [] // login can be called when no settings are saved (e.g. from Signup.vue) if (state) { + if (!username) { + username = state.loggedIn.username + } // The retrieved local data might need to be completed in case it was originally saved // under an older version of the app where fewer/other Vuex modules were implemented. sbp('state/vuex/postUpgradeVerification', state) @@ -389,8 +393,8 @@ export default (sbp('sbp/selectors/register', { // (1) We're looking for group contracts state.contracts[contractID].type === 'gi.contracts/group' && // (2) That potentially haven't been joined by us - // (in which case state.profiles?.[username] will be undefined) - !state.profiles?.[username] + // (in which case state[contractID].profiles?.[username] will be undefined) + !state[contractID].profiles?.[username] ) })) } @@ -437,9 +441,9 @@ export default (sbp('sbp/selectors/register', { throw new Error('Unable to sync identity contract') }).then(() => - sbp('chelonia/contract/sync', contractIDs).then(async function () { - // contract sync might've triggered an async call to /remove, so wait before proceeding - await sbp('chelonia/contract/wait', contractIDs) + sbp('chelonia/contract/sync', contractIDs, { force: true }).then(async function () { + // contract sync might've triggered an async call to /remove, so wait before proceeding + await sbp('chelonia/contract/wait') // similarly, since removeMember may have triggered saveOurLoginState asynchronously, // we must re-sync our identity contract again to ensure we don't rejoin a group we // were just kicked out of @@ -453,15 +457,17 @@ export default (sbp('sbp/selectors/register', { // Call 'gi.actions/group/join' on all groups which may need re-joining await Promise.all(groupsToRejoin.map(groupId => { return ( - // (1) Check whether the contract exists (may have been removed - // after sync) + // (1) Check whether the contract exists (may have been removed after sync) state.contracts[groupId] && - // (2) Check whether the join process is still incomplete - // This needs to be re-checked because it may have changed after - // sync - !state.profiles?.[username] && - // (3) Call join - sbp('gi.actions/group/join', { contractID: groupId, contractName: 'gi.contracts/group' }) + // (2) Check whether the join process is still incomplete + // This needs to be re-checked because it may have changed after sync + !state[groupId].profiles?.[username] && + // (3) Call join + sbp('gi.actions/group/join', { + contractID: groupId, + contractName: 'gi.contracts/group' + // TODO: consider to add 'originatingContractID' parameter here + }) ) })) @@ -471,7 +477,7 @@ export default (sbp('sbp/selectors/register', { .forEach(cId => { // We send this action only for groups we have fully joined (i.e., // accepted an invite add added our profile) - if (state[cId]?.profiles?.[username]) { + if (state[cId]?.profiles?.[username]?.status === PROFILE_STATUS.ACTIVE) { sbp('gi.actions/group/updateLastLoggedIn', { contractID: cId }).catch(console.error) } }) diff --git a/frontend/main.js b/frontend/main.js index ceafcf8b8b..c737403314 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -110,12 +110,11 @@ async function startApp () { 'state/vuex/state', 'state/vuex/settings', 'state/vuex/commit', 'state/vuex/getters', 'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'controller/router', 'chelonia/contract/suitableSigningKey', 'chelonia/contract/currentKeyIdByName', - 'chelonia/queueInvocation', 'gi.actions/identity/updateLoginStateUponLogin', + 'chelonia/queueInvocation', 'gi.actions/identity/updateLoginStateUponLogin', 'gi.actions/identity/saveOurLoginState', 'gi.actions/chatroom/leave', 'gi.actions/group/groupProfileUpdate', 'gi.actions/group/displayMincomeChangedPrompt', - 'gi.notifications/emit', - 'gi.actions/out/rotateKeys', 'gi.actions/group/shareNewKeys', 'gi.actions/chatroom/shareNewKeys', 'gi.actions/identity/shareNewPEK', - 'chelonia/out/keyDel', - 'chelonia/contract/disconnect' + 'gi.notifications/emit', 'gi.actions/out/rotateKeys', 'gi.actions/group/shareNewKeys', + 'gi.actions/chatroom/shareNewKeys', 'gi.actions/identity/shareNewPEK', + 'chelonia/out/keyDel', 'chelonia/contract/disconnect' ], allowedDomains: ['okTurtles.data', 'okTurtles.events', 'okTurtles.eventQueue', 'gi.db', 'gi.contracts'], preferSlim: true, diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index f6d4cd8d21..3b6b16c1f6 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -297,26 +297,27 @@ sbp('chelonia/defineContract', { }, 'gi.contracts/chatroom/leave': { validate: objectOf({ - username: optional(string), // coming from the gi.contracts/group/leaveChatRoom - member: string // username to be removed + username: string, + // NOTE: 'showKickedBy' is someone whose profile picture should be used for the notification message + // it has it's value only when someone else kicks 'data.username' from the chatroom + showKickedBy: optional(string) }), process ({ data, meta, hash, id, contractID }, { state }) { - const { member } = data - const isKicked = data.username && member !== data.username - if (!state.onlyRenderMessage && !state.users[member]) { - console.warn(`Can not leave the chatroom ${contractID} which ${member} is not part of`) - return + const { username, showKickedBy } = data + const isKicked = showKickedBy && username !== showKickedBy + if (!state.onlyRenderMessage && !state.users[username]) { + throw new Error(`Can not leave the chatroom ${contractID} which ${username} is not part of`) } - Vue.delete(state.users, member) + Vue.delete(state.users, username) if (!state.onlyRenderMessage || state.attributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { return } - const notificationType = !isKicked ? MESSAGE_NOTIFICATIONS.LEAVE_MEMBER : MESSAGE_NOTIFICATIONS.KICK_MEMBER - const notificationData = createNotificationData(notificationType, isKicked ? { username: member } : {}) + const notificationType = isKicked ? MESSAGE_NOTIFICATIONS.KICK_MEMBER : MESSAGE_NOTIFICATIONS.LEAVE_MEMBER + const notificationData = createNotificationData(notificationType, isKicked ? { username } : {}) const newMessage = createMessage({ - meta: isKicked ? meta : { ...meta, username: member }, + meta: { ...meta, username: showKickedBy || username }, hash, id, data: notificationData, @@ -325,7 +326,7 @@ sbp('chelonia/defineContract', { state.messages.push(newMessage) }, sideEffect ({ data, hash, contractID, meta }, { state }) { - if (data.member === sbp('state/vuex/state').loggedIn.username) { + if (data.username === sbp('state/vuex/state').loggedIn.username) { if (sbp('chelonia/contract/isSyncing', contractID)) { return } @@ -335,14 +336,14 @@ sbp('chelonia/defineContract', { setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) if (state.attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE) { - sbp('gi.contracts/chatroom/rotateKeys', contractID, state) + sbp('gi.contracts/chatroom/rotateKeys', contractID) } } const rootGetters = sbp('state/vuex/getters') - const userID = rootGetters.ourContactProfiles[data.member]?.contractID + const userID = rootGetters.ourContactProfiles[data.username]?.contractID if (userID) { - sbp('gi.contracts/chatroom/removeForeignKeys', contractID, userID, state) + sbp('gi.contracts/chatroom/removeForeignKeys', contractID, userID) } } }, @@ -689,7 +690,8 @@ sbp('chelonia/defineContract', { } }, methods: { - 'gi.contracts/chatroom/rotateKeys': (contractID, state) => { + 'gi.contracts/chatroom/rotateKeys': (contractID) => { + const state = sbp('state/vuex/state')[contractID] if (!state._volatile) Vue.set(state, '_volatile', Object.create(null)) if (!state._volatile.pendingKeyRevocations) Vue.set(state._volatile, 'pendingKeyRevocations', Object.create(null)) @@ -703,7 +705,8 @@ sbp('chelonia/defineContract', { console.warn(`rotateKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e) }) }, - 'gi.contracts/chatroom/removeForeignKeys': (contractID, userID, state) => { + 'gi.contracts/chatroom/removeForeignKeys': (contractID, userID) => { + const state = sbp('state/vuex/state')[contractID] const keyIds = findForeignKeysByContractID(state, userID) if (!keyIds?.length) return diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index c793f0f7ff..04ff589877 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -899,7 +899,7 @@ sbp('chelonia/defineContract', { { contractID, meta, state, getters } ) }, - async sideEffect ({ data, meta, contractID }, { state, getters }) { + sideEffect ({ data, meta, contractID }, { state, getters }) { const rootState = sbp('state/vuex/state') const rootGetters = sbp('state/vuex/getters') const contracts = rootState.contracts || {} @@ -911,44 +911,39 @@ sbp('chelonia/defineContract', { if (sbp('okTurtles.data/get', 'JOINING_GROUP-' + contractID)) { return } - - // NOTE: should remove archived data from IndexedStorage - // regarding the current group (proposals, payments) - await sbp('gi.contracts/group/removeArchivedProposals', contractID) - await sbp('gi.contracts/group/removeArchivedPayments', contractID) - - // grab the groupID of any group that we've successfully finished joining - const groupIdToSwitch = Object.keys(contracts) - .filter(cID => contracts[cID].type === 'gi.contracts/group' && cID !== contractID) - .sort((cID1, cID2) => rootState[cID1].profiles?.[username] ? -1 : 1)[0] || null - sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) - sbp('state/vuex/commit', 'setCurrentGroupId', groupIdToSwitch) - // we can't await on this in here, because it will cause a deadlock, since Chelonia processes - // this sideEffect on the eventqueue for this contractID, and /remove uses that same eventqueue - sbp('chelonia/contract/remove', contractID).catch(e => { - console.error(`sideEffect(removeMember): ${e.name} thrown by /remove ${contractID}:`, e) + sbp('chelonia/queueInvocation', contractID, async () => { + if (rootState[contractID]?.profiles?.[username]?.status === PROFILE_STATUS.REMOVED) { + // NOTE: should remove archived data from IndexedStorage + // regarding the current group (proposals, payments) + await sbp('gi.contracts/group/removeArchivedProposals', contractID) + await sbp('gi.contracts/group/removeArchivedPayments', contractID) + + // grab the groupID of any group that we've successfully finished joining + const groupIdToSwitch = Object.keys(contracts) + .filter(cID => contracts[cID].type === 'gi.contracts/group' && cID !== contractID) + .sort((cID1, cID2) => rootState[cID1].profiles?.[username] ? -1 : 1)[0] || null + sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) + sbp('state/vuex/commit', 'setCurrentGroupId', groupIdToSwitch) + // we can't await on this in here, because it will cause a deadlock, since Chelonia processes + // this sideEffect on the eventqueue for this contractID, and /remove uses that same eventqueue + await sbp('chelonia/contract/remove', contractID).catch(e => { + console.error(`sideEffect(removeMember): ${e.name} thrown by /remove ${contractID}:`, e) + }) + await sbp('gi.actions/identity/saveOurLoginState').then(function () { + const router = sbp('controller/router') + const switchFrom = router.currentRoute.path + const switchTo = groupIdToSwitch ? '/dashboard' : '/' + if (switchFrom !== '/join' && switchFrom !== switchTo) { + router.push({ path: switchTo }).catch(console.warn) + } + }).catch(e => { + console.error(`sideEffect(removeMember): ${e.name} thrown during queueEvent to ${contractID} by saveOurLoginState:`, e) + }) + await sbp('gi.contracts/group/revokeGroupKeyAndRotateOurPEK', contractID, true).catch(e => { + console.error(`sideEffect(removeMember): ${e.name} thrown during revokeGroupKeyAndRotateOurPEK to ${contractID}:`, e) + }) + } }) - // this looks crazy, but doing this was necessary to fix a race condition in the - // group-member-removal Cypress tests where due to the ordering of asynchronous events - // we were getting the same latestHash upon re-logging in for test "user2 rejoins groupA". - // We add it to the same queue as '/remove' above gets run on so that it is run after - // contractID is removed. See also comments in 'gi.actions/identity/login'. - sbp('chelonia/queueInvocation', contractID, ['gi.actions/identity/saveOurLoginState']) - .then(function () { - const router = sbp('controller/router') - const switchFrom = router.currentRoute.path - const switchTo = groupIdToSwitch ? '/dashboard' : '/' - if (switchFrom !== '/join' && switchFrom !== switchTo) { - router.push({ path: switchTo }).catch(console.warn) - } - }) - .catch(e => { - console.error(`sideEffect(removeMember): ${e.name} thrown during queueEvent to ${contractID} by saveOurLoginState:`, e) - }) - .then(() => sbp('gi.contracts/group/revokeGroupKeyAndRotateOurPEK', contractID, true)) - .catch(e => { - console.error(`sideEffect(removeMember): ${e.name} thrown during revokeGroupKeyAndRotateOurPEK to ${contractID}:`, e) - }) // TODO - #828 remove other group members contracts if applicable // NOTE: remove all notifications whose scope is in this group @@ -969,9 +964,8 @@ sbp('chelonia/defineContract', { username: memberRemovedThemselves ? meta.username : data.member }) - // gi.contracts/group/removeOurselves will eventually trigger this - // as well - sbp('gi.contracts/group/rotateKeys', contractID, state).then(() => { + // gi.contracts/group/removeOurselves will eventually trigger this as well + sbp('gi.contracts/group/rotateKeys', contractID).then(() => { return sbp('gi.contracts/group/revokeGroupKeyAndRotateOurPEK', contractID, false) }).catch((e) => { console.error('Error rotating group keys or our PEK', e) @@ -980,7 +974,7 @@ sbp('chelonia/defineContract', { const rootGetters = sbp('state/vuex/getters') const userID = rootGetters.ourContactProfiles[data.member]?.contractID if (userID) { - sbp('gi.contracts/group/removeForeignKeys', contractID, userID, state) + sbp('gi.contracts/group/removeForeignKeys', contractID, userID) } } // TODO - #828 remove the member contract if applicable. @@ -1249,38 +1243,16 @@ sbp('chelonia/defineContract', { 'gi.contracts/group/leaveChatRoom': { validate: objectOf({ chatRoomID: string, - member: string, - leavingGroup: boolean // leave chatroom by leaving group + username: string }), process ({ data, meta }, { state }) { Vue.set(state.chatRooms[data.chatRoomID], 'users', - state.chatRooms[data.chatRoomID].users.filter(u => u !== data.member)) - }, - async sideEffect ({ meta, data, contractID }, { state }) { - const rootState = sbp('state/vuex/state') - if (meta.username === rootState.loggedIn.username && !sbp('okTurtles.data/get', 'JOINING_GROUP-' + contractID)) { - const sendingData = data.leavingGroup - ? { member: data.member } - : { member: data.member, username: meta.username } - await sbp('gi.actions/chatroom/leave', { - contractID: data.chatRoomID, - data: sendingData, - // When a group is being left, we want to also leave chatrooms, - // including private chatrooms. Since the user issuing the action - // may not be a member of the chatroom, we use the group's CSK - // unconditionally in this situation, which should be a key in the - // chatroom (either the CSK or the groupKey) - ...(data.leavingGroup && { - signingKeyId: sbp('chelonia/contract/currentKeyIdByName', state, 'csk'), - innerSigningContractID: null - }) - }) - } + state.chatRooms[data.chatRoomID].users.filter(u => u !== data.username)) } }, 'gi.contracts/group/joinChatRoom': { - validate: objectMaybeOf({ - username: string, + validate: objectOf({ + username: optional(string), chatRoomID: string }), process ({ data, meta }, { state }) { @@ -1291,11 +1263,10 @@ sbp('chelonia/defineContract', { const rootState = sbp('state/vuex/state') const username = data.username || meta.username if (username === rootState.loggedIn.username) { - if (!sbp('okTurtles.data/get', 'JOINING_GROUP-' + contractID) || sbp('okTurtles.data/get', 'JOINING_GROUP_CHAT')) { + if (!sbp('okTurtles.data/get', 'JOINING_GROUP-' + contractID)) { // while users are joining chatroom, they don't need to leave chatrooms // this is similar to setting 'JOINING_GROUP' before joining group await sbp('chelonia/contract/sync', data.chatRoomID) - sbp('okTurtles.data/set', 'JOINING_GROUP_CHAT', false) } } } @@ -1518,7 +1489,8 @@ sbp('chelonia/defineContract', { }) } }, - 'gi.contracts/group/rotateKeys': (contractID, state) => { + 'gi.contracts/group/rotateKeys': (contractID) => { + const state = sbp('state/vuex/state')[contractID] if (!state._volatile) Vue.set(state, '_volatile', Object.create(null)) if (!state._volatile.pendingKeyRevocations) Vue.set(state._volatile, 'pendingKeyRevocations', Object.create(null)) @@ -1528,8 +1500,8 @@ sbp('chelonia/defineContract', { Vue.set(state._volatile.pendingKeyRevocations, CSKid, true) Vue.set(state._volatile.pendingKeyRevocations, CEKid, true) - return sbp('chelonia/queueInvocation', contractID, ['gi.actions/out/rotateKeys', contractID, 'gi.contracts/group', 'pending', 'gi.actions/group/shareNewKeys']).catch(e => { - console.warn(`rotateKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e) + return sbp('gi.actions/out/rotateKeys', contractID, 'gi.contracts/group', 'pending', 'gi.actions/group/shareNewKeys').catch(e => { + console.warn(`rotateKeys: ${e.name} thrown:`, e) }) }, 'gi.contracts/group/revokeGroupKeyAndRotateOurPEK': (groupContractID, disconnectGroup: ?boolean) => { @@ -1537,6 +1509,7 @@ sbp('chelonia/defineContract', { const { identityContractID } = rootState.loggedIn const state = rootState[identityContractID] + if (!state._volatile) Vue.set(state, '_volatile', Object.create(null)) if (!state._volatile.pendingKeyRevocations) Vue.set(state._volatile, 'pendingKeyRevocations', Object.create(null)) const CSKid = findKeyIdByName(state, 'csk') @@ -1558,10 +1531,9 @@ sbp('chelonia/defineContract', { contractName: 'gi.contracts/identity', data: groupCSKids, signingKeyId: CSKid - }]) - .catch(e => { - console.error(`revokeGroupKeyAndRotateOurPEK: ${e.name} thrown during keyDel to ${identityContractID}:`, e) - }) + }]).catch(e => { + console.error(`revokeGroupKeyAndRotateOurPEK: ${e.name} thrown during keyDel to ${identityContractID}:`, e) + }) } sbp('chelonia/queueInvocation', identityContractID, ['chelonia/contract/disconnect', identityContractID, groupContractID]).catch(e => { @@ -1573,7 +1545,8 @@ sbp('chelonia/defineContract', { console.error(`revokeGroupKeyAndRotateOurPEK: ${e.name} thrown during queueEvent to ${identityContractID}:`, e) }) }, - 'gi.contracts/group/removeForeignKeys': (contractID, userID, state) => { + 'gi.contracts/group/removeForeignKeys': (contractID, userID) => { + const state = sbp('state/vuex/state')[contractID] const keyIds = findForeignKeysByContractID(state, userID) if (!keyIds?.length) return @@ -1583,13 +1556,13 @@ sbp('chelonia/defineContract', { if (!CEKid) throw new Error('Missing encryption key') - sbp('chelonia/queueInvocation', contractID, ['chelonia/out/keyDel', { + return sbp('chelonia/out/keyDel', { contractID, contractName: 'gi.contracts/group', data: keyIds, signingKeyId: CSKid - }]).catch(e => { - console.warn(`removeForeignKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e) + }).catch(e => { + console.warn(`removeForeignKeys: ${e.name} error thrown:`, e) }) } } diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index 8ec342cce4..183370b533 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -103,6 +103,11 @@ sbp('chelonia/defineContract', { sideEffect ({ contractID }) { // it only makes sense to call updateLoginStateUponLogin for ourselves if (contractID === sbp('state/vuex/getters').ourIdentityContractId) { + if (sbp('chelonia/contract/isSyncing', contractID)) { + // NOTE: no need to enqueue `updateLoginStateUponLoin` invocation + // while syncing/loggingin because it is called in `gi.actions/identity/login` + return + } // makes sure that updateLoginStateUponLogin gets run after the entire identity // state has been synced, this way we don't end up joining groups we've left, etc. sbp('chelonia/queueInvocation', contractID, ['gi.actions/identity/updateLoginStateUponLogin']) diff --git a/frontend/model/contracts/manifests.json b/frontend/model/contracts/manifests.json index 9cde60e09c..1ea86e0b24 100644 --- a/frontend/model/contracts/manifests.json +++ b/frontend/model/contracts/manifests.json @@ -1,7 +1,7 @@ { "manifests": { - "gi.contracts/chatroom": "21XWnNJr1tLQM1XwNVBUxVCuKADSSJmXVK3DWShAEt6HJeAvD8", - "gi.contracts/group": "21XWnNHZDdLhYw8tYLsEENxoPVTSa3PfAyJLyxk8WGspEiEzJe", - "gi.contracts/identity": "21XWnNSxNrVFTMBCUVcZV3CG833gTbJ9V8ZyX7Fxgmpj9rNy4r" + "gi.contracts/chatroom": "21XWnNRZiCfy6ZM16LGgtxapCB6ZCbEUQ99fCir1xstksZz7Aj", + "gi.contracts/group": "21XWnNExSrzhPmoNNS2GeAgxYFUP4RRZeUAb1pbzSFSEf2nuUC", + "gi.contracts/identity": "21XWnNH65WVfnY3JLjS6gNVhChtzzStjGvxjh4teA3J7sSeQF7" } } diff --git a/frontend/model/contracts/shared/voting/proposals.js b/frontend/model/contracts/shared/voting/proposals.js index cc7968b346..264c71493e 100644 --- a/frontend/model/contracts/shared/voting/proposals.js +++ b/frontend/model/contracts/shared/voting/proposals.js @@ -136,10 +136,11 @@ const proposals: Object = { } const message = { data: messageData, meta, contractID } sbp('gi.contracts/group/removeMember/process', message, state) + archiveProposal({ state, proposalHash, proposal, contractID }) + sbp('gi.contracts/group/pushSideEffect', contractID, ['gi.contracts/group/removeMember/sideEffect', message] ) - archiveProposal({ state, proposalHash, proposal, contractID }) }, [VOTE_AGAINST]: voteAgainst }, diff --git a/frontend/model/state.js b/frontend/model/state.js index 25480a8a89..ba52501de3 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -647,7 +647,7 @@ const getters = { groupUnreadMessages (state, getters) { return (groupID: string) => Object.keys(getters.ourUnreadMessages) .filter(cID => getters.isDirectMessage(cID) || Object.keys(state[groupID]?.chatRooms || {}).includes(cID)) - .map(cID => getters.ourUnreadMessages[cID].messages.length) + .map(cID => getters.ourUnreadMessages[cID].messages?.length || 0) .reduce((sum, n) => sum + n, 0) }, groupIdFromChatRoomId (state, getters) { diff --git a/frontend/views/containers/access/LoginForm.vue b/frontend/views/containers/access/LoginForm.vue index 68e5cbd674..a0a08d5983 100644 --- a/frontend/views/containers/access/LoginForm.vue +++ b/frontend/views/containers/access/LoginForm.vue @@ -122,7 +122,7 @@ export default ({ passwordFn: wrapValueInFunction(this.form.password) }) await finishedLoggingIn - await this.postSubmit() + await this.postSubmit?.() this.$emit('submit-succeeded') requestNotificationPermission() } catch (e) { diff --git a/frontend/views/containers/access/SignupForm.vue b/frontend/views/containers/access/SignupForm.vue index a85a6ec5bb..ef29cb0cf9 100644 --- a/frontend/views/containers/access/SignupForm.vue +++ b/frontend/views/containers/access/SignupForm.vue @@ -130,7 +130,7 @@ export default ({ email: this.form.email, passwordFn: wrapValueInFunction(this.form.password) }) - await this.postSubmit() + await this.postSubmit?.() this.$emit('submit-succeeded') requestNotificationPermission() diff --git a/frontend/views/containers/chatroom/ChatMembersAllModal.vue b/frontend/views/containers/chatroom/ChatMembersAllModal.vue index 864def30bc..2674def7ec 100644 --- a/frontend/views/containers/chatroom/ChatMembersAllModal.vue +++ b/frontend/views/containers/chatroom/ChatMembersAllModal.vue @@ -260,9 +260,9 @@ export default ({ const { creator } = this.chatRoomAttribute if (this.currentGroupState.generalChatRoomId === this.currentChatRoomId) { return false - } else if (this.ourUsername === creator) { - return true } else if (this.ourUsername === username) { + return false + } else if (this.ourUsername === creator) { return true } return false @@ -277,8 +277,10 @@ export default ({ contractID: this.currentGroupId, data: { chatRoomID: this.currentChatRoomId, - member: username, - leavingGroup: false + username + }, + options: { + showKickedBy: this.ourUsername } }) if (undoing) { diff --git a/frontend/views/containers/chatroom/LeaveChannelModal.vue b/frontend/views/containers/chatroom/LeaveChannelModal.vue index 4dcaac9e2d..9334efae0f 100644 --- a/frontend/views/containers/chatroom/LeaveChannelModal.vue +++ b/frontend/views/containers/chatroom/LeaveChannelModal.vue @@ -62,8 +62,7 @@ export default ({ contractID: this.currentGroupId, data: { chatRoomID: this.currentChatRoomId, - member: this.loggedIn.username, - leavingGroup: false + username: this.loggedIn.username } }) } catch (e) { diff --git a/package-lock.json b/package-lock.json index 468d8bc664..a8098e370a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5072,9 +5072,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001468", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001468.tgz", - "integrity": "sha512-zgAo8D5kbOyUcRAgSmgyuvBkjrGk5CGYG5TYgFdpQv+ywcyEpo1LOWoG8YmoflGnh+V+UsNuKYedsoYs0hzV5A==", + "version": "1.0.30001554", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001554.tgz", + "integrity": "sha512-A2E3U//MBwbJVzebddm1YfNp7Nud5Ip+IPn4BozBmn4KqVX7AvluoIDFWjsv5OkGnKUXQVmMSoMKLa3ScCblcQ==", "dev": true, "funding": [ { @@ -5084,6 +5084,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -20775,9 +20779,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001468", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001468.tgz", - "integrity": "sha512-zgAo8D5kbOyUcRAgSmgyuvBkjrGk5CGYG5TYgFdpQv+ywcyEpo1LOWoG8YmoflGnh+V+UsNuKYedsoYs0hzV5A==", + "version": "1.0.30001554", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001554.tgz", + "integrity": "sha512-A2E3U//MBwbJVzebddm1YfNp7Nud5Ip+IPn4BozBmn4KqVX7AvluoIDFWjsv5OkGnKUXQVmMSoMKLa3ScCblcQ==", "dev": true }, "caseless": { diff --git a/shared/domains/chelonia/GIMessage.js b/shared/domains/chelonia/GIMessage.js index dcccebc0bd..190a4395f8 100644 --- a/shared/domains/chelonia/GIMessage.js +++ b/shared/domains/chelonia/GIMessage.js @@ -235,8 +235,8 @@ export class GIMessage { contractID: string | null, originatingContractID?: string, originatingContractHeight?: number, - previousHEAD?: string | null, - height: number, + previousHEAD?: ?string, + height?: ?number, op: GIOpRaw, manifest: string, } @@ -358,25 +358,24 @@ export class GIMessage { } decryptedValue (): any { - const value = this.message() - const data = unwrapMaybeEncryptedData(value) - // Did decryption succeed? (unwrapMaybeEncryptedData will return undefined - // on failure) - if (data?.data) { + try { + const value = this.message() + const data = unwrapMaybeEncryptedData(value) + // Did decryption succeed? (unwrapMaybeEncryptedData will return undefined + // on failure) + if (data?.data) { // The data inside could be signed. In this case, we unwrap that to get // to the inner contents - if (isSignedData(data.data)) { - try { + if (isSignedData(data.data)) { return data.data.valueOf() - } catch { - // Signature verification failed. In this case, we return undefined - return undefined + } else { + return data.data } - } else { - return data.data } + } catch { + // Signature or encryption error + return undefined } - return undefined } head (): Object { return this._head } @@ -407,7 +406,7 @@ export class GIMessage { return `${desc}|${this.hash()} of ${this.contractID()}>` } - isFirstMessage (): boolean { return !this.head().previousHEAD } + isFirstMessage (): boolean { return !this.head().contractID } contractID (): string { return this.head().contractID || this.hash() } diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index f11cd58856..1d8eeed21d 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -32,6 +32,8 @@ export type ChelRegParams = { keys: (GIKey | EncryptedData)[]; hooks?: { prepublishContract?: (GIMessage) => void; + postpublishContract?: (GIMessage) => void; + preSendCheck?: (GIMessage, Object) => void; prepublish?: (GIMessage) => void; postpublish?: (GIMessage) => void; }; @@ -332,12 +334,15 @@ export default (sbp('sbp/selectors/register', { const op = (operation !== '*') ? [operation] : operation return !!findSuitableSecretKeyId(contractIDOrState, op, ['sig']) }, - 'chelonia/contract/currentKeyIdByName': function (contractIDOrState: string | Object, name: string) { + 'chelonia/contract/currentKeyIdByName': function (contractIDOrState: string | Object, name: string, requireSecretKey?: boolean) { if (typeof contractIDOrState === 'string') { const rootState = sbp(this.config.stateSelector) contractIDOrState = rootState[contractIDOrState] } const currentKeyId = findKeyIdByName(contractIDOrState, name) + if (requireSecretKey && !sbp('chelonia/haveSecretKey', currentKeyId)) { + return + } return currentKeyId }, 'chelonia/contract/historicalKeyIdsByName': function (contractIDOrState: string | Object, name: string) { @@ -687,17 +692,17 @@ export default (sbp('sbp/selectors/register', { }: GIOpContract) const contractMsg = GIMessage.createV1_0({ contractID: null, - previousHEAD: null, - height: 0, op: [ GIMessage.OP_CONTRACT, signedOutgoingDataWithRawKey(signingKey, payload) ], manifest: manifestHash }) - hooks?.prepublishContract?.(contractMsg) const contractID = contractMsg.hash() - await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions) + await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions, hooks && { + prepublish: hooks.prepublishContract, + postpublish: hooks.postpublishContract + }) console.log('Register contract, sending action', { params, xx: { @@ -741,8 +746,6 @@ export default (sbp('sbp/selectors/register', { throw new Error('Contract name not found') } - const { HEAD: previousHEAD, height: previousHeight } = atomic ? { HEAD: contractID, height: 0 } : await sbp('chelonia/private/out/latestHEADinfo', contractID) - const payload = (data: GIOpKeyShare) if (!params.signingKeyId && !params.signingKey) { @@ -753,8 +756,6 @@ export default (sbp('sbp/selectors/register', { let msg = GIMessage.createV1_0({ contractID: contractID, originatingContractID, - previousHEAD, - height: previousHeight + 1, op: [ GIMessage.OP_KEY_SHARE, params.signingKeyId @@ -765,9 +766,7 @@ export default (sbp('sbp/selectors/register', { manifest: destinationManifestHash }) if (!atomic) { - hooks?.prepublish?.(msg) - msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions) - hooks?.postpublish?.(msg) + msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions, hooks) } return msg }, @@ -782,7 +781,6 @@ export default (sbp('sbp/selectors/register', { } const state = contract.state(contractID) - const { HEAD: previousHEAD, height: previousHeight } = atomic ? { HEAD: contractID, height: 0 } : await sbp('chelonia/private/out/latestHEADinfo', contractID) const payload = (data: GIOpKeyAdd).filter((wk) => { const k = (((isEncryptedData(wk) ? wk.valueOf() : wk): any): GIKey) if (has(state._vm.authorizedKeys, k.id)) { @@ -799,8 +797,6 @@ export default (sbp('sbp/selectors/register', { validateKeyAddPermissions(contractID, state._vm.authorizedKeys[params.signingKeyId], state, payload) let msg = GIMessage.createV1_0({ contractID, - previousHEAD, - height: previousHeight + 1, op: [ GIMessage.OP_KEY_ADD, signedOutgoingData(state, params.signingKeyId, payload, this.transientSecretKeys) @@ -808,9 +804,7 @@ export default (sbp('sbp/selectors/register', { manifest: manifestHash }) if (!atomic) { - hooks?.prepublish?.(msg) - msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions) - hooks?.postpublish?.(msg) + msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions, hooks) } return msg }, @@ -822,7 +816,6 @@ export default (sbp('sbp/selectors/register', { throw new Error('Contract name not found') } const state = contract.state(contractID) - const { HEAD: previousHEAD, height: previousHeight } = atomic ? { HEAD: contractID, height: 0 } : await sbp('chelonia/private/out/latestHEADinfo', contractID) const payload = (data: GIOpKeyDel).map((keyId) => { if (isEncryptedData(keyId)) return keyId // $FlowFixMe @@ -836,8 +829,6 @@ export default (sbp('sbp/selectors/register', { validateKeyDelPermissions(contractID, state._vm.authorizedKeys[params.signingKeyId], state, (payload: any)) let msg = GIMessage.createV1_0({ contractID, - previousHEAD, - height: previousHeight + 1, op: [ GIMessage.OP_KEY_DEL, signedOutgoingData(state, params.signingKeyId, (payload: any), this.transientSecretKeys) @@ -845,9 +836,7 @@ export default (sbp('sbp/selectors/register', { manifest: manifestHash }) if (!atomic) { - hooks?.prepublish?.(msg) - msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions) - hooks?.postpublish?.(msg) + msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions, hooks) } return msg }, @@ -859,7 +848,6 @@ export default (sbp('sbp/selectors/register', { throw new Error('Contract name not found') } const state = contract.state(contractID) - const { HEAD: previousHEAD, height: previousHeight } = atomic ? { HEAD: contractID, height: 0 } : await sbp('chelonia/private/out/latestHEADinfo', contractID) const payload = (data: GIOpKeyUpdate).map((key) => { if (isEncryptedData(key)) return key // $FlowFixMe @@ -873,8 +861,6 @@ export default (sbp('sbp/selectors/register', { validateKeyUpdatePermissions(contractID, state._vm.authorizedKeys[params.signingKeyId], state, (payload: any)) let msg = GIMessage.createV1_0({ contractID, - previousHEAD, - height: previousHeight + 1, op: [ GIMessage.OP_KEY_UPDATE, signedOutgoingData(state, params.signingKeyId, (payload: any), this.transientSecretKeys) @@ -882,9 +868,7 @@ export default (sbp('sbp/selectors/register', { manifest: manifestHash }) if (!atomic) { - hooks?.prepublish?.(msg) - msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions) - hooks?.postpublish?.(msg) + msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions, hooks) } return msg }, @@ -901,7 +885,6 @@ export default (sbp('sbp/selectors/register', { const state = rootState[contractID] if (!rootState[contractID]) this.config.reactiveSet(rootState, contractID, state) const originatingState = originatingContract.state(originatingContractID) - const { HEAD: previousHEAD, height: previousHeight } = await sbp('chelonia/private/out/latestHEADinfo', contractID) const keyRequestReplyKey = keygen(EDWARDS25519SHA512BATCH) const keyRequestReplyKeyId = keyId(keyRequestReplyKey) @@ -949,8 +932,6 @@ export default (sbp('sbp/selectors/register', { }: GIOpKeyRequest) let msg = GIMessage.createV1_0({ contractID, - previousHEAD, - height: previousHeight + 1, op: [ GIMessage.OP_KEY_REQUEST, signedOutgoingData(state, params.signingKeyId, @@ -961,9 +942,7 @@ export default (sbp('sbp/selectors/register', { ], manifest: manifestHash }) - hooks?.prepublish?.(msg) - msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions) - hooks?.postpublish?.(msg) + msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions, hooks) return msg }, 'chelonia/out/keyRequestResponse': async function (params: ChelKeyRequestResponseParams): Promise { @@ -974,12 +953,9 @@ export default (sbp('sbp/selectors/register', { throw new Error('Contract name not found') } const state = contract.state(contractID) - const { HEAD: previousHEAD, height: previousHeight } = atomic ? { HEAD: contractID, height: 0 } : await sbp('chelonia/private/out/latestHEADinfo', contractID) const payload = (data: GIOpKeyRequestSeen) let message = GIMessage.createV1_0({ contractID, - previousHEAD, - height: previousHeight + 1, op: [ GIMessage.OP_KEY_REQUEST_SEEN, signedOutgoingData(state, params.signingKeyId, payload, this.transientSecretKeys) @@ -987,9 +963,7 @@ export default (sbp('sbp/selectors/register', { manifest: manifestHash }) if (!atomic) { - hooks?.prepublish?.(message) - message = await sbp('chelonia/private/out/publishEvent', message, publishOptions) - hooks?.postpublish?.(message) + message = await sbp('chelonia/private/out/publishEvent', message, publishOptions, hooks) } return message }, @@ -1001,7 +975,6 @@ export default (sbp('sbp/selectors/register', { throw new Error('Contract name not found') } const state = contract.state(contractID) - const { HEAD: previousHEAD, height: previousHeight } = await sbp('chelonia/private/out/latestHEADinfo', contractID) const payload = (await Promise.all(data.map(([selector, opParams]) => { if (!['chelonia/out/actionEncrypted', 'chelonia/out/actionUnencrypted', 'chelonia/out/keyAdd', 'chelonia/out/keyDel', 'chelonia/out/keyUpdate', 'chelonia/out/keyRequestResponse', 'chelonia/out/keyShare'].includes(selector)) { throw new Error('Selector not allowed in OP_ATOMIC: ' + selector) @@ -1012,17 +985,13 @@ export default (sbp('sbp/selectors/register', { }) let msg = GIMessage.createV1_0({ contractID, - previousHEAD, - height: previousHeight + 1, op: [ GIMessage.OP_ATOMIC, signedOutgoingData(state, params.signingKeyId, (payload: any), this.transientSecretKeys) ], manifest: manifestHash }) - hooks?.prepublish?.(msg) - msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions) - hooks?.postpublish?.(msg) + msg = await sbp('chelonia/private/out/publishEvent', msg, publishOptions, hooks) return msg }, 'chelonia/out/protocolUpgrade': async function () { @@ -1052,7 +1021,6 @@ async function outEncryptedOrUnencryptedAction ( const manifestHash = this.config.contracts.manifests[contractName] const { contract } = this.manifestToContract[manifestHash] const state = contract.state(contractID) - const { HEAD: previousHEAD, height: previousHeight } = atomic ? { HEAD: contractID, height: 0 } : await sbp('chelonia/private/out/latestHEADinfo', contractID) const meta = await contract.metadata.create() const gProxy = gettersProxy(state, contract.getters) contract.metadata.validate(meta, { state, ...gProxy, contractID }) @@ -1071,8 +1039,6 @@ async function outEncryptedOrUnencryptedAction ( : encryptedOutgoingData(state, ((params.encryptionKeyId: any): string), signedMessage) let message = GIMessage.createV1_0({ contractID, - previousHEAD, - height: previousHeight + 1, op: [ opType, signedOutgoingData(state, params.signingKeyId, (payload: any), this.transientSecretKeys) @@ -1080,9 +1046,7 @@ async function outEncryptedOrUnencryptedAction ( manifest: manifestHash }) if (!atomic) { - hooks?.prepublish?.(message) - message = await sbp('chelonia/private/out/publishEvent', message, publishOptions) - hooks?.postpublish?.(message) + message = await sbp('chelonia/private/out/publishEvent', message, publishOptions, hooks) } return message } diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 30b3bb1562..f28db86933 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -160,6 +160,7 @@ export default (sbp('sbp/selectors/register', { if (allowedSels[selector] || allowedDoms[domain]) { return sbp(selector, ...args) } else { + console.error('[chelonia] selector not on allowlist', { selector, allowedSels, allowedDoms }) throw new Error(`[chelonia] selector not on allowlist: '${selector}'`) } } @@ -254,11 +255,43 @@ export default (sbp('sbp/selectors/register', { }, // used by, e.g. 'chelonia/contract/wait' 'chelonia/private/noop': function () {}, - 'chelonia/private/out/publishEvent': async function (entry: GIMessage, { maxAttempts = 5 } = {}) { + 'chelonia/private/out/publishEvent': async function (entry: GIMessage, { maxAttempts = 5 } = {}, hooks) { let attempt = 1 + let lastAttemptedHeight // auto resend after short random delay // https://github.com/okTurtles/group-income/issues/608 + hooks?.prepublish?.(entry) + while (true) { + // Queued event to ensure that we send the event with whatever the + // 'latest' state may be for that contract (in case we were receiving + // something over the web socket) + // This also ensures that the state doesn't change while reading it + lastAttemptedHeight = entry.height() + entry = await sbp('okTurtles.eventQueue/queueEvent', entry.contractID(), () => { + const rootState = sbp(this.config.stateSelector) + const state = rootState[entry.contractID()] + + if (hooks?.preSendCheck) { + if (!hooks.preSendCheck(entry, state)) { + console.info(`[chelonia] Not sending message as preSendCheck hook returned non-truish value: ${entry.description()}`) + return + } + } + + // if this isn't OP_CONTRACT, recreate and resend message + // We always call recreateEvent because we may have received new events + // in the web socket + if (!entry.isFirstMessage()) { + return recreateEvent(entry, state) + } + + return entry + }) + + // If there is no event to send, return + if (!entry) return + const r = await fetch(`${this.config.connectionURL}/event`, { method: 'POST', body: entry.serialize(), @@ -268,10 +301,11 @@ export default (sbp('sbp/selectors/register', { } }) if (r.ok) { + hooks?.postpublish?.(entry) return entry } if (r.status === 409) { - if (attempt + 1 > maxAttempts) { + if (attempt >= maxAttempts) { console.error(`[chelonia] failed to publish ${entry.description()} after ${attempt} attempts`, entry) throw new Error(`publishEvent: ${r.status} - ${r.statusText}. attempt ${attempt}`) } @@ -280,14 +314,12 @@ export default (sbp('sbp/selectors/register', { console.warn(`[chelonia] publish attempt ${attempt} of ${maxAttempts} failed. Waiting ${randDelay} msec before resending ${entry.description()}`) attempt += 1 await delay(randDelay) // wait randDelay ms before sending it again - // if this isn't OP_CONTRACT, recreate and resend message - if (!entry.isFirstMessage()) { - const rootState = sbp(this.config.stateSelector) - const newEntry = await recreateEvent(entry, rootState) - if (!newEntry) { - return - } - entry = newEntry + + // TODO: The [pubsub] code seems to miss events that happened between + // a call to sync and the subscription time. This is a temporary measure + // to handle this until [pubsub] is updated. + if (entry.height() === lastAttemptedHeight) { + await sbp('okTurtles.eventQueue/queueEvent', entry.contractID(), ['chelonia/private/in/syncContract', entry.contractID()]) } } else { const message = (await r.json())?.message @@ -463,11 +495,11 @@ export default (sbp('sbp/selectors/register', { await sbp('okTurtles.eventQueue/queueEvent', v.contractID, [ 'chelonia/begin', ['chelonia/contract/removeImmediately', v.contractID], - ['chelonia/private/in/syncContract', v.contractID], + // ['chelonia/private/in/syncContract', v.contractID], ['okTurtles.events/emit', CONTRACT_HAS_RECEIVED_KEYS, { contractID: v.contractID }] ]) - targetState = cheloniaState[v.contractID] + targetState = cheloniaState[v.contractID] || {} if (previousVolatileState && has(previousVolatileState, 'watch')) { if (!targetState._volatile) config.reactiveSet(targetState, '_volatile', Object.create(null)) @@ -880,6 +912,15 @@ export default (sbp('sbp/selectors/register', { }) }, 'chelonia/private/in/syncContractAndWatchKeys': async function (contractID: string, externalContractID: string) { + const pendingInvocations = sbp('okTurtles.eventQueue/queuedInvocations', contractID) + if (pendingInvocations.length > 1) { + console.log('[syncContractAndWatchKeys]: Moving syncContractAndWatchKeys at the end of the invocation queue') + sbp('okTurtles.eventQueue/queueEvent', contractID, ['chelonia/private/in/syncContractAndWatchKeys', contractID, externalContractID]).catch((e) => { + console.error(`Error at syncContractAndWatchKeys for contractID ${contractID} and externalContractID ${externalContractID}`, e) + }) + return + } + const rootState = sbp(this.config.stateSelector) const externalContractState = rootState[externalContractID] const pendingWatch = externalContractState?._vm?.pendingWatch?.[contractID]?.splice(0) @@ -904,7 +945,9 @@ export default (sbp('sbp/selectors/register', { return } - await sbp('chelonia/private/in/syncContract', contractID) + if (!has(rootState, contractID)) { + await sbp('chelonia/private/in/syncContract', contractID) + } const contractState = rootState[contractID] const keysToDelete = [] @@ -955,15 +998,18 @@ export default (sbp('sbp/selectors/register', { const keysToDelete = Object.entries(pendingKeyRevocations).filter(([, v]) => v === 'del').map(([id]) => id) // Aggregate the keys that we can delete to send them in a single operation - const [, signingKeyId, keyIds] = keysToDelete.reduce((acc, cv) => { + const [, signingKeyId, keyIds] = keysToDelete.reduce((acc, keyId) => { const [currentRingLevel, currentSigningKeyId, currentKeyIds] = acc - const ringLevel = Math.min(currentRingLevel, contractState._vm?.authorizedKeys?.[keyId].ringLevel) + const contractRingLevel = contractState._vm?.authorizedKeys?.[keyId]?.ringLevel ?? Number.POSITIVE_INFINITY + const ringLevel = Math.min(currentRingLevel, contractRingLevel) if (ringLevel >= currentRingLevel) { - return [currentRingLevel, currentSigningKeyId, (currentKeyIds: any).push(cv)] + (currentKeyIds: any).push(keyId) + return [currentRingLevel, currentSigningKeyId, currentKeyIds] } else if (Number.isFinite(ringLevel)) { const signingKeyId = findSuitableSecretKeyId(contractState, [GIMessage.OP_KEY_DEL], ['sig'], ringLevel) if (signingKeyId) { - return [ringLevel, signingKeyId, (currentKeyIds: any).push(cv)] + (currentKeyIds: any).push(keyId) + return [ringLevel, signingKeyId, currentKeyIds] } } return acc @@ -974,8 +1020,8 @@ export default (sbp('sbp/selectors/register', { const contractName = contractState._vm.type // This is safe to do without await because it's sending an operation - // Using await could deadlock when retying to send the message - sbp('chelonia/out/keyDel', { contractID, contractName: contractName, data: keyIds, signingKeyId }) + // Using await could deadlock when retrying to send the message + sbp('chelonia/out/keyDel', { contractID, contractName, data: keyIds, signingKeyId }) }, 'chelonia/private/respondToAllKeyRequests': function (contractID: string) { const state = sbp(this.config.stateSelector) @@ -1235,16 +1281,13 @@ export default (sbp('sbp/selectors/register', { if (!processingErrored && !state[contractID]?._volatile?.dirty) { // Gets run get when skipSideEffects is false if (Array.isArray(internalSideEffectStack) && internalSideEffectStack.length > 0) { - // We don't await on internal side-effects as it may cause deadlocks - internalSideEffectStack.forEach(fn => sbp('okTurtles.eventQueue/queueEvent', `sideEffect:${contractID}`, fn).catch((e) => { + await Promise.all(internalSideEffectStack.map(fn => Promise.resolve(fn()).catch((e) => { console.error(`[chelonia] ERROR '${e.name}' in internal side effect for ${message.description()}: ${e.message}`, e, { message: message.serialize() }) - })) + }))) } - // We don't await on side-effects as it may deadlock if the side-effect - // ends up putting things in the queue if (!this.config.skipActionProcessing && !this.config.skipSideEffects) { - Promise.resolve().then(() => handleEvent.processSideEffects.call(this, message, state[contractID])).catch((e) => { + await handleEvent.processSideEffects.call(this, message, state[contractID])?.catch((e) => { console.error(`[chelonia] ERROR '${e.name}' in sideEffect for ${message.description()}: ${e.message}`, e, { message: message.serialize() }) // We used to revert the state and rethrow the error here, but we no longer do that // see this issue for why: https://github.com/okTurtles/group-income/issues/1544 @@ -1327,64 +1370,66 @@ const handleEvent = { }, processSideEffects (message: GIMessage, state: Object) { const opT = message.opType() - if ([GIMessage.OP_ATOMIC, GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ACTION_UNENCRYPTED].includes(opT)) { - const contractID = message.contractID() - const manifestHash = message.manifest() - const hash = message.hash() - const id = message.id() - const signingKeyId = message.signingKeyId() - - const callSideEffect = (field) => { - let v = field.valueOf() - let innerSigningKeyId: string | typeof undefined - if (isSignedData(v)) { - innerSigningKeyId = (v: any).signingKeyId - v = (v: any).valueOf() - } + if (![GIMessage.OP_ATOMIC, GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ACTION_UNENCRYPTED].includes(opT)) { + return + } - const { action, data, meta } = (v: any) - const mutation = { - data, - meta, - hash, - id, - contractID, - description: message.description(), - direction: message.direction(), - signingKeyId, - get signingContractID () { - return getContractIDfromKeyId(contractID, signingKeyId, state) - }, - innerSigningKeyId, - get innerSigningContractID () { - return getContractIDfromKeyId(contractID, innerSigningKeyId, state) - } - } - return sbp('okTurtles.eventQueue/queueEvent', `sideEffect:${contractID}`, [`${manifestHash}/${action}/sideEffect`, mutation]) - } - const msg = Object(message.message()) + const contractID = message.contractID() + const manifestHash = message.manifest() + const hash = message.hash() + const id = message.id() + const signingKeyId = message.signingKeyId() - if (opT !== GIMessage.OP_ATOMIC) { - return callSideEffect(msg) + const callSideEffect = (field) => { + let v = field.valueOf() + let innerSigningKeyId: string | typeof undefined + if (isSignedData(v)) { + innerSigningKeyId = (v: any).signingKeyId + v = (v: any).valueOf() } - const reducer = (acc, [opT, opV]) => { - if ([GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ACTION_UNENCRYPTED].includes(opT)) { - acc.push(Object(opV)) + const { action, data, meta } = (v: any) + const mutation = { + data, + meta, + hash, + id, + contractID, + description: message.description(), + direction: message.direction(), + signingKeyId, + get signingContractID () { + return getContractIDfromKeyId(contractID, signingKeyId, state) + }, + innerSigningKeyId, + get innerSigningContractID () { + return getContractIDfromKeyId(contractID, innerSigningKeyId, state) } - return acc } + return Promise.resolve(sbp(`${manifestHash}/${action}/sideEffect`, mutation)) + } + const msg = Object(message.message()) - const actionsOpV = ((msg: any): GIOpAtomic).reduce(reducer, []) + if (opT !== GIMessage.OP_ATOMIC) { + return callSideEffect(msg) + } - return Promise.allSettled(actionsOpV.map((action) => callSideEffect(action))).then((results) => { - const errors = results.filter((r) => r.status === 'rejected').map((r) => (r: any).reason) - if (errors.length > 0) { - // $FlowFixMe[cannot-resolve-name] - throw new AggregateError(errors, `Error at side effects for ${contractID}`) - } - }) + const reducer = (acc, [opT, opV]) => { + if ([GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ACTION_UNENCRYPTED].includes(opT)) { + acc.push(Object(opV)) + } + return acc } + + const actionsOpV = ((msg: any): GIOpAtomic).reduce(reducer, []) + + return Promise.allSettled(actionsOpV.map((action) => callSideEffect(action))).then((results) => { + const errors = results.filter((r) => r.status === 'rejected').map((r) => (r: any).reason) + if (errors.length > 0) { + // $FlowFixMe[cannot-resolve-name] + throw new AggregateError(errors, `Error at side effects for ${contractID}`) + } + }) }, revertProcess ({ message, state, contractID, contractStateCopy }) { console.warn(`[chelonia] reverting mutation ${message.description()}: ${message.serialize()}. Any side effects will be skipped!`) diff --git a/shared/domains/chelonia/signedData.js b/shared/domains/chelonia/signedData.js index 485f524c35..c3577668a3 100644 --- a/shared/domains/chelonia/signedData.js +++ b/shared/domains/chelonia/signedData.js @@ -41,6 +41,7 @@ const signData = function (sKeyId: string, data: any, extraFields: Object, addit if (!additionalData) { throw new ChelErrorSignatureError('Signature additional data must be provided') } + // console.debug('@@@@SIGNING_DATA [11]', JSON.parse(JSON.stringify({ state: this, sKeyId, data }))) // Has the key been revoked? If so, attempt to find an authorized key by the same name // $FlowFixMe const designatedKey = this._vm?.authorizedKeys?.[sKeyId] @@ -49,10 +50,10 @@ const signData = function (sKeyId: string, data: any, extraFields: Object, addit )) { throw new ChelErrorSignatureError(`Signing key ID ${sKeyId} is missing or is missing signing purpose`) } - if (designatedKey._notAfterHeight !== undefined) { + if (designatedKey._notAfterHeight != null) { const name = (this._vm: any).authorizedKeys[sKeyId].name console.log({ state: this }) - const newKeyId = (Object.values(this._vm?.authorizedKeys).find((v: any) => v._notAfterHeight === undefined && v.name === name && v.purpose.includes('sig')): any)?.id + const newKeyId = (Object.values(this._vm?.authorizedKeys).find((v: any) => v._notAfterHeight != null && v.name === name && v.purpose.includes('sig')): any)?.id if (!newKeyId) { throw new ChelErrorSignatureError(`Signing key ID ${sKeyId} has been revoked and no new key exists by the same name (${name})`) diff --git a/shared/domains/chelonia/utils.js b/shared/domains/chelonia/utils.js index 2dc1f83d7e..229e630da2 100644 --- a/shared/domains/chelonia/utils.js +++ b/shared/domains/chelonia/utils.js @@ -400,30 +400,15 @@ export const subscribeToForeignKeyContracts = function (contractID: string, stat // duplicate operations. For operations involving keys, the payload will be // rewritten to eliminate no-longer-relevant keys. In most cases, this would // result in an empty payload, in which case the message is omitted entirely. -export const recreateEvent = async (entry: GIMessage, rootState: Object): Promise => { +export const recreateEvent = async (entry: GIMessage, state: Object): Promise => { const contractID = entry.contractID() - // We sync the contract to ensure we have the correct local state - // This way we can fetch new keys and correctly re-sign and re-encrypt - // messages as needed. This also ensures that we can rewrite (or omit) the - // payload to remove irrelevant parts. - // Note that in cases of high contention sync may fail to retrieve the latest - // state. In such cases, the operation (publishEvent) will fail after the - // maximum number of attempts is exhausted. - // Note also that this assumes (and requires) that we are subscribed to a - // contract before being able to write to it. This is because we rely on the - // contract state to identify the current keys (in _vm.authorizedKeys) which - // are used for signatures and for encryption. - // When recreateEvent is called we may already be in a queued event, so we - // call syncContract directly instead of sync - await sbp('chelonia/contract/sync', contractID, { force: true }) - const { HEAD: previousHEAD, height: previousHeight } = await sbp('chelonia/queueInvocation', contractID, ['chelonia/db/latestHEADinfo', contractID]) || {} + const { HEAD: previousHEAD, height: previousHeight } = await sbp('chelonia/db/latestHEADinfo', contractID) || {} if (!previousHEAD) { throw new Error('recreateEvent: Giving up because the contract has been removed') } const head = entry.head() const [opT, rawOpV] = entry.rawOp() - const state = rootState[contractID] const recreateOperation = (opT: string, rawOpV: SignedData) => { const opV = rawOpV.valueOf() @@ -468,11 +453,13 @@ export const recreateEvent = async (entry: GIMessage, rootState: Object): Promis } } else if (opT === GIMessage.OP_ATOMIC) { if (!Array.isArray(opV)) throw new Error('Invalid message format') - newOpV = ((((opV: any): GIOpAtomic).map(([t, v]) => recreateOperationInternal(t, v)).filter(Boolean): any): GIOpAtomic) + newOpV = ((((opV: any): GIOpAtomic).map(([t, v]) => [t, recreateOperationInternal(t, v)]).filter(([, v]) => !!v): any): GIOpAtomic) if (newOpV.length === 0) { console.info('Omitting empty OP_ATOMIC', { head }) } else if (newOpV.length === opV.length && newOpV.reduce((acc, cv, i) => acc && cv === opV[i], true)) { return opV + } else { + return newOpV } } else { return opV @@ -498,12 +485,13 @@ export const recreateEvent = async (entry: GIMessage, rootState: Object): Promis if (!newRawOpV) return + const newOp = opT === GIMessage.OP_ATOMIC && (newRawOpV: any).length === 1 + ? (newRawOpV: any)[0] + : [opT, newRawOpV] + entry = GIMessage.cloneWith( - head, - [ - opT, - (newRawOpV: any) - ], { previousHEAD, height: previousHeight + 1 }) + head, newOp, { previousHEAD, height: previousHeight + 1 } + ) return entry }