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:
Kgothatso Ngako
2026-09-13 16:07:45 +02:00
parent 310d3bbbd3
commit 679d5249b2
5 changed files with 360 additions and 6 deletions

View File

@@ -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,

View File

@@ -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"

View File

@@ -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