diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 4eacf017..9d05d9e2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -324,19 +324,30 @@ class DatabaseChatRepository( giftWrapPayload: GiftWrapPayload, nostrSignerSync: NostrSignerSync ) { - // An MLS room must never produce a NIP-17 gift wrap. Its messages already travel - // inside kind:445, and its direct messages are real, correctly signed NIP-59 wraps - // -- indistinguishable from something this path would be right to publish. The - // only thing keeping one off a relay is that it never becomes a GiftWrapPayload, - // so refuse here too rather than trusting every future caller to know that. - // See docs/marmot-direct-messages.md. - val chatRoom = database.chatRoomDao().findChatRoomById(giftWrapPayload.chatRoomId) - if (chatRoom?.chatRoom?.mlsGroupState != null) { - logger.e( - "Refusing to seal payload ${giftWrapPayload.id}: ${giftWrapPayload.chatRoomId} is an MLS room, " + - "and sealing would broadcast it to relays" - ) - return + // An MLS room must never produce a NIP-17 gift wrap of its *contents*. Its messages + // already travel inside kind:445, and its direct messages are real, correctly signed + // NIP-59 wraps -- indistinguishable from something this path would be right to + // publish. The only thing keeping one off a relay is that it never becomes a + // GiftWrapPayload, so refuse here too rather than trusting every future caller to + // know that. See docs/marmot-direct-messages.md. + // + // The Welcome is the one thing an MLS room is *supposed* to publish this way, and + // the reason it reaches this path at all. MIP-02 addresses kind:444 to a joiner who + // holds no group state yet: they cannot read a kind:445, so a relay-borne gift wrap + // is the only way to reach them, and `MarmotOutboundDao.deliveryWelcome` queues one + // here deliberately. Every Welcome carries its room's nostrGroupId, so a refusal + // keyed on `mlsGroupState` alone catches all of them and no invite is ever + // delivered -- and because the unsealed queue is a single-row flow, the refused + // Welcome sits at its head and blocks every payload behind it too. + if (giftWrapPayload.kind != WelcomeEvent.KIND) { + val chatRoom = database.chatRoomDao().findChatRoomById(giftWrapPayload.chatRoomId) + if (chatRoom?.chatRoom?.mlsGroupState != null) { + logger.e( + "Refusing to seal payload ${giftWrapPayload.id}: ${giftWrapPayload.chatRoomId} is an MLS room, " + + "and sealing would broadcast it to relays" + ) + return + } } database.participantDao().findParticipantsByChatRoomId(giftWrapPayload.chatRoomId).forEach { participant -> diff --git a/docs/marmot-direct-messages.md b/docs/marmot-direct-messages.md index 8540c89d..4314ce2d 100644 --- a/docs/marmot-direct-messages.md +++ b/docs/marmot-direct-messages.md @@ -159,9 +159,20 @@ but the tables it is kept out of stops it going to a relay. `GiftWrapMessage` could not be written anyway without a `NostrEvent` row — its foreign key — and `NostrEvent` is the broadcast join target. The rule is also enforced at the other end: `sealGiftWrapPayload` refuses any payload whose room has -a non-null `mlsGroupState`, and logs. An MLS room should never produce a NIP-17 gift -wrap for any reason, and an invariant in code is what stops a later refactor from -walking a direct message onto a relay without reading this page first. +a non-null `mlsGroupState`, and logs. An invariant in code is what stops a later +refactor from walking a direct message onto a relay without reading this page first. + +**The Welcome is exempt, and the exemption is not optional.** MIP-02 addresses +kind:444 to someone who is not yet in the group and holds no key to read a +kind:445 — a relay-borne gift wrap is the only way to reach them, and +`MarmotOutboundDao.deliveryWelcome` writes exactly such a `GiftWrapPayload` row on +purpose. Every Welcome carries its own room's `nostrGroupId`, so a refusal keyed on +`mlsGroupState` alone matches all of them: while it did, no invite to any Marmot +room was ever delivered, and — since the unsealed queue is a single-row flow — the +first refused Welcome sat at its head and blocked every payload queued behind it. +The check is therefore on kind first, room second. When adding a kind to that +exemption, the question to answer is whether its recipient can read a kind:445; if +they can, it does not belong on this path. ## Attribution comes from MLS, not from the payload @@ -301,7 +312,7 @@ sender, `senderIdentity` is the only source of attribution there is. | `managers/MarmotInboundManager.kt` | the kind:1059 carve-out; requires the sender identity | | `database/model/ChatMessage.kt` | `TYPE_DIRECT_MESSAGE`; the kind:1059 arm and its three outcomes | | `database/dao/NostrDao.kt` | resolves `senderIdentity` and passes it in | -| `database/repository/DatabaseChatRepository.kt` | queues the rumor; refuses to seal an MLS room's payload | +| `database/repository/DatabaseChatRepository.kt` | queues the rumor; refuses to seal an MLS room's payload, except a Welcome | | `database/dao/MarmotOutboundDao.kt` | wraps on the way out; scrubs the plaintext | | `database/model/MarmotInnerEvent.kt` | `directMessageRecipientPublicKey`, the outbound signal | | `ui/view/model/ChatMessageListViewModel.kt` | armed state; the two renderings |