Completes the outbound half: a message with a recipient is queued as the
rumor a gift wrap will carry, and the notary wraps it on its way into MLS.
Everything downstream -- MLS encrypt, outer ChaCha20, kind:445,
persistence, broadcast -- is untouched and does not know the difference.
sendChatMessage resolves the recipient against the room before it writes
anything. A recipient outside the room cannot be sent to: the wrap would
be undecryptable by every member including them, while the group still saw
that a private message had gone somewhere. Sending to yourself is refused
for a different reason -- the wrap's key is discarded, so it could never be
read back.
A direct message keeps kind:14 rather than being mapped down to the
group's kind:9 the way an ordinary message is. It is a NIP-17 chat message
that happens to travel inside a group, and the kind is what tells the two
apart on the way back in.
Two things here are less arbitrary than they look:
The queued row IS the rumor -- same kind, same tags, same content, same
timestamp -- so its id is the one the recipient computes after unwrapping.
That is the identity of the message on both sides.
Which means the wire event's id is NOT the row's, and one existing lookup
assumed it was. `getChatMessagesByMarmotInnerEventId(innerEvent.id)` now
keys on the queued row instead. Left alone, a direct message's wrap id
would match no ChatMessage, the lookup would come back null, and no
BroadcastNostrEventRequest would ever be inserted -- encrypted, persisted,
and silently never sent, with no error anywhere. The two ids are the same
value for every other kind of message, so nothing else changes behaviour.
The plaintext is scrubbed from the queued row once it has been sent. It is
already on the ChatMessage row, which is the sender's only copy; a second
one would be cleartext left behind in a table that otherwise holds nothing
but wire events.
sealGiftWrapPayload now refuses any payload belonging to an MLS room. That
path is the one way a gift wrap reaches a relay -- the notary watches for
payloads with no seal, seals them, and broadcasts -- and a Marmot direct
message is a real, correctly signed NIP-59 wrap, indistinguishable from
something this path would be right to publish. Keeping direct messages out
of GiftWrapPayload is what makes them unbroadcastable; this refuses at the
other end too, rather than trusting every future caller to know that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>