fix(groups): refuse a welcome for a group another profile on this device holds, and say so in the room
Phase 6 of docs/curated-to-mantra-profiles.md, the fourth decision's follow-up, written natively before the several-profiles line reaches users. ChatRoom is keyed by the group id alone, and the MLS state, the DKG sessions and the FROST shares all hang off the room; before several profiles on a device the second local member could not exist, and after it a user can invite their own second profile into their own collective. What the DAO did with that Welcome was already a refusal, by accident: the welcome branch of indexNostrEvent checks for an existing room before it writes one, so the joiner's state never overwrote the holder's -- but the branch could not tell a redelivered Welcome for our own room from a Welcome for a room another profile holds, logged "Chat Room already exists" at warning level for both, and told nobody. The invitee never joined, the inviter's device believed the invite had landed, and the failure had the exact shape of "the other device never got it". The branch now distinguishes the two by the room's userPublicKey. The same profile's redelivery stays the quiet no-op it was. Another profile's Welcome is refused with a warning naming both keys, and a membership line is written into the room that exists -- the holder's, because it is the only room on the device the group has and the holder is the one who can act on it: leave, or tell the inviter which profile to invite instead. The line is TYPE_WELCOME_REFUSED_OTHER_PROFILE, in MEMBERSHIP_TYPES so the transcript draws it as a notice rather than a bubble, with the error tint and a PersonOff icon since the person and not the invite is what it is about; its content is a whole sentence naming the inviter, the invitee and the holder, resolved at write time the way the other membership lines are. The invitee's key package bundle is left unconsumed, as the branch always left it; a second Welcome for the same package arrives here again and writes a second line. The test is two devices for real: an MLS group on the inviter's database, a key package whose private keys the invitee's database holds -- the fixture now returns the bundle beside the public package, and marmotKeyPackageFor is a projection of it -- and the Welcome the invite queued, sealed the way sealGiftWrapPayload seals one and stored through storeNostrEvent under the invitee's key. Three cases: the group held by another profile is refused, its row and MLS state untouched, the line in the room naming all three and the bundle unconsumed; the same profile welcomed twice writes nothing; and with no profile in the way the Welcome is a join, which is what shows the fixture is real. jvmTest 868 -> 871, both compilers clean, every audit budget met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import press.mantra.compose.exceptions.MarmotMissingKeyPackageBundleException
|
||||
import press.mantra.compose.exceptions.MarmotMissingNostrGroupDataExtension
|
||||
import press.mantra.compose.exceptions.MarmotNotMemberOfChatGroupException
|
||||
import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException
|
||||
import press.mantra.compose.extensions.shortened
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.nostr.Relays
|
||||
@@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -915,6 +917,23 @@ abstract class NostrDao(
|
||||
// content = "Coming soon.", // TODO: Figure out what to do here...
|
||||
// )
|
||||
// )
|
||||
} else if (localChatRoom.chatRoom.userPublicKey != activeKeyPair.pubKey.toHex()) {
|
||||
// Another profile on this device already holds this
|
||||
// group. ChatRoom is keyed by the group id alone, and
|
||||
// every table that hangs off it -- the MLS state, the
|
||||
// DKG sessions, the FROST shares -- is keyed by the
|
||||
// room, so letting a second local member in would
|
||||
// overwrite the first's state with the joiner's and
|
||||
// break both. Refuse, and say so where the group is:
|
||||
// in the room, under the profile that holds it. See
|
||||
// docs/curated-to-mantra-profiles.md, the fourth
|
||||
// decision.
|
||||
refuseWelcomeForOtherProfile(
|
||||
localChatRoom = localChatRoom,
|
||||
inviteePublicKey = activeKeyPair.pubKey.toHex(),
|
||||
inviterPublicKey = decryptedGiftWrapPayload.publicKey,
|
||||
welcomePayloadId = decryptedGiftWrapPayload.id,
|
||||
)
|
||||
} else {
|
||||
logger.w("Chat Room already exists for $nostrGroupId")
|
||||
}
|
||||
@@ -1652,6 +1671,53 @@ abstract class NostrDao(
|
||||
* room needs. Creating a group locally sends nothing to anyone, which is why the
|
||||
* first arrival for a group is just as likely to be a key ceremony as a message.
|
||||
*/
|
||||
/**
|
||||
* A Welcome for a group this device already holds under another of its profiles,
|
||||
* refused, and the refusal written into the room as a membership line.
|
||||
*
|
||||
* The line goes into the room that exists -- the other profile's -- because that is
|
||||
* the only place on this device the group has, and the profile that holds it is the
|
||||
* one who can do something about it: leave, or tell the inviter which profile to
|
||||
* invite instead. The invitee's key package bundle is left unconsumed, as the
|
||||
* "already exists" branch has always left it; nothing here changes what a second
|
||||
* Welcome for the same package would do, which is arrive here again and write a
|
||||
* second line.
|
||||
*
|
||||
* Content is a whole sentence with both names written in, the way the other
|
||||
* membership lines are, so nothing prefixes an author to it: this is nobody's words.
|
||||
*/
|
||||
private suspend fun refuseWelcomeForOtherProfile(
|
||||
localChatRoom: LocalChatRoom,
|
||||
inviteePublicKey: HexKey,
|
||||
inviterPublicKey: HexKey,
|
||||
welcomePayloadId: HexKey,
|
||||
) {
|
||||
val holder = localChatRoom.chatRoom.userPublicKey
|
||||
logger.w(
|
||||
"Refusing Welcome $welcomePayloadId for ${localChatRoom.chatRoom.id}: this device already holds the group " +
|
||||
"as $holder, and $inviteePublicKey is another profile on it"
|
||||
)
|
||||
|
||||
suspend fun nameOf(publicKey: HexKey): String =
|
||||
database.profileDao().getProfileByPublicKey(publicKey)?.humanReadableNameOrPubkey()
|
||||
?: publicKey.shortened()
|
||||
|
||||
database.chatMessageDao().upsert(
|
||||
ChatMessage(
|
||||
content = "${nameOf(inviterPublicKey)} invited ${nameOf(inviteePublicKey)}, another profile on this device, " +
|
||||
"to this group. The invitation was not accepted: this device already holds the group as " +
|
||||
"${nameOf(holder)}, and one device cannot hold a group twice.",
|
||||
messageType = ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
senderPublicKey = holder,
|
||||
isUserMessage = true,
|
||||
giftWrapPayloadId = welcomePayloadId,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getOrCreateNip17ChatRoom(
|
||||
decryptedGiftWrapPayload: GiftWrapPayload,
|
||||
activeKeyPair: KeyPair,
|
||||
|
||||
@@ -437,6 +437,20 @@ data class ChatMessage(
|
||||
const val TYPE_MEMBER_INVITE_SENT = "memberInviteSent"
|
||||
const val TYPE_MEMBER_INVITE_FAILED = "memberInviteFailed"
|
||||
|
||||
/**
|
||||
* A Welcome that arrived for another profile on this device, for a group this
|
||||
* device already holds, and was refused.
|
||||
*
|
||||
* Written by the invitee's side, into the room the other profile holds, since
|
||||
* that is the only room on the device the group has. ChatRoom is keyed by the
|
||||
* group id alone and everything that hangs off it -- MLS state, ceremonies,
|
||||
* shares -- by the room, so a second local member would overwrite the first.
|
||||
* The inviter's device sees nothing of this and believes the invite landed,
|
||||
* which is why the line names both profiles: the one who was invited and the
|
||||
* one who holds the group. See docs/curated-to-mantra-profiles.md.
|
||||
*/
|
||||
const val TYPE_WELCOME_REFUSED_OTHER_PROFILE = "welcomeRefusedOtherProfile"
|
||||
|
||||
/**
|
||||
* Every membership line, for the one check the transcript dispatches on.
|
||||
*
|
||||
@@ -447,6 +461,7 @@ data class ChatMessage(
|
||||
TYPE_MEMBER_INVITED,
|
||||
TYPE_MEMBER_INVITE_SENT,
|
||||
TYPE_MEMBER_INVITE_FAILED,
|
||||
TYPE_WELCOME_REFUSED_OTHER_PROFILE,
|
||||
)
|
||||
|
||||
const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer"
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PanTool
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.PersonOff
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material.icons.filled.WorkspacePremium
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
@@ -815,6 +816,9 @@ private fun RitualNotice(
|
||||
ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd
|
||||
ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline
|
||||
// Somebody on this device was kept out of a group it already holds; the
|
||||
// person, not the invite, is what the line is about.
|
||||
ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE -> Icons.Default.PersonOff
|
||||
|
||||
else -> Icons.Default.PanTool
|
||||
}
|
||||
@@ -835,7 +839,8 @@ private fun RitualNotice(
|
||||
val tint = when {
|
||||
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED ->
|
||||
chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED ||
|
||||
chatMessage.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE ->
|
||||
MaterialTheme.colorScheme.error
|
||||
isRequest -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Reference in New Issue
Block a user