From 248a52726786bafa855c1081f95e2cbd0614f453 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 15:55:54 +0200 Subject: [PATCH 01/33] fix: store the framed commit on MarmotCommitResult, not the exporter secret `MarmotOutboundDao.inviteMember` persisted the commit row with framedCommitBytes = commitResult.preCommitExporterSecret, two lines below the argument that value belongs to, which was already assigning it correctly. It now reads `commitResult.framedCommitBytes`. The row is written on the deferred branch, after the kind:445 commit has gone out and while the welcome waits on a relay acknowledgement, so what it holds is meant to be the record of what was published. ## Why the compiler had nothing to say `MarmotCommitResult` carries quartz's `CommitResult` payload fields verbatim -- `commitBytes`, `welcomeBytes`, `groupInfoBytes`, `framedCommitBytes`, `preCommitExporterSecret`, same names, same order, same defaults. Both of the fields in question are `ByteArray`, so the wrong field of the right object is indistinguishable from the correct one at the type level. The call site lists its named arguments in a different order than the declaration, which is what put `preCommitExporterSecret` and `framedCommitBytes` two lines apart. The entity also repeats quartz's `framedCommitBytes: ByteArray = commitBytes` default, so the explicit argument was overriding a fallback that -- while still the raw commit rather than the framed envelope -- was at least a commit. ## What it cost, and what it would have cost Nothing so far. `framedCommitBytes` has exactly two references in the tree: this assignment, and `encryptedCommitEvent` at the top of the same branch, which takes `commitResult.framedCommitBytes` from the in-memory `CommitResult` rather than from the row. The bytes that reached the relay were always the right ones; the wrong ones only ever sat in the column. They would stop merely sitting there as soon as anything reads the row back. `DatabaseNostrRepository` already reloads these rows on acknowledgement, at `getMarmotCommitRequestById`, to pick up `welcomeBytes` and fire `deliveryWelcome`. An ack-triggered rebroadcast or a replay reaching one field further along would publish 32 bytes of exporter secret where a `MlsMessage(PublicMessage(FramedContent(commit)))` envelope was expected: not a message recipients drop, but a group key on a relay. The smaller half holds whether or not anything ever reads it. The group's pre-commit `MLS-Exporter("marmot", "group-event", 32)` output was being written to a second column that is not intended to hold key material, doubling its footprint at rest alongside the `preCommitExporterSecret` field that exists for it. Only at rest -- the ack path logs the row, but the data class has no `toString` override, so `ByteArray` prints as an identity hash rather than contents. ## Scope `MarmotCommitResult` has a single construction site in the codebase, the one changed here, so there is no second copy of this to fix. Worth checking rather than assuming: the shape that produced it -- adjacent `ByteArray` fields with identical names on both sides of the copy -- reproduces anywhere the entity is built again. Co-Authored-By: Claude Opus 5 --- .../press/mantra/compose/database/dao/MarmotOutboundDao.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index ca3413e3..c03944bd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -442,7 +442,7 @@ abstract class MarmotOutboundDao( commitBytes = commitResult.commitBytes, preCommitExporterSecret = commitResult.preCommitExporterSecret, welcomeBytes = commitResult.welcomeBytes, - framedCommitBytes = commitResult.preCommitExporterSecret, + framedCommitBytes = commitResult.framedCommitBytes, groupInfoBytes = commitResult.groupInfoBytes, userPublicKey = userPublicKey, peerKeyPackageEventId = peerKeyPackage.id, From 178ddd0181744b8fab45e35e1c04a4ff858ab4d8 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:01:47 +0200 Subject: [PATCH 02/33] docs: write down how a long-running chat sync would work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chat sync today is a pull: a screen queues a request row, a pump drains it, the relay answers, the subscription is closed. Nothing arrives between pulls, so a message sent one second after EOSE waits for the next time someone opens a screen. This note works out what it takes to hold the two chat subscriptions open for as long as the app is active — kind 1059 p-tagged to us, and kind 445 h-tagged with every group we belong to — and, more usefully, what in the current pipeline quietly assumes a subscription is short: - completeOnSubscriptionEnd finishes the flow at EOSE, which is what releases the slot and sends the CLOSE, - SUBSCRIPTION_TIMEOUT hard-kills anything still open at 120s, - subscriptionSlots is a Semaphore(4) shared with the backfill queue, so a permanent subscription is a permanently-held permit, - both saveNostrEvent overloads need a request row to attach provenance to and to flip to "processed", - and nothing in the app reconnects a dropped socket at all. That is invisible today only because every subscription is short and the next queued request re-opens the socket on its way out. The design keeps the queue and its three pumps exactly as they are: live subscriptions replace polling, not reconciliation. Negentropy stays the tool for first login, the catch-up after a background gap, and "load older". The group filter is derived from chatRepository.observeChatRoomListByPublicKey rather than wired at each join site, because a group id can appear four ways and only one of them (creating a group) is somewhere anyone would think to call a subscribe function — being added arrives as a Welcome processed deep inside NostrDao.storeNostrEvent. Observing the room list also closes the loop: a Welcome lands on the live gift wrap subscription, a ChatRoom row is written, the Flow re-emits, and the group filter widens without anyone opening a chat. Two findings fell out of checking the details against our own code: - `since = now` on kind 1059 would silently drop messages. Gift wraps are stamped with TimeUtils.randomWithTwoDays(), so a wrap published now can carry a created_at two days in the past. Kind 445 uses TimeUtils.now() and can take a watermark — opposite treatment for the two kinds we care about. - the "sent-messages" filter (kinds=[1059], authors=[me]) cannot match anything, because gift wraps are signed with a fresh throwaway KeyPair(). It is also unnecessary: createNip17ChatRoom puts the user in their own participant list, so we wrap a copy to ourselves and the account-wide #p=[me] subscription already picks it up. Co-Authored-By: Claude Opus 5 --- docs/README.md | 4 +- docs/long-running-sync.md | 333 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 docs/long-running-sync.md diff --git a/docs/README.md b/docs/README.md index 892a9ba8..76adeea4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,5 +9,7 @@ silent, or a decision that looked arbitrary and was not. | [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering | | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | +| [long-running-sync.md](./long-running-sync.md) | keeping the chat subscriptions open instead of pulling once per screen — what the request-queue pipeline assumes about short subscriptions, and how the group filter follows the room list | -Start with the ceremony if you are new to this area; the other two both assume it. +Start with the ceremony if you are new to this area; the next two both assume it. +The sync note stands alone. diff --git a/docs/long-running-sync.md b/docs/long-running-sync.md new file mode 100644 index 00000000..ef8ad0c0 --- /dev/null +++ b/docs/long-running-sync.md @@ -0,0 +1,333 @@ +# A long-running chat sync + +Today every chat sync in this app is a *pull*: a screen queues a request row, a +pump drains it, the relay answers, the subscription is closed. Nothing arrives +between pulls. This document works out what it takes to keep the chat +subscriptions **open** for as long as the app is active, so relays push new gift +wraps and group events at us instead of us asking, and what has to change in the +existing pipeline before that is even possible. + +The target is the two kinds that carry conversation: + +- **kind 1059**, NIP-59 gift wraps p-tagged to us — DMs, and the Marmot Welcome + events that make us a member of a new group. +- **kind 445**, Marmot group events h-tagged with a group id we belong to. + +And one hard requirement: the group subscription must widen the moment we join +or create a group, without anyone remembering to call a "subscribe" function. + +## What happens today + +Three pumps in `SynchronizationViewModel` drain three single-row queues — +broadcast, REQ, negentropy — each row observed with `... ORDER BY createdAt ASC +LIMIT 1`. Screens are producers. Nothing else opens a socket. + +| screen | queues | +|---|---| +| [`ChatRoomListViewModel.scheduleSynchronization`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt:71) | negentropy over `1059 #p=[me] limit 50`, then negentropy over `445 #h=[every group id]`, once per DM relay | +| [`ChatMessageListViewModel.scheduleSynchronization`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt:113) | for an MLS room: negentropy over `445 #h=[this group]`. For a NIP-17 room: gift wraps p-tagged to the *peer* and authored by us, or the peer's kind-10050 if we lack it | + +A negentropy exchange that finds ids we are missing turns them into +`SynchronizeNostrEventRequest` rows, which the REQ pump then fetches in chunks of +500 ids. Both subscription types end the same way: at EOSE the flow completes, +the `finally` sends CLOSE, the semaphore permit is released. + +That design is good at what it is for — reconciling a set we already have +against a set the relay has. It is structurally incapable of delivering a message +that arrives one second after EOSE. + +## Why we cannot simply stop closing + +Five properties of the current pipeline each have to be undone deliberately. +None of them is wrong; all of them assume a subscription is short. + +**1. EOSE ends the flow.** +[`completeOnSubscriptionEnd`](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt:42) +is what makes a collector finish at all — the socket's `incomingMessages` is hot +and a filtered view of it never completes on its own. For a live subscription, +EOSE is not the end, it is the boundary between "stored history" and "live +tail", and CLOSED is the only genuinely terminal message. + +**2. Every subscription is force-killed at 120 seconds.** +[`SUBSCRIPTION_TIMEOUT`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt:98) +exists so a relay that goes quiet cannot park a slot forever. A live +subscription is exactly the case it is designed to kill. + +**3. There are four subscription slots, total, across all relays.** +[`subscriptionSlots`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt:154) +is a `Semaphore(4)`. A permanently-open subscription is a permanently-held +permit. Two live subscriptions would leave the backfill queue with two slots and +a permanent halving of throughput. **Live subscriptions must not draw from this +semaphore** — they need their own budget, and the semaphore's comment ("well +under the ~20-per-connection cap relays commonly enforce") needs re-reading as a +combined budget rather than a queue-only one. + +**4. The queue row *is* the subscription identity and the provenance record.** +The subId is the row's UUID, and both `saveNostrEvent` overloads +([`DatabaseNostrRepository.kt:483`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt:483), +[`:515`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt:515)) +take a request object, use its `relayURL`/`level`, and flip its status to +"processed". A live subscription has no row and never finishes. It needs a third +entry point that goes straight to +[`NostrDao.storeNostrEvent`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt:141) +with an explicit `relayURL` and `level = 0`. + +**5. Nothing reconnects.** +`NostrSocketClientImpl` catches a socket failure, calls `close()`, and fires +`onSocketConnectionClosed`, which only flips a boolean in `relayPoolStatus`. +There is no retry loop anywhere. Today that is invisible: every subscription is +short, and the next queued request re-opens the socket through +`ensureSocketConnectionOrThrow`. A live subscription that outlives a socket drop +is a subscription that has silently stopped, on a socket whose status flag says +so and which nobody reads. + +There is a sixth, quieter problem. `_incomingMessages` is a +[`MutableSharedFlow()`](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:53) +with no replay and no buffer, and each frame is emitted from its own `launch` +([`processIncomingMessage`](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:146)). +A message emitted while nothing is collecting is dropped, and messages can be +delivered out of order relative to the wire. For request/response that is +harmless — the collector is attached before the REQ goes out. For a firehose +whose consumer does MLS decryption and SQLite writes, it is not: give the router +a buffered channel and process in arrival order per relay. + +## What Wisp does + +Wisp (`/home/sigidli/Documents/development/nostr/wisp`) keeps its subscriptions +open for the life of the foreground app. Five mechanisms are worth copying: + +- **Stable, meaningful subscription ids.** `"dms"`, `"notif"`, `"grp-"`. Not + UUIDs. Re-sending a REQ with the same id replaces the filter server-side, which + makes "widen the filter" a send rather than a close-and-reopen. +- **The REQ text is retained per relay.** `activeSubscriptions: Map>`, and `resyncSubscriptions(relay)` re-sends all of them + when that relay reconnects (`relay/RelayPool.kt`). +- **A per-relay subscription budget.** `SubscriptionTracker` enforces a soft cap + of 20, with a priority prefix list (`"dms"`, `"notif"`, `"grp-"`, …) that + bypasses the cap so a transient feed subscription can never crowd out DMs. +- **Explicit reconnect policy.** `RelayLifecycleManager` observes connectivity + and app pause/resume, debounces, and drives `reconnectAll()`; on reconnect only + the long-lived prefixes are retained and everything transient is dropped. +- **One router, not one collector per request.** `viewmodel/EventRouter.kt` + dispatches by subscription id. + +And one detail that is a direct warning to us: Wisp deliberately does **not** +put a `since` on its kind-1059 filter, because NIP-59 randomizes `created_at` +into the past. + +## The shape to build + +### A live tier beside the queue, not inside it + +A `LiveSubscriptionManager` owned at the same level as `SynchronizationViewModel` +(the application scope, not a screen), holding one piece of state: + +```kotlin +// what we want to be subscribed to, per relay +data class LiveSubscription(val subId: String, val relayUrl: String, val filters: List) +``` + +and doing one thing: **reconcile desired state against what has actually been +sent to each socket.** Recompute the desired set, diff it against the sent set, +send a REQ for anything added or changed, send CLOSE for anything removed. Every +trigger — a new group, a relay reconnect, an app resume, a relay-list change — +becomes a call to the same `reconcile()`. That is the property that makes this +maintainable: there is no "subscribe" path and "resubscribe" path that can drift. + +The sent-set belongs in `RelayPool`, next to `socketClients`, because that is +where a reconnect is observed. Mirror Wisp: keep the serialized REQ per +`(relayUrl, subId)` and re-send on connect. + +### The two filters, and their timestamps + +```kotlin +// live-giftwrap +Filter(kinds = listOf(GiftWrapEvent.KIND), tags = mapOf("p" to listOf(myPubkey))) + +// live-groups- +Filter(kinds = listOf(GroupEvent.KIND), tags = mapOf("h" to groupIdChunk)) +``` + +The two kinds need opposite treatment on `since`, and this is not a matter of +taste — it is visible in our own outbound code. + +Gift wraps are stamped with +[`TimeUtils.randomWithTwoDays()`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt:344), +so a wrap published *now* can carry a `created_at` up to two days in the past. +A live filter with `since = now` would silently drop a large fraction of +genuinely new messages, and the failure mode is "some DMs just don't arrive" — +the worst kind to debug. Either omit `since` entirely (gift wraps are low volume +and `storeNostrEvent` already no-ops on a known id) or floor it at +`now - 2 days` and accept the redelivery. + +Group events use +[`TimeUtils.now()`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt:578). +A watermark is safe there: `since = (newest 445 we hold for these groups) - slack`. +Keep the slack generous (minutes, not seconds) — relay clocks drift, and the +cost of re-receiving a handful of 445s is a few no-op `storeNostrEvent` calls. + +`#h` has to be chunked. One subscription per group is the simple option and what +Wisp does for NIP-29, but Marmot groups here are all on the same DM relay set, so +one subscription carrying N group ids is far cheaper in sockets and slots. Chunk +at a conservative width (~100 ids) across `live-groups-0`, `live-groups-1`, … and +accept that a chunk boundary shift re-sends one REQ. + +### Routing + +One collector per socket, attached for the life of the socket, dispatching on +subscription id prefix — the `EventRouter` shape. That collector is also what +fixes problem six above: it is always attached, so nothing is dropped for want +of a subscriber, and it can impose ordering and back-pressure in one place. + +Events off a live subscription go to the new request-free `saveNostrEvent`, and +from there through the same `storeNostrEvent` → `MarmotInboundManager` path as +everything else. **No inbound processing changes.** A 1059 carrying a Welcome +still lands in the branch at +[`NostrDao.kt:720`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt:720); +a 445 still reaches +[`processGroupEvent`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt:415). +Only the delivery mechanism is new. + +### Keeping the group filter current + +This is the requirement that decides the design, and it is the easy part — +provided we derive the filter from the database instead of from the join sites. + +```kotlin +chatRepository.observeChatRoomListByPublicKey(publicKey) + .map { rooms -> rooms.filter { it.chatRoom.mlsGroupState != null && it.chatRoom.leftGroupAt == null } + .map { it.chatRoom.id }.sorted() } + .distinctUntilChanged() + .debounce(500.milliseconds) + .collect { groupIds -> live.setGroupIds(groupIds); live.reconcile() } +``` + +`ChatRoomListViewModel` already collects this exact Flow to render the list. The +difference is where it is collected: at application scope, so it keeps running +when no chat screen is open. + +Driving it off the DB rather than off the join sites matters because there are +several ways a group id appears, and they are not all in view models: + +- we create a group (`MarmotOutboundDao`), +- we are added to one — the Welcome arrives inside a gift wrap and is processed + deep inside `NostrDao.storeNostrEvent`, on the *inbound* path, +- membership shifts under us via a commit + (`MarmotInboundManager.processGroupMembershipChanges`), +- and we leave one (`leftGroupAt`). + +Only the first is a place a developer would naturally think to call +`subscribeToGroup()`. Observing the room list catches all four, and it closes the +loop cleanly: **a Welcome arrives on the live gift wrap subscription → a +`ChatRoom` row is written → the Flow re-emits → the group filter widens → the +first message in that new group arrives without anyone opening the chat.** + +Debounce is not optional. Joining a group writes the room, its participants, and +placeholder profiles in quick succession; without it we would re-send the group +REQ several times per join. + +### Lifecycle + +Mantra has no app lifecycle observer at all today — +[`MainActivity`](composeApp/src/androidMain/kotlin/press/mantra/android/MainActivity.kt) +only calls `setContent`. Long-running subscriptions make one necessary: + +- **background:** stop the live subscriptions (CLOSE, or just drop the sockets). + Holding a socket open behind a doze window achieves nothing except battery. +- **foreground:** reconnect and reconcile. Assume the server-side subscription is + gone — Wisp's `forceReconnectAll` exists precisely because a socket that + survives an OS sleep reports `isConnected == true` while being functionally + dead — and queue a bounded catch-up negentropy pass for the gap. +- **connectivity change:** reconnect, debounced, with the suppression window + Wisp uses to avoid a resume and a network-change event double-reconnecting. + +The foreground catch-up is what preserves correctness across the gap. Live +subscriptions cover the online window; negentropy covers everything else. + +## What the screens stop doing, and what they keep + +Once the live tier exists: + +- `ChatRoomListViewModel.scheduleSynchronization` — drop both queues on open. + Keep the code path behind an explicit pull-to-refresh. +- `ChatMessageListViewModel.scheduleSynchronization` — drop the `mlsMessages` + branch entirely; it is a strict subset of `live-groups-*`. Keep the + `ChatMessageRelayListEvent` lookup: that is *discovery* (which relay does this + peer read from), not message sync, and it still has to happen on open. +- Everything not chat — profiles, follows, feeds, key packages — is untouched. + +One thing to fix while in here, since it is one of the per-chat syncs we are +proposing to stop firing: the `"sent-messages"` filter +(`kinds=[1059], authors=[me], #p=[peer]`, at +[`ChatMessageListViewModel.kt:113`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt:113) +and [`NostrDao.kt:643`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt:643)) +**cannot match anything.** Gift wraps are signed with a fresh throwaway key +([`val wrapperKeyPair = KeyPair()`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt:312)), +so `authors = [myPubkey]` matches no wrap this app has ever published. It also +turns out to be unnecessary: `createNip17ChatRoom` puts the user in their own +participant list +([`NostrNip17Dao.kt:50`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt:50)), +so we wrap a copy to ourselves and the account-wide `#p=[me]` subscription picks +our own sent messages up on every device. Delete the filter rather than port it. + +## Negentropy's remaining job + +Live subscriptions replace *polling*, not *reconciliation*. Negentropy is still +the right tool for: + +- first login / restore, where the local set is empty, +- the foreground catch-up after a background gap, +- "load older messages" in a chat, +- and repairing a relay that was unreachable while we were online. + +The distinction to hold onto: a live REQ answers "what is new since I connected"; +negentropy answers "what do you have that I don't". Neither subsumes the other, +and the queue and its three pumps stay exactly as they are. + +## Traps, in the order they will bite + +1. **Reusing the `subscriptionSlots` semaphore.** Two permanent permits out of + four halves backfill throughput forever. Give the live tier its own budget and + read the two together against the relay's real cap. +2. **`completeOnSubscriptionEnd` on a live flow.** It will complete at the first + EOSE and the subscription will look established while delivering nothing. + The live variant must terminate on CLOSED only. +3. **`since = now` on kind 1059.** Randomized wrap timestamps mean silent, + partial message loss. See above. +4. **Treating CLOSED as "retry now".** `isBackPressure` already exists in + `SynchronizationViewModel` and should be reused: a rate-limited CLOSED on a + live subscription needs exponential backoff, an auth-required CLOSED needs the + AUTH flow (which the app does not implement yet — worth knowing before + pointing this at a relay that requires it), and only an unsupported-filter + CLOSED should fall back to a queued REQ. +5. **A NOTICE has no subscription id.** It is already admitted to every + collector by `filterBySubscriptionId`; with a permanent collector attached, + every notice reaches it forever. Handle it once in the router, not per + subscription. +6. **Duplicate delivery.** `storeNostrEvent` no-ops on a known id, so duplicate + 1059s are cheap. Duplicate *445s* are not automatically cheap — + `MarmotInboundManager` maintains epoch state and a commit tracker; confirm the + re-delivery path is idempotent before widening the `since` slack. +7. **Relay list changes.** `RelaysSocketManager.updateRelayPools` calls + `relayPool.changeRelays`, which closes sockets. Live subscriptions must be + reconciled after that, or they quietly vanish on any relay edit. +8. **The DM relay set is one relay.** `Relays.DefaultDMRelayList` is + `[wss://ephemeral.mantra.press]`. Every point above about per-relay budgets is + currently a budget of one socket — which makes this cheap to build and makes a + single relay outage total. Worth widening the set at the same time. + +## Suggested order + +1. **Reconnect first.** A supervised reconnect loop in `NostrSocketClientImpl` + plus per-relay REQ retention and resync in `RelayPool`. This is a strict + improvement on its own, before any live subscription exists. +2. **The router.** One permanent collector per socket, dispatching by subId + prefix; the request-free `saveNostrEvent`. Existing pumps keep working. +3. **`live-giftwrap`.** One subscription, one relay, no `since`. Verifiable by + sending yourself a DM from another client with the chat list closed. +4. **`live-groups-*`,** driven off the room-list Flow. Verify by having a second + device add this one to a group and watching messages land with no chat screen + ever opened. +5. **Lifecycle + foreground catch-up.** +6. **Then, and only then,** delete `scheduleSynchronization` from the two chat + view models. From c3b0c8671bfbe217e322a760398cefd84517b7bd Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:02:11 +0200 Subject: [PATCH 03/33] feat(relays): reconnect dropped sockets and replay what they were carrying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in this app reconnected a websocket. NostrSocketClientImpl caught a failure, called close(), and fired onSocketConnectionClosed — which only flips a boolean in relayPoolStatus that nobody reads. A socket that failed stayed failed, and the only reason that was survivable is that every subscription is short: the next queued request opens a fresh socket on its way out through ensureSocketConnectionOrThrow. That stops being survivable the moment a subscription is meant to outlive the socket, so this lands first, on its own. It is already a fix without any of that: a REQ interrupted mid-download used to sit there until SUBSCRIPTION_TIMEOUT gave up 120s later, having saved whatever partial set arrived before the drop. Now the socket comes back and the REQ is re-sent. The socket client: - a supervised reconnect loop with exponential backoff (1s doubling to 60s) plus up to 25% jitter, because every relay in the pool drops at once when the network does and without jitter they all come back in lockstep. The exponent is capped so a socket failing for hours cannot overflow the doubling into Infinity, which Duration * Double rejects outright. - `autoReconnect`, off by default and owned by the pool. Reconnecting a socket nobody is subscribed on is battery spent on nothing, so the pool turns it on for exactly as long as it retains a subscription for that relay. - `closedByClient`, so closePool() is not answered by every socket in it politely reconnecting. Cleared by the next caller-driven connect. - onSessionLost() as the single exit point for a session that ended without the client asking, replacing the close()-from-inside-the-receiver dance. It identity-checks the session before clearing it, so a reconnect that already installed a newer one is not torn down by its predecessor's cleanup, and runs NonCancellable because the receiver job is cancelled as part of a replacement connect. - Frame.Close now breaks the receive loop rather than closing by hand. The relay closing us is not the client closing us, so it earns a reconnect too. - a new SocketConnectionReopenedCallback, fired only when a session is established on a socket that had connected before. Kept separate from "opened" because on a FIRST connect a replay would double-send the very REQ whose sendMESSAGE opened the socket. Two bugs fixed in passing, both of the silent kind: - the compression REQ in the post-connect handshake was written to `wsSession` before the new session was assigned to it, so it went to the previous (usually null) session and was dropped. wsSession is now assigned first. - sendMESSAGE used `wsSession?.send(...)`, so a send on a dropped socket was a no-op and the caller waited forever for an answer to a message never sent. It now warns. The pool: - retains the REQ text per (relay, subscription id), and replays it when that relay's socket is re-established. A relay answers a repeated REQ on the same subscription id by replacing the filter, so replay is a send rather than a close-and-reopen, and the collector already attached to the socket's message flow simply starts receiving again. - retains on query() BEFORE the send, so a socket that dies between there and the relay's first answer is still covered; releases on closeQuery(), which every pump already calls from a NonCancellable finally. - deliberately does NOT retain negentropy. NEG-OPEN carries a fingerprint of the local set and each round depends on the last, so replaying one mid-exchange would reconcile against a conversation the relay is no longer having. An interrupted negentropy request is abandoned and re-queued. - drops retained work for relays removed by changeRelays/removeRelays/ closePool, so a relay edit does not leave a socket reconnecting for subscriptions nobody wants. - collapses the five hand-rolled `socketClients.find { normalize... }` lookups into socketClientFor(), now that there were about to be several more. Co-Authored-By: Claude Opus 5 --- .../compose/network/relays/RelayPool.kt | 119 +++++++++- .../network/sockets/NostrSocketClient.kt | 9 + .../sockets/NostrSocketClientFactory.kt | 4 + .../network/sockets/NostrSocketClientImpl.kt | 210 ++++++++++++++++-- .../sockets/SocketConnectionCallbacks.kt | 10 + 5 files changed, 325 insertions(+), 27 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt index 0d0b0ad0..16b1d44b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt @@ -55,6 +55,20 @@ class RelayPool( private val relayMutex = Mutex() + /** + * The REQ text of every subscription still believed to be open, keyed by normalized + * relay url and then by subscription id, so a socket that comes back can be handed + * its subscriptions again. + * + * Only plain REQs live here. A negentropy exchange is stateful — NEG-OPEN carries a + * fingerprint of the local set and each round depends on the last — so replaying one + * mid-exchange would reconcile against a conversation the relay is no longer having. + * An interrupted negentropy request is abandoned and re-queued instead. + */ + private val retainedRequests = mutableMapOf>() + + private val retainedRequestsMutex = Mutex() + @VisibleForTesting var socketClients = setOf() @@ -75,6 +89,10 @@ class RelayPool( updateRelayStatus(url = url, connected = false) } + private val onSocketConnectionReopenedCallback: press.mantra.compose.network.sockets.SocketConnectionReopenedCallback = { url -> + scope.launch { replayRetainedRequests(url) } + } + fun changeRelays(relays: List) { val existingRelayUrls = socketClients.map { it.socketUrl } val newRelayUrls = relays.map { it.url } @@ -93,6 +111,7 @@ class RelayPool( updateRelayStatus(url = client.socketUrl, connected = false) scope.launch { client.close() } } + forgetRetainedRequests(toRemoveSocketClients.map { it.socketUrl }) this.relays.clear() this.relays.addAll(relays) } @@ -111,6 +130,7 @@ class RelayPool( updateRelayStatus(url = client.socketUrl, connected = false) scope.launch { client.close() } } + forgetRetainedRequests(toRemoveSocketClients.map { it.socketUrl }) this.relays.removeAll(relays) } @@ -139,6 +159,7 @@ class RelayPool( updateRelayStatus(url = client.socketUrl, connected = false) scope.launch { client.close() } } + forgetRetainedRequests(socketClients.map { it.socketUrl }) socketClients = emptySet() relays.clear() } @@ -151,12 +172,92 @@ class RelayPool( } } + private fun socketClientFor(relayUrl: String): press.mantra.compose.network.sockets.NostrSocketClient? { + val wanted = NormalizedRelayUrl(relayUrl).displayUrl() + + return socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == wanted } + } + + /** + * Remembers a subscription so it can be re-sent if this relay's socket comes back, + * and tells the socket it is now worth reconnecting. + */ + private suspend fun retainRequest(relayUrl: String, subId: String, message: String) { + val key = NormalizedRelayUrl(relayUrl).displayUrl() + + retainedRequestsMutex.withLock { + retainedRequests.getOrPut(key) { mutableMapOf() }[subId] = message + } + socketClientFor(relayUrl)?.autoReconnect = true + } + + /** + * Forgets a subscription that has been closed. Once a relay is carrying none of ours, + * its socket stops reconnecting on its own: keeping a socket alive for a relay we have + * nothing open on is battery spent on nothing. + */ + private suspend fun releaseRequest(relayUrl: String, subId: String) { + val key = NormalizedRelayUrl(relayUrl).displayUrl() + + val stillWanted = retainedRequestsMutex.withLock { + val forRelay = retainedRequests[key] + forRelay?.remove(subId) + if (forRelay.isNullOrEmpty()) { + retainedRequests.remove(key) + false + } else { + true + } + } + + if (!stillWanted) socketClientFor(relayUrl)?.autoReconnect = false + } + + private fun forgetRetainedRequests(relayUrls: List) { + if (relayUrls.isEmpty()) return + + val keys = relayUrls.map { NormalizedRelayUrl(it).displayUrl() } + scope.launch { + retainedRequestsMutex.withLock { + keys.forEach { retainedRequests.remove(it) } + } + } + } + + /** + * Hands a relay back the subscriptions it was serving before its socket dropped. + * + * Only ever called for a RE-connection. A relay answers a repeated REQ on the same + * subscription id by replacing the filter, so this is a send rather than a + * close-and-reopen, and the collector already attached to the socket's message flow + * simply starts receiving again. + */ + private suspend fun replayRetainedRequests(relayUrl: String) { + val key = NormalizedRelayUrl(relayUrl).displayUrl() + val messages = retainedRequestsMutex.withLock { retainedRequests[key]?.toMap() } + + if (messages.isNullOrEmpty()) return + + val nostrSocketClient = socketClientFor(relayUrl) + if (nostrSocketClient == null) { + logger.w("Cannot replay ${messages.size} subscription(s): $relayUrl has no socket") + return + } + + logger.i("Replaying ${messages.size} subscription(s) on $relayUrl: ${messages.keys}") + messages.forEach { (subId, message) -> + runCatching { nostrSocketClient.sendMESSAGE(message) } + .onFailure { logger.w(throwable = it) { "Failed to replay $subId on $relayUrl" } } + } + } + private fun List.mapAsNostrSocketClient() = this.map { nostrSocketClientFactory.create( wssUrl = it.url, onSocketConnectionOpened = onSocketConnectionOpenedCallback, onSocketConnectionClosed = onSocketConnectionClosedCallback, + onSocketConnectionReopened = onSocketConnectionReopenedCallback, ) } @@ -186,13 +287,19 @@ class RelayPool( ) logger.d("socketClients: ${socketClients.map { it.socketUrl }}") - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) val filterRequest = OptimizedJsonMapper.toJson(reqCommand) if (nostrSocketClient == null) { throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected") } + + // Retained BEFORE the send, so a socket that dies between here and the relay's + // first answer still gets the REQ replayed when it comes back. Released by + // closeQuery, which every pump calls from a NonCancellable finally. + retainRequest(relayUrl = relayUrl, subId = reqCommand.subId, message = filterRequest) + return coroutineScope { val eventFlow = nostrSocketClient.queryAsFlow(reqCommand.subId) with(nostrSocketClient) { @@ -210,7 +317,7 @@ class RelayPool( ) logger.d("socketClients: ${socketClients.map { it.socketUrl }}") - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) val negentropySyncRequest = OptimizedJsonMapper.toJson(negOpenCmd) @@ -232,20 +339,22 @@ class RelayPool( * [negentropySync] rather than opening a new one. */ suspend fun sendNegentropyMessage(negMsgCmd: NegMsgCmd, relayUrl: String) { - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) ?: throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected") nostrSocketClient.sendMESSAGE(OptimizedJsonMapper.toJson(negMsgCmd)) } suspend fun closeQuery(closeCmd: CloseCmd, relayUrl: String) { + releaseRequest(relayUrl = relayUrl, subId = closeCmd.subId) + addRelaysIfMissing( setOf( NormalizedRelayUrl(relayUrl).url.toRelayDTO() ) ) - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) val closeSubscription = OptimizedJsonMapper.toJson(closeCmd) @@ -267,7 +376,7 @@ class RelayPool( ) logger.d("socketClients: ${socketClients.map { it.socketUrl }}") - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) val closeNegentropySubscription = OptimizedJsonMapper.toJson(negCloseCmd) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt index 222a6a04..44105ce7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt @@ -9,6 +9,15 @@ interface NostrSocketClient { val incomingMessages: SharedFlow + /** + * Whether a socket that drops should be re-opened on its own, with backoff. + * + * Off by default, and owned by the pool rather than the socket: reconnecting a + * socket nobody is subscribed on is pure battery cost, so the pool turns this on + * for exactly as long as it is retaining at least one subscription for the relay. + */ + var autoReconnect: Boolean + suspend fun close() @Throws( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt index 735d543b..d751989f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt @@ -27,6 +27,7 @@ object NostrSocketClientFactory { incomingCompressionEnabled: Boolean = false, onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, onSocketConnectionClosed: SocketConnectionClosedCallback? = null, + onSocketConnectionReopened: SocketConnectionReopenedCallback? = null, ): NostrSocketClient { return NostrSocketClientImpl( httpClient = httpClient, @@ -34,6 +35,7 @@ object NostrSocketClientFactory { incomingCompressionEnabled = incomingCompressionEnabled, onSocketConnectionOpened = onSocketConnectionOpened, onSocketConnectionClosed = onSocketConnectionClosed, + onSocketConnectionReopened = onSocketConnectionReopened, ) } @@ -42,11 +44,13 @@ object NostrSocketClientFactory { incomingCompressionEnabled: Boolean = false, onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, onSocketConnectionClosed: SocketConnectionClosedCallback? = null, + onSocketConnectionReopened: SocketConnectionReopenedCallback? = null, ) = create( httpClient = defaultSocketsHttpClient, wssUrl = wssUrl, incomingCompressionEnabled = incomingCompressionEnabled, onSocketConnectionOpened = onSocketConnectionOpened, onSocketConnectionClosed = onSocketConnectionClosed, + onSocketConnectionReopened = onSocketConnectionReopened, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt index e5c8235e..aa4442d8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -21,6 +22,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import okio.Buffer import okio.GzipSink @@ -28,8 +30,14 @@ import okio.Inflater import okio.InflaterSource import okio.buffer import okio.use +import kotlin.concurrent.Volatile import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.min +import kotlin.math.pow +import kotlin.random.Random +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -40,15 +48,60 @@ internal class NostrSocketClientImpl( private val incomingCompressionEnabled: Boolean = false, private val onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, private val onSocketConnectionClosed: SocketConnectionClosedCallback? = null, + private val onSocketConnectionReopened: SocketConnectionReopenedCallback? = null, ) : NostrSocketClient { val logger = Logger.withTag("NostrSocketClientImpl") + companion object { + /** Wait before the first reconnect attempt; doubles per failure up to [MAX_RECONNECT_DELAY]. */ + private val INITIAL_RECONNECT_DELAY = 1.seconds + + private val MAX_RECONNECT_DELAY = 60.seconds + + /** + * Bounds the exponent so a socket that has been failing for hours cannot overflow + * the doubling into `Infinity`, which `Duration * Double` rejects outright. + */ + private const val MAX_RECONNECT_EXPONENT = 16 + + /** + * Up to this fraction of the delay is added at random. Every relay in the pool + * drops at once when the network does, and without jitter they would all come + * back in lockstep for as long as the outage lasts. + */ + private const val RECONNECT_JITTER = 0.25 + } + + /** Outcome of a connect attempt, so the caller can tell a first connect from a repair. */ + private enum class ConnectOutcome { ALREADY_CONNECTED, OPENED, REOPENED } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val wsMutex = Mutex() private var wsSession: WebSocketSession? = null private var wsReceiverJob: Job? = null + private var reconnectJob: Job? = null + + /** Consecutive failed reconnect attempts; reset the moment a session is established. */ + private var reconnectAttempts = 0 + + /** + * True once a session has been established, so the next one is a RE-connection and + * the pool owes it a subscription replay. + */ + private var hasConnected = false + + /** + * Set by [close], cleared by the next caller-driven connect. A socket the client + * deliberately closed must stay closed — otherwise `closePool` would be answered by + * every socket in it politely reconnecting. + */ + @Volatile + private var closedByClient = false + + @Volatile + override var autoReconnect: Boolean = false private val _incomingMessages = MutableSharedFlow() override val incomingMessages = _incomingMessages.asSharedFlow() @@ -56,20 +109,21 @@ internal class NostrSocketClientImpl( override val socketUrl = wssUrl.cleanWebSocketUrl() override suspend fun ensureSocketConnectionOrThrow() { - if (wsSession != null && wsSession?.isActive == true) return + if (isSessionActive()) return - wsMutex.withLock { - if (wsSession == null || wsSession?.isActive == false) { - wsSession = acquireWebSocketSession(socketUrl) - } - } - } + val outcome = wsMutex.withLock { + // Asking for the socket is asking for it to stay up: clear the flag close() + // set so a drop after this point reconnects again. + closedByClient = false - private suspend fun acquireWebSocketSession(url: String): WebSocketSession { - return try { - httpClient.webSocketSession(urlString = url).apply { - launchWebSocketReceiver() - onSocketConnectionOpened?.invoke(url) + if (isSessionActive()) { + ConnectOutcome.ALREADY_CONNECTED + } else { + wsSession = openSession() + // Assigned BEFORE the post-connect handshake below, because sendMESSAGE + // writes to `wsSession` rather than to the session it was handed — the + // compression REQ used to go to the previous (usually null) session and + // was silently dropped. if (incomingCompressionEnabled) { val id = Uuid.generateV4().toHexDashString() sendMESSAGE( @@ -77,11 +131,35 @@ internal class NostrSocketClientImpl( ensureSessionBeforeSend = false, ) } + + val reopened = hasConnected + hasConnected = true + reconnectAttempts = 0 + if (reopened) ConnectOutcome.REOPENED else ConnectOutcome.OPENED } + } + + if (outcome == ConnectOutcome.ALREADY_CONNECTED) return + + // Fired outside the lock. A callback that turns around and sends on this socket + // would otherwise queue behind a mutex the caller still holds. + onSocketConnectionOpened?.invoke(socketUrl) + if (outcome == ConnectOutcome.REOPENED) onSocketConnectionReopened?.invoke(socketUrl) + } + + private fun isSessionActive() = wsSession?.isActive == true + + /** Opens a session and starts its receiver. Call with [wsMutex] held. */ + private suspend fun openSession(): WebSocketSession { + return try { + httpClient.webSocketSession(urlString = socketUrl).apply { launchWebSocketReceiver() } } catch (error: Exception) { - logger.w("NostrSocketClient::acquireWebSocketSession($socketUrl) failed.", error) - close() + logger.w("NostrSocketClient::openSession($socketUrl) failed.", error) + wsReceiverJob?.cancel() + wsReceiverJob = null + wsSession = null onSocketConnectionClosed?.invoke(socketUrl, error) + scheduleReconnect() throw press.mantra.compose.exceptions.NetworkException(cause = error) } } @@ -94,6 +172,7 @@ internal class NostrSocketClientImpl( } private suspend fun WebSocketSession.receiveSocketMessages() { + var failure: Throwable? = null try { for (frame in incoming) { when (frame) { @@ -112,8 +191,9 @@ internal class NostrSocketClientImpl( is Frame.Close -> { val closeReason = frame.readReason() logger.w { "WS $socketUrl closed. [${closeReason?.code}, ${closeReason?.message}]" } - close() - onSocketConnectionClosed?.invoke(socketUrl, null) + // Leave the teardown to onSessionLost below. The relay closing us + // is not the client closing us, so this still earns a reconnect. + break } else -> Unit @@ -124,23 +204,102 @@ internal class NostrSocketClientImpl( throw error } catch (error: Exception) { logger.w("NostrSocketClient::receiveSocketMessages() on $socketUrl failed.", error) - close() + failure = error + } + + onSessionLost(session = this, error = failure) + } + + /** + * The single exit point for a session that ended without the client asking. Clears the + * session, tells the pool, and arms the reconnect. + * + * NonCancellable because the receiver job is cancelled as part of a replacement connect, + * and a cancelled cleanup would leave `wsSession` pointing at a dead socket. + */ + private suspend fun onSessionLost(session: WebSocketSession, error: Throwable?) = + withContext(NonCancellable) { + val wasCurrent = wsMutex.withLock { + // Identity check, not a null check: a reconnect may already have installed a + // newer session, and clearing that one would drop a healthy socket. + if (wsSession !== session) { + false + } else { + wsSession = null + true + } + } + + if (!wasCurrent) return@withContext + + runCatching { + session.close( + reason = CloseReason( + code = CloseReason.Codes.NORMAL, + message = "Session ended.", + ), + ) + } onSocketConnectionClosed?.invoke(socketUrl, error) + scheduleReconnect() + } + + /** + * Re-opens a dropped socket with exponential backoff, for as long as the pool says it + * still wants one. Nothing in this app used to reconnect at all: a socket that failed + * stayed failed, and the only reason that was survivable is that every subscription was + * short and the next queued request opened a fresh socket on its way out. + */ + private fun scheduleReconnect() { + if (!autoReconnect || closedByClient) return + if (reconnectJob?.isActive == true) return + + reconnectJob = scope.launch { + while (isActive && autoReconnect && !closedByClient) { + val attempt = ++reconnectAttempts + val wait = reconnectDelay(attempt) + logger.i { "Reconnecting to $socketUrl in $wait (attempt $attempt)" } + delay(wait) + + if (!autoReconnect || closedByClient) return@launch + + val reconnected = runCatching { ensureSocketConnectionOrThrow() } + if (reconnected.isSuccess) { + logger.i { "Reconnected to $socketUrl after $attempt attempt(s)" } + return@launch + } + } } } + private fun reconnectDelay(attempt: Int): Duration { + val doublings = 2.0.pow(min(attempt - 1, MAX_RECONNECT_EXPONENT)) + val backoff = minOf(INITIAL_RECONNECT_DELAY * doublings, MAX_RECONNECT_DELAY) + + return backoff + backoff * RECONNECT_JITTER * Random.nextDouble() + } + override suspend fun close() { - wsReceiverJob?.cancel() - wsReceiverJob = null + val session = wsMutex.withLock { + closedByClient = true + val current = wsSession + wsSession = null + wsReceiverJob?.cancel() + wsReceiverJob = null + current + } + + reconnectJob?.cancel() + reconnectJob = null + runCatching { - wsSession?.close( + session?.close( reason = CloseReason( code = CloseReason.Codes.NORMAL, message = "Closed by client.", ), ) } - wsSession = null } private fun processIncomingMessage(text: String) { @@ -159,7 +318,14 @@ internal class NostrSocketClientImpl( ensureSocketConnectionOrThrow() } logLargeText(text = text, url = socketUrl, incoming = false) - wsSession?.send(Frame.Text(text = text)) + val session = wsSession + if (session == null) { + // Used to be a silent `?.` no-op, which is how a dropped socket could swallow + // a send and leave the caller waiting on an answer to a message never sent. + logger.w { "Dropping a send to $socketUrl: no session" } + return + } + session.send(Frame.Text(text = text)) } override suspend fun sendREQ(subscriptionId: String, data: JsonObject) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/SocketConnectionCallbacks.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/SocketConnectionCallbacks.kt index 580da97b..81f778a4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/SocketConnectionCallbacks.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/SocketConnectionCallbacks.kt @@ -2,3 +2,13 @@ package press.mantra.compose.network.sockets typealias SocketConnectionOpenedCallback = (url: String) -> Unit typealias SocketConnectionClosedCallback = (url: String, error: Throwable?) -> Unit + +/** + * Fired when a session is established on a socket that had already been connected + * once before — i.e. after a drop, not on the first connect. + * + * Deliberately separate from [SocketConnectionOpenedCallback]. The pool replays the + * subscriptions a socket was carrying when it comes back, and on a FIRST connect + * that replay would double-send the very REQ whose `sendMESSAGE` opened the socket. + */ +typealias SocketConnectionReopenedCallback = (url: String) -> Unit From 3ade53607a23d0eb767030b807bf39bf22253f80 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:05:19 +0200 Subject: [PATCH 04/33] feat(relays): plumbing for subscriptions that are meant to stay open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces, none of which opens a subscription yet — the manager that does lands next. **A subscription that survives EOSE.** RelayPool.openLiveSubscription is query() without completeOnSubscriptionEnd. For a one-shot request EOSE is the end: it is what finishes the collector, frees the subscription slot and triggers the CLOSE. For a live subscription EOSE is only the boundary between stored history and the live tail, and the relay owes us nothing further to mark an end — so the flow ends when, and only when, the caller stops collecting. The returned flow is attached to the socket CLIENT's message flow rather than to a session, so it survives a drop: the socket reconnects, the previous commit's replay re-sends the REQ, and the same collector starts receiving again with nothing rebuilt. updateLiveSubscription re-sends under the same subscription id to widen or narrow the filter in place — a relay answers a repeated REQ on an existing id by replacing that subscription's filter, so there is no close-and-reopen and the collector never notices. **Ordered, buffered inbound.** The socket's incomingMessages was a rendezvous SharedFlow emitted into from a coroutine launched per message. Two consequences a short request/response collector never noticed, and a permanent one would: - messages reached collectors in whatever order those coroutines happened to be scheduled, and - an emit with every collector busy blocked on the slowest of them. It now has a 256-message buffer and is emitted into inline, on the reader, in wire order. That also lets the 75ms sleep before every EOSE go: it existed to hope that the events preceding an EOSE had already been delivered by their own coroutines, which ordering now guarantees outright. The buffer is the back-pressure boundary — a collector may fall 256 behind before it slows its socket's reader down. Deep enough to absorb the burst between a REQ and its EOSE while a collector writes each event to SQLite, not so deep that a stuck collector is invisible. **A saveNostrEvent that does not need a request row.** Both existing overloads take the row an event was fetched for, because they also record which request produced it and flip that row to "processed". A live subscription has no row and never finishes, so the new overload carries relayURL and level itself and goes straight to storeNostrEvent. Everything downstream — indexing, gift wrap unwrapping, Welcome handling, MLS decryption — is unchanged. Fixed while adding it: the negentropy overload was writing outside storeNostrEventMutex while the other one held it. storeNostrEvent reads an event and then writes it and its indexes, so two of those interleaving is a lost update. Survivable while a single queue was the only writer; not survivable with a live subscription writing alongside a backfill. Deviation from docs/long-running-sync.md worth noting: the doc proposed one permanent collector per socket dispatching by subscription id prefix. With the inbound flow now buffered and only a handful of live subscriptions per relay, a collector per subscription has the same properties for less machinery. If the live subscription count per relay ever grows, the router is the next move. Co-Authored-By: Claude Opus 5 --- .../repository/DatabaseNostrRepository.kt | 61 +++++++++++++------ .../compose/network/relays/RelayPool.kt | 59 ++++++++++++++++++ .../network/relays/RelaysSocketManager.kt | 24 ++++++++ .../network/sockets/NostrSocketClientImpl.kt | 44 +++++++++---- .../compose/repository/NostrRepository.kt | 27 ++++++++ 5 files changed, 185 insertions(+), 30 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 27cfca37..67da567f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -518,28 +518,55 @@ class DatabaseNostrRepository( synchronizationRelayURLs: List, activeKeyPair: KeyPair ) { - logger.d("saveNostrEvent: $nostrEvent") + // Under the same lock as the other overloads. storeNostrEvent reads the event, then + // writes it and its indexes; two of those interleaving is a lost update. This path + // used to skip the lock, which was survivable only while a single queue was the one + // thing writing — a live subscription writing alongside a backfill is not that. + storeNostrEventMutex.withLock { + logger.d("saveNostrEvent: $nostrEvent") - try { - database.nostrDao().storeNostrEvent( - nostrEvent, - relayURL = negentropySynchronizeRequest.relayURL, - synchronizationRelayURLs = synchronizationRelayURLs, - level = negentropySynchronizeRequest.level, - activeKeyPair = activeKeyPair - ) - - database.negentropySynchronizeRequestDao().upsert( - negentropySynchronizeRequest.copy( - status = "processed" + try { + database.nostrDao().storeNostrEvent( + nostrEvent, + relayURL = negentropySynchronizeRequest.relayURL, + synchronizationRelayURLs = synchronizationRelayURLs, + level = negentropySynchronizeRequest.level, + activeKeyPair = activeKeyPair ) - ) - } catch (e: Throwable) { - logger.e("Failed to save nostr event $nostrEvent.id", e) + + database.negentropySynchronizeRequestDao().upsert( + negentropySynchronizeRequest.copy( + status = "processed" + ) + ) + } catch (e: Throwable) { + logger.e("Failed to save nostr event ${nostrEvent.id}", e) + } } + } + override suspend fun saveNostrEvent( + nostrEvent: NostrEvent, + relayURL: String, + synchronizationRelayURLs: List, + level: Int, + activeKeyPair: KeyPair + ) { + storeNostrEventMutex.withLock { + logger.d("saveNostrEvent (live): $nostrEvent") - + try { + database.nostrDao().storeNostrEvent( + nostrEvent, + relayURL = relayURL, + synchronizationRelayURLs = synchronizationRelayURLs, + level = level, + activeKeyPair = activeKeyPair + ) + } catch (e: Throwable) { + logger.e("Failed to save nostr event ${nostrEvent.id}", e) + } + } } override suspend fun queueSynchronizeNostrEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt index 16b1d44b..09752bbd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt @@ -309,6 +309,65 @@ class RelayPool( } } + /** + * Opens a subscription that is meant to stay open, and returns a flow that never + * completes on its own. + * + * The difference from [query] is the absence of `completeOnSubscriptionEnd`. For a + * one-shot request EOSE is the end — it is what finishes the collector, frees the + * subscription slot and triggers the CLOSE. For a live subscription EOSE is only the + * boundary between the stored history and the live tail, and the relay owes us nothing + * further to mark the end. The flow therefore ends when, and only when, the caller + * stops collecting it. + * + * The returned flow is attached to the socket CLIENT's message flow, not to a session, + * so it survives a drop: the socket reconnects, [replayRetainedRequests] re-sends this + * REQ, and the same collector starts receiving again without anything being rebuilt. + */ + suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow { + addRelaysIfMissing( + setOf( + NormalizedRelayUrl(relayUrl).url.toRelayDTO() + ) + ) + + val nostrSocketClient = socketClientFor(relayUrl) + ?: throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected") + + val filterRequest = OptimizedJsonMapper.toJson(reqCommand) + + // Attached before the send: the socket's message flow is buffered but not replayed, + // so a collector subscribing after the first events arrived would miss them. + val eventFlow = nostrSocketClient.incomingMessages.filterBySubscriptionId(id = reqCommand.subId) + + retainRequest(relayUrl = relayUrl, subId = reqCommand.subId, message = filterRequest) + nostrSocketClient.sendMESSAGE(filterRequest) + + return eventFlow + } + + /** + * Narrows or widens a live subscription in place. + * + * A relay answers a repeated REQ on an existing subscription id by replacing that + * subscription's filter, so this never closes anything: the collector opened by + * [openLiveSubscription] keeps running and simply starts matching the new filter. + * Re-retained too, so a reconnect replays the new filter rather than the old one. + */ + suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { + val nostrSocketClient = socketClientFor(relayUrl) + ?: throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected") + + val filterRequest = OptimizedJsonMapper.toJson(reqCommand) + + retainRequest(relayUrl = relayUrl, subId = reqCommand.subId, message = filterRequest) + nostrSocketClient.sendMESSAGE(filterRequest) + } + + /** Ends a live subscription: forgets the retained REQ and tells the relay to stop. */ + suspend fun closeLiveSubscription(subId: String, relayUrl: String) = + closeQuery(CloseCmd(subId = subId), relayUrl) + suspend fun negentropySync(negOpenCmd: NegOpenCmd, relayUrl: String): Flow { addRelaysIfMissing( setOf( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index fe9bc561..256828de 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -144,6 +144,30 @@ class RelaysSocketManager( } + /** @see RelayPool.openLiveSubscription */ + suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow { + return relayPool.openLiveSubscription( + reqCommand = reqCommand, + relayUrl = relayUrl + ) + } + + /** @see RelayPool.updateLiveSubscription */ + suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { + return relayPool.updateLiveSubscription( + reqCommand = reqCommand, + relayUrl = relayUrl + ) + } + + /** @see RelayPool.closeLiveSubscription */ + suspend fun closeLiveSubscription(subId: String, relayUrl: String) { + return relayPool.closeLiveSubscription( + subId = subId, + relayUrl = relayUrl + ) + } + suspend fun closeQuery(closeCmd: CloseCmd, relayUrl: String) { return relayPool.closeQuery( closeCmd = closeCmd, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt index aa4442d8..23740028 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt @@ -36,7 +36,6 @@ import kotlin.math.min import kotlin.math.pow import kotlin.random.Random import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -71,6 +70,14 @@ internal class NostrSocketClientImpl( * back in lockstep for as long as the outage lasts. */ private const val RECONNECT_JITTER = 0.25 + + /** + * How far a collector may lag before its socket's reader is made to wait for it. + * Deep enough to absorb the burst a relay sends between a REQ and its EOSE while a + * collector writes each event to SQLite; not so deep that a genuinely stuck + * collector is invisible. + */ + private const val INBOUND_BUFFER = 256 } /** Outcome of a connect attempt, so the caller can tell a first connect from a repair. */ @@ -103,7 +110,19 @@ internal class NostrSocketClientImpl( @Volatile override var autoReconnect: Boolean = false - private val _incomingMessages = MutableSharedFlow() + /** + * Buffered on purpose. This used to be a rendezvous flow emitted into from a coroutine + * launched per message, which had two consequences a short-lived request/response + * collector never noticed and a permanent one would: messages reached collectors in + * whatever order those coroutines were scheduled, and an emit with every collector busy + * blocked on the slowest of them. + * + * With a buffer, a collector may fall [INBOUND_BUFFER] messages behind before it slows + * the socket reader down, and the reader emits in wire order. + */ + private val _incomingMessages = MutableSharedFlow( + extraBufferCapacity = INBOUND_BUFFER, + ) override val incomingMessages = _incomingMessages.asSharedFlow() override val socketUrl = wssUrl.cleanWebSocketUrl() @@ -179,13 +198,13 @@ internal class NostrSocketClientImpl( is Frame.Text -> { val text = frame.readText() logLargeText(text = text, url = socketUrl, incoming = true) - processIncomingMessage(text = text) + emitIncomingMessage(text = text) } is Frame.Binary -> { val decompressedMessage = decompressMessage(frame.data) logLargeText(text = decompressedMessage, url = socketUrl, incoming = true) - processIncomingMessage(text = decompressedMessage) + emitIncomingMessage(text = decompressedMessage) } is Frame.Close -> { @@ -302,15 +321,14 @@ internal class NostrSocketClientImpl( } } - private fun processIncomingMessage(text: String) { - text.parseIncomingMessage()?.let { - scope.launch { - if (it is NostrIncomingMessage.EoseMessage) { - delay(75.milliseconds) - } - _incomingMessages.emit(value = it) - } - } + /** + * Emits inline, on the reader, so messages reach collectors in the order the relay sent + * them. That replaces a 75ms delay this used to sleep before every EOSE — a way of + * hoping the events that preceded it had already been delivered by their own coroutines. + * Ordering is now a property rather than a race that usually resolved in time. + */ + private suspend fun emitIncomingMessage(text: String) { + text.parseIncomingMessage()?.let { _incomingMessages.emit(value = it) } } override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 44171146..c96ccc72 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -107,6 +107,23 @@ interface NostrRepository { activeKeyPair: KeyPair ) + /** + * Save a nostrEvent that arrived on a subscription rather than in answer to a queued + * request. + * + * The other two overloads take the request row an event was fetched for, because they + * also have to record which request produced it and flip that row to "processed". A + * live subscription has no row and never finishes, so it carries the relay and level + * itself. + */ + suspend fun saveNostrEvent( + nostrEvent: NostrEvent, + relayURL: String, + synchronizationRelayURLs: List, + level: Int, + activeKeyPair: KeyPair + ) + suspend fun queueSynchronizeNostrEvent( synchronizeNostrEventRequests: List, ) @@ -280,6 +297,16 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun saveNostrEvent( + nostrEvent: NostrEvent, + relayURL: String, + synchronizationRelayURLs: List, + level: Int, + activeKeyPair: KeyPair + ) { + TODO("Not yet implemented") + } + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { TODO("Not yet implemented") } From ba05ec704d5b1cb7591b61dcde2ee49163591425 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:09:08 +0200 Subject: [PATCH 05/33] feat(chat): keep a gift wrap subscription open for as long as the app is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first live subscription: kind 1059 p-tagged to us, on every DM relay, open until the wallet changes or the process ends. That covers direct messages and the Marmot Welcome events that make us a member of a group — so a DM now lands whether or not anyone has the chat list open, and a group invite is noticed without waiting for the next screen to schedule a sync. Nothing is removed from the queue yet. ChatRoomListViewModel and ChatMessageListViewModel still schedule what they always did; this runs beside them and the duplicate events cost a no-op storeNostrEvent each. The screens stop doing it in the last step, once groups are covered too. **No `since`.** NIP-59 randomizes a wrap's created_at into the past, and our own outbound path stamps them with TimeUtils.randomWithTwoDays() — so a wrap published right now can carry a timestamp two days old, and a `since` anywhere near the present would silently drop it. "Some messages just never arrive" is the worst failure mode to debug, and re-receiving a wrap costs one no-op write. **`limit` instead.** NIP-01 scopes limit to the initial query — the stored events a relay sends before EOSE — and explicitly not to the stream that follows, so limit=100 bounds what a reconnect costs without touching the live tail. Filling in the rest of the history stays negentropy's job. **What ends a subscription, and what doesn't.** CLOSED does, and is retried with backoff from 5s to 5m, jumping straight to the maximum when the reason parses as back-pressure — answering "too many subscriptions" by promptly opening another is how one refusal becomes a flood. EOSE does not end anything: it is the boundary between stored history and the live tail, and treating it as an end is precisely what made every sync a poll. It does reset the backoff, since a subscription that got that far was working and whatever ended it is a new problem. A dropped socket needs nothing from this loop at all — the pool replays the REQ on reconnect and the collector, attached to the socket client rather than to a session, simply starts receiving again. A NOTICE is logged and otherwise ignored. It carries no subscription id, so it reaches every collector on the socket; acting on one here would let an unrelated relay complaint tear this subscription down. Events are saved inline rather than in a launched coroutine, so they land in the order the relay sent them and the socket's buffer does the back-pressure, instead of fanning a burst out into a coroutine per event. Wiring: LiveSubscriptionManager is built by SynchronizationViewModel because it has to share that class's RelaysSocketManager — a second one would mean a second RelayPool and a duplicate socket per relay — and is launched inside the same per-wallet supervisorScope as the three pumps, so a wallet switch tears it down. It deliberately does not take from the pumps' Semaphore(4): a subscription that never finishes would hold a permit forever and permanently halve backfill throughput. The comment there now says so. isBackPressure moves out of SynchronizationViewModel to network/relays/RelayBackPressure.kt, unchanged, now that two callers need it. Co-Authored-By: Claude Opus 5 --- .../managers/LiveSubscriptionManager.kt | 245 ++++++++++++++++++ .../network/relays/RelayBackPressure.kt | 23 ++ .../ui/view/model/SynchronizationViewModel.kt | 37 +-- 3 files changed, 289 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayBackPressure.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt new file mode 100644 index 00000000..cccce2c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt @@ -0,0 +1,245 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +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.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.network.relays.RelaysSocketManager +import press.mantra.compose.network.relays.isRelayBackPressure +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.Relays +import press.mantra.compose.repository.NostrRepository +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Holds the chat subscriptions open for as long as it runs, so relays push new events at us + * instead of us asking for them once per screen. + * + * See `docs/long-running-sync.md`. The short version: every chat sync in this app used to be + * a pull — a screen queues a request row, a pump drains it, the relay answers, the + * subscription is closed — which is structurally incapable of delivering a message that + * arrives one second after EOSE. + * + * This deliberately does NOT share `SynchronizationViewModel`'s subscription semaphore. That + * budget is four permits across all relays, sized for requests that finish; a subscription + * that never finishes would hold one forever and permanently halve the backfill throughput. + * The two budgets have to be read together against what a relay will actually tolerate + * (commonly ~20 per connection), which they comfortably are. + */ +class LiveSubscriptionManager( + private val relaysSocketManager: RelaysSocketManager, + private val nostrRepository: NostrRepository, +) { + private val logger = Logger.withTag(TAG) + + companion object { + private const val TAG = "LiveSubscriptionManager" + + /** + * Stable, and the same on every relay. A subscription id is scoped to its socket, and + * a relay answers a repeated REQ on an existing id by replacing that subscription's + * filter — which is what lets a filter be widened later without a close-and-reopen. + */ + const val GIFT_WRAP_SUB_ID = "live-giftwrap" + + /** + * How much history to ask for when the subscription is opened. + * + * NIP-01 scopes `limit` to the initial query — the stored events a relay sends before + * EOSE — and explicitly not to the stream that follows. So this bounds what a + * reconnect costs without touching the live tail at all. Filling in the rest of the + * history is negentropy's job, not this subscription's. + */ + private const val INITIAL_HISTORY_LIMIT = 100 + + private val INITIAL_REOPEN_DELAY = 5.seconds + + private val MAX_REOPEN_DELAY = 5.minutes + } + + /** + * Runs until cancelled. Cancellation is the only exit: the caller scopes this to the + * active wallet, so a wallet switch tears every subscription down and the new wallet's + * call builds its own. + */ + suspend fun observe(keyPair: KeyPair): Unit = coroutineScope { + val publicKey = keyPair.pubKey.toHexKey() + logger.i("Opening live subscriptions for $publicKey") + + Relays.DefaultDMRelayList.forEach { relay -> + launch(Dispatchers.IO) { + runLiveSubscription( + subId = GIFT_WRAP_SUB_ID, + relayUrl = relay.url, + filters = listOf(giftWrapFilter(publicKey)), + keyPair = keyPair, + ) + } + } + } + + /** + * Every gift wrap addressed to us: direct messages, and the Marmot Welcome events that + * make us a member of a group. + * + * There is deliberately no `since`. NIP-59 randomizes a wrap's `created_at` into the past + * — our own outbound path stamps them with `TimeUtils.randomWithTwoDays()` — so a wrap + * published right now can carry a timestamp two days old, and a `since` anywhere near the + * present would silently drop it. "Some messages just never arrive" is the worst failure + * mode to debug, and re-receiving a wrap costs one no-op `storeNostrEvent`. + */ + private fun giftWrapFilter(publicKey: HexKey) = Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(publicKey)), + limit = INITIAL_HISTORY_LIMIT, + ) + + /** + * Keeps one subscription open on one relay, re-opening it if the relay ends it. + * + * A socket that merely drops needs nothing from here: the pool replays the REQ on + * reconnect and the collector below, which is attached to the socket client rather than + * to a session, simply starts receiving again. This loop is for the other case — a relay + * that answered with CLOSED, which is terminal for that subscription and needs a new one. + */ + private suspend fun runLiveSubscription( + subId: String, + relayUrl: String, + filters: List, + keyPair: KeyPair, + ) { + var reopenDelay = INITIAL_REOPEN_DELAY + + try { + while (currentCoroutineContext().isActive) { + val end = try { + collectUntilClosed( + subId = subId, + relayUrl = relayUrl, + filters = filters, + keyPair = keyPair, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + logger.e("Live subscription $subId on $relayUrl failed", error) + SubscriptionEnd(reason = error.message, reachedEose = false) + } + + // A subscription that got as far as EOSE was working. Whatever ended it is a + // new problem, not a continuing one, so it does not inherit the old backoff. + if (end.reachedEose) reopenDelay = INITIAL_REOPEN_DELAY + + reopenDelay = if (isRelayBackPressure(end.reason)) { + // Answering "you have too many subscriptions" by opening another one + // promptly is how one refusal becomes a flood of them. + logger.w("$relayUrl is rate limiting $subId; backing off to the maximum") + MAX_REOPEN_DELAY + } else { + minOf(reopenDelay * 2, MAX_REOPEN_DELAY) + } + + logger.w("Re-opening $subId on $relayUrl in $reopenDelay (ended: ${end.reason})") + delay(reopenDelay) + } + } finally { + // NonCancellable so a cancelled manager still releases the retained REQ, which is + // what tells the socket it no longer has a reason to reconnect. + withContext(NonCancellable) { + runCatching { relaysSocketManager.closeLiveSubscription(subId, relayUrl) } + .onFailure { logger.w(throwable = it) { "Failed to close $subId on $relayUrl" } } + } + } + } + + private data class SubscriptionEnd( + /** The relay's CLOSED reason, or the failure that ended the collection. */ + val reason: String?, + /** Whether the relay got as far as sending EOSE before it ended. */ + val reachedEose: Boolean, + ) + + private suspend fun collectUntilClosed( + subId: String, + relayUrl: String, + filters: List, + keyPair: KeyPair, + ): SubscriptionEnd { + var reason: String? = null + var reachedEose = false + + relaysSocketManager.openLiveSubscription( + reqCommand = ReqCmd(subId = subId, filters = filters), + relayUrl = relayUrl, + ).transformWhile { message -> + emit(message) + // CLOSED is the only message that ends a live subscription. EOSE emphatically + // does not: it is the boundary between the stored history and the live tail, + // and treating it as an end is exactly what makes a sync a poll. + message !is NostrIncomingMessage.ClosedMessage + }.collect { message -> + when (message) { + is NostrIncomingMessage.EventMessage -> { + message.nostrEvent?.let { save(it, relayUrl, keyPair) } + } + + is NostrIncomingMessage.EventsMessage -> { + message.nostrEvents.forEach { save(it, relayUrl, keyPair) } + } + + is NostrIncomingMessage.EoseMessage -> { + reachedEose = true + logger.i("$subId on $relayUrl has caught up; now live") + } + + is NostrIncomingMessage.ClosedMessage -> { + reason = message.message + logger.w("Relay closed $subId ($relayUrl): ${message.message}") + } + + is NostrIncomingMessage.NoticeMessage -> { + // Advisory only. A NOTICE carries no subscription id, so it is delivered + // to every collector on the socket — acting on one here would let an + // unrelated relay complaint tear this subscription down. + logger.d("Notice while running $subId ($relayUrl): ${message.message}") + } + + else -> logger.d("Unhandled message on $subId ($relayUrl): $message") + } + } + + return SubscriptionEnd(reason = reason, reachedEose = reachedEose) + } + + /** + * Saved inline rather than in a launched coroutine, on purpose. Sequential writes keep + * the events in the order the relay sent them and let the socket's buffer do the + * back-pressure, instead of fanning a burst out into a coroutine per event. + */ + private suspend fun save(nostrEvent: NostrEvent, relayUrl: String, keyPair: KeyPair) { + nostrRepository.saveNostrEvent( + nostrEvent = nostrEvent, + relayURL = relayUrl, + synchronizationRelayURLs = listOf(relayUrl), + level = 0, + activeKeyPair = keyPair, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayBackPressure.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayBackPressure.kt new file mode 100644 index 00000000..54c0ae96 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayBackPressure.kt @@ -0,0 +1,23 @@ +package press.mantra.compose.network.relays + +/** + * Whether a relay's CLOSED/NOTICE reason means "you are asking for too much" rather than + * "I cannot serve this". + * + * The difference decides what to do next, and getting it wrong is expensive in both + * directions: answering back-pressure by immediately opening another subscription is what + * turns one refusal into a flood of them, while treating an unsupported filter as + * back-pressure leaves a request unserved for minutes. + * + * NIP-01 gives `rate-limited:` as the machine-readable prefix; the free-text forms are what + * relays actually send. + */ +fun isRelayBackPressure(reason: String?): Boolean { + val text = reason?.lowercase() ?: return false + + return text.startsWith("rate-limited") || + text.contains("rate limit") || + text.contains("too many") || + text.contains("concurrent") || + text.contains("slow down") +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index 9f8fa9aa..95e3ffb9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -8,8 +8,10 @@ import press.mantra.compose.database.model.BroadcastNostrEventRequest import press.mantra.compose.database.model.SynchronizeNostrEventRequest import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.network.dto.toRelayDTO +import press.mantra.compose.managers.LiveSubscriptionManager import press.mantra.compose.network.relays.RelayPool import press.mantra.compose.network.relays.RelaysSocketManager +import press.mantra.compose.network.relays.isRelayBackPressure import press.mantra.compose.network.sockets.NostrIncomingMessage import press.mantra.compose.network.sockets.NostrSocketClientFactory import press.mantra.compose.repository.CachingImportRepository @@ -70,6 +72,16 @@ class SynchronizationViewModel( relayRepository = relayRepository, ) + /** + * The chat subscriptions that stay open while a wallet is active. Built here because it + * has to share this class's RelaysSocketManager — a second one would mean a second + * RelayPool and a duplicate socket per relay. + */ + private val liveSubscriptionManager = LiveSubscriptionManager( + relaysSocketManager = relaysSocketManager, + nostrRepository = nostrRepository, + ) + companion object { private const val TAG = "SynchronizationViewModel" @@ -150,6 +162,10 @@ class SynchronizationViewModel( * delivered to every collector on the socket, logged it once per open collector. * Requests queue on this semaphore instead, so the backlog still drains, just not * all at once. + * + * Live subscriptions are deliberately outside this budget (see LiveSubscriptionManager): + * a subscription that never finishes would hold a permit forever. The two have to be + * read together against what a relay tolerates per connection, not separately. */ private val subscriptionSlots = Semaphore(MAX_CONCURRENT_SUBSCRIPTIONS) @@ -167,21 +183,6 @@ class SynchronizationViewModel( } } - /** - * Whether a relay's CLOSED/NOTICE reason means "you are asking for too much" - * rather than "I cannot serve this". NIP-01 gives `rate-limited:` as the - * machine-readable prefix; the free-text forms are what relays actually send. - */ - private fun isBackPressure(reason: String?): Boolean { - val text = reason?.lowercase() ?: return false - - return text.startsWith("rate-limited") || - text.contains("rate limit") || - text.contains("too many") || - text.contains("concurrent") || - text.contains("slow down") - } - init { scope.launch { activeWalletStateFlow.collectLatest { activeWallet -> @@ -213,6 +214,10 @@ class SynchronizationViewModel( launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) } + // Runs until cancelled rather than draining a queue, but it belongs + // here for the same reason the pumps do: it needs the active wallet's + // key pair, and a wallet switch must tear it down. + launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } } } } @@ -512,7 +517,7 @@ class SynchronizationViewModel( // is telling us to ease off: answering back-pressure // by opening another subscription is what turned one // refusal into a flood of them. - if (isBackPressure(nostrIncomingMessage.message)) { + if (isRelayBackPressure(nostrIncomingMessage.message)) { logger.w("Relay ${negentropySynchronizeRequest.relayURL} is rate limiting; not retrying this request") } else if (negentropySynchronizeRequest.purpose != "mlsMessages") { nostrRepository.queueSynchronizeNostrEvent( From f81dc6af691b9a6bf7b0ec417a3fd0c967848400 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:12:40 +0200 Subject: [PATCH 06/33] feat(chat): keep group subscriptions open, and follow membership as it changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second live subscription: kind 445 h-tagged with every Marmot group we belong to, chunked 100 group ids to a filter, open on every DM relay. Group messages now arrive whether or not that chat is on screen — or any chat. **Derived from the room list, not from the join sites.** The filter comes from chatRepository.observeChatRoomListByPublicKey, the same Flow the chat list screen renders, collected at wallet scope so it keeps running with no chat screen open. That is the whole reason this stays correct: a group id can appear four ways, and only one of them is somewhere anyone would think to call a subscribe function. - we create a group (MarmotOutboundDao), - we are added to one — the Welcome arrives inside a gift wrap and is processed deep inside NostrDao.storeNostrEvent, on the inbound path, - membership shifts under us via a commit (MarmotInboundManager.processGroupMembershipChanges), - we leave, or the room is deleted. Observing the table catches all four, and it closes the loop with the previous commit: a Welcome lands on the gift wrap subscription, a ChatRoom row is written, this flow re-emits, the group filter widens — and the first message in a group we were just added to arrives without anyone opening a chat. Rooms are filtered to those with mlsGroupState (a NIP-17 room has none and is served by the gift wrap subscription instead) and without leftGroupAt or deletedAt — a room we have left keeps its history locally but must stop pulling new messages. Ids are sorted before distinctUntilChanged so the same membership in a different row order is the same value, then debounced 500ms: joining a group writes the room, its participants and placeholder profiles in quick succession, each of which re-emits the list. **Reconciling, not rebuilding.** A chunk that already has a subscription is updated in place — the relay replaces that subscription's filter on a repeated REQ with the same id — so adding a group does not interrupt delivery on the groups already being watched. Only chunks that no longer exist are torn down, and they are cancelAndJoin'd rather than cancelled: the CLOSE is sent from the coroutine's finally, and returning before it lands would let a later reconcile open a subscription on that id which the old one then closes out from under. The filter is read from a supplier at every open rather than captured at launch, so a subscription re-opened after a CLOSED comes back with the current membership rather than the membership it was created with. **No `since` here either, but for a different reason.** Group events do carry honest timestamps — MarmotOutboundDao stamps them with TimeUtils.now(), unlike gift wraps — so a watermark would be safe. It is still not used: limit already bounds the initial burst, and a watermark would have to be recomputed every time a chunk's membership changed. The group limit is 500 rather than the gift wrap subscription's 100, because one chunk covers up to 100 conversations. Also in here: RelayPool.updateLiveSubscription now retains the new REQ before attempting to send it, and treats a missing or failing socket as "send it on reconnect" rather than throwing. Throwing would have left the OLD filter retained, so a reconnect would restore a subscription the caller had already moved on from — the one outcome worse than not sending. Co-Authored-By: Claude Opus 5 --- .../managers/LiveSubscriptionManager.kt | 196 +++++++++++++++++- .../compose/network/relays/RelayPool.kt | 17 +- .../ui/view/model/SynchronizationViewModel.kt | 1 + 3 files changed, 205 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt index cccce2c3..a37fd240 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt @@ -1,6 +1,7 @@ package press.mantra.compose.managers import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -8,12 +9,19 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.IO +import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -23,8 +31,10 @@ import press.mantra.compose.network.relays.RelaysSocketManager import press.mantra.compose.network.relays.isRelayBackPressure import press.mantra.compose.network.sockets.NostrIncomingMessage import press.mantra.compose.nostr.Relays +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository -import kotlin.time.Duration +import kotlin.concurrent.Volatile +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -37,6 +47,11 @@ import kotlin.time.Duration.Companion.seconds * subscription is closed — which is structurally incapable of delivering a message that * arrives one second after EOSE. * + * Two subscriptions, on every DM relay: + * - [GIFT_WRAP_SUB_ID], kind 1059 p-tagged to us: direct messages, and the Marmot Welcome + * events that make us a member of a group. + * - [GROUP_SUB_ID_PREFIX]``, kind 445 h-tagged with the groups we belong to, in chunks. + * * This deliberately does NOT share `SynchronizationViewModel`'s subscription semaphore. That * budget is four permits across all relays, sized for requests that finish; a subscription * that never finishes would hold one forever and permanently halve the backfill throughput. @@ -46,6 +61,7 @@ import kotlin.time.Duration.Companion.seconds class LiveSubscriptionManager( private val relaysSocketManager: RelaysSocketManager, private val nostrRepository: NostrRepository, + private val chatRepository: ChatRepository, ) { private val logger = Logger.withTag(TAG) @@ -59,8 +75,19 @@ class LiveSubscriptionManager( */ const val GIFT_WRAP_SUB_ID = "live-giftwrap" + const val GROUP_SUB_ID_PREFIX = "live-groups-" + /** - * How much history to ask for when the subscription is opened. + * Group ids per `#h` filter. One subscription per group would be simpler, but every + * Marmot group here lives on the same DM relay set, so carrying many ids in one + * filter costs a fraction of the subscriptions. Conservative rather than maximal: + * relays cap the size of a filter's tag arrays, and a chunk that is refused is a + * chunk of conversations that go quiet. + */ + private const val MAX_GROUPS_PER_SUBSCRIPTION = 100 + + /** + * How much history to ask for when a subscription is opened. * * NIP-01 scopes `limit` to the initial query — the stored events a relay sends before * EOSE — and explicitly not to the stream that follows. So this bounds what a @@ -69,11 +96,34 @@ class LiveSubscriptionManager( */ private const val INITIAL_HISTORY_LIMIT = 100 + /** + * A group chunk covers up to [MAX_GROUPS_PER_SUBSCRIPTION] conversations at once, so + * the same initial window has to stretch much further than the gift wrap one. + */ + private const val INITIAL_GROUP_HISTORY_LIMIT = 500 + + /** + * Joining a group writes the room, its participants and placeholder profiles in quick + * succession, each of which re-emits the room list. Without this the group filter + * would be re-sent several times per join. + */ + private val GROUP_CHANGE_DEBOUNCE = 500.milliseconds + private val INITIAL_REOPEN_DELAY = 5.seconds private val MAX_REOPEN_DELAY = 5.minutes } + /** + * The group ids each `live-groups-` subscription is currently responsible for. + * + * Read by the subscription coroutines when they (re-)open, written by the single coroutine + * collecting the room list. Replaced wholesale rather than mutated, so a reader always + * sees a coherent chunking. + */ + @Volatile + private var groupIdChunks: List> = emptyList() + /** * Runs until cancelled. Cancellation is the only exit: the caller scopes this to the * active wallet, so a wallet switch tears every subscription down and the new wallet's @@ -88,11 +138,13 @@ class LiveSubscriptionManager( runLiveSubscription( subId = GIFT_WRAP_SUB_ID, relayUrl = relay.url, - filters = listOf(giftWrapFilter(publicKey)), + filters = { listOf(giftWrapFilter(publicKey)) }, keyPair = keyPair, ) } } + + launch(Dispatchers.IO) { followGroupMembership(publicKey = publicKey, keyPair = keyPair) } } /** @@ -111,6 +163,132 @@ class LiveSubscriptionManager( limit = INITIAL_HISTORY_LIMIT, ) + /** + * Marmot group messages for a chunk of the groups we belong to. + * + * Unlike gift wraps these carry honest timestamps (`MarmotOutboundDao` stamps them with + * `TimeUtils.now()`), so a `since` watermark would be safe here. It is still not used: + * `limit` already bounds the initial burst, and a watermark would have to be recomputed + * every time the chunk's membership changed. + */ + private fun groupFilter(groupIds: List) = Filter( + kinds = listOf(GroupEvent.KIND), + tags = mapOf("h" to groupIds), + limit = INITIAL_GROUP_HISTORY_LIMIT, + ) + + /** + * Keeps the group subscriptions matching the groups we are actually in. + * + * Derived from the room list rather than wired into the places a group is joined, because + * a group id can appear four ways and only one of them is somewhere anyone would think to + * call a subscribe function: we create a group, we are added to one (a Welcome processed + * deep inside `NostrDao.storeNostrEvent`), membership shifts under us via a commit, or we + * leave. Observing the table catches all four. + * + * It also closes the loop: a Welcome arrives on the gift wrap subscription above, a + * ChatRoom row is written, this flow re-emits, and the group filter widens — so the first + * message in a group we were just added to arrives without anyone opening a chat. + */ + @OptIn(FlowPreview::class) + private suspend fun followGroupMembership(publicKey: HexKey, keyPair: KeyPair): Unit = coroutineScope { + val subscriptions = mutableMapOf() + + chatRepository.observeChatRoomListByPublicKey(publicKey) + .map { rooms -> + rooms + // An MLS group is a room with group state; a NIP-17 room has none and is + // served by the gift wrap subscription instead. A room we have left or + // deleted keeps its history locally but must stop pulling new messages. + .filter { it.chatRoom.mlsGroupState != null } + .filter { it.chatRoom.leftGroupAt == null && it.chatRoom.deletedAt == null } + .map { it.chatRoom.id } + .sorted() + } + // Sorted first so that the same membership in a different row order is the same + // value, and a re-emit that changes nothing costs nothing. + .distinctUntilChanged() + .debounce(GROUP_CHANGE_DEBOUNCE) + .collect { groupIds -> + reconcileGroupSubscriptions( + groupIds = groupIds, + subscriptions = subscriptions, + keyPair = keyPair, + scope = this, + ) + } + } + + /** + * Brings the open group subscriptions in line with [groupIds]. + * + * A chunk that already has a subscription is updated in place — the relay replaces that + * subscription's filter and the collector never notices — so a new group does not + * interrupt delivery on the groups that were already being watched. Chunks that no longer + * exist have their coroutine cancelled, which sends the CLOSE from its `finally`. + */ + private suspend fun reconcileGroupSubscriptions( + groupIds: List, + subscriptions: MutableMap, + keyPair: KeyPair, + scope: CoroutineScope, + ) { + val chunks = groupIds.chunked(MAX_GROUPS_PER_SUBSCRIPTION) + groupIdChunks = chunks + + logger.i("Live group membership: ${groupIds.size} group(s) over ${chunks.size} subscription(s)") + + Relays.DefaultDMRelayList.forEach { relay -> + val relayUrl = relay.url + + chunks.forEachIndexed { index, chunk -> + val subId = "$GROUP_SUB_ID_PREFIX$index" + val key = subscriptionKey(relayUrl = relayUrl, subId = subId) + + if (subscriptions[key]?.isActive == true) { + relaysSocketManager.updateLiveSubscription( + reqCommand = ReqCmd(subId = subId, filters = listOf(groupFilter(chunk))), + relayUrl = relayUrl, + ) + } else { + subscriptions[key] = scope.launch(Dispatchers.IO) { + runLiveSubscription( + subId = subId, + relayUrl = relayUrl, + // Read at every open rather than captured, so a subscription that + // has to be re-opened after a CLOSED comes back with the current + // membership rather than the membership it was created with. + filters = { + groupIdChunks.getOrNull(index) + ?.takeIf { it.isNotEmpty() } + ?.let { listOf(groupFilter(it)) } + .orEmpty() + }, + keyPair = keyPair, + ) + } + } + } + + // Membership shrank far enough to need fewer subscriptions. + subscriptions.keys + .filter { it.startsWith("$relayUrl|$GROUP_SUB_ID_PREFIX") } + .filter { subscriptionIndex(it) >= chunks.size } + .forEach { key -> + logger.i("Closing $key: no groups left in that chunk") + // Joined, not just cancelled. The CLOSE is sent from that coroutine's + // finally, so returning before it lands would let a later reconcile open a + // subscription on the same id that the old one then closes out from under. + subscriptions.remove(key)?.cancelAndJoin() + } + } + } + + private fun subscriptionKey(relayUrl: String, subId: String) = "$relayUrl|$subId" + + private fun subscriptionIndex(key: String) = + key.substringAfterLast(GROUP_SUB_ID_PREFIX).toIntOrNull() ?: Int.MAX_VALUE + /** * Keeps one subscription open on one relay, re-opening it if the relay ends it. * @@ -122,18 +300,26 @@ class LiveSubscriptionManager( private suspend fun runLiveSubscription( subId: String, relayUrl: String, - filters: List, + filters: () -> List, keyPair: KeyPair, ) { var reopenDelay = INITIAL_REOPEN_DELAY try { while (currentCoroutineContext().isActive) { + val currentFilters = filters() + if (currentFilters.isEmpty()) { + // Nothing to ask for right now — a group chunk emptied out and the + // reconcile that will cancel this coroutine has not run yet. + delay(INITIAL_REOPEN_DELAY) + continue + } + val end = try { collectUntilClosed( subId = subId, relayUrl = relayUrl, - filters = filters, + filters = currentFilters, keyPair = keyPair, ) } catch (error: CancellationException) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt index 09752bbd..73a08734 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt @@ -355,13 +355,22 @@ class RelayPool( * Re-retained too, so a reconnect replays the new filter rather than the old one. */ suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { - val nostrSocketClient = socketClientFor(relayUrl) - ?: throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected") - val filterRequest = OptimizedJsonMapper.toJson(reqCommand) + // Retained first, and unconditionally. Recording the new filter is what matters: if + // the send below cannot happen the reconnect replays this, whereas a throw here would + // leave the OLD filter retained and a reconnect would restore a subscription the + // caller has already moved on from. retainRequest(relayUrl = relayUrl, subId = reqCommand.subId, message = filterRequest) - nostrSocketClient.sendMESSAGE(filterRequest) + + val nostrSocketClient = socketClientFor(relayUrl) + if (nostrSocketClient == null) { + logger.w("No socket for $relayUrl; ${reqCommand.subId} will be sent when one opens") + return + } + + runCatching { nostrSocketClient.sendMESSAGE(filterRequest) } + .onFailure { logger.w(throwable = it) { "Failed to update ${reqCommand.subId} on $relayUrl; retained for reconnect" } } } /** Ends a live subscription: forgets the retained REQ and tells the relay to stop. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index 95e3ffb9..146fbc81 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -80,6 +80,7 @@ class SynchronizationViewModel( private val liveSubscriptionManager = LiveSubscriptionManager( relaysSocketManager = relaysSocketManager, nostrRepository = nostrRepository, + chatRepository = chatRepository, ) companion object { From 0ef0a33350d7066bbc18d19de3799564b26b4a67 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:15:51 +0200 Subject: [PATCH 07/33] feat(chat): close live subscriptions in the background, catch up on return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscriptions that stay open are only free while the app is on screen. Holding a socket open behind a doze window achieves nothing but battery, and the relay drops the subscription anyway — so this ties them to the app lifecycle and adds the reconciliation that covers the gap. Nothing in this app observed the app lifecycle at all: MainActivity only calls setContent. AppLifecycle is a small singleton holding an isForeground StateFlow, fed from a LifecycleEventObserver in MantraNavHost (ON_START/ON_STOP), and read by LiveSubscriptionManager. A singleton rather than something threaded through the composition because the consumers are not composables — they are application-scoped coroutines started before any screen exists and outliving all of them. It defaults to foreground: on a platform that has not wired the observer up, "always on" is the behaviour that predates this file, and a subscription that never opens is a far worse failure than one that stays open too long. collectLatest over that flow is the entire mechanism. Backgrounding cancels the block holding the subscriptions, and each one's finally sends its CLOSE and releases the retained REQ on the way out — which is also what tells the socket it no longer has a reason to reconnect. On the way back: **Reconnect before asking for anything.** RelayPool.reconnectAll tears every socket down and immediately rebuilds it. Trusting the connection is the mistake here: a socket that was open when the OS suspended the process reports itself connected on the way back while being functionally dead. Re-opening eagerly rather than leaving it to the next send is deliberate — it is what makes this a RE-connection, so retained subscriptions are replayed and any collector still attached from before the gap starts receiving again. That also covers queue requests that were mid-flight when we went away, which would otherwise sit until SUBSCRIPTION_TIMEOUT. **Then a catch-up reconciliation.** A live subscription answers "what is new since I connected"; negentropy answers "what do you have that I don't". Coming back from a gap is exactly the question only the second can answer — `limit` on the re-opened subscriptions is a window, not a guarantee. queueCatchUpSynchronization queues the same two negentropy requests ChatRoomListViewModel queues on open: gift wraps p-tagged to us, and group events h-tagged with every group we are in. Deliberately the same filter shape as the screen's, down to limit=50. A negentropy request is stored under a hash of its filter, so an identical shape collapses into one row instead of queueing the same reconciliation twice while both callers exist. The value itself barely matters — the negentropy pump drops `limit` outright and it only survives into the plain-REQ fallback. The room-to-group-id rule (has MLS state, not left, not deleted, sorted) now lives in one place, since the catch-up and the subscription reconcile have to agree on what "a group we are in" means. Not covered here: connectivity changes. A network switch mid-foreground is still only noticed by the socket's own reconnect loop, which handles the common case but cannot know the network changed underneath it. That wants a platform connectivity observer, and is its own change. Co-Authored-By: Claude Opus 5 --- .../press/mantra/compose/AppLifecycle.kt | 32 +++++ .../managers/LiveSubscriptionManager.kt | 128 ++++++++++++++++-- .../compose/network/relays/RelayPool.kt | 21 +++ .../network/relays/RelaysSocketManager.kt | 3 + .../ui/composable/navigation/MantraNavHost.kt | 20 +++ .../ui/view/model/SynchronizationViewModel.kt | 2 + 6 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/AppLifecycle.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/AppLifecycle.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/AppLifecycle.kt new file mode 100644 index 00000000..7f833932 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/AppLifecycle.kt @@ -0,0 +1,32 @@ +package press.mantra.compose + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether the app is in the foreground, for the parts of the app that hold network + * connections open. + * + * A singleton rather than something threaded through the composition, because the consumers + * are not composables: they are long-lived coroutines at application scope, started before + * any screen exists and outliving all of them. + * + * Defaults to foreground. Nothing here can observe a platform that has not wired + * [enteredForeground]/[enteredBackground] up, and on such a platform "always on" is the + * behaviour that predates this file — a subscription that never opens is a far worse failure + * than one that stays open too long. + */ +object AppLifecycle { + private val _isForeground = MutableStateFlow(true) + + val isForeground: StateFlow = _isForeground.asStateFlow() + + fun enteredForeground() { + _isForeground.value = true + } + + fun enteredBackground() { + _isForeground.value = false + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt index a37fd240..04145fc1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -26,7 +28,9 @@ import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.network.relays.RelaysSocketManager import press.mantra.compose.network.relays.isRelayBackPressure import press.mantra.compose.network.sockets.NostrIncomingMessage @@ -62,6 +66,7 @@ class LiveSubscriptionManager( private val relaysSocketManager: RelaysSocketManager, private val nostrRepository: NostrRepository, private val chatRepository: ChatRepository, + private val isForeground: StateFlow, ) { private val logger = Logger.withTag(TAG) @@ -112,6 +117,17 @@ class LiveSubscriptionManager( private val INITIAL_REOPEN_DELAY = 5.seconds private val MAX_REOPEN_DELAY = 5.minutes + + /** + * Matches what `ChatRoomListViewModel` queued, deliberately: the id a negentropy + * request is stored under is a hash of its filter, so an identical filter shape + * collapses into the same row rather than queueing the same reconciliation twice + * while both callers exist. + * + * The value itself barely matters — the negentropy pump drops `limit` outright, and it + * only survives into the plain-REQ fallback. + */ + private const val CATCH_UP_LIMIT = 50 } /** @@ -129,9 +145,33 @@ class LiveSubscriptionManager( * active wallet, so a wallet switch tears every subscription down and the new wallet's * call builds its own. */ - suspend fun observe(keyPair: KeyPair): Unit = coroutineScope { + suspend fun observe(keyPair: KeyPair) { val publicKey = keyPair.pubKey.toHexKey() - logger.i("Opening live subscriptions for $publicKey") + + // collectLatest is the whole lifecycle mechanism: going to the background cancels the + // block below, and every subscription's `finally` sends its CLOSE on the way out. + isForeground.collectLatest { foreground -> + if (!foreground) { + logger.i("Backgrounded; live subscriptions closed") + return@collectLatest + } + + runWhileForeground(publicKey = publicKey, keyPair = keyPair) + } + } + + private suspend fun runWhileForeground(publicKey: HexKey, keyPair: KeyPair): Unit = coroutineScope { + logger.i("Foregrounded; opening live subscriptions for $publicKey") + + // Before anything is asked for. A socket that was open when the OS suspended us + // reports itself connected on the way back while being functionally dead, and the + // relay dropped our subscriptions long ago. + runCatching { relaysSocketManager.reconnectAll() } + .onFailure { logger.w(throwable = it) { "Reconnect on foreground failed" } } + + // Live subscriptions cover the window we are online for; this covers the gap we were + // not. Neither subsumes the other. + launch(Dispatchers.IO) { queueCatchUpSynchronization(publicKey) } Relays.DefaultDMRelayList.forEach { relay -> launch(Dispatchers.IO) { @@ -147,6 +187,62 @@ class LiveSubscriptionManager( launch(Dispatchers.IO) { followGroupMembership(publicKey = publicKey, keyPair = keyPair) } } + /** + * Reconciles what we hold against what the relays hold, for the time we were away. + * + * A live subscription answers "what is new since I connected"; negentropy answers "what do + * you have that I don't". Coming back from the background is exactly the question only the + * second one can answer, and `limit` on the re-opened subscriptions is a window, not a + * guarantee. + */ + private suspend fun queueCatchUpSynchronization(publicKey: HexKey) { + val groupIds = liveGroupIds(publicKey) + + val giftWrapFilter = SynchronizationFilter( + kinds = arrayOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(publicKey)), + limit = CATCH_UP_LIMIT, + ) + + val requests = mutableListOf() + + Relays.DefaultDMRelayList.forEach { relay -> + requests += NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = relay.url, + synchronizationFilter = giftWrapFilter, + ), + purpose = "chat", + synchronizationFilter = giftWrapFilter, + relayURL = relay.url, + level = 0, + ) + + if (groupIds.isEmpty()) return@forEach + + val groupSynchronizationFilter = SynchronizationFilter( + kinds = arrayOf(GroupEvent.KIND), + tags = mapOf("h" to groupIds), + ) + requests += NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = relay.url, + synchronizationFilter = groupSynchronizationFilter, + ), + purpose = "mlsMessages", + synchronizationFilter = groupSynchronizationFilter, + relayURL = relay.url, + level = 0, + ) + } + + logger.i("Queueing ${requests.size} catch-up reconciliation(s) over ${groupIds.size} group(s)") + nostrRepository.queueNegentropySynchronizeRequest(requests) + } + + private suspend fun liveGroupIds(publicKey: HexKey): List = + chatRepository.getChatRoomListByPublicKey(publicKey).toLiveGroupIds() + /** * Every gift wrap addressed to us: direct messages, and the Marmot Welcome events that * make us a member of a group. @@ -195,18 +291,7 @@ class LiveSubscriptionManager( val subscriptions = mutableMapOf() chatRepository.observeChatRoomListByPublicKey(publicKey) - .map { rooms -> - rooms - // An MLS group is a room with group state; a NIP-17 room has none and is - // served by the gift wrap subscription instead. A room we have left or - // deleted keeps its history locally but must stop pulling new messages. - .filter { it.chatRoom.mlsGroupState != null } - .filter { it.chatRoom.leftGroupAt == null && it.chatRoom.deletedAt == null } - .map { it.chatRoom.id } - .sorted() - } - // Sorted first so that the same membership in a different row order is the same - // value, and a re-emit that changes nothing costs nothing. + .map { rooms -> rooms.toLiveGroupIds() } .distinctUntilChanged() .debounce(GROUP_CHANGE_DEBOUNCE) .collect { groupIds -> @@ -284,6 +369,21 @@ class LiveSubscriptionManager( } } + /** + * An MLS group is a room with group state; a NIP-17 room has none and is served by the + * gift wrap subscription instead. A room we have left or deleted keeps its history + * locally but must stop pulling new messages. + * + * Sorted so the same membership in a different row order is the same value, and a + * re-emit that changes nothing costs nothing downstream. + */ + private fun List.toLiveGroupIds() = + this + .filter { it.chatRoom.mlsGroupState != null } + .filter { it.chatRoom.leftGroupAt == null && it.chatRoom.deletedAt == null } + .map { it.chatRoom.id } + .sorted() + private fun subscriptionKey(relayUrl: String, subId: String) = "$relayUrl|$subId" private fun subscriptionIndex(key: String) = diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt index 73a08734..d4a1be20 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt @@ -166,6 +166,27 @@ class RelayPool( fun hasRelays() = relays.isNotEmpty() + /** + * Tears every socket down and immediately builds it again. + * + * For coming back from the background, where trusting the connection is the mistake: a + * socket that was open when the OS suspended the process reports itself connected on the + * way back while being functionally dead, and the relay has long since dropped the + * subscriptions it was carrying. + * + * Re-opening here rather than leaving it to the next send is deliberate — it is what makes + * this a RE-connection, so the retained subscriptions are replayed and any collector still + * attached from before the gap starts receiving again. + */ + suspend fun reconnectAll() { + socketClients.forEach { client -> + updateRelayStatus(url = client.socketUrl, connected = false) + runCatching { client.close() } + runCatching { client.ensureSocketConnectionOrThrow() } + .onFailure { logger.w(throwable = it) { "Could not re-open ${client.socketUrl}" } } + } + } + suspend fun tryConnectingToRelay(url: String) { runCatching { socketClients.find { it.socketUrl == url }?.ensureSocketConnectionOrThrow() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index 256828de..8b18428f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -126,6 +126,9 @@ class RelaysSocketManager( ) } + /** @see RelayPool.reconnectAll */ + suspend fun reconnectAll() = relayPool.reconnectAll() + fun tryConnectingToAllRelays() { relayPool.relays.forEach { scope.launch { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 9f9d612e..1a6cd763 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -3,10 +3,14 @@ package press.mantra.compose.ui.composable.navigation import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import press.mantra.compose.AppLifecycle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController @@ -219,6 +223,22 @@ fun MantraNavHost( // TODO: Produce a synchronization UI element... + // The one place in the app that knows whether it is on screen. Everything that holds a + // relay connection open reads AppLifecycle rather than a lifecycle owner, because those + // consumers are application-scoped coroutines that outlive any composition. + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> AppLifecycle.enteredForeground() + Lifecycle.Event.ON_STOP -> AppLifecycle.enteredBackground() + else -> Unit + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + LaunchedEffect(lifecycleOwner) { navigationViewModel.navigationUIState.collect { state -> when (state) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index 146fbc81..fab0fb71 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.model.BroadcastNostrEventRequest import press.mantra.compose.database.model.SynchronizeNostrEventRequest import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.network.dto.toRelayDTO +import press.mantra.compose.AppLifecycle import press.mantra.compose.managers.LiveSubscriptionManager import press.mantra.compose.network.relays.RelayPool import press.mantra.compose.network.relays.RelaysSocketManager @@ -81,6 +82,7 @@ class SynchronizationViewModel( relaysSocketManager = relaysSocketManager, nostrRepository = nostrRepository, chatRepository = chatRepository, + isForeground = AppLifecycle.isForeground, ) companion object { From ca1093637c81d941d74d126107360232fba86af8 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:18:28 +0200 Subject: [PATCH 08/33] refactor(chat): stop syncing chat messages on screen open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The point of the previous four commits. Both chat screens scheduled a message sync every time they were opened; both are now covered by subscriptions that are already open, so the sync on open is work with nothing left to do. **ChatRoomListViewModel** no longer syncs on initiate(). scheduleSynchronization itself stays, and is unchanged: it is exactly the reconciliation LiveSubscriptionManager runs when the app returns from the background, and it is what an explicit user-initiated refresh should call. That is the one case the live tier genuinely does not answer, because it is the user saying they believe something is missing. **ChatMessageListViewModel** loses its message sync entirely. The MLS branch (negentropy over 445 h-tagged with this one room) is a strict subset of live-groups-*. The NIP-17 branch's gift wrap sync is a subset of live-giftwrap. What survives is discovery rather than sync: if we do not hold a participant's kind-10050 we cannot address a message to them, and that is worth resolving the moment a chat is opened rather than whenever a background pass reaches it. The function now does only that, and only when the relay list is actually missing — it used to queue a request in both branches of that test. **The dead "sent-messages" reconciliation is deleted**, in the view model and at both sites in NostrDao. It asked for kind 1059 with authors=[userPublicKey], and could never match a single event: a gift wrap is signed with a throwaway KeyPair (DatabaseChatRepository), so its pubkey is random and never ours. It was also unnecessary — createNip17ChatRoom puts us in our own participant list, so we wrap a copy to ourselves and the live gift wrap subscription picks our own sent messages up on every device. Removing it inverts the surrounding test in NostrDao from `if (relayList != null) { sync } else { discover }` to `if (relayList == null) { discover }`. The discovery half is untouched. Net effect on a session: opening the chat list queues nothing, opening a chat queues at most a kind-10050 lookup for a participant we cannot yet address, and messages arrive because a subscription is open rather than because a screen asked. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 77 +++---------- .../ui/view/model/ChatMessageListViewModel.kt | 103 +++++------------- .../ui/view/model/ChatRoomListViewModel.kt | 11 +- 3 files changed, 51 insertions(+), 140 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 304a048c..026e3439 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -639,41 +639,15 @@ abstract class NostrDao( } } - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - tags = mapOf( - Pair( - "p", - listOf(participant.participantPublicKey) - ) - ), - limit = 50 - ) - database.negentropySynchronizeRequestDao() - .insert( - chatMessageRelayListEvent.relays() - .map { normalizedRelayUrl -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = normalizedRelayUrl.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "sent-messages", - synchronizationFilter = synchronizationFilter, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - ) - } else { + // A "sent-messages" reconciliation used to be queued here, + // for gift wraps with authors=[userPublicKey] p-tagged at this + // participant. It could never match anything: a gift wrap is + // signed with a throwaway key (see DatabaseChatRepository), so + // its pubkey is random and never ours. It was also unnecessary + // — createNip17ChatRoom puts us in our own participant list, so + // we wrap a copy to ourselves and the live gift wrap + // subscription picks our own sent messages up on every device. + if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") // Sync ChatMessageRelayListEvent publicKey... @@ -875,35 +849,10 @@ abstract class NostrDao( } } - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - tags = mapOf( - Pair("p", listOf(participant.participantPublicKey)) - ), - limit = 50 - ) - database.negentropySynchronizeRequestDao().insert( - chatMessageRelayListEvent.relays().map { normalizedRelayUrl -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = normalizedRelayUrl.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "sent-messages", - synchronizationFilter = synchronizationFilter, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - ) - } else { + // See the identical block above: the "sent-messages" + // reconciliation that used to live here could never match, and is + // covered by the live gift wrap subscription regardless. + if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") // Sync ChatMessageRelayListEvent publicKey... diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 27d40b07..7cb011d2 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -70,9 +70,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.view.state.ChatMessageListUIState import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged @@ -110,63 +108,50 @@ class ChatMessageListViewModel( } } + /** + * Finds out where this room's participants read their messages. + * + * This used to schedule the room's message sync as well — gift wraps for a NIP-17 room, + * group events for an MLS one. Both are now covered by the subscriptions + * LiveSubscriptionManager holds open for the whole account, so opening a chat no longer + * asks for its messages; they are already arriving. + * + * What is left is discovery, not message sync: if we do not hold a participant's + * kind-10050 we cannot address a message to them, and that is worth resolving the moment + * a chat is opened rather than whenever a background pass gets to it. + */ fun scheduleSynchronization() { logger.d("scheduleSynchronization") viewModelScope.launch(Dispatchers.IO) { - // Sync Notifications... might want to also run this in the background if (localChatRoom.chatRoom.mlsGroupState == null) { localChatRoom.localParticipants.filter { it.participant.participantPublicKey != localChatRoom.chatRoom.userPublicKey }.forEach { recipients -> val chatMessageRelayListEvent = chatRepository.getChatMessageRelayForPublicKey(recipients.participant.participantPublicKey) - val relayAndSynchronizationFilter = if (chatMessageRelayListEvent != null) { - // Sync messages from this relay... - Pair( - chatMessageRelayListEvent.relays(), - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - tags = mapOf( - Pair("p", listOf(recipients.participant.participantPublicKey)) - ), - limit = 50 - ) - ) + // We already know where to reach them; nothing to find out. + if (chatMessageRelayListEvent != null) return@forEach - } else { - // Try to find the ChatMessageRelayList for this participant... - isReceiverChatMessageRelayListMissing.value = true - // TODO: compute the timestamp for when list we sent messages and use that as since... - Pair( - Relays.DefaultDMRelayList, - SynchronizationFilter( - kinds = arrayOf( - ChatMessageRelayListEvent.KIND, - ), - authors = arrayOf( - recipients.participant.participantPublicKey - ), - limit = 50 - ) - ) - } + isReceiverChatMessageRelayListMissing.value = true - logger.i("GiftWrapFilter: ${relayAndSynchronizationFilter.second}") + val chatMessageRelayListFilter = SynchronizationFilter( + kinds = arrayOf( + ChatMessageRelayListEvent.KIND, + ), + authors = arrayOf( + recipients.participant.participantPublicKey + ), + limit = 50 + ) - val negentropySynchronizeRequests = relayAndSynchronizationFilter.first.map { normalizedRelayUrl -> + val negentropySynchronizeRequests = Relays.DefaultDMRelayList.map { normalizedRelayUrl -> NegentropySynchronizeRequest( id = NegentropySynchronizeRequest.computeId( relayURL = normalizedRelayUrl.url, - synchronizationFilter = relayAndSynchronizationFilter.second + synchronizationFilter = chatMessageRelayListFilter ), - purpose = if (isReceiverChatMessageRelayListMissing.value) { - "chat-message-relays" - } else { - "sent-messages" - }, - synchronizationFilter = relayAndSynchronizationFilter.second, + purpose = "chat-message-relays", + synchronizationFilter = chatMessageRelayListFilter, relayURL = normalizedRelayUrl.url, level = 0 ) @@ -177,39 +162,7 @@ class ChatMessageListViewModel( negentropySynchronizeRequests ) } - } else { - // Sync mlsMessages - val relayChatRoomMaps = Relays.DefaultDMRelayList.map { dmRelay -> - dmRelay.url to listOf( - localChatRoom.chatRoom.id - ) - } - - val negentropySyncRequests = relayChatRoomMaps.map { relayChatRoomMap -> - val mlsGroupMessageFilter = SynchronizationFilter( - kinds = arrayOf(GroupEvent.KIND), - tags = mapOf("h" to relayChatRoomMap.second), - ) - logger.d("mlsGroupMessageFilter: $mlsGroupMessageFilter") - - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = relayChatRoomMap.first, - synchronizationFilter = mlsGroupMessageFilter - ), - purpose = "mlsMessages", - synchronizationFilter = mlsGroupMessageFilter, - relayURL = relayChatRoomMap.first, - level = 0 - ) - } - - logger.d("negentropySyncRequests: ${negentropySyncRequests.map { it.synchronizationFilter }}") - nostrRepository.queueNegentropySynchronizeRequest( - negentropySyncRequests - ) } - } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt index 994f76bb..8251e967 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt @@ -50,7 +50,6 @@ class ChatRoomListViewModel( fun initiate() { logger.d("init") - scheduleSynchronization() observeChatRoomFeed() } @@ -68,6 +67,16 @@ class ChatRoomListViewModel( } } + /** + * Reconciles this account's chat history against the relays: gift wraps addressed to us, + * and group events for every group we are in. + * + * No longer called on open. LiveSubscriptionManager holds both of those subscriptions + * open for as long as the app is in the foreground and runs exactly this reconciliation + * when it returns from the background, so opening the list is no longer a reason to ask. + * This stays for an explicit user-initiated refresh — the one case the live tier does not + * answer, because it is the user saying they believe something is missing. + */ fun scheduleSynchronization() { logger.d("scheduleSynchronization") viewModelScope.launch(Dispatchers.IO) { From 385c58ba7e5f7350e661067cd8846380f8bd7fe5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:20:08 +0200 Subject: [PATCH 09/33] docs: rewrite the sync note as what exists rather than what to build The design landed across the six commits before this one, so the note is now describing code. Reorganised around that: the reasoning that made it worth writing is unchanged, but "the shape to build" is now "how it holds together" and points at the classes, and the numbered traps have become properties of the thing rather than warnings about a thing that did not exist yet. Three sections earn their place after the fact: - the two timestamp decisions, which are the ones most likely to be "cleaned up" by someone who has not read this: no `since` on kind 1059 because our own wraps are stamped up to two days in the past, and no watermark on 445 even though it would be safe, because `limit` already bounds the burst. - the four ways a group id can appear, which is why the group filter is derived from the room list rather than wired at the join sites. - "Not done", which was previously implicit in a staging plan: connectivity changes, NIP-42 AUTH, the collector-per-socket router the design originally called for, and the fact that the DM relay set is one relay. The "suggested order" section is gone; git log is a better record of it. Co-Authored-By: Claude Opus 5 --- docs/README.md | 2 +- docs/long-running-sync.md | 440 ++++++++++++++------------------------ 2 files changed, 164 insertions(+), 278 deletions(-) diff --git a/docs/README.md b/docs/README.md index 76adeea4..92b74d2e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ silent, or a decision that looked arbitrary and was not. | [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering | | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | -| [long-running-sync.md](./long-running-sync.md) | keeping the chat subscriptions open instead of pulling once per screen — what the request-queue pipeline assumes about short subscriptions, and how the group filter follows the room list | +| [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | Start with the ceremony if you are new to this area; the next two both assume it. The sync note stands alone. diff --git a/docs/long-running-sync.md b/docs/long-running-sync.md index ef8ad0c0..9ed04e0e 100644 --- a/docs/long-running-sync.md +++ b/docs/long-running-sync.md @@ -1,333 +1,219 @@ -# A long-running chat sync +# The long-running chat sync -Today every chat sync in this app is a *pull*: a screen queues a request row, a -pump drains it, the relay answers, the subscription is closed. Nothing arrives -between pulls. This document works out what it takes to keep the chat -subscriptions **open** for as long as the app is active, so relays push new gift -wraps and group events at us instead of us asking, and what has to change in the -existing pipeline before that is even possible. +Chat messages arrive because a subscription is open, not because a screen asked. +This note is what that means, what it replaced, and the handful of decisions +inside it that look arbitrary and are not. -The target is the two kinds that carry conversation: +Two subscriptions carry conversation, and they are held open for as long as the +app is in the foreground: - **kind 1059**, NIP-59 gift wraps p-tagged to us — DMs, and the Marmot Welcome events that make us a member of a new group. -- **kind 445**, Marmot group events h-tagged with a group id we belong to. +- **kind 445**, Marmot group events h-tagged with every group id we belong to. -And one hard requirement: the group subscription must widen the moment we join -or create a group, without anyone remembering to call a "subscribe" function. +The code is [`LiveSubscriptionManager`](composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt), +built and scoped by `SynchronizationViewModel`. -## What happens today +## What this replaced -Three pumps in `SynchronizationViewModel` drain three single-row queues — -broadcast, REQ, negentropy — each row observed with `... ORDER BY createdAt ASC -LIMIT 1`. Screens are producers. Nothing else opens a socket. +Every chat sync used to be a *pull*. A screen queued a request row, a pump in +`SynchronizationViewModel` drained it, the relay answered, the subscription was +closed at EOSE. Nothing arrived between pulls, so a message sent one second after +EOSE waited for the next time someone opened a screen. -| screen | queues | +| screen | queued | |---|---| -| [`ChatRoomListViewModel.scheduleSynchronization`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt:71) | negentropy over `1059 #p=[me] limit 50`, then negentropy over `445 #h=[every group id]`, once per DM relay | -| [`ChatMessageListViewModel.scheduleSynchronization`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt:113) | for an MLS room: negentropy over `445 #h=[this group]`. For a NIP-17 room: gift wraps p-tagged to the *peer* and authored by us, or the peer's kind-10050 if we lack it | +| `ChatRoomListViewModel.scheduleSynchronization` | negentropy over `1059 #p=[me]`, then over `445 #h=[every group id]`, per DM relay | +| `ChatMessageListViewModel.scheduleSynchronization` | for an MLS room, negentropy over `445 #h=[this group]`; for a NIP-17 room, gift wraps p-tagged at the peer | -A negentropy exchange that finds ids we are missing turns them into -`SynchronizeNostrEventRequest` rows, which the REQ pump then fetches in chunks of -500 ids. Both subscription types end the same way: at EOSE the flow completes, -the `finally` sends CLOSE, the semaphore permit is released. +Neither runs on open any more. The first stays as the entry point for an +explicit user-initiated refresh — the one thing the live tier does not answer, +because it is the user saying they believe something is missing. The second now +does discovery only: if we do not hold a participant's kind-10050 we cannot +address a message to them, and that is worth resolving when a chat is opened +rather than whenever a background pass reaches it. -That design is good at what it is for — reconciling a set we already have -against a set the relay has. It is structurally incapable of delivering a message -that arrives one second after EOSE. +## Why the old pipeline could not simply stop closing -## Why we cannot simply stop closing +Five properties, each of which had to be undone deliberately. None was wrong; +all of them assume a subscription is short. -Five properties of the current pipeline each have to be undone deliberately. -None of them is wrong; all of them assume a subscription is short. +**1. EOSE ends the flow.** `completeOnSubscriptionEnd` is what makes a collector +finish at all — the socket's `incomingMessages` is hot, and a filtered view of it +never completes on its own. `RelayPool.openLiveSubscription` is `query()` without +it: for a live subscription EOSE is only the boundary between stored history and +the live tail, and CLOSED is the only genuinely terminal message. -**1. EOSE ends the flow.** -[`completeOnSubscriptionEnd`](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt:42) -is what makes a collector finish at all — the socket's `incomingMessages` is hot -and a filtered view of it never completes on its own. For a live subscription, -EOSE is not the end, it is the boundary between "stored history" and "live -tail", and CLOSED is the only genuinely terminal message. - -**2. Every subscription is force-killed at 120 seconds.** -[`SUBSCRIPTION_TIMEOUT`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt:98) -exists so a relay that goes quiet cannot park a slot forever. A live -subscription is exactly the case it is designed to kill. +**2. Every subscription was force-killed at 120 seconds.** `SUBSCRIPTION_TIMEOUT` +exists so a relay that goes quiet cannot park a slot forever. It applies to the +queue, and nothing else. **3. There are four subscription slots, total, across all relays.** -[`subscriptionSlots`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt:154) -is a `Semaphore(4)`. A permanently-open subscription is a permanently-held -permit. Two live subscriptions would leave the backfill queue with two slots and -a permanent halving of throughput. **Live subscriptions must not draw from this -semaphore** — they need their own budget, and the semaphore's comment ("well -under the ~20-per-connection cap relays commonly enforce") needs re-reading as a -combined budget rather than a queue-only one. +`subscriptionSlots` is a `Semaphore(4)`. A permanently-open subscription is a +permanently-held permit, so the live tier deliberately does not draw from it. The +two budgets have to be read together against what a relay tolerates per +connection (commonly ~20), not separately. -**4. The queue row *is* the subscription identity and the provenance record.** -The subId is the row's UUID, and both `saveNostrEvent` overloads -([`DatabaseNostrRepository.kt:483`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt:483), -[`:515`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt:515)) -take a request object, use its `relayURL`/`level`, and flip its status to -"processed". A live subscription has no row and never finishes. It needs a third -entry point that goes straight to -[`NostrDao.storeNostrEvent`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt:141) -with an explicit `relayURL` and `level = 0`. +**4. The queue row was the subscription identity and the provenance record.** Both +original `saveNostrEvent` overloads take a request object, use its `relayURL` and +`level`, and flip its status to "processed". A live subscription has no row and +never finishes, so there is a third overload carrying relay and level itself. -**5. Nothing reconnects.** -`NostrSocketClientImpl` catches a socket failure, calls `close()`, and fires -`onSocketConnectionClosed`, which only flips a boolean in `relayPoolStatus`. -There is no retry loop anywhere. Today that is invisible: every subscription is -short, and the next queued request re-opens the socket through -`ensureSocketConnectionOrThrow`. A live subscription that outlives a socket drop -is a subscription that has silently stopped, on a socket whose status flag says -so and which nobody reads. +**5. Nothing reconnected.** `NostrSocketClientImpl` caught a failure, called +`close()`, and fired a callback that only flipped a boolean nobody read. That was +survivable *only* because every subscription was short: the next queued request +opened a fresh socket on its way out. See "Reconnecting" below. -There is a sixth, quieter problem. `_incomingMessages` is a -[`MutableSharedFlow()`](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:53) -with no replay and no buffer, and each frame is emitted from its own `launch` -([`processIncomingMessage`](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:146)). -A message emitted while nothing is collecting is dropped, and messages can be -delivered out of order relative to the wire. For request/response that is -harmless — the collector is attached before the REQ goes out. For a firehose -whose consumer does MLS decryption and SQLite writes, it is not: give the router -a buffered channel and process in arrival order per relay. +There was a sixth, quieter one. `_incomingMessages` was a rendezvous +`MutableSharedFlow` emitted into from a coroutine launched per message, so +messages reached collectors in scheduling order rather than wire order, and an +emit with every collector busy blocked on the slowest. It now has a 256-message +buffer and is emitted into inline, on the reader. That also retired a 75ms sleep +before every EOSE, which existed to *hope* the events preceding it had already +been delivered. -## What Wisp does +## How it holds together -Wisp (`/home/sigidli/Documents/development/nostr/wisp`) keeps its subscriptions -open for the life of the foreground app. Five mechanisms are worth copying: +### Reconnecting -- **Stable, meaningful subscription ids.** `"dms"`, `"notif"`, `"grp-"`. Not - UUIDs. Re-sending a REQ with the same id replaces the filter server-side, which - makes "widen the filter" a send rather than a close-and-reopen. -- **The REQ text is retained per relay.** `activeSubscriptions: Map>`, and `resyncSubscriptions(relay)` re-sends all of them - when that relay reconnects (`relay/RelayPool.kt`). -- **A per-relay subscription budget.** `SubscriptionTracker` enforces a soft cap - of 20, with a priority prefix list (`"dms"`, `"notif"`, `"grp-"`, …) that - bypasses the cap so a transient feed subscription can never crowd out DMs. -- **Explicit reconnect policy.** `RelayLifecycleManager` observes connectivity - and app pause/resume, debounces, and drives `reconnectAll()`; on reconnect only - the long-lived prefixes are retained and everything transient is dropped. -- **One router, not one collector per request.** `viewmodel/EventRouter.kt` - dispatches by subscription id. +A socket that drops now comes back on its own, with exponential backoff (1s +doubling to 60s, plus up to 25% jitter — every relay drops at once when the +network does, and without jitter they return in lockstep). `autoReconnect` is off +by default and owned by the pool, which turns it on for exactly as long as it is +retaining a subscription for that relay: reconnecting a socket nobody is +subscribed on is battery spent on nothing. -And one detail that is a direct warning to us: Wisp deliberately does **not** -put a `since` on its kind-1059 filter, because NIP-59 randomizes `created_at` -into the past. +`RelayPool` retains the REQ text per `(relay, subscription id)` and replays it +when that relay's socket is re-established. A relay answers a repeated REQ on the +same id by *replacing* that subscription's filter, so replay is a send rather +than a close-and-reopen — and the collector, attached to the socket **client** +rather than to a session, simply starts receiving again. A live subscription +therefore needs no drop handling of its own. -## The shape to build +Negentropy is deliberately not retained. NEG-OPEN carries a fingerprint of the +local set and each round depends on the last, so replaying one mid-exchange would +reconcile against a conversation the relay is no longer having. -### A live tier beside the queue, not inside it +### The filters, and their timestamps -A `LiveSubscriptionManager` owned at the same level as `SynchronizationViewModel` -(the application scope, not a screen), holding one piece of state: +The two kinds need opposite treatment, and it is visible in our own outbound code. -```kotlin -// what we want to be subscribed to, per relay -data class LiveSubscription(val subId: String, val relayUrl: String, val filters: List) -``` +Gift wraps are stamped with `TimeUtils.randomWithTwoDays()` +([`DatabaseChatRepository.kt:344`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt:344)), +so a wrap published *now* can carry a `created_at` two days old. **A `since` +anywhere near the present would silently drop a large fraction of genuinely new +messages** — and "some DMs just don't arrive" is the worst failure mode to debug. +There is none. -and doing one thing: **reconcile desired state against what has actually been -sent to each socket.** Recompute the desired set, diff it against the sent set, -send a REQ for anything added or changed, send CLOSE for anything removed. Every -trigger — a new group, a relay reconnect, an app resume, a relay-list change — -becomes a call to the same `reconcile()`. That is the property that makes this -maintainable: there is no "subscribe" path and "resubscribe" path that can drift. +Group events use `TimeUtils.now()` +([`MarmotOutboundDao.kt:578`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt:578)), +so a watermark would be safe there. It still is not used: `limit` already bounds +the initial burst, and a watermark would have to be recomputed every time a +chunk's membership changed. -The sent-set belongs in `RelayPool`, next to `socketClients`, because that is -where a reconnect is observed. Mirror Wisp: keep the serialized REQ per -`(relayUrl, subId)` and re-send on connect. +`limit` does the bounding instead, because NIP-01 scopes it to the initial query +— the stored events sent before EOSE — and explicitly not to the stream that +follows. So it caps what a reconnect costs without touching the live tail at all. +100 for gift wraps; 500 for a group chunk, which covers up to 100 conversations. -### The two filters, and their timestamps +`#h` is chunked 100 group ids to a subscription. One subscription per group would +be simpler, but every Marmot group here lives on the same DM relay set, so +carrying many ids in one filter costs a fraction of the subscriptions. -```kotlin -// live-giftwrap -Filter(kinds = listOf(GiftWrapEvent.KIND), tags = mapOf("p" to listOf(myPubkey))) +### Following group membership -// live-groups- -Filter(kinds = listOf(GroupEvent.KIND), tags = mapOf("h" to groupIdChunk)) -``` +The group filter is derived from `chatRepository.observeChatRoomListByPublicKey` +— the same Flow the chat list screen renders, collected at wallet scope so it +keeps running with no chat screen open. Rooms are filtered to those with MLS +state (a NIP-17 room has none and is served by the gift wrap subscription), minus +any we have left or deleted; ids are sorted, de-duplicated with +`distinctUntilChanged`, and debounced 500ms. -The two kinds need opposite treatment on `since`, and this is not a matter of -taste — it is visible in our own outbound code. - -Gift wraps are stamped with -[`TimeUtils.randomWithTwoDays()`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt:344), -so a wrap published *now* can carry a `created_at` up to two days in the past. -A live filter with `since = now` would silently drop a large fraction of -genuinely new messages, and the failure mode is "some DMs just don't arrive" — -the worst kind to debug. Either omit `since` entirely (gift wraps are low volume -and `storeNostrEvent` already no-ops on a known id) or floor it at -`now - 2 days` and accept the redelivery. - -Group events use -[`TimeUtils.now()`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt:578). -A watermark is safe there: `since = (newest 445 we hold for these groups) - slack`. -Keep the slack generous (minutes, not seconds) — relay clocks drift, and the -cost of re-receiving a handful of 445s is a few no-op `storeNostrEvent` calls. - -`#h` has to be chunked. One subscription per group is the simple option and what -Wisp does for NIP-29, but Marmot groups here are all on the same DM relay set, so -one subscription carrying N group ids is far cheaper in sockets and slots. Chunk -at a conservative width (~100 ids) across `live-groups-0`, `live-groups-1`, … and -accept that a chunk boundary shift re-sends one REQ. - -### Routing - -One collector per socket, attached for the life of the socket, dispatching on -subscription id prefix — the `EventRouter` shape. That collector is also what -fixes problem six above: it is always attached, so nothing is dropped for want -of a subscriber, and it can impose ordering and back-pressure in one place. - -Events off a live subscription go to the new request-free `saveNostrEvent`, and -from there through the same `storeNostrEvent` → `MarmotInboundManager` path as -everything else. **No inbound processing changes.** A 1059 carrying a Welcome -still lands in the branch at -[`NostrDao.kt:720`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt:720); -a 445 still reaches -[`processGroupEvent`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt:415). -Only the delivery mechanism is new. - -### Keeping the group filter current - -This is the requirement that decides the design, and it is the easy part — -provided we derive the filter from the database instead of from the join sites. - -```kotlin -chatRepository.observeChatRoomListByPublicKey(publicKey) - .map { rooms -> rooms.filter { it.chatRoom.mlsGroupState != null && it.chatRoom.leftGroupAt == null } - .map { it.chatRoom.id }.sorted() } - .distinctUntilChanged() - .debounce(500.milliseconds) - .collect { groupIds -> live.setGroupIds(groupIds); live.reconcile() } -``` - -`ChatRoomListViewModel` already collects this exact Flow to render the list. The -difference is where it is collected: at application scope, so it keeps running -when no chat screen is open. - -Driving it off the DB rather than off the join sites matters because there are -several ways a group id appears, and they are not all in view models: +Deriving it from the table rather than from the join sites is the decision that +keeps this correct, because a group id can appear four ways and only one of them +is somewhere anyone would think to call a subscribe function: - we create a group (`MarmotOutboundDao`), - we are added to one — the Welcome arrives inside a gift wrap and is processed deep inside `NostrDao.storeNostrEvent`, on the *inbound* path, - membership shifts under us via a commit (`MarmotInboundManager.processGroupMembershipChanges`), -- and we leave one (`leftGroupAt`). +- we leave, or the room is deleted. -Only the first is a place a developer would naturally think to call -`subscribeToGroup()`. Observing the room list catches all four, and it closes the -loop cleanly: **a Welcome arrives on the live gift wrap subscription → a +It also closes the loop: **a Welcome lands on the gift wrap subscription → a `ChatRoom` row is written → the Flow re-emits → the group filter widens → the -first message in that new group arrives without anyone opening the chat.** +first message in that new group arrives without anyone opening a chat.** -Debounce is not optional. Joining a group writes the room, its participants, and -placeholder profiles in quick succession; without it we would re-send the group -REQ several times per join. +Reconciling is an update, not a rebuild. A chunk that already has a subscription +gets a repeated REQ under the same id, so adding a group does not interrupt +delivery on the groups already being watched. Chunks that no longer exist are +`cancelAndJoin`ed — the CLOSE goes out from the coroutine's `finally`, and +returning before it lands would let a later reconcile open a subscription on that +id which the old one then closes out from under. ### Lifecycle -Mantra has no app lifecycle observer at all today — -[`MainActivity`](composeApp/src/androidMain/kotlin/press/mantra/android/MainActivity.kt) -only calls `setContent`. Long-running subscriptions make one necessary: +`AppLifecycle` is a singleton `StateFlow` fed by a `LifecycleEventObserver` +in `MantraNavHost`. A singleton rather than something threaded through the +composition, because the consumers are application-scoped coroutines that outlive +any screen. It defaults to foreground: on a platform where the observer is not +wired up, "always on" is the behaviour that predates it, and a subscription that +never opens is far worse than one that stays open too long. -- **background:** stop the live subscriptions (CLOSE, or just drop the sockets). - Holding a socket open behind a doze window achieves nothing except battery. -- **foreground:** reconnect and reconcile. Assume the server-side subscription is - gone — Wisp's `forceReconnectAll` exists precisely because a socket that - survives an OS sleep reports `isConnected == true` while being functionally - dead — and queue a bounded catch-up negentropy pass for the gap. -- **connectivity change:** reconnect, debounced, with the suppression window - Wisp uses to avoid a resume and a network-change event double-reconnecting. +`collectLatest` over that flow is the whole mechanism. Backgrounding cancels the +block holding the subscriptions and each `finally` sends its CLOSE. -The foreground catch-up is what preserves correctness across the gap. Live -subscriptions cover the online window; negentropy covers everything else. +Returning does two things before anything else: -## What the screens stop doing, and what they keep +1. **`RelayPool.reconnectAll`** tears every socket down and immediately rebuilds + it. Trusting the connection is the mistake here — a socket that was open when + the OS suspended the process reports itself connected on the way back while + being functionally dead. Rebuilding eagerly rather than leaving it to the next + send is what makes it a *re*-connection, so retained subscriptions are replayed + and collectors still attached from before the gap resume. +2. **A catch-up reconciliation.** A live subscription answers "what is new since I + connected"; negentropy answers "what do you have that I don't". A background + gap is exactly the question only the second can answer — `limit` on the + re-opened subscriptions is a window, not a guarantee. -Once the live tier exists: +### What negentropy is still for -- `ChatRoomListViewModel.scheduleSynchronization` — drop both queues on open. - Keep the code path behind an explicit pull-to-refresh. -- `ChatMessageListViewModel.scheduleSynchronization` — drop the `mlsMessages` - branch entirely; it is a strict subset of `live-groups-*`. Keep the - `ChatMessageRelayListEvent` lookup: that is *discovery* (which relay does this - peer read from), not message sync, and it still has to happen on open. -- Everything not chat — profiles, follows, feeds, key packages — is untouched. +Live subscriptions replaced *polling*, not *reconciliation*. The queue and its +three pumps are unchanged, and negentropy remains the right tool for first +login, the foreground catch-up, "load older messages", and repairing a relay that +was unreachable while we were online. -One thing to fix while in here, since it is one of the per-chat syncs we are -proposing to stop firing: the `"sent-messages"` filter -(`kinds=[1059], authors=[me], #p=[peer]`, at -[`ChatMessageListViewModel.kt:113`](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt:113) -and [`NostrDao.kt:643`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt:643)) -**cannot match anything.** Gift wraps are signed with a fresh throwaway key -([`val wrapperKeyPair = KeyPair()`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt:312)), -so `authors = [myPubkey]` matches no wrap this app has ever published. It also -turns out to be unnecessary: `createNip17ChatRoom` puts the user in their own -participant list -([`NostrNip17Dao.kt:50`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt:50)), -so we wrap a copy to ourselves and the account-wide `#p=[me]` subscription picks -our own sent messages up on every device. Delete the filter rather than port it. +## Two things found while building this -## Negentropy's remaining job +**The `"sent-messages"` filter could never match anything.** It asked for kind +1059 with `authors=[userPublicKey]`, but gift wraps are signed with a fresh +throwaway `KeyPair()`, so the pubkey is random and never ours. It was also +unnecessary: `createNip17ChatRoom` puts the user in their own participant list, so +we wrap a copy to ourselves and the account-wide `#p=[me]` subscription picks our +own sent messages up on every device. Deleted, in the view model and at both +sites in `NostrDao`. -Live subscriptions replace *polling*, not *reconciliation*. Negentropy is still -the right tool for: +**The negentropy `saveNostrEvent` wrote outside `storeNostrEventMutex`** while +the other overload held it. `storeNostrEvent` reads an event and then writes it +and its indexes, so two of those interleaving is a lost update — survivable while +a single queue was the only writer, not survivable with a live subscription +writing alongside a backfill. -- first login / restore, where the local set is empty, -- the foreground catch-up after a background gap, -- "load older messages" in a chat, -- and repairing a relay that was unreachable while we were online. +## Not done -The distinction to hold onto: a live REQ answers "what is new since I connected"; -negentropy answers "what do you have that I don't". Neither subsumes the other, -and the queue and its three pumps stay exactly as they are. - -## Traps, in the order they will bite - -1. **Reusing the `subscriptionSlots` semaphore.** Two permanent permits out of - four halves backfill throughput forever. Give the live tier its own budget and - read the two together against the relay's real cap. -2. **`completeOnSubscriptionEnd` on a live flow.** It will complete at the first - EOSE and the subscription will look established while delivering nothing. - The live variant must terminate on CLOSED only. -3. **`since = now` on kind 1059.** Randomized wrap timestamps mean silent, - partial message loss. See above. -4. **Treating CLOSED as "retry now".** `isBackPressure` already exists in - `SynchronizationViewModel` and should be reused: a rate-limited CLOSED on a - live subscription needs exponential backoff, an auth-required CLOSED needs the - AUTH flow (which the app does not implement yet — worth knowing before - pointing this at a relay that requires it), and only an unsupported-filter - CLOSED should fall back to a queued REQ. -5. **A NOTICE has no subscription id.** It is already admitted to every - collector by `filterBySubscriptionId`; with a permanent collector attached, - every notice reaches it forever. Handle it once in the router, not per - subscription. -6. **Duplicate delivery.** `storeNostrEvent` no-ops on a known id, so duplicate - 1059s are cheap. Duplicate *445s* are not automatically cheap — - `MarmotInboundManager` maintains epoch state and a commit tracker; confirm the - re-delivery path is idempotent before widening the `since` slack. -7. **Relay list changes.** `RelaysSocketManager.updateRelayPools` calls - `relayPool.changeRelays`, which closes sockets. Live subscriptions must be - reconciled after that, or they quietly vanish on any relay edit. -8. **The DM relay set is one relay.** `Relays.DefaultDMRelayList` is - `[wss://ephemeral.mantra.press]`. Every point above about per-relay budgets is - currently a budget of one socket — which makes this cheap to build and makes a - single relay outage total. Worth widening the set at the same time. - -## Suggested order - -1. **Reconnect first.** A supervised reconnect loop in `NostrSocketClientImpl` - plus per-relay REQ retention and resync in `RelayPool`. This is a strict - improvement on its own, before any live subscription exists. -2. **The router.** One permanent collector per socket, dispatching by subId - prefix; the request-free `saveNostrEvent`. Existing pumps keep working. -3. **`live-giftwrap`.** One subscription, one relay, no `since`. Verifiable by - sending yourself a DM from another client with the chat list closed. -4. **`live-groups-*`,** driven off the room-list Flow. Verify by having a second - device add this one to a group and watching messages land with no chat screen - ever opened. -5. **Lifecycle + foreground catch-up.** -6. **Then, and only then,** delete `scheduleSynchronization` from the two chat - view models. +- **Connectivity changes.** A network switch mid-foreground is only noticed by + the socket's own reconnect loop, which handles the common case but cannot know + the network changed underneath it. Wisp's `RelayLifecycleManager` is the model: + observe connectivity, debounce, and suppress a network-change reconnect shortly + after a resume so the two do not fire twice. +- **NIP-42 AUTH.** A relay that answers CLOSED with `auth-required` is treated as + any other refusal and retried with backoff. It will never succeed. Worth + knowing before pointing this at a relay that requires auth. +- **A router.** The original design called for one permanent collector per socket + dispatching by subscription id prefix. With the inbound flow buffered and only a + handful of live subscriptions per relay, a collector per subscription has the + same properties for less machinery. If that count grows, the router is the next + move. +- **`Relays.DefaultDMRelayList` is one relay.** Every per-relay budget above is + currently a budget of one socket, which makes this cheap and makes a single + relay outage total. From c8c962e4f45713b10274508af78fe50e94980724 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 16:28:21 +0200 Subject: [PATCH 10/33] docs: inventory the unreferenced code in the sync and relay stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while building the long-running sync. One item was orphaned by that change; the rest was already dead and only became visible because the subsystem was being read closely. Written down rather than deleted because several pieces are one decision away from being wanted, and those decisions are not the sync change's to make. Every claim is "this identifier appears exactly once in composeApp/src, at its own declaration", with the two things that method cannot see called out: Room DAO methods are reached through generated code, and Compose entry points can be invoked without a textual reference. The DAO cluster is flagged as the least certain for exactly that reason. Three findings are more than leftovers: - RelaysSocketManager.userRelays is a field nothing ever writes. The `userRelays` inside observeRelays is a different, shadowing local, so the single-argument publishEvent always takes its FALLBACK_RELAYS branch and the user's own relay list is never used for publishing. That is a bug wearing dead code's clothes, and the fix is to populate the field, not to delete it. - NostrPublisherRepository is entirely unreferenced, and it is the only consumer of CachingImportRepository.importEvents. RelayPool and RelaysSocketManager each take a cachingImportRepository parameter they store and never dereference, satisfied by NO_OP_CACHING_IMPORT_REPOSITORY — so the whole seam is a parameter passed from nowhere to nothing. Removing the publisher lets the interface and both parameters go with it. - sendAUTH is unused because NIP-42 is unimplemented, not because it is surplus. AuthMessage is parsed and dropped, so a relay answering CLOSED with auth-required is retried forever and can never succeed. Deleting sendAUTH means deciding against authenticated relays; that is worth doing on purpose or not at all. sendCOUNT and CountMessage are a similar matched pair — both go or neither, since a CountMessage cannot arrive if nothing sends a COUNT. isRecommendedRelay on the two request entities is separated out as its own risk class: never written, never read, but a Room column, so it wants a migration rather than a delete. Ends with an order to do it in, cheapest and least risky first. Co-Authored-By: Claude Opus 5 --- docs/README.md | 4 +- docs/dead-code.md | 194 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 docs/dead-code.md diff --git a/docs/README.md b/docs/README.md index 92b74d2e..71c340dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,8 @@ silent, or a decision that looked arbitrary and was not. | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | | [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | +| [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | Start with the ceremony if you are new to this area; the next two both assume it. -The sync note stands alone. +The last two stand alone, and the dead-code inventory reads as a follow-up to the +sync note. diff --git a/docs/dead-code.md b/docs/dead-code.md new file mode 100644 index 00000000..e8daa3c3 --- /dev/null +++ b/docs/dead-code.md @@ -0,0 +1,194 @@ +# Unreferenced code in the sync and relay stack + +An inventory of code nothing calls, found while building the long-running chat +sync (see [long-running-sync.md](./long-running-sync.md)). Some of it was +orphaned by that change; most of it was already dead and only became visible +because the subsystem was being read closely. + +None of it is removed yet. It is written down rather than deleted because +several items are one decision away from being wanted, and that decision is not +the sync change's to make. + +## How this was checked + +Every claim below is "the identifier appears exactly once in +`composeApp/src`, at its own declaration", verified with a script that tokenises +every `.kt` file outside `build/` and counts occurrences: + +```bash +grep -rn "\bidentifierName\b" --include=*.kt composeApp/src | grep -v '/build/' +``` + +Two things that method cannot see, so each item was also read in context: + +- **Room DAOs** are called through generated code as `database.xDao().method()`, + so the method name does appear at the call site and a genuinely unused DAO + query still counts as one occurrence. The DAO section below is therefore the + least certain. +- **Compose** entry points and `@Composable` functions invoked from `setContent` + or navigation graphs may be reached without a textual reference. + +## Orphaned by the live-sync change + +**`ChatRoomListViewModel.scheduleSynchronization`** — +[ChatRoomListViewModel.kt:80](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt:80) + +No longer called by `initiate()`. It was kept as the entry point for a +user-initiated refresh, which is the one thing the live tier does not answer — +but no refresh affordance exists in the UI, so today it is unreferenced. + +It also now duplicates `LiveSubscriptionManager.queueCatchUpSynchronization`, +which queues the same two negentropy requests on every foreground, including the +first one after launch. Drift between the two is harmless (a negentropy request +is keyed on a hash of its filter, and both are valid reconciliations), but there +is no reason for two copies. + +**Decide one of:** wire a pull-to-refresh to it, or delete it. Deleting it also +orphans `nostrRepository` in that view model, which ripples into its `factory` +and into `HomeScreen` — the reason it was left alone rather than removed in the +same commit. + +## Dead on arrival, in the relay stack + +Each of these predates the sync work. + +| what | where | note | +|---|---|---| +| `RelayPool.removeRelays` | [RelayPool.kt:119](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:119) | never called; `changeRelays` and `closePool` cover every path that removes a relay | +| `RelayPool.hasRelays` | [RelayPool.kt:167](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:167) | never called | +| `RelayPool.transformWhileEventsAreIncoming` | [RelayPool.kt:482](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:482) | private, never called. Superseded by `completeOnSubscriptionEnd`, which ends a flow on EOSE/CLOSED/NEG-ERR rather than on "the last message was not an event" | +| the commented-out publish gate | [RelayPool.kt:543-556](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:543) | the only thing keeping `import kotlinx.coroutines.flow.transform` ([:30](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:30)) alive | +| `RelaysSocketManager.clearRelayPools` | [RelaysSocketManager.kt:100](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:100) | private, never called. Nothing tears the pool down on sign-out | +| `RelaysSocketManager.tryConnectingToAllRelays` | [RelaysSocketManager.kt:132](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:132) | never called; the only caller of `RelayPool.tryConnectingToRelay`, which is otherwise dead too | +| the commented `tryConnectingToUserRelay` | [RelaysSocketManager.kt:140](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:140) | | +| `NostrIncomingMessage?.verifyOrThrow` | [NostrIncomingMessage.kt:59](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessage.kt:59) | never called. It also treats any NOTICE as a failure, which is wrong for the reason `filterByEventId` documents — a NOTICE has no subscription id and reaches every collector | +| `String?.decodeFromJsonStringOrNull` | [CommonJson.kt:37](composeApp/src/commonMain/kotlin/press/mantra/compose/network/serialization/CommonJson.kt:37) | never called | +| `NostrSocketClientImpl.compressMessage` | [NostrSocketClientImpl.kt:384](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:384) | already carries `@Suppress("unused")`. Outgoing compression is not a thing any relay here asks for | + +### `RelaysSocketManager.userRelays` is a field that is never written + +[RelaysSocketManager.kt:52](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:52) + +Worth separating from the table, because it does not read as dead — it reads as +a bug. + +```kotlin +private val userRelays = mutableSetOf() // nothing ever adds to this +``` + +The `userRelays` at [:83](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:83) +is a *different, shadowing local* inside `observeRelays`. The field itself is +only ever read, at [:107](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:107): + +```kotlin +return if (userRelays.isNotEmpty()) { ...publish to the user's relays... } + else { ...publish to FALLBACK_RELAYS... } +``` + +So the single-argument `publishEvent` **always** takes the fallback branch, and +the user's own relay list is never used for publishing. Either populate the +field from `updateRelayPools` or delete it and the branch; leaving a permanently +false condition in place is the one option that keeps the bug. + +### The unused halves of the socket protocol + +`NostrSocketClient` declares five send methods. Only `sendEVENT` and +`sendMESSAGE` are ever called — everything else in the pool serialises its own +command with `OptimizedJsonMapper` and goes through `sendMESSAGE`. + +| declaration | impl | +|---|---| +| `sendREQ` [:37](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:37) | [:349](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:349) | +| `sendCLOSE` [:31](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:31) | [:361](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:361) | +| `sendCOUNT` [:33](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:33) | [:354](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:354) | +| `sendAUTH` [:29](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:29) | [:365](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:365) | + +Removing them also orphans `buildNostrREQMessage`, `buildNostrCOUNTMessage`, +`buildNostrCLOSEMessage` and `buildNostrAUTHMessage` in +`NostrOutgoingMessageBuilder`. + +Two of the four are worth keeping for a named reason rather than out of habit: + +- **`sendAUTH` is the NIP-42 hole.** `NostrIncomingMessage.AuthMessage` + ([:34](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessage.kt:34)) + is parsed and then dropped — nothing anywhere handles an AUTH challenge. A + relay that answers CLOSED with `auth-required` is currently retried with + backoff forever and will never succeed. `sendAUTH` is half of the fix, so + delete it only alongside a decision not to support authenticated relays. +- **`sendCOUNT` and `CountMessage`** ([:38](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessage.kt:38)) + are a matched pair with no caller. `CountMessage` is parsed and admitted by + `filterBySubscriptionId`, but since nothing sends a COUNT it can never arrive. + Both go, or neither. + +`sendREQ` and `sendCLOSE` have no such argument — the pool does not use them and +would not start. + +## Dead all the way down: the publisher chain + +**`NostrPublisherRepository`** — +[NostrPublisherRepository.kt:15](composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt:15) + +The whole class is unreferenced: nothing constructs it, and neither of its two +methods (`signPublishImportNostrEvent`, `publishRelayList`) is called. Publishing +goes through the broadcast queue in `SynchronizationViewModel` instead. + +It matters beyond itself because it is the **only** consumer of +`CachingImportRepository.importEvents`. `RelayPool` and `RelaysSocketManager` +both take a `cachingImportRepository` constructor parameter, store it, and never +dereference it — and `SynchronizationViewModel` satisfies that parameter with +`NO_OP_CACHING_IMPORT_REPOSITORY`. So the entire caching-import seam is a +parameter passed from nowhere to nothing. + +Removing `NostrPublisherRepository` and the two unused constructor parameters +would let `CachingImportRepository` go with them. + +## Dead columns (need a migration) + +`isRecommendedRelay` on both +[SynchronizeNostrEventRequest.kt:40](composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt:40) +and +[NegentropySynchronizeRequest.kt:39](composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt:39) +is never written by any caller (every construction takes the `false` default) +and never read. + +A different risk class from everything else here: these are Room columns, so +removing them is a schema change with a migration, not a delete. Cheap to leave; +worth folding into the next migration that touches those tables. + +## Wider sweep, needing per-item judgement + +The same sweep across all of `composeApp/src` turns up ~99 single-occurrence +function declarations. Most are outside the sync stack and were not read in +context. Two clusters stand out and are recorded here so the next person does +not have to re-derive them: + +- **Unused DAO queries** — roughly 30, concentrated in + `BroadcastNostrEventReceiptDao`, `BroadcastNostrEventRequestDao`, + `UnsignedNostrEventDao`, `RepostedRelationDao`, `QuotedRelationDao`, + `InReplyToRelationDao`, `ReactionDao` and `ZapDao`. Mostly + `getAllX()`/`getXById()` shapes that look like they were written to round out + a DAO rather than because something needed them. Cheap to delete and cheap to + re-add, but see the Room caveat above: confirm each against the generated + implementation, not just by grep. +- **Bech32/credential helpers** in + [Credentials.kt](composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Credentials.kt) — + `assureValidNpub`, `assureValidPubKeyHex`, `hexToNoteHrp`, `urlToLnUrlHrp`, + `bechToBytesOrThrow`. A conversion toolkit where only some conversions are + used. Worth keeping as a set if the intent is a complete encoding surface; + worth deleting if not. + +## Suggested order, if this gets done + +1. `NostrPublisherRepository` + the two `cachingImportRepository` parameters + + `CachingImportRepository`. Largest reduction, zero behavioural risk, and it + removes a constructor parameter from two classes at the centre of the relay + stack. +2. The relay-stack table above, plus the two commented-out blocks and the + `transform` import they keep alive. +3. `userRelays` — but as a **fix**, not a deletion, unless publishing to the + user's own relay list is deliberately not wanted. +4. `sendREQ`/`sendCLOSE`, keeping `sendAUTH` and `sendCOUNT` pending the NIP-42 + and COUNT decisions. +5. `ChatRoomListViewModel.scheduleSynchronization`, once there is a refresh + affordance or a decision that there will not be one. +6. `isRecommendedRelay`, with the next migration that touches those tables. From 79e99ae7023a409c3c6fedbed1f86838b1dd07e6 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 18:57:11 +0200 Subject: [PATCH 11/33] feat: build the envelope a direct message travels in A one-to-one message inside a Marmot group is a stock NIP-59 gift wrap carried as the MLS application payload: a throwaway-keyed kind:1059 around a sender-signed kind:13 seal around the kind:14 rumor holding the words. Every member decrypts the MLS layer and sees the wrap; only the recipient can open it. See docs/marmot-direct-messages.md. This is the crypto on its own, with no database and no MLS state, because the outbound path (the notary) and the inbound path (the kind switch in ChatMessage) both need it and neither can be unit-tested -- there is no sqlite driver on the JVM test classpath. Extracting it first is what makes the ten tests here possible; real secp256k1 does load under testDebugUnitTest, so none of this is mocked. Three choices worth stating, all of them consequences of the wrap using a throwaway key rather than the sender's own: Nothing in the wrap names the sender. GiftWrapEvent.create mints and discards its own random key, so who sent a message comes from the MLS frame around it -- authenticated to a leaf, and unforgeable -- rather than from a self-asserted pubkey field. The seal inside is the only layer the sender signs, which is what the inbound path will bind to the MLS sender identity before it renders a word. The sender cannot reopen their own message. The throwaway key is gone at send time and nothing reconstructs it. `the sender cannot reopen their own message` asserts that rather than leaving it to be discovered, because the obvious fix -- persisting the throwaway private key -- would be strictly worse than the identity-keyed wrap this was chosen over, and would reintroduce the attribution the throwaway key exists to remove. No layer is fuzzed. NIP-59 randomises the wrap and the seal by up to two days to frustrate correlation at a relay, and both GiftWrapEvent.create and SealedRumorEvent.create default to it. There is no relay at this layer and the kind:445 already carries the true time, so fuzzing would only scatter the "sent a private message" line up to two days out of position in every other member's transcript. open() returns null rather than throwing on every way a wrap can fail to open -- somebody else's message, a malformed payload, a layer that is not the kind it claims. Its caller is midway through processing a kind:445 that may carry a perfectly good message for somebody else, and an exception would abandon all of it. Co-Authored-By: Claude Opus 5 --- .../compose/nostr/MarmotDirectMessage.kt | 164 ++++++++++++++++ .../compose/nostr/MarmotDirectMessageTest.kt | 185 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt new file mode 100644 index 00000000..5b58bf32 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt @@ -0,0 +1,164 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * A one-to-one message carried inside a Marmot group, as the application payload of a + * kind:445 group event. See docs/marmot-direct-messages.md. + * + * The payload is a stock NIP-59 gift wrap: a throwaway-keyed kind:1059 around a + * sender-signed kind:13 seal around the kind:14 rumor that holds the words. Every member + * of the group decrypts the MLS layer and sees the wrap; only the recipient can open it. + * + * Everything here is a pure function of its arguments, with no database and no MLS state, + * which is what makes it testable — Room-backed code cannot be unit-tested in this project. + * + * **Nothing built here may ever be broadcast.** It is a genuine, correctly signed NIP-59 + * gift wrap, indistinguishable from one the NIP-17 path would be right to publish, so the + * only thing keeping it off a relay is the tables it is kept out of: a Marmot direct + * message lives in `MarmotInnerEvent`, never in `GiftWrapPayload` or `GiftWrapMessage`. + */ +object MarmotDirectMessage { + /** + * A wrap that opened, both layers of it. + * + * The seal is returned alongside the rumor because it is the only layer that names + * the sender, and the caller has to bind it to the MLS sender identity before it + * believes a word — see [MarmotDirectMessage] and the inbound path. + */ + data class Opened( + val seal: Event, + val rumor: Event, + ) + + /** + * Builds the payload for a direct message to [recipientPublicKey]. + * + * [createdAt] is used unfuzzed on all three layers. NIP-59 randomises the wrap and + * the seal by up to two days to frustrate correlation at a relay, and both + * `GiftWrapEvent.create` and `SealedRumorEvent.create` default to that; there is no + * relay at this layer, the kind:445 around it already carries the true time, and + * fuzzing would only scatter the "sent a private message" line up to two days out of + * position in every other member's transcript. + * + * The rumor is assembled from exactly the fields the caller queued, so its id is the + * one the recipient computes after unwrapping — and, on the way out, the one the + * outbound path uses to find the chat message to link and broadcast. + */ + fun wrap( + signer: NostrSignerSync, + recipientPublicKey: HexKey, + kind: Kind, + createdAt: Long, + tags: Array>, + content: String, + recipientRelayHint: NormalizedRelayUrl? = null, + ): GiftWrapEvent { + val rumor = rumor(signer.pubKey, kind, createdAt, tags, content) + + // The only layer that names the sender, and the only one they sign. Everything + // the recipient believes about who wrote this rests on this signature. + // + // Built by hand rather than through SealedRumorEvent.create, which is suspend, + // takes a NostrSigner rather than the sync signer the outbound path holds, and + // defaults createdAt to the two-day fuzz. + val seal = + signer.signNormal( + createdAt = createdAt, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(rumor.toJson(), recipientPublicKey), + ) + + // create() mints and discards its own random key -- "GiftWrap is always a random + // key" -- which is the point: nothing in the wrap names the sender. Who sent it + // comes from the MLS frame instead, which is authenticated and cannot be forged. + return GiftWrapEvent.create( + event = seal, + recipientPubKey = recipientPublicKey, + createdAt = createdAt, + recipientRelayHint = recipientRelayHint, + ) + } + + /** + * The rumor a direct message carries, unsigned and unencrypted. + * + * Exposed so the outbound queue can compute a row id before it has a signer, and so + * tests can assert that the id the recipient arrives at is the id the sender queued. + */ + fun rumor( + senderPublicKey: HexKey, + kind: Kind, + createdAt: Long, + tags: Array>, + content: String, + ): Event = + RumorAssembler.assembleRumor( + pubKey = senderPublicKey, + ev = + EventTemplate( + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + ), + ) + + /** + * Opens [wrap] with [me]'s key, or returns null if it was not addressed to them. + * + * Trying is the test: the `p` tag is a routing hint, not the authority, and the wrap + * either decrypts with our key or it does not. Null covers every way that can fail -- + * somebody else's message, a malformed payload, a layer that is not the kind it + * claims -- because the caller is inside inbound processing for a kind:445 that may + * carry a perfectly good message for somebody, and a throw would abandon all of it. + * + * **The sender cannot open their own wrap.** The throwaway key is discarded at send + * time and nothing can reconstruct it, so a sender's own message returns null here + * too. That is a property, not a defect: see docs/marmot-direct-messages.md. Do not + * "fix" it by storing the throwaway private key. + */ + fun open( + wrap: Event, + me: KeyPair, + ): Opened? { + if (wrap.kind != GiftWrapEvent.KIND) return null + val privateKey = me.privKey ?: return null + + val seal = decryptEvent(wrap.content, privateKey, wrap.pubKey) ?: return null + if (seal.kind != SealedRumorEvent.KIND) return null + + val rumor = decryptEvent(seal.content, privateKey, seal.pubKey) ?: return null + + return Opened(seal = seal, rumor = rumor) + } + + private fun decryptEvent( + ciphertext: String, + privateKey: ByteArray, + fromPublicKey: HexKey, + ): Event? = + try { + Event.fromJsonOrNull( + Nip44.decrypt( + payload = ciphertext, + privateKey = privateKey, + pubKey = fromPublicKey.hexToByteArray(), + ), + ) + } catch (_: Exception) { + null + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt new file mode 100644 index 00000000..1d3d2474 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt @@ -0,0 +1,185 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The envelope a direct message travels in, run against real secp256k1 and real NIP-44. + * + * Two of these assert properties that read like bugs and are not. The wrap names nobody, + * so the group can only learn who sent it from the MLS frame around it; and the sender + * cannot reopen their own message, because the key that sealed it was discarded. Both are + * consequences of using a throwaway key, both are documented in + * docs/marmot-direct-messages.md, and both are here so that a later change which quietly + * reverses them fails rather than ships. + */ +class MarmotDirectMessageTest { + private val alice = NostrSignerSync(KeyPair()) + private val bob = KeyPair() + private val eve = KeyPair() + + private val bobPublicKey = bob.pubKey.toHexKey() + private val at = 1_700_000_000L + private val text = "the vote is at six, do not tell the room" + + private fun aliceWrapsForBob(content: String = text): GiftWrapEvent = + MarmotDirectMessage.wrap( + signer = alice, + recipientPublicKey = bobPublicKey, + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = content, + ) + + @Test + fun `the recipient reads what the sender wrote`() { + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob), "Bob could not open a wrap addressed to him") + + assertEquals(text, opened.rumor.content) + assertEquals(alice.pubKey, opened.rumor.pubKey) + assertEquals(ChatMessageEvent.KIND, opened.rumor.kind) + } + + @Test + fun `the sender cannot reopen their own message`() { + // The throwaway key is gone, so this is unrecoverable by construction. Asserted + // rather than merely documented, because the obvious "fix" -- persisting the + // throwaway private key -- would be strictly worse than the identity-keyed wrap + // this design was chosen over, and would reintroduce the attribution the + // throwaway key exists to remove. + assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), alice.keyPair)) + } + + @Test + fun `a bystander gets nothing, and no exception`() { + // Null rather than a throw: the caller is midway through processing a kind:445 + // that carries a real message for somebody, and an exception would abandon it. + assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), eve)) + } + + @Test + fun `the wrap names nobody`() { + val first = aliceWrapsForBob() + val second = aliceWrapsForBob() + + assertNotEquals(alice.pubKey, first.pubKey, "the wrap is keyed to its sender") + assertNotEquals(bobPublicKey, first.pubKey, "the wrap is keyed to its recipient") + assertNotEquals(first.pubKey, second.pubKey, "the throwaway key is being reused") + + // Signed by the throwaway key, per NIP-59. The signature attributes nothing -- + // the signer is meaningless and discarded -- and keeping it is what makes this a + // real gift wrap that GiftWrapEvent.create builds and unwrapOrNull opens. + assertTrue(first.verify(), "the wrap does not verify against its own key") + assertEquals(GiftWrapEvent.KIND, first.kind) + } + + @Test + fun `the seal is what binds the words to their author`() { + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob)) + + assertEquals(SealedRumorEvent.KIND, opened.seal.kind) + // The inbound path checks exactly this pair against the MLS sender identity. It + // is what replaces the MIP-03 pubkey check for kind:1059, so if the seal stops + // being signed by the sender, the carve-out becomes a hole. + assertEquals(alice.pubKey, opened.seal.pubKey) + assertTrue(opened.seal.verify(), "the seal's signature does not verify") + assertEquals(opened.seal.pubKey, opened.rumor.pubKey) + } + + @Test + fun `a seal from somebody else is still opened, and is caught by its pubkey`() { + // open() decrypts; it does not adjudicate. Eve can seal her own rumor to Bob and + // wrap it, and Bob's key will open it -- what stops it being rendered as Alice's + // is the inbound check that the seal's pubkey is the MLS sender's identity. This + // test pins the half open() is responsible for: the pubkey it reports is Eve's. + val eveSigner = NostrSignerSync(eve) + val forged = + MarmotDirectMessage.wrap( + signer = eveSigner, + recipientPublicKey = bobPublicKey, + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = "alice here, send the funds", + ) + + val opened = assertNotNull(MarmotDirectMessage.open(forged, bob)) + + assertEquals(eveSigner.pubKey, opened.seal.pubKey) + assertNotEquals(alice.pubKey, opened.seal.pubKey) + } + + @Test + fun `the rumor id is the one the sender queued`() { + val tags = arrayOf(PTag.assemble(bobPublicKey, null)) + + // What the outbound queue computes before it has a signer, and what it later uses + // to find the chat message to link and broadcast. If these ever diverge the + // message is encrypted, stored, and silently never sent. + val queued = + EventHasher.hashId( + alice.pubKey, + at, + ChatMessageEvent.KIND, + tags, + text, + ) + + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob)) + + assertEquals(queued, opened.rumor.id) + assertEquals(queued, MarmotDirectMessage.rumor(alice.pubKey, ChatMessageEvent.KIND, at, tags, text).id) + } + + @Test + fun `no layer is fuzzed into the past`() { + val wrap = aliceWrapsForBob() + val opened = assertNotNull(MarmotDirectMessage.open(wrap, bob)) + + // NIP-59 defaults every one of these to randomWithTwoDays(). Inside MLS that only + // scatters the bystander line up to two days out of position in every other + // member's transcript, so all three carry the real message time. + assertEquals(at, wrap.createdAt) + assertEquals(at, opened.seal.createdAt) + assertEquals(at, opened.rumor.createdAt) + } + + @Test + fun `the recipient is on the wrap where the group can see it`() { + val wrap = aliceWrapsForBob() + + // Deliberate: it is what lets a bystander's transcript say who the message was + // for. Removing it would hide the recipient from the group at the cost of the + // named line -- see the decisions table in docs/marmot-direct-messages.md. + assertEquals(bobPublicKey, wrap.tags.firstTagValue("p")) + } + + @Test + fun `garbage in the wrap does not become a message`() { + val notAWrap = + alice.signNormal( + createdAt = at, + kind = GiftWrapEvent.KIND, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = "not nip-44 at all", + ) + + assertNull(MarmotDirectMessage.open(notAWrap, bob)) + } +} From 296011dcd56b6628eacbfd86b0942d985ef7a998 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 18:58:57 +0200 Subject: [PATCH 12/33] feat: give a queued message somewhere to say who it is private to Two nullable columns and the v5 migration that adds them, ahead of the code that fills them, so the schema lands on its own and can be reverted on its own. MarmotInnerEvent.directMessageRecipientPublicKey is the outbound signal. The notary reads a queued row and has no other way to know a message is meant for one member rather than the room -- the plaintext is identical either way -- so this is what routes it into the gift wrap path. Inbound rows leave it null on purpose: the recipient is on the wrap's `p` tag, which is where every member reads it from, so a second copy on the row would be a second thing that can disagree. ChatMessage.directMessageRecipientPublicKey is what the transcript reads. Both lines a direct message can produce need it -- the one its two parties see, and the "sent a private message to Bob" line everybody else gets -- and holding it on the row keeps the view model off a join for a fact it already has to render. MarmotInnerEvent hand-writes equals and hashCode over every field, so both are extended too. A field missing from those is not a compile error and not a test failure; it is two rows that differ comparing equal, which surfaces much later as an upsert that does nothing. Room generates the migration -- verified as two ADD COLUMNs with no table rebuild, so nothing is copied and nothing can be dropped: ALTER TABLE `ChatMessage` ADD COLUMN `directMessageRecipientPublicKey` TEXT DEFAULT NULL ALTER TABLE `MarmotInnerEvent` ADD COLUMN `directMessageRecipientPublicKey` TEXT DEFAULT NULL Rows written before this come back null, which reads as "not a direct message" -- the only answer that is true of all of them. v5 is an AutoMigration entry rather than a hand-written Migration like MIGRATION_3_4 next to it, because that one rewrote data without changing shape and this one changes shape without touching data. Co-Authored-By: Claude Opus 5 --- .../5.json | 5056 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 9 +- .../compose/database/model/ChatMessage.kt | 9 + .../database/model/MarmotInnerEvent.kt | 14 + 4 files changed, 5086 insertions(+), 2 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/5.json diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/5.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/5.json new file mode 100644 index 00000000..aa13c292 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/5.json @@ -0,0 +1,5056 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "d25975dfd3c660ff7248e332d6c58a38", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd25975dfd3c660ff7248e332d6c58a38')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index f2c9c27b..09e5b8d1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -164,7 +164,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 4, + version = 5, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -173,11 +173,16 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // additions need no default and drop no data, so Room generates this one // too. Rituals already in flight come back with all three null, which reads // as "not approved yet" and simply asks the member for each step. - AutoMigration(from = 2, to = 3) + AutoMigration(from = 2, to = 3), // v4 changes no schema at all -- it rewrites `dkgApprovalNeeded` chat rows // into one type per ritual step. Data, not shape, so it is a manual // migration passed to the builder rather than an entry here. See // MIGRATION_3_4. + // v5 adds a nullable direct message recipient to MarmotInnerEvent and + // ChatMessage. Nullable additions need no default and drop no data, so Room + // generates this one. Rows written before it come back null, which reads as + // "not a direct message" -- the only answer that is true of all of them. + AutoMigration(from = 4, to = 5), ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 7fe1264f..0391ad42 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -77,6 +77,15 @@ data class ChatMessage( val content: String, val messageType: String = "message", + /** + * Who a direct message was for, or null for anything else. + * + * Carried on the row rather than read back off the wrap so the transcript can name + * the recipient without a join, on both the line the two of them can read and the + * line everybody else gets. See docs/marmot-direct-messages.md. + */ + val directMessageRecipientPublicKey: HexKey? = null, + override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = Clock.System.now(), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt index 75f3c387..dbf6ba30 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt @@ -69,6 +69,18 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... */ val marmotGroupEventId: String? = null, + /** + * Who this is a direct message to, or null for an ordinary group message. + * + * Set on the way out only. It is what tells the notary to gift wrap this row for one + * member instead of sending it to the whole group, and afterwards what tells the + * transcript the row was private. Inbound rows leave it null -- the recipient is on + * the wrap's `p` tag, which is where every member reads it from. + * + * See docs/marmot-direct-messages.md. + */ + val directMessageRecipientPublicKey: HexKey? = null, + /** * Current time */ @@ -118,6 +130,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... if (content != other.content) return false if (quotedEventId != other.quotedEventId) return false if (marmotGroupEventId != other.marmotGroupEventId) return false + if (directMessageRecipientPublicKey != other.directMessageRecipientPublicKey) return false if (createdAt != other.createdAt) return false if (updatedAt != other.updatedAt) return false if (savedAt != other.savedAt) return false @@ -137,6 +150,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... result = 31 * result + content.hashCode() result = 31 * result + (quotedEventId?.hashCode() ?: 0) result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0) + result = 31 * result + (directMessageRecipientPublicKey?.hashCode() ?: 0) result = 31 * result + createdAt.hashCode() result = 31 * result + updatedAt.hashCode() result = 31 * result + savedAt.hashCode() From 1700e6d8997488a82f0a872194b2498fe296f3fb Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 19:01:50 +0200 Subject: [PATCH 13/33] feat: send a direct message into the group, wrapped for one member 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 --- .../compose/database/dao/MarmotOutboundDao.kt | 35 +++++++- .../compose/database/model/ChatMessage.kt | 13 +++ .../repository/DatabaseChatRepository.kt | 79 ++++++++++++++++--- .../compose/repository/ChatRepository.kt | 15 +++- 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index ca3413e3..dba928bb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -22,6 +22,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.exporterSecret import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW +import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag @@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup 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.EventHasher @@ -545,7 +547,20 @@ abstract class MarmotOutboundDao( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync ): GroupEvent { - val innerEvent = RumorAssembler.assembleRumor( + // A direct message is gift wrapped for one member before it goes into MLS, so + // what the group carries is a kind:1059 whose id is not this row's. Everything + // downstream -- MLS encrypt, outer ChaCha20, kind:445, persistence, broadcast -- + // is the same either way. See MarmotDirectMessage. + val innerEvent: Event = marmotInnerEvent.directMessageRecipientPublicKey?.let { recipient -> + MarmotDirectMessage.wrap( + signer = nostrSignerSync, + recipientPublicKey = recipient, + kind = marmotInnerEvent.kind, + createdAt = marmotInnerEvent.createdAt.epochSeconds, + tags = marmotInnerEvent.tags, + content = marmotInnerEvent.content + ) + } ?: RumorAssembler.assembleRumor( pubKey = nostrSignerSync.pubKey, ev = EventTemplate( createdAt = marmotInnerEvent.createdAt.epochSeconds, @@ -628,11 +643,25 @@ abstract class MarmotOutboundDao( database.marmotInnerEventDao().upsert( marmotInnerEvent.copy( - marmotGroupEventId = groupEvent.id + marmotGroupEventId = groupEvent.id, + // A direct message's plaintext is already on its ChatMessage row, which is + // the sender's only copy of it. A second one here would be cleartext left + // in a table that otherwise holds nothing but wire events. + content = if (marmotInnerEvent.directMessageRecipientPublicKey != null) { + "" + } else { + marmotInnerEvent.content + } ) ) - val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(innerEvent.id) + // Keyed on the queued row, not on `innerEvent.id`. For a direct message those + // differ -- the row is the rumor, the wire event is the wrap built around it -- + // and the wrap's id matches no ChatMessage, so this lookup would come back null, + // the message would never be linked, and no BroadcastNostrEventRequest would ever + // be inserted. Encrypted, stored, and silently never sent. They are the same value + // for every other kind of message. + val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id) logger.d("chatMessageOrNull: $chatMessageOrNull") chatMessageOrNull?.let { chatMessage -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 0391ad42..61435d4f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -94,6 +94,19 @@ data class ChatMessage( ): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity { companion object { + /** + * A one-to-one message inside a group, as a line in the group's chat. + * + * Written by both sides of the room. For its two parties [content] is the words; + * for everybody else it is empty, and the line says only that a private message + * was sent and to whom -- which is all the group can learn, and all it should. + * + * [directMessageRecipientPublicKey] is set on every one of these regardless of + * who can read it, because naming the recipient is the point of the line the + * bystanders get. See docs/marmot-direct-messages.md. + */ + const val TYPE_DIRECT_MESSAGE = "directMessage" + /** * A ChillDKG ritual message, as a line in the group's chat. * 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 b12e3723..4eacf017 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 @@ -173,24 +173,58 @@ class DatabaseChatRepository( override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind + messageType: Kind, + directMessageRecipientPublicKey: HexKey? ) { logger.i("sendChatMessage($text): $localChatRoom") val mlsGroup = localChatRoom.chatRoom.toMlsGroup() if (mlsGroup != null) { - // Create ChatRumor... - val createdAt = Clock.System.now().epochSeconds - val marmotInnerEventKind = if (messageType == ChatMessageEvent.KIND) { - ChatEvent.KIND // nip-17 chatMessageEvent.kind is in MLS chatMessage.kind - } else { - messageType + // Resolved before anything is written. 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 -- and + // the room is also where the relay hint for the `p` tag comes from. + val directMessageRecipient = directMessageRecipientPublicKey?.let { recipient -> + require(recipient != localChatRoom.chatRoom.userPublicKey) { + "Cannot send a direct message to yourself: the wrap's throwaway key means it could never be read back" + } + + localChatRoom.localParticipants + .map { it.participant } + .firstOrNull { it.participantPublicKey == recipient } + ?: throw IllegalArgumentException( + "Cannot send a direct message to $recipient: not a member of ${localChatRoom.chatRoom.id}" + ) } + // Create ChatRumor... + val createdAt = Clock.System.now().epochSeconds + val marmotInnerEventKind = when { + // A direct message is a NIP-17 chat message that happens to travel inside + // the group, so it keeps kind:14 instead of being mapped down to the + // group's kind:9. That is also what tells the two apart on the way back in. + directMessageRecipient != null -> ChatMessageEvent.KIND + messageType == ChatMessageEvent.KIND -> ChatEvent.KIND // nip-17 chatMessageEvent.kind is in MLS chatMessage.kind + else -> messageType + } + + val marmotInnerEventTags = directMessageRecipient?.let { recipient -> + arrayOf( + PTag.assemble( + recipient.participantPublicKey, + recipient.relayHint?.let { NormalizedRelayUrl(it) } + ) + ) + } ?: emptyArray() + + // For a direct message the queued row IS the rumor the wrap will carry, so + // this id is the one the recipient arrives at after unwrapping -- and, on the + // way out, the one the notary uses to find this chat message again. The wrap + // built around it has its own id, which is why that lookup keys on the row. val marmotInnerEventId = EventHasher.hashId( pubKey = localChatRoom.chatRoom.userPublicKey, createdAt = createdAt, - tags = emptyArray(), + tags = marmotInnerEventTags, content = text, kind = marmotInnerEventKind ) @@ -200,10 +234,11 @@ class DatabaseChatRepository( id = marmotInnerEventId, publicKey = localChatRoom.chatRoom.userPublicKey, createdAt = Instant.fromEpochSeconds(createdAt), - tags = emptyArray(), + tags = marmotInnerEventTags, content = text, chatRoomId = localChatRoom.chatRoom.id, - kind = marmotInnerEventKind + kind = marmotInnerEventKind, + directMessageRecipientPublicKey = directMessageRecipient?.participantPublicKey ) ) @@ -216,6 +251,15 @@ class DatabaseChatRepository( giftWrapPayloadId = null, marmotGroupEventId = null, marmotInnerEventId = marmotInnerEventId, + // The sender's only copy: the wrap is sealed with a key that is + // discarded, so nothing can reconstruct these words from the + // transcript later, on this device or any other. + messageType = if (directMessageRecipient != null) { + ChatMessage.TYPE_DIRECT_MESSAGE + } else { + "message" + }, + directMessageRecipientPublicKey = directMessageRecipient?.participantPublicKey ) ) } else { @@ -280,6 +324,21 @@ 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 + } + database.participantDao().findParticipantsByChatRoomId(giftWrapPayload.chatRoomId).forEach { participant -> if (giftWrapPayload.kind == WelcomeEvent.KIND) { logger.i("Only giftWrap welcomeEvent payload (${giftWrapPayload.id}) to the participant who published the related keyPackage") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 4669fae9..1f4a3894 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -79,10 +79,20 @@ interface ChatRepository { peers: List>, ): List + /** + * Sends [text] to the room, or -- when [directMessageRecipientPublicKey] is given -- + * to that one member of it. + * + * A direct message still travels as one group event that every member receives and + * can see the shape of; only its contents are private. See + * docs/marmot-direct-messages.md. NIP-17 rooms do not support it: there is no group + * for the rest of to see it, so a private message there is just a message. + */ suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind = ChatMessageEvent.KIND + messageType: Kind = ChatMessageEvent.KIND, + directMessageRecipientPublicKey: HexKey? = null ) fun updateChatRoomSubject(publicKey: String, subject: String): ChatRoom? @@ -192,7 +202,8 @@ interface ChatRepository { override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind + messageType: Kind, + directMessageRecipientPublicKey: HexKey? ) { // TODO("Not yet implemented") } From 5ae974517b7bc40645d4a081b48a5783ee584c8c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 19:04:39 +0200 Subject: [PATCH 14/33] feat: read a direct message, or say one was sent Completes the inbound half. A member now files one of three things when a kind:1059 arrives as an application payload, and which one depends only on whether their key opens it. The carve-out first. MarmotInboundManager rejects any inner event whose pubkey is not the MLS sender's credential identity -- MIP-03, and the check that stops a member minting events attributed to somebody else. A gift wrap is keyed to a throwaway key by construction and names nobody, so it cannot satisfy a check about its author; kind:1059 is now exempt. The check is not weakened, it is relocated. What replaces it is `seal.pubKey == senderIdentity` on a seal whose signature verifies -- a signature bound to an MLS leaf, rather than a plaintext field compared to one. It is strictly harder to forge: the attack it stops is a member re-wrapping a seal they were legitimately sent and passing it off to a third party as its author's, and that fails here because the MLS frame says who actually sent this one. senderIdentity also stops being optional. It was previously only compared; now every sender-derived field reads from it, because the payload carries no author at all. A leaf with no identity is an error rather than a mismatch. Attribution is resolved in NostrDao and handed to fromGroupEventResult, rather than added to GroupEventResult.ApplicationMessage where it belongs. quartz is a binary dependency here (com.vitorpamplona.quartz:quartz:1.14.0) and the local checkout is a reference copy, not a build input, so the result type cannot gain a field without publishing a fork. NostrDao holds the group, the leaf index is already on the result, and an application message advances no epoch, so the tree has not moved by the time it reads it. Same value, no fork. The three outcomes: The recipient opens the wrap and gets the words. The rumor is stored as its own MarmotInnerEvent keyed on the rumor's id -- the id the sender queued -- so both sides of the conversation hold one message under one identity. The wrap keeps its own row as the wire artifact. A bystander gets a line with no content. That is the feature working: the group is meant to see that a private message was sent and to whom, and nothing else. The sender, on a re-sync, is indistinguishable from a bystander, because the wrap's key was discarded and we cannot open our own message. Left unguarded this files an empty placeholder over the row sendChatMessage wrote -- which is the only copy of those words anywhere. Hence the early return on senderIdentity == us, mirroring the guard NostrDao.persistInboundChatMessage already carries on the NIP-17 path. A failed validation drops the message and logs rather than throwing. The caller is inside storeNostrEvent's transaction, and a forged direct message should cost its own line, not the whole event. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 7 + .../compose/database/model/ChatMessage.kt | 158 +++++++++++++++++- .../compose/managers/MarmotInboundManager.kt | 24 ++- 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 304a048c..694dc176 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -445,6 +445,13 @@ abstract class NostrDao( activeKeyPair = activeKeyPair, groupEvent = groupEvent, groupEventResult = groupEventResult, + // Who sent it comes from the MLS frame, not the + // payload. Resolved here because this is where the + // group is; the leaf index is on the result, and the + // tree has not moved -- an application message + // advances no epoch. + senderIdentity = (groupEventResult as? GroupEventResult.ApplicationMessage) + ?.let { mlsGroup.memberIdentityHex(it.senderLeafIndex) }, )?.let { chatMessage -> logger.d("chatMessage: $chatMessage") database.chatMessageDao().upsert( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 61435d4f..1f54f41f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -28,6 +28,12 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationEvent +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.nostr.MarmotDirectMessage import kotlin.time.Clock import kotlin.time.Instant @@ -210,11 +216,151 @@ data class ChatMessage( TYPE_DKG_FAILED, ) + private val logger = Logger.withTag("ChatMessage") + + /** + * Files one direct message, from whichever side of it this device is on. + * + * Three outcomes, and the third is the one that destroys data if it is missed: + * + * **The recipient** opens the wrap and gets the words. The rumor inside is stored + * as its own [MarmotInnerEvent] -- keyed on the rumor's id, which is the id the + * sender queued -- and the chat message points at that rather than at the wrap. + * + * **A bystander** cannot open it and gets a line with no content: the group is + * meant to see that a private message was sent and to whom, and nothing more. + * + * **The sender**, on a re-sync, is indistinguishable from a bystander -- the + * wrap's key was discarded at send time, so we cannot open our own message -- and + * so would file an empty placeholder over the row `sendChatMessage` wrote on the + * way out. That row is the only copy of those words that exists. Hence the early + * return; `NostrDao.persistInboundChatMessage` carries the same guard, for the + * same reason, on the NIP-17 path. + * + * See docs/marmot-direct-messages.md. + */ + private suspend fun directMessage( + database: MantraDatabase, + activeKeyPair: KeyPair, + groupEvent: GroupEvent, + chatRoomId: String, + wrap: Event, + senderIdentity: HexKey? + ): ChatMessage? { + if (senderIdentity == null) { + logger.e("Dropping direct message ${wrap.id}: no MLS sender identity to attribute it to") + return null + } + + val recipientPublicKey = wrap.tags.firstTagValue("p") + + // Our own words coming back. See the third outcome above. + if (senderIdentity == activeKeyPair.pubKey.toHex()) { + logger.d("Direct message ${wrap.id} is ours; the row written on send is the only copy") + return null + } + + val opened = MarmotDirectMessage.open(wrap, activeKeyPair) + + if (opened == null) { + // Relays redeliver and negentropy re-syncs; the wrap yields the same id + // every time, so this is what keeps a second delivery from becoming a + // second line. ChatMessage.id is autogenerated and would take a duplicate. + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(wrap.id) != null) { + return null + } + + return ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = wrap.id, + senderPublicKey = senderIdentity, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + content = "" + ) + } + + // What replaces MIP-03's pubkey check for kind:1059, and the only thing + // standing between the words and being rendered as somebody else's. The seal + // is the one layer the sender signs; binding it to the MLS leaf that sent the + // message is what stops a member re-wrapping a seal they were sent and + // passing it off as its author's. A forged one is dropped, not rendered. + if (opened.seal.pubKey != senderIdentity) { + logger.e( + "Dropping direct message ${wrap.id}: sealed by ${opened.seal.pubKey} " + + "but sent by $senderIdentity" + ) + return null + } + + if (!opened.seal.verify()) { + logger.e("Dropping direct message ${wrap.id}: the seal's signature does not verify") + return null + } + + if (opened.rumor.pubKey != opened.seal.pubKey) { + logger.e( + "Dropping direct message ${wrap.id}: rumor by ${opened.rumor.pubKey} " + + "inside a seal by ${opened.seal.pubKey}" + ) + return null + } + + if (opened.rumor.kind != ChatMessageEvent.KIND) { + logger.w("Dropping direct message ${wrap.id}: unsupported rumor kind ${opened.rumor.kind}") + return null + } + + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(opened.rumor.id) != null) { + return null + } + + // Keyed on the rumor, not the wrap. This is the id the sender queued, so both + // sides of the conversation hold the same message under the same identity. + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = opened.rumor.id, + publicKey = opened.rumor.pubKey, + marmotGroupEventId = groupEvent.id, + tags = opened.rumor.tags, + content = opened.rumor.content, + chatRoomId = chatRoomId, + kind = opened.rumor.kind, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt) + ) + ) + + return ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = opened.rumor.id, + senderPublicKey = senderIdentity, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt), + content = opened.rumor.content + ) + } + + /** + * [senderIdentity] is the MLS credential identity of the leaf that sent this, + * resolved by the caller, which holds the group. It is not derivable from the + * payload: a direct message's wrap is keyed to a throwaway key and names nobody, + * and even for kinds that do carry a pubkey the MLS frame is the authenticated + * source while the field is merely asserted. + */ suspend fun fromGroupEventResult( database: MantraDatabase, activeKeyPair: KeyPair, groupEvent: GroupEvent, - groupEventResult: GroupEventResult + groupEventResult: GroupEventResult, + senderIdentity: HexKey? = null ): ChatMessage? { return when(groupEventResult) { is GroupEventResult.ApplicationMessage -> { @@ -507,6 +653,16 @@ data class ChatMessage( ) } } + GiftWrapEvent.KIND -> { + directMessage( + database = database, + activeKeyPair = activeKeyPair, + groupEvent = groupEvent, + chatRoomId = groupEventResult.groupId, + wrap = event, + senderIdentity = senderIdentity + ) + } else -> { ChatMessage( giftWrapPayloadId = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt index c7be3dba..1cf9bdfc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Profile @@ -267,7 +268,28 @@ object MarmotInboundManager { if (innerEvent != null) { val senderIdentity = mlsGroup.memberIdentityHex(decrypted.senderLeafIndex) - if (senderIdentity == null || innerEvent.pubKey != senderIdentity) { + + // Required rather than merely compared. Every sender-derived field + // downstream now reads from here -- who a message is from, and whether + // it is ours -- because a gift wrap payload carries no author of its + // own. Without an identity there is nothing to attribute a line to. + if (senderIdentity == null) { + return GroupEventResult.Error( + groupId, + "No MLS credential identity for sender leaf ${decrypted.senderLeafIndex}", + ) + } + + // A gift wrap's pubkey is a throwaway key by construction, so there is + // no author field here to compare against and MIP-03's check cannot be + // applied as written. The authorship claim moves inside, to the signed + // kind:13 seal, which ChatMessage.fromGroupEventResult binds to this + // same senderIdentity before it renders a word -- a verified signature + // bound to an MLS leaf, rather than a plaintext field compared to one. + // + // Other Marmot clients do not have this carve-out and will drop these + // as impersonation. See docs/marmot-direct-messages.md. + if (innerEvent.kind != GiftWrapEvent.KIND && innerEvent.pubKey != senderIdentity) { return GroupEventResult.Error( groupId, "MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)", From 79e62d8239573449d9166301026899d7e1df4543 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 19:05:49 +0200 Subject: [PATCH 15/33] fix: attribute a group message to the member who sent it isUserMessage was computed by comparing the active key against groupEvent.pubKey -- the kind:445's signer. That is a fresh random ephemeral key on every send (`NostrSignerInternal(KeyPair())` in encryptAndSendMarmotInnerEvent), so it could never equal anybody's identity. The comparison was false for every Marmot message in every room, which means every message a member sent themselves rendered as somebody else's: wrong side of the transcript, wrong colour, and no delivery status, which is drawn only for our own lines. All fourteen arms now read the MLS sender identity, which is authenticated to a leaf and is the only thing this can honestly be computed from. For the four results that carry no leaf index -- the commit and proposal statuses -- it is null and yields false, exactly what they got before. Separated from the direct message work that exposed it because it changes bubble alignment in every existing MLS room, and that is worth being able to revert on its own. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 1f54f41f..5d040b11 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -354,6 +354,16 @@ data class ChatMessage( * payload: a direct message's wrap is keyed to a throwaway key and names nobody, * and even for kinds that do carry a pubkey the MLS frame is the authenticated * source while the field is merely asserted. + * + * It is also the only thing [ChatMessage.isUserMessage] can honestly be computed + * from. Every arm below used to compare the active key against `groupEvent.pubKey` + * -- the kind:445's signer, which is a fresh random ephemeral key on every send + * (`NostrSignerInternal(KeyPair())` in `encryptAndSendMarmotInnerEvent`). That can + * never equal anybody's identity, so it was false for every Marmot message in + * every room, and every one of them rendered as somebody else's. + * + * Null for results that carry no leaf index -- the commit and proposal statuses -- + * which yields false, exactly as before. */ suspend fun fromGroupEventResult( database: MantraDatabase, @@ -387,7 +397,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(event.createdAt), content = event.content, // TODO: Figure out what to do here... @@ -417,7 +427,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(event.createdAt), content = "Added ${mantraArtifact.name} to artifacts" @@ -448,7 +458,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(event.createdAt), content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name... @@ -479,7 +489,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(event.createdAt), content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name... @@ -534,7 +544,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(event.createdAt), content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name... @@ -565,7 +575,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(event.createdAt), content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name... @@ -646,7 +656,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(event.createdAt), content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text @@ -670,7 +680,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = groupEventResult.innerEventJson, // TODO: Figure out what to do here... @@ -685,7 +695,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "pendingCommit", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Pending Commit in epoch ${groupEventResult.epoch}", // TODO: Figure out what to do here... @@ -698,7 +708,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "processedCommit", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Processed Commit in epoch ${groupEventResult.newEpoch}", // TODO: Figure out what to do here... @@ -711,7 +721,7 @@ data class ChatMessage( // groupEventId = groupEvent.id, // messageType = "duplicate", // senderPublicKey = groupEvent.pubKey, -// isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, +// isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), // chatRoomId = groupEventResult.groupId, // createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), // content = groupEventResult.innerEventJson, // TODO: Figure out what to do here... @@ -724,7 +734,7 @@ data class ChatMessage( // groupEventId = groupEvent.id, // messageType = "error", // senderPublicKey = groupEvent.pubKey, -// isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, +// isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), // chatRoomId = groupEventResult.groupId, // createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), // content = groupEventResult.innerEventJson, // TODO: Figure out what to do here... @@ -738,7 +748,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "proposalStaged", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Proposal staged: ${groupEventResult.senderLeafIndex}", // TODO: Figure out what to do here... @@ -751,7 +761,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "undecryptableOuterLayer", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Undecryptable Message", // TODO: Figure out what to do here... From b0d019911327938b9f6f696c28cbcd5b26dd683e Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 19:08:36 +0200 Subject: [PATCH 16/33] feat: let a member send and read a private message in the room The UI half. Tapping somebody else's message in a group offers "Reply privately to ", which arms the composer; sending clears it. The riskiest thing about this feature is not cryptographic. It is somebody sending to the room what they meant for one person, or the reverse, and neither can be taken back once it is on the wire. So an armed composer carries three signals at once -- a chip naming the recipient, a placeholder that says "Private message to " instead of "Say what now?", and a tinted field -- and the chip's close button is the single tap back to the room. The recipient is read and cleared together with the text before the send suspends, so a second message cannot inherit the first one's audience. Two renderings, because a direct message looks different depending on whether this device can open it. Readable: the ordinary bubble, plus a lock and "Private to " (or "Private to you"). A private message must never pass for a public one, and the label names the other party because that is what a reader would otherwise assume was the whole room. Opaque: a system line -- "Alice sent a private message to Bob" -- in the shape of RitualNotice rather than a bubble. An empty bubble attributed to Alice would read as her having said nothing, and one with placeholder text would read as her having said the placeholder. It is not tappable; there is nothing behind it to open. Reply privately is offered only on somebody else's message in an MLS room. A NIP-17 room has no audience for a message to be private from, so there the option would be meaningless. Names resolve from the room's participants, which the view model already holds -- the recipient is a member by definition -- so neither line needs a join, and both fall back to a shortened key rather than to "unknown". A line that cannot say which member it means is worse than an ugly one. Co-Authored-By: Claude Opus 5 --- .../ui/composable/ChatRoomMessagingScreen.kt | 65 ++++++- .../ui/view/model/ChatMessageListViewModel.kt | 176 +++++++++++++++++- 2 files changed, 237 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 3cd87d96..d8d5d386 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -4,15 +4,19 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.BottomAppBarDefaults @@ -157,6 +161,49 @@ fun ChatRoomMessagingScreen( .background(BottomAppBarDefaults.containerColor) .navigationBarsPadding() ) { + // An armed composer has to be impossible to miss. The whole risk + // of this feature is somebody sending to the room what they meant + // for one person, or the reverse, and by the time either is on the + // wire it cannot be taken back. Hence three signals at once: the + // chip, the placeholder, and the tinted field. + val directMessageRecipient = chatMessageListViewModel.directMessageRecipient + + if (directMessageRecipient != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + modifier = Modifier.weight(1f), + text = "Private to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium + ) + + // The one way back to the room, and it has to be one tap. + IconButton( + onClick = { chatMessageListViewModel.cancelDirectMessage() } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Send to the whole group instead", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + Box( modifier = Modifier.fillMaxWidth() ) { @@ -166,11 +213,25 @@ fun ChatRoomMessagingScreen( colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent + disabledBorderColor = Color.Transparent, + focusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + }, + unfocusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + } ), placeholder = { Text( - text = "Say what now?" + text = if (directMessageRecipient != null) { + "Private message to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}" + } else { + "Say what now?" + } ) }, trailingIcon = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 27d40b07..4a7c4722 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn @@ -31,9 +32,12 @@ import androidx.compose.material.icons.filled.WorkspacePremium import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Pending import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator @@ -72,6 +76,8 @@ import press.mantra.compose.ui.view.state.ChatMessageListUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import press.mantra.compose.database.model.Participant +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -90,6 +96,60 @@ class ChatMessageListViewModel( val isReceiverChatMessageRelayListMissing: MutableState = mutableStateOf(false) + /** + * The member the next message goes to privately, or null to send it to the room. + * + * Held here rather than on the composer because it survives recomposition and is read + * by both the input and the send call. It is cleared on send: a private message is a + * deliberate act each time, and leaving the composer armed after one is how somebody + * sends the room's next message to one person, or one person's to the room. + */ + var directMessageRecipient: Participant? by mutableStateOf(null) + private set + + /** The message whose actions are open, or null. One at a time. */ + var openMessageActionsFor: Long? by mutableStateOf(null) + private set + + fun startDirectMessage(participant: Participant) { + logger.d("Arming a direct message to ${participant.participantPublicKey}") + directMessageRecipient = participant + openMessageActionsFor = null + } + + fun cancelDirectMessage() { + directMessageRecipient = null + } + + fun openMessageActions(chatMessageId: Long?) { + openMessageActionsFor = chatMessageId + } + + /** The member behind a pubkey, if they are in this room. */ + fun participantFor(publicKey: HexKey?): Participant? = + publicKey?.let { key -> + localChatRoom.localParticipants + .map { it.participant } + .firstOrNull { it.participantPublicKey == key } + } + + /** + * What to call somebody, for a line that has to name them. + * + * Falls back to the key itself rather than to "unknown": a direct message names a + * real member of this room, and a line that cannot say which one is worse than an + * ugly one. + */ + fun nameFor(publicKey: HexKey?): String { + if (publicKey == null) return "someone" + + return localChatRoom.localParticipants + .firstOrNull { it.participant.participantPublicKey == publicKey } + ?.profile + ?.humanReadableNameOrPubkey() + ?: publicKey.shortened() + } + fun initiate() { logger.d("init") scheduleSynchronization() @@ -347,6 +407,20 @@ class ChatMessageListViewModel( return@items } + // A private message this device cannot open. Everything + // about it is known except the one thing that matters, + // so it is a notice rather than an empty bubble -- + // which would read as the sender having said nothing. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE && + localChatMessage.chatMessage.content.isBlank() + ) { + PrivateMessageNotice( + sender = nameFor(localChatMessage.chatMessage.senderPublicKey), + recipient = nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey) + ) + return@items + } + BoxWithConstraints( modifier = Modifier.fillMaxWidth() ) { @@ -360,12 +434,22 @@ class ChatMessageListViewModel( Arrangement.Start } ) { + // Only somebody else's message, and only in a + // group -- a NIP-17 room has no audience for a + // message to be private from. + val canReplyPrivately = + localChatRoom.chatRoom.mlsGroupState != null && + !localChatMessage.chatMessage.isUserMessage && + participantFor(localChatMessage.chatMessage.senderPublicKey) != null + Card( modifier = Modifier.widthIn( max = screenWidth * 0.8f ).wrapContentWidth(), onClick = { - + if (canReplyPrivately) { + openMessageActions(localChatMessage.chatMessage.id) + } }, ) { Column( @@ -387,6 +471,34 @@ class ChatMessageListViewModel( ) } + // A readable direct message must never + // pass for a public one. The label says + // who the other party is, since that is + // the thing a reader would otherwise + // assume was the whole room. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = if (localChatMessage.chatMessage.isUserMessage) { + "Private to ${nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)}" + } else { + "Private to you" + }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall + ) + } + } + SelectionContainer { Text( @@ -435,6 +547,24 @@ class ChatMessageListViewModel( } } } + + DropdownMenu( + expanded = openMessageActionsFor == localChatMessage.chatMessage.id, + onDismissRequest = { openMessageActions(null) } + ) { + DropdownMenuItem( + text = { + Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}") + }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + onClick = { + participantFor(localChatMessage.chatMessage.senderPublicKey) + ?.let { startDirectMessage(it) } + } + ) + } } } } @@ -468,12 +598,17 @@ class ChatMessageListViewModel( fun sendMessage(textFieldState: TextFieldState) { if (textFieldState.text.isNotBlank()) { val text = textFieldState.text.toString() + // Read and cleared together with the text, before the send suspends, so a + // second message cannot inherit the first one's audience. + val recipient = directMessageRecipient textFieldState.clearText() + directMessageRecipient = null viewModelScope.launch(Dispatchers.IO) { chatRepository.sendChatMessage( text = text, - localChatRoom = localChatRoom + localChatRoom = localChatRoom, + directMessageRecipientPublicKey = recipient?.participantPublicKey ) } } @@ -500,6 +635,43 @@ class ChatMessageListViewModel( } } +/** + * A private message this device cannot read, as a system line. + * + * Deliberately not a bubble. The group is meant to know that a private message was sent + * and to whom -- that is the honest half of the feature -- but an empty bubble attributed + * to the sender would read as them having said nothing, and a bubble with placeholder text + * would read as them having said the placeholder. + * + * Not tappable: there is nothing behind it to open. See docs/marmot-direct-messages.md. + */ +@Composable +private fun PrivateMessageNotice( + sender: String, + recipient: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "$sender sent a private message to $recipient", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall + ) + } +} + /** * A ChillDKG milestone, as a system line across the transcript. * From 635cef931180a7955b37f1ab629eae395cc72def Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 19:10:22 +0200 Subject: [PATCH 17/33] docs: write down how a direct message travels, and what it costs The reasoning behind this is not recoverable from the code, which is the bar docs/README.md sets for having a document at all. Three things in particular would otherwise have to be rediscovered by whoever changes this next, and two of them are traps. Why the wrap uses a throwaway key rather than the sender's own -- and what that does not buy. It does not hide the sender from the group: MLS authenticates every application message to a leaf, so the identity is there regardless. What it costs is a carve-out in MIP-03's pubkey check and the sender's ability to ever read their own messages back. Why the check that carve-out removes is not a hole. The authorship claim moves from the wrap's plaintext pubkey to the seal's verified signature, bound to the MLS leaf that sent it -- strictly harder to forge than what it replaced. The one query that would broadcast one of these. What this builds is a genuine, correctly signed NIP-59 gift wrap, indistinguishable from what the NIP-17 path would be right to publish, and the only thing keeping it off a relay is that it never becomes a GiftWrapPayload. Written against what shipped rather than what was planned, so it records two deviations. senderIdentity is resolved in NostrDao rather than added to GroupEventResult.ApplicationMessage, because quartz is a binary dependency here and the local checkout is a reference copy, not a build input. And a failed validation drops the message and logs rather than throwing, because the caller is inside storeNostrEvent's transaction. The unbuilt parts are listed as absences rather than left implied: there is no member picker, so a private message can only be a reply to one somebody already sent, and nothing in the UI yet tells a user in words that the group can see who they messaged. Co-Authored-By: Claude Opus 5 --- docs/README.md | 3 +- docs/marmot-direct-messages.md | 338 +++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 docs/marmot-direct-messages.md diff --git a/docs/README.md b/docs/README.md index 892a9ba8..8686fe7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,5 +9,6 @@ silent, or a decision that looked arbitrary and was not. | [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering | | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | +| [marmot-direct-messages.md](./marmot-direct-messages.md) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it | -Start with the ceremony if you are new to this area; the other two both assume it. +Start with the ceremony if you are new to this area; everything else here assumes it. diff --git a/docs/marmot-direct-messages.md b/docs/marmot-direct-messages.md new file mode 100644 index 00000000..4beb4506 --- /dev/null +++ b/docs/marmot-direct-messages.md @@ -0,0 +1,338 @@ +# Direct messages inside a Marmot group + +A one-to-one message carried as an MLS application message: an ordinary NIP-59 gift +wrap, ephemeral-keyed and signed the way NIP-59 says, addressed to one member and +never broadcast to a relay. Every member of the group sees that a private message +was sent and to whom. Only the recipient can read it. + +Read [Why a throwaway key, and what it costs](#why-a-throwaway-key-and-what-it-costs) +before changing anything here. Two consequences of that choice reach into every +part of this, and one of them cannot be undone. + +## What a direct message is, outside in + +``` +kind:445 GroupEvent ephemeral signer, h-tag = nostrGroupId → relays +└─ ChaCha20-Poly1305 MLS exporter secret → group + └─ MLS PrivateMessage ContentType.APPLICATION → group + │ sender authenticated by MLS leaf ────────┐ + └─ kind:1059 wrap pubkey = throwaway, signed by it │ → group + └─ NIP-44 conversation key: throwaway ↔ recipient │ + └─ kind:13 seal signed by the sender ───── bound to ─────┘ + └─ NIP-44 → recipient + └─ kind:14 rumor → recipient +``` + +The group reaches the fourth layer and stops. Everything above it is what a +bystander renders from: a recipient, a time, a wall — and a sender who comes from +the MLS frame rather than from anything written in the wrap. + +The two outer layers are unchanged. A direct message is an ordinary kind:445 to +anyone watching a relay and an ordinary application message to `MlsGroup`; the +whole feature is what goes in as the application payload. + +`MarmotDirectMessage` builds and opens it, as pure functions of their arguments +with no database and no MLS state — which is what makes it testable, since +Room-backed code cannot be unit-tested in this project. + +## Why a throwaway key, and what it costs + +The wrap uses a fresh random key and is signed by it, exactly as NIP-59 specifies +and exactly as `GiftWrapEvent.create` builds one. Nothing in the wrap names the +sender. + +**What it does not buy: hiding the sender from the group.** MLS authenticates every +application message to a leaf. `mlsGroup.memberIdentityHex(decrypted.senderLeafIndex)` +yields the sender's real pubkey no matter what the inner event claims, and there is +no way to send an application message that does not. The throwaway key removes a +*redundant* copy of an identity the MLS frame already proves — it does not remove +the identity. + +**What it does buy:** a wrap lifted out of its MLS frame — a log line, a database +export, a crash dump — is not attributable to anyone. It is also a well-formed +NIP-59 gift wrap rather than a Marmot-shaped variant, which is worth something to +anyone reading the payload with ordinary nostr tooling. + +Two costs follow, and both are load-bearing. + +### It requires a carve-out in a security check + +`MarmotInboundManager.processPrivateMessage` rejects any inner application event +whose `pubKey` is not the MLS sender's credential identity: + +``` +MIP-03: inner event pubkey (…) does not match MLS sender identity (…) +``` + +That is MIP-03, not local policy, and it exists so a member cannot mint events +claiming a different author. A throwaway-keyed wrap cannot satisfy a check about +its author, so kind:1059 is exempt from it: + +```kotlin +if (innerEvent.kind != GiftWrapEvent.KIND && innerEvent.pubKey != senderIdentity) { + return GroupEventResult.Error(groupId, "MIP-03: inner event pubkey …") +} +``` + +`senderIdentity` is now *required* rather than merely compared. Under the old check +a null identity failed only because the comparison failed; attribution depends on +it outright, so it fails on its own. + +**The check is not weakened, it is relocated and strengthened.** What replaces it +for kind:1059 is `seal.pubKey == senderIdentity` on a seal whose `verify()` passes +— a signature bound to an MLS leaf, rather than a plaintext field compared to one. +The attack it stops is a member re-wrapping a seal they were legitimately sent and +passing it to a third party as its author's; that fails because the MLS frame says +who actually sent *this* one. Forging one outright needs the other member's private +key. + +> **Compatibility.** Every member's client needs this carve-out. A Marmot client +> implementing MIP-03 as written drops these messages as impersonation — silently, +> as a `GroupEventResult.Error` — so a group with one unpatched member has one +> member who never receives a direct message and is never told why. This is a +> divergence from the spec, and a reason to raise it upstream rather than carry it +> indefinitely. + +### The sender cannot read their own messages back + +The throwaway private key is discarded at send time, so nothing can reopen the wrap +afterwards — not even the person who built it. NIP-17 solves this by sending a +second wrap addressed to yourself; here that would be a second application message, +and so a second *"sent a private message"* line in everyone else's transcript, so it +is not available. + +What follows: + +- The sending device keeps its own copy, because `sendChatMessage` writes the + plaintext `ChatMessage` row on the way out. Normal use is unaffected. +- A second device, or a reinstall, gets **nothing** — the sender's own half of + every conversation is unreadable to them anywhere it was not typed. The + recipient's half is unaffected. +- On a re-sync the sender's own message comes back as an opaque wrap they cannot + open, indistinguishable from a bystander's view. `ChatMessage.directMessage` + guards that case explicitly and files nothing, because a bystander line would + replace the words on the row `sendChatMessage` wrote — the only copy there is. + `NostrDao.persistInboundChatMessage` carries the same guard, for the same reason, + on the NIP-17 path. + +Do not solve this by storing the throwaway private key. A per-message private key at +rest is strictly worse than the identity-keyed wrap it was chosen over, and it +reintroduces the attribution the throwaway key exists to remove. There is a test — +`the sender cannot reopen their own message` — whose only job is to make that +reversal fail loudly rather than ship. + +## The one way to broadcast this by accident + +Nothing sweeps events to relays on its own. Broadcast is driven by +`BroadcastNostrEventRequest` rows joined to `NostrEvent`, inserted explicitly. So +the requirement "this must never be broadcast" reduces to one query: + +```sql +SELECT * FROM GiftWrapPayload WHERE publicKey = :publicKey AND giftWrapSealId IS NULL +``` + +`GiftWrapPayloadDao.observeUnsealedGiftWrapPayloads`. The notary watches it, and +`DatabaseChatRepository.sealGiftWrapPayload` seals every row it returns and hands it +to `NostrNip17Dao.persistAndBroadcastGiftWrap` — which inserts a +`BroadcastNostrEventRequest` per relay. **Write one `GiftWrapPayload` row authored +by the local user with a null seal id and the direct message leaves the device.** + +Hence the rule: + +> A Marmot direct message never writes to `GiftWrapPayload` or `GiftWrapMessage` +> at all. Its outbound queue is `MarmotInnerEvent`, and so is its inbound record. + +The risk is sharper here than it would be with a Marmot-shaped payload, because what +this builds is a genuine, well-formed, correctly signed NIP-59 gift wrap. It is +indistinguishable from something the NIP-17 path would be right to publish. Nothing +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. + +## Attribution comes from MLS, not from the payload + +Because nothing in the wrap names the sender, every sender-derived field is read +from the MLS frame. This is not a workaround; it is the correct source, and it is +what makes the carve-out above safe. + +`GroupEventResult.ApplicationMessage` carries `senderLeafIndex` but not the resolved +identity, and `ChatMessage.fromGroupEventResult` has no `MlsGroup` to resolve it +with. The obvious fix — adding `senderIdentity` to the result — is not available: +quartz is a **binary dependency** here (`com.vitorpamplona.quartz:quartz:1.14.0`), +and the local checkout at `~/Documents/development/nostr/amethyst/quartz` is a +reference copy, not a build input. Changing the result type would mean publishing a +fork. + +So `NostrDao` resolves it at the call site instead and passes it in. It holds the +group, the leaf index is already on the result, and an application message advances +no epoch, so the tree has not moved by the time it reads it. Same value, no fork. + +This also settled a bug that predates the feature. All fourteen arms of +`fromGroupEventResult` computed: + +```kotlin +isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey +``` + +`groupEvent.pubKey` is the kind:445's signer, and that is a fresh random ephemeral +key on every send — `NostrSignerInternal(KeyPair())` in +`encryptAndSendMarmotInnerEvent` — so it could never equal the active key. It was +false for every Marmot message in every room, which meant every message a member +sent themselves rendered as somebody else's: wrong side of the transcript, wrong +colour, and no delivery status, which is drawn only for our own lines. All fourteen +now read `senderIdentity`. For the four results that carry no leaf index — the +commit and proposal statuses — it is null and yields false, exactly as before. + +## The message's identity is the rumor id + +The queued `MarmotInnerEvent` for a direct message **is the rumor** — kind:14, +plaintext, p-tagged — so its id is a hash of exactly the fields the recipient will +have after unwrapping. + +This matters in two places. It keeps the outbound link intact, which is the +difference between a message being sent and silently not being sent (see +[Two bugs this uncovered](#two-bugs-this-uncovered)). And it gives the recipient a +stable id for dedupe across redelivery and re-sync. + +It does **not** give sender and recipient a shared identity for the same message the +way an identity-keyed wrap would, because the sender never re-derives the rumor from +an echo they cannot open. Two devices belonging to the sender do not converge; they +simply do not both have the message. + +The wire event — the wrap — has its own id, so a direct message leaves two rows on a +recipient's device: one for the artifact that travelled and one for the message it +carried. + +## Timestamps are not fuzzed + +NIP-59 randomises the wrap's and the seal's `created_at` by up to two days to +frustrate correlation at a relay, and both `GiftWrapEvent.create` and +`SealedRumorEvent.create` default to `TimeUtils.randomWithTwoDays()`. + +The wrap never reaches a relay, and the kind:445 around it already carries the true +time. Fuzzing would do nothing but scatter the *"sent a private message"* line up to +two days out of position in every other member's transcript. `MarmotDirectMessage` +passes the real message time to all three layers — a default to override, not one to +accept, and the easiest thing here to get wrong by omission. There is a test for it. + +## Three decisions + +| decision | taken | what it costs | +|---|---|---| +| Is the recipient visible to the group? | yes — the `p` tag stays on the wrap | The group learns who messages whom, and how often. Omitting the tag is genuinely cheap here: the recipient decrypts against the wrap's own throwaway pubkey, so they need no tag to find their own mail, and one failed NIP-44 decrypt per member per message is the whole cost. What it takes is the named bystander line — *"sent a private message"* with no recipient — and any ordering by conversation. Worth revisiting; not free, but close. | +| Is the wrap signed? | yes — by the throwaway key, per NIP-59 | Nothing, which is the point. The signer is meaningless and discarded, so the signature attributes nothing, and keeping it means the payload is a real NIP-59 gift wrap that `GiftWrapEvent.create` builds and `unwrapOrNull` opens with no special cases. | +| What kind is the rumor? | `ChatMessageEvent.KIND` (14) | Group messages here are kind:9 (`ChatEvent`) — `sendChatMessage` maps 14 → 9 on the way in for an ordinary message and leaves a direct message at 14. 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. Costs one more arm in the inbound switch. | + +## The path a message takes + +**Out.** `sendChatMessage` resolves the recipient against the room's participants — +refusing a non-member, whose wrap nobody could open, and refusing yourself, whose +wrap could never be read back — and writes two rows: the queued `MarmotInnerEvent` +(the rumor) and the `ChatMessage` holding the plaintext. The notary picks the queued +row up, `MarmotOutboundDao` calls `MarmotDirectMessage.wrap` instead of assembling a +bare rumor, and everything downstream is unchanged. Once sent, the plaintext is +scrubbed from the queued row: it is already on the `ChatMessage`, and a second copy +would be cleartext left in a table that otherwise holds nothing but wire events. + +**In.** `MarmotInboundManager` decrypts and resolves the sender's identity; +`ChatMessage.directMessage` files one of three things. The recipient opens the wrap, +validates the seal against `senderIdentity`, stores the rumor as its own +`MarmotInnerEvent` keyed on the rumor's id, and gets the words. A bystander cannot +open it and gets a line with no content. The sender gets nothing, because their +local row is the only copy. + +A failed validation drops the message and logs; it does not throw. The caller is +inside `storeNostrEvent`'s transaction, and a forged direct message should cost its +own line, not the whole event. + +**On screen.** Tapping somebody else's message in a group offers *Reply privately*, +which arms the composer; sending clears it. An armed composer shows a chip naming +the recipient, a placeholder that says who it is going to, and a tinted field, and +the chip's close button is the one tap back to the room. Three signals rather than +one because the failure this guards against — sending to the room what was meant for +one person, or the reverse — cannot be taken back once it is on the wire. + +## Two bugs this uncovered + +### The wrap's id is not the rumor's + +`encryptAndSendMarmotInnerEvent` found the chat message to link and broadcast with +`getChatMessagesByMarmotInnerEventId(innerEvent.id)` — the *recomputed* id. For a +direct message that is the wrap's id, not the queued rumor's, so the lookup returned +null, the `ChatMessage` was never linked, **and no `BroadcastNostrEventRequest` was +ever inserted**. The message would have been encrypted, persisted, and never sent, +with no error anywhere. + +It now keys on `marmotInnerEvent.id` — what `sendChatMessage` wrote into +`ChatMessage.marmotInnerEventId`, and identical to `innerEvent.id` for every +non-direct message, so nothing else moved. + +### `isUserMessage` was always false for Marmot messages + +Covered under [Attribution comes from MLS](#attribution-comes-from-mls-not-from-the-payload). +It stopped being merely cosmetic here: with nothing in the payload naming the +sender, `senderIdentity` is the only source of attribution there is. + +## Where it lives + +| file | what | +|---|---| +| `nostr/MarmotDirectMessage.kt` | wrap / open, the pure seam both directions call | +| `commonTest/.../MarmotDirectMessageTest.kt` | ten cases, against real secp256k1 and real NIP-44 | +| `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/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 | +| `ui/composable/ChatRoomMessagingScreen.kt` | the recipient chip | + +Untouched, deliberately: `NostrNip17Dao`, `GiftWrapMessage`, `GiftWrapSeal`, +`GiftWrapPayload`, and every broadcast path. + +## What this does not do + +Each of these will be reported as a bug at some point. They are not. + +**The group learns that a direct message happened, and to whom.** Only the contents +are private, and the sender is authenticated by MLS whatever the wrap says. That is +the design, and the UI should say so in words somewhere a user meets before their +first private message — it does not yet. + +**The sender cannot read their own messages anywhere they were not typed.** The +throwaway key is gone. A reinstall or a second device recovers the recipient's half +of a conversation and none of its own. + +**Other Marmot clients drop these messages.** Until the carve-out is upstream, a +group needs every member on a client that carries it, and an unpatched member fails +silently. + +**The inner layer is not forward secret.** The kind:445 envelope inherits MLS epoch +forward secrecy; the NIP-44 layers inside do not. A recipient's identity key that +leaks opens every direct message they still hold, including ones sent years earlier. +This is strictly weaker than the group messages sitting beside them in the same +room. + +**A removed member keeps what they already have.** Removal advances the epoch; it +does not reach back into their device. + +**Disappearing messages apply at the envelope only.** The group's +`disappearingMessageSecs` puts a NIP-40 expiration on the kind:445, which relays +honour. Local rows are unaffected, exactly as for group messages today. + +**One recipient per message.** Several would mean several wraps, and so several +bystander lines for one message. Worth doing; worth designing first. + +**No reactions, receipts, replies or attachments.** A reply needs an `e` tag inside +the rumor and is a small addition later. A reaction is a design question rather than +a coding one, because the reaction itself would be visible to the group. + +**No member picker.** The only way to start a private message is to reply to one the +member already sent, so you cannot open a conversation with somebody who has not +spoken. From f57644aa1fc8d0957488ec177bd590c4a52b87ec Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:06:49 +0200 Subject: [PATCH 18/33] fix: stop discarding gift wraps addressed to someone else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inbound kind:1059 whose `p` tag is not our pubkey took down the entire save transaction: java.lang.IllegalStateException: Invalid Mac: Calculated f1db537e…, decoded: 45c8c86a… at com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf.fastExpand at com.vitorpamplona.quartz.nip44Encryption.Nip44v2.checkMessageKeys … at press.mantra.compose.database.model.GiftWrapMessage.decryptGiftWrapSeal at press.mantra.compose.database.dao.NostrDao.indexNostrEvent at press.mantra.compose.database.dao.NostrDao.storeNostrEvent Two separate things were wrong. The first is that decryptGiftWrapSeal attempted the decryption at all. When the recipient did not match our key it logged "We are unwrapping a message we may have sent" and called Nip44.decrypt(content, privateKey = ourPrivKey, pubKey = giftWrapEvent.pubKey) giftWrapEvent.pubKey is the wrap's ephemeral author. NIP-59 encrypts the wrap under ECDH(ephemeralPriv, recipientPub), and GiftWrapEvent.create mints that ephemeral key with NostrSignerSync(KeyPair()) and discards it on return. ECDH(ourPriv, ephemeralPub) is a third, unrelated key, so the MAC check could never pass. A sender genuinely cannot unwrap their own gift wrap; that is the point of the construction, not a gap in it. The call threw its result away anyway (keyPair.privKey?.let { …; null }) and fell through to the trailing `return null`, so it was a probe whose only possible outcome was an exception. The second is that a null seal was treated as a failure. indexNostrEvent throws GiftWrapUnsealException on null, which unwinds out of the Room transaction in storeNostrEvent and rolls back everything written for the event: the NostrEvent row, its NostrEventRelay row, and the GiftWrapMessage upserted moments earlier. The only catch sits in DatabaseNostrRepository, which logs and continues — and that catch also swallows the `status = "processed"` upsert on the SynchronizeNostrEventRequest, so the event was re-fetched and re-failed on every later sync pass. isAddressedTo now answers the question with no crypto at all, and the indexer returns early for wraps that are not ours: the event and the wrap row survive, the remainder of indexNostrEvent still runs, the transaction commits, and the sync request is marked processed. GiftWrapUnsealException goes back to meaning what it says — addressed to us, but unsealing failed. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 9 +++++ .../compose/database/model/GiftWrapMessage.kt | 33 ++++++++++--------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 18685171..9533c89e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -523,6 +523,15 @@ abstract class NostrDao( giftWrapMessage ) + if (!giftWrapMessage.isAddressedTo(activeKeyPair)) { + // Undecryptable by design rather than by failure, so keep the event and + // the wrap we just stored and stop here. Throwing would roll the whole + // transaction back and lose both. + logger.d("GiftWrap ${nostrEvent.id} is addressed to ${giftWrapMessage.receiverPublicKey}, nothing to index") + + return@let + } + giftWrapMessage.decryptGiftWrapSeal( activeKeyPair ).let { giftWrapSeal -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt index c0b471ea..a7b92840 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt @@ -7,13 +7,11 @@ import androidx.room3.PrimaryKey import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlin.time.Clock import kotlin.time.Instant @@ -83,6 +81,18 @@ data class GiftWrapMessage( @Ignore private val logger = Logger.withTag("GiftWrapMessage") + /** + * Whether this gift wrap is addressed to [keyPair], i.e. whether we hold the + * private key that can unwrap it. + * + * NIP-59 encrypts the wrap to its recipient using an ephemeral key that + * [GiftWrapEvent.create] throws away, so a wrap addressed to anyone else can + * never be decrypted by us, not even one we sent ourselves. + */ + fun isAddressedTo( + keyPair: KeyPair + ): Boolean = receiverPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true) + suspend fun decryptGiftWrapSeal( keyPair: KeyPair ): GiftWrapSeal? { @@ -105,19 +115,12 @@ data class GiftWrapMessage( giftWrapEvent.recipientPubKey()?.let { recipientPublicKey -> logger.d("Recipient PublicKey: $recipientPublicKey") - if (keyPair.pubKey.toHexKey() != recipientPublicKey) { - logger.e("We are unwrapping a message we may have sent from ${keyPair.pubKey.toHexKey()}") - - keyPair.privKey?.let { privateKey -> - val sealJSON = Nip44.decrypt( - giftWrapEvent.content, - privateKey = privateKey, - pubKey = giftWrapEvent.pubKey.hexToByteArray() - ) - logger.d("SealJSON: $sealJSON") - - null - } + if (!recipientPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true)) { + // Not ours to open, and no key we hold ever will be: the wrap is + // encrypted to the recipient with a one-off key that + // GiftWrapEvent.create() discards, so not even the sender can + // unwrap their own gift wrap. + logger.d("GiftWrap $id is addressed to $recipientPublicKey, not to us") } else { val nostrSigner = NostrSignerInternal( keyPair = KeyPair(privKey = keyPair.privKey) From f38a5f12f33159e909ef5bf5efbc7d5fae9bb77e Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:07:03 +0200 Subject: [PATCH 19/33] fix: ask relays for gift wraps addressed to us, not to our peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three kind:1059 sync filters named the wrong pubkey. ChatMessageListViewModel asked for `#p:[peer]` with no author constraint, which subscribes to every wrap anyone has ever sent that peer. None of it is decryptable by us, and it is the direct source of the Invalid Mac saves fixed in the previous commit. It now asks for `#p:[us]` on our own DM relays — the only shape of gift wrap filter that can return something we hold a key for. The peer's relays were the wrong place to look regardless: under NIP-17 a sender publishes to the *recipient's* DM relays, so our mail lands on ours. The two in NostrDao asked for `authors:[userPublicKey]` + `#p:[participant]`, commented "messages from this relay that were sent by us". A gift wrap is signed by the throwaway key from GiftWrapEvent.create, never by the sender's identity key, so no author value we could know will ever match one. These requests were queued once per participant and always reconciled to empty — failing silently rather than loudly, which is why they outlived the bug that made the third filter visible. Both `if (chatMessageRelayListEvent != null)` branches held nothing else, so each is inverted to the `== null` case that does the real work: warn, and queue a profile sync for the participant whose DM relay list we are missing. Nothing is lost; neither filter ever returned an event. Two things worth recording about what a filter can and cannot express here. A wrap discloses only its recipient, so "the messages in this conversation" is not askable — `#p:[us]` pulls the whole inbox and that is the narrowest correct request. That is the privacy property being paid for, not a limitation to work around. Sent-message recovery is likewise not a filter problem. It needs a second wrap addressed to ourselves at send time, which giftWrapAndBroadcast does not yet emit; the `#p:[us]` filters already in place would pick those up with no new subscription. purpose on the chat message request changes from "sent-messages" to "chat", matching the now-identical filter in ChatRoomListViewModel. Since computeId buckets by minute and NegentropySynchronizeRequestDao upserts, the two collapse into a single request rather than racing as separate rows. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 72 +++---------------- .../ui/view/model/ChatMessageListViewModel.kt | 12 ++-- 2 files changed, 16 insertions(+), 68 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 9533c89e..26a35736 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -670,41 +670,11 @@ abstract class NostrDao( } } - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - tags = mapOf( - Pair( - "p", - listOf(participant.participantPublicKey) - ) - ), - limit = 50 - ) - database.negentropySynchronizeRequestDao() - .insert( - chatMessageRelayListEvent.relays() - .map { normalizedRelayUrl -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = normalizedRelayUrl.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "sent-messages", - synchronizationFilter = synchronizationFilter, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - ) - } else { + // Nothing to sync when we do have the relay list: a gift + // wrap is authored by a throwaway key, so authors=us matched + // nothing and this request was always empty. Our own inbox is + // synced by p-tag on the chat room list instead. + if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") // Sync ChatMessageRelayListEvent publicKey... @@ -906,35 +876,9 @@ abstract class NostrDao( } } - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - tags = mapOf( - Pair("p", listOf(participant.participantPublicKey)) - ), - limit = 50 - ) - database.negentropySynchronizeRequestDao().insert( - chatMessageRelayListEvent.relays().map { normalizedRelayUrl -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = normalizedRelayUrl.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "sent-messages", - synchronizationFilter = synchronizationFilter, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - ) - } else { + // See the identical block above: authors=us never matches a + // gift wrap, so only the missing-relay-list case has work to do. + if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") // Sync ChatMessageRelayListEvent publicKey... diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 2d2a20d6..1a5d0357 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -123,15 +123,19 @@ class ChatMessageListViewModel( val chatMessageRelayListEvent = chatRepository.getChatMessageRelayForPublicKey(recipients.participant.participantPublicKey) val relayAndSynchronizationFilter = if (chatMessageRelayListEvent != null) { - // Sync messages from this relay... + // Refresh our own inbox. A wrap names only its recipient, so + // "the messages in this conversation" is not something a filter + // can ask for, and the recipient's relays hold their mail, not + // ours. p-tagging the peer here fetched other people's wraps, + // which no key of ours can open. Pair( - chatMessageRelayListEvent.relays(), + Relays.DefaultDMRelayList, SynchronizationFilter( kinds = arrayOf( GiftWrapEvent.KIND, ), tags = mapOf( - Pair("p", listOf(recipients.participant.participantPublicKey)) + Pair("p", listOf(localChatRoom.chatRoom.userPublicKey)) ), limit = 50 ) @@ -166,7 +170,7 @@ class ChatMessageListViewModel( purpose = if (isReceiverChatMessageRelayListMissing.value) { "chat-message-relays" } else { - "sent-messages" + "chat" }, synchronizationFilter = relayAndSynchronizationFilter.second, relayURL = normalizedRelayUrl.url, From a74a4b71cf00a71b66d579930aea940ed371f9d5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:17:43 +0200 Subject: [PATCH 20/33] test: cover the two decisions that decide who said what The crypto was tested; the logic that acts on it was not. Both untested pieces were the security-critical ones, and neither fails loudly when it goes wrong -- one silently widens who may impersonate whom, the other silently destroys a message. Extracted MarmotDirectMessage.classify, which decides what an arriving wrap is to this device, from ChatMessage.directMessage, which turns that decision into rows. The decision is pure; only the filing needs a database, and Room-backed code cannot be unit-tested in this project. Same split, and for the same reason, as pulling the wrap/open crypto out of the DAO in the first place. Extracted MarmotInboundManager.mip03Rejection for the same reason. Its kind:1059 exemption is the most dangerous line in this feature: widened to another kind, or stripped of its kind guard, it hands every member of every group the ability to publish events as anybody, and nothing else in the pipeline would notice. There is now a test that walks seven kinds and asserts each is still held to MIP-03. Fifteen cases, the ones worth naming: `our own message is ours, even though we cannot open it` and `ours is decided before anything is opened`. A sender cannot decrypt their own wrap -- the key was discarded -- so by decryption alone this is indistinguishable from a bystander's view, and only the MLS identity separates them. Get it wrong and the inbound path files an empty placeholder over the row sendChatMessage wrote, which holds the only copy of those words. It is the one failure here that loses data rather than rendering something wrong. `words sealed by one member and sent by another are dropped`. The check that replaces MIP-03 for this kind, tested directly rather than described in a comment as it was before. One test asserts something I had wrong. I expected a seal relabelled with another member's pubkey to be caught by the signature check; it never reaches it. NIP-44 derives the conversation key from the pubkey being claimed, so relabelling a seal makes it undecryptable by the person it was encrypted for -- the label is bound to the key, not merely asserted alongside it. The outcome is Unreadable, which is the truth: the recipient genuinely cannot read it. `a seal tampered with after signing is dropped` covers what verify() does catch, using an alteration that survives decryption. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 195 ++++++------- .../compose/managers/MarmotInboundManager.kt | 67 +++-- .../compose/nostr/MarmotDirectMessage.kt | 84 ++++++ .../managers/MarmotMip03CarveOutTest.kt | 82 ++++++ .../nostr/MarmotDirectMessageDeliveryTest.kt | 276 ++++++++++++++++++ 5 files changed, 566 insertions(+), 138 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 5d040b11..b99ad908 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -30,8 +30,6 @@ import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationEvent import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.crypto.verify -import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import press.mantra.compose.nostr.MarmotDirectMessage import kotlin.time.Clock @@ -221,23 +219,8 @@ data class ChatMessage( /** * Files one direct message, from whichever side of it this device is on. * - * Three outcomes, and the third is the one that destroys data if it is missed: - * - * **The recipient** opens the wrap and gets the words. The rumor inside is stored - * as its own [MarmotInnerEvent] -- keyed on the rumor's id, which is the id the - * sender queued -- and the chat message points at that rather than at the wrap. - * - * **A bystander** cannot open it and gets a line with no content: the group is - * meant to see that a private message was sent and to whom, and nothing more. - * - * **The sender**, on a re-sync, is indistinguishable from a bystander -- the - * wrap's key was discarded at send time, so we cannot open our own message -- and - * so would file an empty placeholder over the row `sendChatMessage` wrote on the - * way out. That row is the only copy of those words that exists. Hence the early - * return; `NostrDao.persistInboundChatMessage` carries the same guard, for the - * same reason, on the NIP-17 path. - * - * See docs/marmot-direct-messages.md. + * The decision is [MarmotDirectMessage.classify]'s, which is where it is tested; + * this turns it into rows. See docs/marmot-direct-messages.md. */ private suspend fun directMessage( database: MantraDatabase, @@ -247,105 +230,91 @@ data class ChatMessage( wrap: Event, senderIdentity: HexKey? ): ChatMessage? { - if (senderIdentity == null) { - logger.e("Dropping direct message ${wrap.id}: no MLS sender identity to attribute it to") - return null - } - val recipientPublicKey = wrap.tags.firstTagValue("p") - // Our own words coming back. See the third outcome above. - if (senderIdentity == activeKeyPair.pubKey.toHex()) { - logger.d("Direct message ${wrap.id} is ours; the row written on send is the only copy") - return null - } - - val opened = MarmotDirectMessage.open(wrap, activeKeyPair) - - if (opened == null) { - // Relays redeliver and negentropy re-syncs; the wrap yields the same id - // every time, so this is what keeps a second delivery from becoming a - // second line. ChatMessage.id is autogenerated and would take a duplicate. - if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(wrap.id) != null) { - return null + return when (val delivery = MarmotDirectMessage.classify(wrap, activeKeyPair, senderIdentity)) { + // Our own words coming back. We cannot open our own wrap -- its key was + // discarded at send time -- so this is indistinguishable from a + // bystander's view by decryption alone, and filing it as one would + // replace the words on the row `sendChatMessage` wrote with an empty + // placeholder. That row is the only copy of them that exists. + // `NostrDao.persistInboundChatMessage` carries the same guard, for the + // same reason, on the NIP-17 path. + MarmotDirectMessage.Delivery.Ours -> { + logger.d("Direct message ${wrap.id} is ours; the row written on send is the only copy") + null } - return ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_DIRECT_MESSAGE, - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = wrap.id, - senderPublicKey = senderIdentity, - directMessageRecipientPublicKey = recipientPublicKey, - isUserMessage = false, - chatRoomId = chatRoomId, - createdAt = Instant.fromEpochSeconds(wrap.createdAt), - content = "" - ) + // A forged or malformed message costs its own line and nothing else. It + // is dropped rather than thrown because the caller is inside + // storeNostrEvent's transaction, and this must not cost the whole event. + is MarmotDirectMessage.Delivery.Rejected -> { + logger.e("Dropping direct message ${wrap.id}: ${delivery.reason}") + null + } + + // Not ours to read. The group is meant to see that a private message was + // sent and to whom, and nothing more. + MarmotDirectMessage.Delivery.Unreadable -> { + // Relays redeliver and negentropy re-syncs; the wrap yields the same + // id every time, so this is what keeps a second delivery from becoming + // a second line. ChatMessage.id is autogenerated and would take a + // duplicate. + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(wrap.id) != null) { + return null + } + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = wrap.id, + senderPublicKey = senderIdentity ?: wrap.pubKey, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + content = "" + ) + } + + is MarmotDirectMessage.Delivery.Readable -> { + val opened = delivery.opened + + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(opened.rumor.id) != null) { + return null + } + + // Keyed on the rumor, not the wrap. This is the id the sender queued, + // so both sides of the conversation hold the same message under the + // same identity. + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = opened.rumor.id, + publicKey = opened.rumor.pubKey, + marmotGroupEventId = groupEvent.id, + tags = opened.rumor.tags, + content = opened.rumor.content, + chatRoomId = chatRoomId, + kind = opened.rumor.kind, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt) + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = opened.rumor.id, + senderPublicKey = opened.seal.pubKey, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt), + content = opened.rumor.content + ) + } } - - // What replaces MIP-03's pubkey check for kind:1059, and the only thing - // standing between the words and being rendered as somebody else's. The seal - // is the one layer the sender signs; binding it to the MLS leaf that sent the - // message is what stops a member re-wrapping a seal they were sent and - // passing it off as its author's. A forged one is dropped, not rendered. - if (opened.seal.pubKey != senderIdentity) { - logger.e( - "Dropping direct message ${wrap.id}: sealed by ${opened.seal.pubKey} " + - "but sent by $senderIdentity" - ) - return null - } - - if (!opened.seal.verify()) { - logger.e("Dropping direct message ${wrap.id}: the seal's signature does not verify") - return null - } - - if (opened.rumor.pubKey != opened.seal.pubKey) { - logger.e( - "Dropping direct message ${wrap.id}: rumor by ${opened.rumor.pubKey} " + - "inside a seal by ${opened.seal.pubKey}" - ) - return null - } - - if (opened.rumor.kind != ChatMessageEvent.KIND) { - logger.w("Dropping direct message ${wrap.id}: unsupported rumor kind ${opened.rumor.kind}") - return null - } - - if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(opened.rumor.id) != null) { - return null - } - - // Keyed on the rumor, not the wrap. This is the id the sender queued, so both - // sides of the conversation hold the same message under the same identity. - database.marmotInnerEventDao().upsert( - MarmotInnerEvent( - id = opened.rumor.id, - publicKey = opened.rumor.pubKey, - marmotGroupEventId = groupEvent.id, - tags = opened.rumor.tags, - content = opened.rumor.content, - chatRoomId = chatRoomId, - kind = opened.rumor.kind, - createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt) - ) - ) - - return ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_DIRECT_MESSAGE, - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = opened.rumor.id, - senderPublicKey = senderIdentity, - directMessageRecipientPublicKey = recipientPublicKey, - isUserMessage = false, - chatRoomId = chatRoomId, - createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt), - content = opened.rumor.content - ) } /** diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt index 1cf9bdfc..af284650 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt @@ -37,6 +37,7 @@ import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import kotlin.time.Clock @@ -62,6 +63,45 @@ object MarmotInboundManager { private val commitTracker = CommitOrdering.EpochCommitTracker() + /** + * Why an inner application event may not be accepted from this sender, or null. + * + * MIP-03: an inner event's `pubkey` MUST equal the MLS sender's credential identity, + * so a member cannot mint events claiming a different author. + * + * A gift wrap is exempt. Its pubkey is a throwaway key by construction -- see + * [MarmotDirectMessage] -- so there is no author field to compare and the check + * cannot be applied as written. The authorship claim moves inside, to the signed + * kind:13 seal, which [MarmotDirectMessage.classify] binds to this same identity + * before a word is rendered: a verified signature bound to an MLS leaf, rather than a + * plaintext field compared to one. + * + * The exemption is on kind:1059 alone. Widening it to any other kind, or dropping the + * kind guard, hands every member the ability to publish events as anybody. + * + * Other Marmot clients do not have this carve-out and drop these messages as + * impersonation. See docs/marmot-direct-messages.md. + * + * [senderIdentity] is required rather than merely compared: every sender-derived + * field downstream reads from it -- who a message is from, and whether it is ours -- + * because a gift wrap payload carries no author of its own. + */ + fun mip03Rejection( + innerEventKind: Int, + innerEventPubKey: HexKey, + senderIdentity: HexKey?, + ): String? { + if (senderIdentity == null) { + return "No MLS credential identity for the sender leaf" + } + + if (innerEventKind != GiftWrapEvent.KIND && innerEventPubKey != senderIdentity) { + return "MIP-03: inner event pubkey ($innerEventPubKey) does not match MLS sender identity ($senderIdentity)" + } + + return null + } + suspend fun processGroupMembershipChanges( database: MantraDatabase, localChatRoom: LocalChatRoom, @@ -269,31 +309,8 @@ object MarmotInboundManager { if (innerEvent != null) { val senderIdentity = mlsGroup.memberIdentityHex(decrypted.senderLeafIndex) - // Required rather than merely compared. Every sender-derived field - // downstream now reads from here -- who a message is from, and whether - // it is ours -- because a gift wrap payload carries no author of its - // own. Without an identity there is nothing to attribute a line to. - if (senderIdentity == null) { - return GroupEventResult.Error( - groupId, - "No MLS credential identity for sender leaf ${decrypted.senderLeafIndex}", - ) - } - - // A gift wrap's pubkey is a throwaway key by construction, so there is - // no author field here to compare against and MIP-03's check cannot be - // applied as written. The authorship claim moves inside, to the signed - // kind:13 seal, which ChatMessage.fromGroupEventResult binds to this - // same senderIdentity before it renders a word -- a verified signature - // bound to an MLS leaf, rather than a plaintext field compared to one. - // - // Other Marmot clients do not have this carve-out and will drop these - // as impersonation. See docs/marmot-direct-messages.md. - if (innerEvent.kind != GiftWrapEvent.KIND && innerEvent.pubKey != senderIdentity) { - return GroupEventResult.Error( - groupId, - "MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)", - ) + mip03Rejection(innerEvent.kind, innerEvent.pubKey, senderIdentity)?.let { reason -> + return GroupEventResult.Error(groupId, reason) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt index 5b58bf32..4a7fc810 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt @@ -4,11 +4,14 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -42,6 +45,87 @@ object MarmotDirectMessage { val rumor: Event, ) + /** + * What this device should do with an arriving wrap. + * + * Separated from the filing of it so the decision can be tested: the persistence it + * drives needs a database, which cannot be unit-tested in this project, and the + * decision includes the check that replaces MIP-03 for kind:1059. That check is the + * only thing standing between somebody else's words and being rendered as a member's + * own, so it is not somewhere to rely on integration testing that does not exist. + */ + sealed interface Delivery { + /** + * Our own message coming back, which we cannot open -- the wrap's key was + * discarded at send time -- and so cannot tell apart from [Unreadable] by + * decryption alone. Only the MLS sender identity distinguishes them. + * + * File nothing: the row written on send holds the only copy of these words, and + * an unreadable line would replace them with a placeholder. + */ + data object Ours : Delivery + + /** Somebody else's. The group sees that it happened, and to whom. */ + data object Unreadable : Delivery + + /** Addressed to us, opened, and consistent with the leaf that sent it. */ + data class Readable( + val opened: Opened, + ) : Delivery + + /** Something is wrong with it. Drop it; do not render it as anybody's. */ + data class Rejected( + val reason: String, + ) : Delivery + } + + /** + * Decides what [wrap] is to this device, given the identity MLS says sent it. + * + * [senderIdentity] is not optional and is not read from the payload: a wrap names + * nobody, so the MLS frame is the only source of who sent it. Absent one there is + * nothing to attribute a line to and nothing to check the seal against. + */ + fun classify( + wrap: Event, + me: KeyPair, + senderIdentity: HexKey?, + ): Delivery { + if (senderIdentity == null) { + return Delivery.Rejected("no MLS sender identity to attribute it to") + } + + if (senderIdentity == me.pubKey.toHexKey()) return Delivery.Ours + + val opened = open(wrap, me) ?: return Delivery.Unreadable + + // The seal is the one layer the sender signs. Binding it to the leaf MLS says + // sent this message is what replaces MIP-03's pubkey check for kind:1059 -- + // a verified signature bound to a leaf, rather than a plaintext field compared + // to one. A divergence here is somebody passing off words as another member's. + if (opened.seal.pubKey != senderIdentity) { + return Delivery.Rejected( + "sealed by ${opened.seal.pubKey} but sent by $senderIdentity", + ) + } + + if (!opened.seal.verify()) { + return Delivery.Rejected("the seal's signature does not verify") + } + + if (opened.rumor.pubKey != opened.seal.pubKey) { + return Delivery.Rejected( + "rumor by ${opened.rumor.pubKey} inside a seal by ${opened.seal.pubKey}", + ) + } + + if (opened.rumor.kind != ChatMessageEvent.KIND) { + return Delivery.Rejected("unsupported rumor kind ${opened.rumor.kind}") + } + + return Delivery.Readable(opened) + } + /** * Builds the payload for a direct message to [recipientPublicKey]. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt new file mode 100644 index 00000000..f6059a92 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt @@ -0,0 +1,82 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The one hole deliberately left in MIP-03's author check, and its edges. + * + * MIP-03 requires an inner application event's pubkey to equal the MLS sender's credential + * identity, which is what stops a member minting events attributed to somebody else. A + * gift wrap cannot satisfy it -- its pubkey is a throwaway key that names nobody -- so + * kind:1059 is exempt, and the authorship claim moves to the signed seal inside. + * + * The exemption is the most dangerous line in this feature. Widened to another kind, or + * stripped of its kind guard, it hands every member of every group the ability to publish + * events as anybody, and nothing else in the pipeline would notice. These tests exist so + * that widening it fails here rather than in a group. + */ +class MarmotMip03CarveOutTest { + private val sender = "a".repeat(64) + private val somebodyElse = "b".repeat(64) + private val throwaway = "c".repeat(64) + + @Test + fun `an ordinary message from its own author is accepted`() { + assertNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, sender)) + } + + @Test + fun `an ordinary message claiming somebody else is rejected`() { + val reason = MarmotInboundManager.mip03Rejection(ChatEvent.KIND, somebodyElse, sender) + + assertNotNull(reason) + assertTrue(reason.startsWith("MIP-03"), "expected the MIP-03 rejection, got: $reason") + } + + @Test + fun `a gift wrap is accepted despite naming nobody`() { + // The carve-out. The wrap's pubkey is a throwaway key and matches no member, which + // is the point of it; what authenticates the sender is the MLS frame, and what + // authenticates the author is the seal inside. See MarmotDirectMessage.classify. + assertNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, sender)) + } + + @Test + fun `the carve-out does not extend to any other kind`() { + // Every kind carried as an application payload today, plus the seal and rumor + // kinds a gift wrap contains -- none of which should ever arrive unwrapped, and + // all of which would be a way to launder an author if the guard were widened. + val kinds = listOf( + ChatEvent.KIND, + ChatMessageEvent.KIND, + SealedRumorEvent.KIND, + GiftWrapEvent.KIND - 1, + GiftWrapEvent.KIND + 1, + 0, + 1, + ) + + kinds.forEach { kind -> + assertNotNull( + MarmotInboundManager.mip03Rejection(kind, somebodyElse, sender), + "kind $kind was allowed to claim an author it does not own", + ) + } + } + + @Test + fun `a sender with no credential identity is rejected, wrap or not`() { + // Required rather than merely compared: with no identity there is nothing to + // attribute a line to, and for a gift wrap there is nothing to check the seal + // against either. Both kinds must fail, not just the one that does a comparison. + assertNotNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, null)) + assertNotNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, null)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt new file mode 100644 index 00000000..ee4b8473 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt @@ -0,0 +1,276 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * What a device decides to do with an arriving wrap. + * + * This is where the check that replaces MIP-03 for kind:1059 is tested. The wrap itself + * names nobody, so everything a transcript says about who sent a private message rests on + * one comparison -- the seal's author against the identity MLS authenticated -- and if it + * stops being made, a member can have their words attributed to somebody else, or + * somebody else's attributed to them. The rest of the inbound path is Room-backed and + * cannot be unit-tested here, which is exactly why the decision was separated from the + * filing of it. + * + * `Ours` carries a second kind of risk. It is indistinguishable from `Unreadable` by + * decryption alone -- we cannot open our own wrap either -- and getting it wrong does not + * fail loudly: it files a placeholder over the only copy of the sender's own words. + */ +class MarmotDirectMessageDeliveryTest { + private val alice = NostrSignerSync(KeyPair()) + private val bob = KeyPair() + private val eve = NostrSignerSync(KeyPair()) + + private val alicePublicKey = alice.pubKey + private val bobPublicKey = bob.pubKey.toHexKey() + + private val at = 1_700_000_000L + private val text = "the vote is at six" + + private fun wrapFor( + signer: NostrSignerSync = alice, + recipient: String = bobPublicKey, + kind: Int = ChatMessageEvent.KIND, + content: String = text, + ): GiftWrapEvent = + MarmotDirectMessage.wrap( + signer = signer, + recipientPublicKey = recipient, + kind = kind, + createdAt = at, + tags = arrayOf(PTag.assemble(recipient, null)), + content = content, + ) + + @Test + fun `the recipient gets the words`() { + val delivery = MarmotDirectMessage.classify(wrapFor(), bob, alicePublicKey) + + val readable = assertIs(delivery) + assertEquals(text, readable.opened.rumor.content) + assertEquals(alicePublicKey, readable.opened.seal.pubKey) + } + + @Test + fun `a bystander gets a line with nothing in it`() { + val carol = KeyPair() + + assertEquals( + MarmotDirectMessage.Delivery.Unreadable, + MarmotDirectMessage.classify(wrapFor(), carol, alicePublicKey), + ) + } + + @Test + fun `our own message is ours, even though we cannot open it`() { + // The case that destroys data if it is missed. Alice cannot decrypt the wrap she + // sent -- its key was discarded -- so nothing about the payload distinguishes this + // from a bystander's view. Only the MLS sender identity does. Were it to come back + // Unreadable, the inbound path would file an empty line over the row sendChatMessage + // wrote, which is the only copy of those words anywhere. + val delivery = MarmotDirectMessage.classify(wrapFor(), alice.keyPair, alicePublicKey) + + assertEquals(MarmotDirectMessage.Delivery.Ours, delivery) + } + + @Test + fun `ours is decided before anything is opened`() { + // Same as above, from the other direction: a wrap Alice could never open, that is + // not even addressed to her, is still hers if MLS says she sent it. Nothing here + // may depend on decryption succeeding. + val toEve = wrapFor(recipient = eve.pubKey) + + assertEquals( + MarmotDirectMessage.Delivery.Ours, + MarmotDirectMessage.classify(toEve, alice.keyPair, alicePublicKey), + ) + } + + @Test + fun `a message with no sender identity is dropped`() { + // Attribution has no other source. A line that cannot say who sent it must not be + // rendered at all rather than be attributed to the wrap, which names nobody. + val delivery = MarmotDirectMessage.classify(wrapFor(), bob, null) + + assertIs(delivery) + } + + @Test + fun `words sealed by one member and sent by another are dropped`() { + // The check that replaces MIP-03. Eve seals to Bob and sends; if MLS says the + // sender was Alice, the two disagree about who is speaking and the message is not + // rendered as anybody's. Without this, the seal inside is free to claim an author + // the frame does not support. + val delivery = MarmotDirectMessage.classify(wrapFor(signer = eve), bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains(eve.pubKey) && rejected.reason.contains(alicePublicKey), + "the reason should name both parties to the disagreement: ${rejected.reason}", + ) + } + + @Test + fun `a seal relabelled with another author cannot even be opened`() { + // Eve's seal with Alice's pubkey written over it. This never reaches the author + // check, because NIP-44 derives the conversation key from the pubkey being + // claimed: relabelling a seal makes it undecryptable by the person it was + // encrypted for. The label is bound to the key, not merely asserted alongside it. + // + // So the outcome is Unreadable rather than Rejected -- Bob genuinely cannot read + // it -- and the group sees a private message it cannot open, which is the truth. + val forged = resealedAs(alicePublicKey, sealedBy = eve, to = bob) + + assertEquals( + MarmotDirectMessage.Delivery.Unreadable, + MarmotDirectMessage.classify(forged, bob, alicePublicKey), + ) + } + + @Test + fun `a seal tampered with after signing is dropped`() { + // Alice's own seal with its timestamp altered. Everything the previous test + // relies on still holds -- the pubkey is Alice's, so the content decrypts -- and + // the pubkey matches what MLS says, so the author check passes. Only the + // signature is left, and it is what catches this: the id no longer commits to the + // fields, so verify() fails. + val tampered = tamperedTimestamp(sealedBy = alice, to = bob) + + val delivery = MarmotDirectMessage.classify(tampered, bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("does not verify"), + "expected a signature rejection, got: ${rejected.reason}", + ) + } + + @Test + fun `a rumor by somebody other than the sealer is dropped`() { + // A correctly signed seal by Alice, wrapped around a rumor claiming to be Eve's. + // Everything outside the innermost layer checks out; the words would be filed + // under whoever the rumor names if this were not caught. + val mismatched = sealAroundForeignRumor(sealedBy = alice, rumorFrom = eve.pubKey, to = bobPublicKey) + + val delivery = MarmotDirectMessage.classify(mismatched, bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("inside a seal by"), + "expected a rumor/seal author mismatch, got: ${rejected.reason}", + ) + } + + @Test + fun `a rumor of the wrong kind is dropped`() { + // kind:9 is what an ordinary group message uses. Arriving gift wrapped it is not a + // direct message this version knows how to render, and rendering it as one would + // put a group message behind a lock. + val delivery = MarmotDirectMessage.classify(wrapFor(kind = ChatEvent.KIND), bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("${ChatEvent.KIND}"), + "expected the offending kind in the reason, got: ${rejected.reason}", + ) + } + + /** [sealedBy]'s seal with [claimedAuthor] written over its pubkey, wrapped for [to]. */ + private fun resealedAs( + claimedAuthor: String, + sealedBy: NostrSignerSync, + to: KeyPair, + ): GiftWrapEvent { + val honest = MarmotDirectMessage.wrap( + signer = sealedBy, + recipientPublicKey = to.pubKey.toHexKey(), + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = emptyArray(), + content = text, + ) + val seal = MarmotDirectMessage.open(honest, to)!!.seal + + val relabelled = + Event( + id = seal.id, + pubKey = claimedAuthor, + createdAt = seal.createdAt, + kind = SealedRumorEvent.KIND, + tags = seal.tags, + content = seal.content, + sig = seal.sig, + ) + + return GiftWrapEvent.create( + event = relabelled, + recipientPubKey = to.pubKey.toHexKey(), + createdAt = at, + ) + } + + /** [sealedBy]'s own seal, its timestamp altered after signing, wrapped for [to]. */ + private fun tamperedTimestamp( + sealedBy: NostrSignerSync, + to: KeyPair, + ): GiftWrapEvent { + val honest = MarmotDirectMessage.wrap( + signer = sealedBy, + recipientPublicKey = to.pubKey.toHexKey(), + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = emptyArray(), + content = text, + ) + val seal = MarmotDirectMessage.open(honest, to)!!.seal + + val altered = + Event( + id = seal.id, + pubKey = seal.pubKey, + createdAt = seal.createdAt + 86_400, + kind = SealedRumorEvent.KIND, + tags = seal.tags, + content = seal.content, + sig = seal.sig, + ) + + return GiftWrapEvent.create( + event = altered, + recipientPubKey = to.pubKey.toHexKey(), + createdAt = at, + ) + } + + /** A properly signed seal by [sealedBy] whose rumor claims [rumorFrom] wrote it. */ + private fun sealAroundForeignRumor( + sealedBy: NostrSignerSync, + rumorFrom: String, + to: String, + ): GiftWrapEvent { + val rumor = MarmotDirectMessage.rumor(rumorFrom, ChatMessageEvent.KIND, at, emptyArray(), text) + + val seal = + sealedBy.signNormal( + createdAt = at, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = sealedBy.nip44Encrypt(rumor.toJson(), to), + ) + + return GiftWrapEvent.create(event = seal, recipientPubKey = to, createdAt = at) + } +} From c24cbed390032e4edc5cfc48257d62bd6ddd05b7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:18:19 +0200 Subject: [PATCH 21/33] docs: record what the coverage work found, and what it left uncovered Three additions. The decision the inbound path makes now has a name and a home -- MarmotDirectMessage.classify -- and the doc says why it is separate from the filing of it: only the filing needs a database, so splitting them is what lets the check that replaces MIP-03 be tested at all. A security property found while writing those tests, which I had asserted backwards. Relabelling a seal with another member's pubkey does not get as far as the signature check: NIP-44 derives the conversation key from the pubkey being claimed, so a relabelled seal is undecryptable by the person it was encrypted for. The label is bound to the key rather than asserted alongside it, and the outcome is a message the recipient genuinely cannot read. verify() catches the narrower case of a seal altered after signing in a way that survives decryption. An honest list of what has no automated test and why -- the recipient validation and the outbound id lookup (both need a database), the two transcript renderings (no Compose UI test dependency in this project), and anything touching a real MlsGroup. Better written down than rediscovered by someone assuming a green suite means the path is covered. Co-Authored-By: Claude Opus 5 --- docs/marmot-direct-messages.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/marmot-direct-messages.md b/docs/marmot-direct-messages.md index 4beb4506..8540c89d 100644 --- a/docs/marmot-direct-messages.md +++ b/docs/marmot-direct-messages.md @@ -86,6 +86,15 @@ passing it to a third party as its author's; that fails because the MLS frame sa who actually sent *this* one. Forging one outright needs the other member's private key. +A seal is harder to relabel than it looks, and not because of the signature. +NIP-44 derives the conversation key from the pubkey being claimed, so writing +another member's key over a seal makes it undecryptable by the person it was +encrypted for — it fails at the decryption, before any check runs, and comes out as +a message the recipient simply cannot read. The label is bound to the key rather +than asserted alongside it. `verify()` is what catches the remaining case: a seal +altered after signing in a way that survives decryption. Both have tests, because +the first was asserted the wrong way round until one of them failed. + > **Compatibility.** Every member's client needs this carve-out. A Marmot client > implementing MIP-03 as written drops these messages as impersonation — silently, > as a `GroupEventResult.Error` — so a group with one unpatched member has one @@ -239,8 +248,11 @@ bare rumor, and everything downstream is unchanged. Once sent, the plaintext is scrubbed from the queued row: it is already on the `ChatMessage`, and a second copy would be cleartext left in a table that otherwise holds nothing but wire events. -**In.** `MarmotInboundManager` decrypts and resolves the sender's identity; -`ChatMessage.directMessage` files one of three things. The recipient opens the wrap, +**In.** `MarmotInboundManager` decrypts and resolves the sender's identity, and +`MarmotDirectMessage.classify` decides what the wrap is to this device — ours, +readable, unreadable, or rejected. `ChatMessage.directMessage` turns that into rows. +The split is so the decision can be tested: only the filing needs a database, and +Room-backed code cannot be unit-tested here. The recipient opens the wrap, validates the seal against `senderIdentity`, stores the rumor as its own `MarmotInnerEvent` keyed on the rumor's id, and gets the words. A bystander cannot open it and gets a line with no content. The sender gets nothing, because their @@ -283,7 +295,9 @@ sender, `senderIdentity` is the only source of attribution there is. | file | what | |---|---| | `nostr/MarmotDirectMessage.kt` | wrap / open, the pure seam both directions call | -| `commonTest/.../MarmotDirectMessageTest.kt` | ten cases, against real secp256k1 and real NIP-44 | +| `commonTest/.../MarmotDirectMessageTest.kt` | the envelope: ten cases, against real secp256k1 and real NIP-44 | +| `commonTest/.../MarmotDirectMessageDeliveryTest.kt` | what a device does with an arriving wrap, including every forgery it must refuse | +| `commonTest/.../MarmotMip03CarveOutTest.kt` | the kind:1059 exemption and its edges | | `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 | @@ -336,3 +350,11 @@ a coding one, because the reaction itself would be visible to the group. **No member picker.** The only way to start a private message is to reply to one the member already sent, so you cannot open a conversation with somebody who has not spoken. + +**Three things have no automated test**, all of them for want of infrastructure +rather than by choice. The recipient validation in `sendChatMessage` (refusing a +non-member, refusing yourself) and the outbound id lookup both need a database; the +invariant the lookup depends on — that the queued row's id is the rumor's — is +tested in its place. The two transcript renderings need Compose UI testing, which +this project has no dependency on. Anything touching a real `MlsGroup` is likewise +untested: the carve-out is tested through `mip03Rejection`, not through a group. From e1d35bbd6c7b7bc00167f9c690c5528fdae08962 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:21:52 +0200 Subject: [PATCH 22/33] test: pin who can open a gift wrap, and what happens to everyone else's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Invalid Mac crash had no test standing between it and a repeat, so this adds one that reproduces it. GiftWrapMessageTest builds real NIP-59 wraps with real secp256k1 rather than recorded fixtures. The property under test is the key agreement itself — whether ECDH(ourPriv, ephemeralPub) can stand in for the conversation key the wrap was sealed under — and a fixture would only prove that the fixture still parses. Three cases carry the regression: - someone else's mail comes back null rather than throwing - not even the sender can reopen what they sent - isAddressedTo answers exactly what unsealing would Checked against the reverted fix, those three fail with the production exception verbatim (java.lang.IllegalStateException: Invalid Mac: Calculated bf2e6480…), while the two describing behaviour that never broke — the happy path, and isAddressedTo's reading of the p tag — stay green. A test that cannot fail against the bug it names is not worth the run time, so the split matters. The last of the three is the one guarding the fix's structure rather than its outcome. NostrDao decides whether to index on isAddressedTo, then throws GiftWrapUnsealException if decryptGiftWrapSeal returns null anyway; those two answers have to agree for either path to be correct. If they drift, the DAO either skips mail we can open or resumes rolling back transactions, and neither shows up as a failure anywhere near the change that caused it. commonTest gains kotlinx-coroutines-test for runTest. decryptGiftWrapSeal is suspending, runBlocking does not exist in common code, and every layer worth testing below the ViewModels — DAOs, repositories, the model's crypto — is suspending too, so the dependency pays for more than this file. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 3 + .../database/model/GiftWrapMessageTest.kt | 141 ++++++++++++++++++ gradle/libs.versions.toml | 1 + 3 files changed, 145 insertions(+) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 71501a81..0021a3d8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -121,6 +121,9 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + // runTest: the DAO and model layers are suspending, so anything worth + // asserting about them needs a coroutine to assert it in. + implementation(libs.kotlinx.coroutinesTest) } jvmMain.dependencies { implementation(compose.desktop.currentOs) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt new file mode 100644 index 00000000..6502f728 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Who can open a gift wrap, and what becomes of everyone else's. + * + * NIP-59 encrypts a wrap under ECDH(ephemeralPriv, recipientPub), and + * [GiftWrapEvent.create] discards that ephemeral key before it returns. The + * recipient named in the `p` tag is therefore the only party who can ever unseal + * one -- the sender included. Reading a wrap that is not ours is not a decryption + * that might fail, it is one that cannot be attempted, and the code that tried + * anyway threw `IllegalStateException: Invalid Mac` out of Nip44 and took the + * enclosing Room transaction down with it, so the event was rolled back and + * re-fetched on every later sync. + * + * These run real secp256k1 rather than recorded fixtures on purpose: the property + * under test is about the key agreement itself, and a fixture would only prove the + * fixture still parses. + */ +class GiftWrapMessageTest { + + private val us = KeyPair() + private val peer = KeyPair() + private val stranger = KeyPair() + + /** + * A real wrap, sealed the way DatabaseChatRepository seals one and then mapped + * into the entity the way NostrEvent.toGiftWrapMessageWithReceiverPTag maps it. + */ + private fun wrap( + sender: KeyPair, + recipient: KeyPair, + ): GiftWrapMessage { + val signer = NostrSignerSync(sender) + + val seal = signer.signNormal( + createdAt = SEALED_AT, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt( + plaintext = """{"kind":14,"content":"dumela"}""", + toPublicKey = recipient.pubKey.toHexKey(), + ), + ) + + val giftWrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = recipient.pubKey.toHexKey(), + createdAt = WRAPPED_AT, + ) + + return GiftWrapMessage( + id = giftWrap.id, + publicKey = giftWrap.pubKey, + receiverPublicKey = recipient.pubKey.toHexKey(), + receiverRelayHit = null, + content = giftWrap.content, + signature = giftWrap.sig, + nostrEventId = giftWrap.id, + createdAt = Instant.fromEpochSeconds(giftWrap.createdAt), + ) + } + + @Test + fun `a wrap addressed to us gives up its seal`() = runTest { + val seal = wrap(sender = peer, recipient = us).decryptGiftWrapSeal(us) + + assertNotNull(seal) + // Only the wrap was anonymous. The seal inside carries the real sender, which + // is what lets the impersonation check downstream compare it to the payload. + assertEquals(peer.pubKey.toHexKey(), seal.publicKey) + } + + @Test + fun `someone else's mail comes back null rather than throwing`() = runTest { + // The event from the crash report: a wrap between two other people, pulled in + // by a filter that named a peer where it should have named us. + val theirs = wrap(sender = stranger, recipient = peer) + + assertNull(theirs.decryptGiftWrapSeal(us)) + } + + @Test + fun `not even the sender can reopen what they sent`() = runTest { + // What the old branch called "a message we may have sent" and tried to decrypt + // regardless. The key that encrypted it no longer exists anywhere; holding the + // sending identity buys nothing back. + val ours = wrap(sender = us, recipient = peer) + + assertNull(ours.decryptGiftWrapSeal(us)) + } + + @Test + fun `isAddressedTo reads the p tag whatever case it arrived in`() { + val message = wrap(sender = peer, recipient = us) + + assertTrue(message.isAddressedTo(us)) + assertFalse(message.isAddressedTo(peer)) + assertTrue( + message + .copy(receiverPublicKey = message.receiverPublicKey.uppercase()) + .isAddressedTo(us), + ) + } + + @Test + fun `isAddressedTo answers exactly what unsealing would`() = runTest { + // NostrDao skips indexing on isAddressedTo and throws GiftWrapUnsealException on + // a null seal. Should those two ever disagree, one path or the other is wrong: + // either mail we can open is skipped, or the transaction rolls back again. + listOf( + wrap(sender = peer, recipient = us), + wrap(sender = stranger, recipient = peer), + wrap(sender = us, recipient = peer), + ).forEach { message -> + assertEquals( + message.isAddressedTo(us), + message.decryptGiftWrapSeal(us) != null, + "isAddressedTo and decryptGiftWrapSeal disagree on ${message.id}", + ) + } + } + + private companion object { + const val SEALED_AT = 1_700_000_000L + const val WRAPPED_AT = 1_700_000_100L + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e51ecfde..9d846a9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } From ad3304a6653a3259301aef2e3ad4d473fecf2079 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:22:06 +0200 Subject: [PATCH 23/33] refactor: build the DM inbox filter once, where it can be asserted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter fix a commit ago changed a value inline in a ViewModel, which is not a place a test can reach: ChatMessageListViewModel needs a repository and a coroutine scope to construct, and NostrDao needs Room. So the filter that had just been wrong in three call sites went back to having no coverage at all. Nip17Filters.inbox is that filter with one definition. ChatMessageListViewModel and ChatRoomListViewModel now both call it — they had been building it separately and identically, which is also what made their negentropy requests collapse into one under computeId, a coincidence better expressed as shared code than left to hold by luck. Nip17FiltersTest asserts every clause that was got wrong in production: - the p tag names us, not a peer - there is no authors clause, because a wrap is signed by the throwaway key GiftWrapEvent.create mints and discards, so authors=[anything knowable] matches nothing on any relay - there is no since cursor, because NIP-59 back-dates a wrap by up to two days and a high-water mark taken from the newest wrap we hold skips mail stamped behind it — the trap waiting for whoever acts on the TODO in NegentropySynchronizeRequest.toSynchronizeNostrEventRequest - the wire JSON is pinned, so an added default cannot quietly split the two callers back into separate requests - the SQL NostrEventFilterQuery builds from it bounds no author either, since negentropy is only as good as the agreement between the set we build locally and the set the relay builds from the same filter Neither of the two failure modes this covers was visible from reading the filter. The authors clause failed silently for as long as it existed, and the peer p-tag failed loudly but somewhere else entirely — in a Room transaction, three files away, as a MAC error out of Nip44. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/nostr/Nip17Filters.kt | 37 +++++++++ .../ui/view/model/ChatMessageListViewModel.kt | 21 ++--- .../ui/view/model/ChatRoomListViewModel.kt | 12 +-- .../mantra/compose/nostr/Nip17FiltersTest.kt | 80 +++++++++++++++++++ 4 files changed, 125 insertions(+), 25 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt new file mode 100644 index 00000000..a1069e6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt @@ -0,0 +1,37 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * The one filter shape that can return a NIP-17 message we are able to read. + * + * A gift wrap hides everything except who it is for. The author is the throwaway + * key [GiftWrapEvent.create] mints and discards, the content is sealed to the + * recipient, and `created_at` is randomised up to two days into the past. That + * leaves the `p` tag as the only clause worth writing, and it has to name us: + * naming a peer subscribes to mail no key of ours can open, and adding `authors` + * matches nothing on any relay, ever. Both mistakes were live in three separate + * call sites, so the filter is built in one place now and asserted in one place. + */ +object Nip17Filters { + + /** + * Everything gift-wrapped to [publicKey], capped at [limit] events. + * + * Deliberately carries no `since`. NIP-59 back-dates a wrap by up to two days, + * so a cursor built from the newest wrap we hold silently skips mail that was + * sent later but stamped earlier. + */ + fun inbox( + publicKey: HexKey, + limit: Int = DEFAULT_LIMIT, + ) = SynchronizationFilter( + kinds = arrayOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(publicKey)), + limit = limit, + ) + + const val DEFAULT_LIMIT = 50 +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 1a5d0357..50334f6a 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -66,6 +66,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.Nip17Filters import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository @@ -74,7 +75,6 @@ import press.mantra.compose.ui.view.state.ChatMessageListUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged @@ -123,22 +123,13 @@ class ChatMessageListViewModel( val chatMessageRelayListEvent = chatRepository.getChatMessageRelayForPublicKey(recipients.participant.participantPublicKey) val relayAndSynchronizationFilter = if (chatMessageRelayListEvent != null) { - // Refresh our own inbox. A wrap names only its recipient, so - // "the messages in this conversation" is not something a filter - // can ask for, and the recipient's relays hold their mail, not - // ours. p-tagging the peer here fetched other people's wraps, - // which no key of ours can open. + // Opening a conversation refreshes our own inbox: this used to + // p-tag the peer and read their relays, which is where their mail + // is kept, not ours. See Nip17Filters for why a per-conversation + // filter is not a thing that can be written. Pair( Relays.DefaultDMRelayList, - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - tags = mapOf( - Pair("p", listOf(localChatRoom.chatRoom.userPublicKey)) - ), - limit = 50 - ) + Nip17Filters.inbox(localChatRoom.chatRoom.userPublicKey), ) } else { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt index 994f76bb..4a709874 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt @@ -27,13 +27,13 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.Nip17Filters import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.ChatRoomListUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch @@ -72,15 +72,7 @@ class ChatRoomListViewModel( logger.d("scheduleSynchronization") viewModelScope.launch(Dispatchers.IO) { // Sync Notifications... might want to also run this in the background - val chatRequestFilter = SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - tags = mapOf( - Pair("p", listOf(publicKey)) - ), - limit = 50 - ) + val chatRequestFilter = Nip17Filters.inbox(publicKey) nostrRepository.queueNegentropySynchronizeRequest( Relays.DefaultDMRelayList.shuffled().map { normalizedRelayUrl -> NegentropySynchronizeRequest( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt new file mode 100644 index 00000000..8ecf0c8f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt @@ -0,0 +1,80 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.query.NostrEventFilterQuery +import press.mantra.compose.network.serialization.encodeToJsonString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Pins the only gift wrap filter that can come back with something we can read. + * + * Every clause here is one that was got wrong in production. Two call sites asked + * for `authors = [our pubkey]`, which cannot match a wrap signed by a throwaway + * key and so returned nothing at all, silently, for as long as it existed. A third + * asked for `p = [the peer]`, which returned other people's mail and crashed the + * save that tried to unseal it. Neither failure was visible from reading the + * filter, so the shape is asserted instead of trusted. + */ +class Nip17FiltersTest { + + private val us = "a".repeat(64) + + @Test + fun `it asks for wraps addressed to us`() { + assertEquals(mapOf("p" to listOf(us)), Nip17Filters.inbox(us).tags) + } + + @Test + fun `it constrains no authors`() { + // GiftWrapEvent.create signs with a key it generates and drops, so the author + // of a wrap is a value nobody can predict -- least of all the sender's own + // pubkey. Any authors clause here silently matches zero events on every relay. + assertNull(Nip17Filters.inbox(us).authors) + } + + @Test + fun `it carries no since cursor`() { + // A wrap is stamped up to two days earlier than it was sent, so a high-water + // mark taken from the newest wrap we hold skips mail that arrives behind it. + // Anything reintroducing `since` has to back-date by at least two days first. + assertNull(Nip17Filters.inbox(us).since) + assertNull(Nip17Filters.inbox(us).until) + } + + @Test + fun `it asks for gift wraps and nothing else`() { + assertEquals(listOf(1059), Nip17Filters.inbox(us).kinds?.toList()) + } + + @Test + fun `two callers asking for the same inbox make one request`() { + // computeId hashes the encoded filter, so the chat room list and the chat + // message screen collapse into a single negentropy request only while both + // encode identically. Building the filter once is what holds that true; the + // wire shape is asserted so an added default cannot quietly split them. + assertEquals(Nip17Filters.inbox(us), Nip17Filters.inbox(us)) + assertEquals( + """{"kinds":[1059],"tags":{"p":["$us"]},"limit":50}""", + Nip17Filters.inbox(us).encodeToJsonString(), + ) + } + + @Test + fun `the local set it builds is the same set the relay is asked for`() { + // Negentropy reconciles our local set against the relay's: this filter goes out + // in NEG-OPEN, and the local side is built by running the same filter through + // NostrEventFilterQuery. A clause that survives one trip and not the other + // reports differences that are not real -- events re-downloaded forever, or + // pushed at a relay that excluded them on purpose. What matters here is that + // the local query reads the p tag and, like the wire filter, bounds no author: + // an authors clause would show up as `pubKey IN (?)`. + val query = NostrEventFilterQuery.build(Nip17Filters.inbox(us)) + + assertEquals( + "SELECT * FROM NostrEvent WHERE kind IN (?) AND (tags LIKE ? ESCAPE '\\') " + + "ORDER BY createdAt DESC, id DESC", + query.sql, + ) + } +} From fb21678813c28ab62b4312d87d3e91e4e310bd12 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:24:21 +0200 Subject: [PATCH 24/33] test: pin where a commit's bytes land when the row recording it is written The mis-routed `framedCommitBytes` fixed in the previous commit was invisible for one reason: nothing anywhere covered the persisted row. The bytes that reach a relay come off the in-memory `CommitResult`, so the wire path stayed correct and the stored path was wrong, and no test looked at the stored path. ## Why the mapping moved before it could be tested A test that built `MarmotCommitResult` itself would have been writing its own copy of the mapping and asserting against that. It would have passed against the buggy code, because the bug was at the call site the test was not using. So the mapping is now `MarmotCommitResult.from`, called by `MarmotOutboundDao.inviteMember` and exercised directly by the test. That also removes the shape that produced the bug rather than just the instance of it: the old call site listed its named arguments in an order different from the declaration, which is what put `preCommitExporterSecret` and `framedCommitBytes` two lines apart. `from` lists the payload in declaration order, in one place, so there is no second site to get wrong. ## What is covered Four tests, each payload given a distinct self-identifying value so that a field arriving in the wrong column names both halves of the mistake instead of comparing equal by accident: - every payload field lands in its own column. - the framed commit column never holds the exporter secret -- the regression, stated as an invariant rather than an equality so it keeps holding for a `CommitResult` this test did not anticipate. - a `CommitResult` that never framed its commit still stores a commit. quartz defaults `framedCommitBytes` to `commitBytes` and the entity repeats that default; the fallback must not quietly become the secret either. - the bookkeeping `DatabaseNostrRepository` reads back on acknowledgement is carried through. `id`, `chatRoomId`, `userPublicKey` and `peerKeyPackageEventId` are all 64-char hex, so two of them swapped in `from` would typecheck exactly as silently as the original bug. Checked by reintroducing `framedCommitBytes = commitResult.preCommitExporterSecret` into `from`: three of the four fail. A green suite that would stay green against the bug it names is not coverage. ## What is not covered, and why That the bytes published equal the bytes stored -- the property one level above this one -- still is not. It needs the DAO, and the DAO needs Room: `commonTest` carries only `kotlin.test`, the room3 KSP processor is registered for the android and ios targets alone with `kspJvm` commented out, and `getInMemoryDatabaseBuilder` wants a `PlatformContext` no unit test has. That is a Robolectric or instrumented target, which is a larger change than this fix earns and is better decided on its own merits than smuggled in here. The ack-triggered rebroadcast that would have turned the bug into a live fault does not exist yet, so there is nothing to test there either. When it is written, the invariant it needs is already asserted. Co-Authored-By: Claude Opus 5 --- .../compose/database/dao/MarmotOutboundDao.kt | 12 +- .../database/model/MarmotCommitResult.kt | 35 +++++ .../model/MarmotCommitResultMappingTest.kt | 134 ++++++++++++++++++ 3 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index c03944bd..caad421b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -435,17 +435,13 @@ abstract class MarmotOutboundDao( // Save commitResult... in case we need to broadcast welcomeEvent after relay acknowledgement... database.marmotCommitResultDao().upsert( - MarmotCommitResult( - id = commitEvent.id, - isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, + MarmotCommitResult.from( + commitEventId = commitEvent.id, + commitResult = commitResult, chatRoomId = nostrGroupId, - commitBytes = commitResult.commitBytes, - preCommitExporterSecret = commitResult.preCommitExporterSecret, - welcomeBytes = commitResult.welcomeBytes, - framedCommitBytes = commitResult.framedCommitBytes, - groupInfoBytes = commitResult.groupInfoBytes, userPublicKey = userPublicKey, peerKeyPackageEventId = peerKeyPackage.id, + isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, createdAt = Instant.fromEpochSeconds(commitEvent.createdAt) ) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt index 160a7a5c..ef749688 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.model.traits.SoftDeletableEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.database.model.traits.UserViewableEntity import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlin.time.Clock import kotlin.time.Instant @@ -72,6 +73,40 @@ data class MarmotCommitResult( // TODO: Rename this to GiftWrapPayload... companion object { const val TAG = "MarmotCommitResult" + /** + * The persisted record of a commit, built from the [CommitResult] that produced it. + * + * The five payload fields are carried over from quartz verbatim -- same names, same + * order, same `ByteArray` type on both sides of the copy -- so a value taken from the + * wrong field of the right object typechecks and reaches the database unnoticed. + * `framedCommitBytes = commitResult.preCommitExporterSecret` survived exactly that way, + * storing the group's pre-commit exporter secret in the column documented to hold a + * broadcastable MLS envelope. + * + * Mapping here rather than at the call site means it is written once, in declaration + * order, and pinned by MarmotCommitResultMappingTest. + */ + fun from( + commitEventId: HexKey, + commitResult: CommitResult, + chatRoomId: HexKey, + userPublicKey: HexKey, + peerKeyPackageEventId: HexKey, + isOneMemberInitialGroupCreation: Boolean, + createdAt: Instant, + ): MarmotCommitResult = MarmotCommitResult( + id = commitEventId, + userPublicKey = userPublicKey, + peerKeyPackageEventId = peerKeyPackageEventId, + chatRoomId = chatRoomId, + isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, + commitBytes = commitResult.commitBytes, + welcomeBytes = commitResult.welcomeBytes, + groupInfoBytes = commitResult.groupInfoBytes, + framedCommitBytes = commitResult.framedCommitBytes, + preCommitExporterSecret = commitResult.preCommitExporterSecret, + createdAt = createdAt, + ) } override fun equals(other: Any?): Boolean { diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt new file mode 100644 index 00000000..74cd329e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt @@ -0,0 +1,134 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Instant + +/** + * Where a commit's bytes land when the row that records it is written. + * + * `MarmotCommitResult` carries quartz's `CommitResult` payload fields verbatim -- + * `commitBytes`, `welcomeBytes`, `groupInfoBytes`, `framedCommitBytes`, + * `preCommitExporterSecret`, the same names and all of them `ByteArray`. A value + * taken from the wrong field of the right object therefore typechecks, and + * `framedCommitBytes = commitResult.preCommitExporterSecret` reached the database + * that way and sat there unnoticed: the column documented to hold a broadcastable + * `MlsMessage(PublicMessage(FramedContent(commit)))` envelope held 32 bytes of the + * group's pre-commit exporter secret instead. + * + * Nothing caught it because nothing read the column. The bytes that reached the + * relay come off the in-memory `CommitResult`, so the wire stayed correct while the + * record of it did not, and the row is written precisely so that the + * acknowledgement path in `DatabaseNostrRepository` can pick work back up later. A + * rebroadcast reading `framedCommitBytes` would have published noise the group + * decrypts, fails to parse, and drops -- silent, which is this subsystem's + * characteristic failure. + * + * So the routing is pinned here. Every payload gets a distinct, self-identifying + * value: a field that ends up in the wrong column names both halves of the mistake + * when it fails, rather than comparing equal by accident. + */ +class MarmotCommitResultMappingTest { + private val commitBytes = "raw-commit".encodeToByteArray() + private val framedCommitBytes = "framed-commit-envelope".encodeToByteArray() + private val welcomeBytes = "welcome".encodeToByteArray() + private val groupInfoBytes = "group-info".encodeToByteArray() + + /** Stands in for `MLS-Exporter("marmot", "group-event", 32)` at the pre-commit epoch. */ + private val preCommitExporterSecret = ByteArray(32) { 0x5E } + + private val commitEventId = "a".repeat(64) + private val chatRoomId = "b".repeat(64) + private val userPublicKey = "c".repeat(64) + private val peerKeyPackageEventId = "d".repeat(64) + private val createdAt = Instant.fromEpochSeconds(1_700_000_000) + + private fun commitResult( + framedCommitBytes: ByteArray = this.framedCommitBytes, + preCommitExporterSecret: ByteArray = this.preCommitExporterSecret, + ) = CommitResult( + commitBytes = commitBytes, + welcomeBytes = welcomeBytes, + groupInfoBytes = groupInfoBytes, + framedCommitBytes = framedCommitBytes, + preCommitExporterSecret = preCommitExporterSecret, + ) + + private fun map(commitResult: CommitResult) = MarmotCommitResult.from( + commitEventId = commitEventId, + commitResult = commitResult, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + peerKeyPackageEventId = peerKeyPackageEventId, + isOneMemberInitialGroupCreation = false, + createdAt = createdAt, + ) + + @Test + fun `every payload field lands in its own column`() { + val row = map(commitResult()) + + assertContentEquals(commitBytes, row.commitBytes, "commitBytes") + assertContentEquals(welcomeBytes, row.welcomeBytes, "welcomeBytes") + assertContentEquals(groupInfoBytes, row.groupInfoBytes, "groupInfoBytes") + assertContentEquals(framedCommitBytes, row.framedCommitBytes, "framedCommitBytes") + assertContentEquals( + preCommitExporterSecret, + row.preCommitExporterSecret, + "preCommitExporterSecret" + ) + } + + @Test + fun `the framed commit column never holds the exporter secret`() { + // The regression. Stated as the invariant rather than as an equality check, + // so it keeps holding for a CommitResult this test did not anticipate. + val row = map(commitResult()) + + assertFalse( + row.framedCommitBytes.contentEquals(row.preCommitExporterSecret), + "the group's exporter secret was stored as the framed commit" + ) + } + + @Test + fun `a CommitResult that never framed its commit still stores a commit`() { + // quartz defaults framedCommitBytes to commitBytes, and the entity repeats that + // default. Whichever of the two a row ends up with, it must be a commit -- the + // fallback must not quietly become the secret either. + val unframed = CommitResult( + commitBytes = commitBytes, + welcomeBytes = welcomeBytes, + groupInfoBytes = groupInfoBytes, + preCommitExporterSecret = preCommitExporterSecret, + ) + + val row = map(unframed) + + assertContentEquals(commitBytes, row.framedCommitBytes) + assertFalse( + row.framedCommitBytes.contentEquals(row.preCommitExporterSecret), + "the group's exporter secret was stored as the framed commit" + ) + } + + @Test + fun `the bookkeeping the acknowledgement path reads is carried through`() { + // DatabaseNostrRepository finds this row by the commit event's id and delivers the + // welcome using chatRoomId, userPublicKey and peerKeyPackageEventId. All four are + // supplied by the caller rather than the CommitResult, so they are checked here to + // keep the argument order of `from` honest -- every one of them is a 64-char hex + // string, and swapping two would otherwise typecheck as silently as the bug did. + val row = map(commitResult()) + + assertEquals(commitEventId, row.id) + assertEquals(chatRoomId, row.chatRoomId) + assertEquals(userPublicKey, row.userPublicKey) + assertEquals(peerKeyPackageEventId, row.peerKeyPackageEventId) + assertEquals(createdAt, row.createdAt) + assertFalse(row.isOneMemberInitialGroupCreation) + } +} From f5eb744ca77975ba5ea2fb5c7367dea4dd4829ba Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:31:11 +0200 Subject: [PATCH 25/33] test: cover the long-running sync, and open the seams needed to do it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six commits that built the live chat sync added no tests. Everything they touch fails silently by nature — a filter that drops messages, a subscription that stops being replayed, a group whose id never reaches the `#h` tag — so the symptom is always "some messages didn't arrive", days later, on someone else's phone. 46 tests, in four files. **What is covered** RelayPoolSubscriptionTest (13) — the pool's half of surviving a dropped socket. A query is retained and replayed on reconnect; a closed one is forgotten and stops the socket reconnecting for it; closing one of two leaves the other alone; a negentropy exchange is never replayed (its rounds are stateful, so resuming one reconciles against a conversation the relay is no longer having); an update to a live subscription replaces what gets replayed, including when the send itself fails; dropping a relay or closing the pool forgets what they carried; replay is scoped to the relay that reconnected. Plus the semantic the whole change rests on, asserted in both directions: a live subscription keeps delivering after EOSE, a one-shot query still ends at it. LiveSubscriptionReconcileTest (12) — the requirement this all exists for: the group filter follows group membership with nobody calling a subscribe function. Joining widens the filter *in place* rather than reopening (a reopen would drop the live tail of every other group in that chunk); leaving drops one; leaving everything closes the subscription; churn inside the debounce window collapses to one update; a NIP-17 room never becomes a group subscription. Then the collect loop: events stored against the relay they came from, an event after EOSE still stored, a CLOSED reopened once the back-off elapses and not before, and a rate-limited CLOSED waiting far longer — but still coming back. Backgrounding closes and foregrounding rebuilds, reconnects, and queues the catch-up. LiveSubscriptionPlanTest (11) — the filter and planning rules, led by the one most likely to be "tidied up" later: the gift wrap filter carries no `since`, because NIP-59 randomizes created_at into the past and a `since` near the present silently drops new messages. RelayBackPressureTest (4) and ReconnectBackoffTest (6) — the two pure decisions. Which CLOSED reasons mean "ease off", and the backoff arithmetic including the exponent clamp: 2.0.pow(4000) is Infinity and Duration * Double throws on it, so without it a socket failing long enough turned its reconnect loop into a crash loop, at the point the network was least likely to recover unaided. **Seams opened to get there**, each a readability win on its own terms: - NostrSocketClientFactory becomes an interface with DefaultNostrSocketClientFactory behind it, so the pool can be driven by a fake socket. - RelayPool takes its CoroutineScope, so the replay a reconnect triggers can be observed rather than raced. - LiveSubscriptionManager depends on a new LiveSubscriptionTransport (4 methods) rather than RelaysSocketManager, which observes the active wallet in its init and cannot be stood up in a test at all. - Its pure planning helpers move to the companion as `internal`, and its launches inherit the caller's dispatcher instead of pinning Dispatchers.IO. SynchronizationViewModel already launches observe() on IO, so nothing moves — but a coroutine that picks its own dispatcher cannot be driven by a test scheduler. - reconnectDelay is extracted to ReconnectBackoff.kt with jitter as a parameter, so the arithmetic can be pinned without randomness. - endsLiveSubscription names the live-subscription termination rule next to isTerminalFor, which is the one-shot rule. Having both named makes the difference between them reviewable rather than implicit. kotlinx-coroutines-test is added to commonTest: the pool's bookkeeping is all suspend functions and there is no runBlocking in a common source set. The tests were checked by mutation, not just by passing — reintroducing a `since`, making EOSE terminal, dropping the leftGroupAt filter and removing retention from query() each produce failures. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 3 + .../managers/LiveSubscriptionManager.kt | 147 +++--- .../relays/LiveSubscriptionTransport.kt | 28 ++ .../compose/network/relays/RelayPool.kt | 8 +- .../network/relays/RelaysSocketManager.kt | 14 +- .../sockets/NostrIncomingMessageExt.kt | 13 + .../sockets/NostrSocketClientFactory.kt | 30 +- .../network/sockets/NostrSocketClientImpl.kt | 30 +- .../network/sockets/ReconnectBackoff.kt | 37 ++ .../ui/view/model/SynchronizationViewModel.kt | 4 +- .../managers/LiveSubscriptionPlanTest.kt | 175 +++++++ .../managers/LiveSubscriptionReconcileTest.kt | 428 ++++++++++++++++++ .../network/relays/RelayBackPressureTest.kt | 58 +++ .../relays/RelayPoolSubscriptionTest.kt | 392 ++++++++++++++++ .../network/sockets/ReconnectBackoffTest.kt | 73 +++ gradle/libs.versions.toml | 1 + 16 files changed, 1333 insertions(+), 108 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/LiveSubscriptionTransport.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/ReconnectBackoff.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionReconcileTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayBackPressureTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7d1eb406..69a2d9c8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -121,6 +121,9 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + // runTest: the relay pool's bookkeeping is all suspend functions, and there is no + // runBlocking in a common source set. + implementation(libs.kotlinx.coroutinesTest) } jvmMain.dependencies { implementation(compose.desktop.currentOs) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt index 04145fc1..e3492a7f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt @@ -10,9 +10,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin @@ -30,10 +28,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter -import press.mantra.compose.network.relays.RelaysSocketManager +import press.mantra.compose.network.relays.LiveSubscriptionTransport import press.mantra.compose.network.relays.isRelayBackPressure import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.network.sockets.endsLiveSubscription import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository @@ -63,7 +63,7 @@ import kotlin.time.Duration.Companion.seconds * (commonly ~20 per connection), which they comfortably are. */ class LiveSubscriptionManager( - private val relaysSocketManager: RelaysSocketManager, + private val relaysSocketManager: LiveSubscriptionTransport, private val nostrRepository: NostrRepository, private val chatRepository: ChatRepository, private val isForeground: StateFlow, @@ -128,6 +128,69 @@ class LiveSubscriptionManager( * only survives into the plain-REQ fallback. */ private const val CATCH_UP_LIMIT = 50 + + /** + * Every gift wrap addressed to us: direct messages, and the Marmot Welcome events + * that make us a member of a group. + * + * There is deliberately no `since`. NIP-59 randomizes a wrap's `created_at` into the + * past — our own outbound path stamps them with `TimeUtils.randomWithTwoDays()` — so + * a wrap published right now can carry a timestamp two days old, and a `since` + * anywhere near the present would silently drop it. "Some messages just never + * arrive" is the worst failure mode to debug, and re-receiving a wrap costs one + * no-op `storeNostrEvent`. + */ + internal fun giftWrapFilter(publicKey: HexKey) = Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(publicKey)), + limit = INITIAL_HISTORY_LIMIT, + ) + + /** + * Marmot group messages for a chunk of the groups we belong to. + * + * Unlike gift wraps these carry honest timestamps (`MarmotOutboundDao` stamps them + * with `TimeUtils.now()`), so a `since` watermark would be safe here. It is still not + * used: `limit` already bounds the initial burst, and a watermark would have to be + * recomputed every time the chunk's membership changed. + */ + internal fun groupFilter(groupIds: List) = Filter( + kinds = listOf(GroupEvent.KIND), + tags = mapOf("h" to groupIds), + limit = INITIAL_GROUP_HISTORY_LIMIT, + ) + + /** + * The groups a live subscription should be watching. + * + * An MLS group is a room with group state; a NIP-17 room has none and is served by + * the gift wrap subscription instead. A room we have left or deleted keeps its + * history locally but must stop pulling new messages. + * + * Sorted so the same membership in a different row order is the same value, and a + * re-emit that changes nothing costs nothing downstream. + */ + internal fun groupIdsFrom(rooms: List): List = + rooms + .filter { it.chatRoom.mlsGroupState != null } + .filter { it.chatRoom.leftGroupAt == null && it.chatRoom.deletedAt == null } + .map { it.chatRoom.id } + .sorted() + + internal fun groupChunks(groupIds: List): List> = + groupIds.chunked(MAX_GROUPS_PER_SUBSCRIPTION) + + internal fun groupSubId(index: Int) = "$GROUP_SUB_ID_PREFIX$index" + + internal fun subscriptionKey(relayUrl: String, subId: String) = "$relayUrl|$subId" + + /** + * The chunk index a [subscriptionKey] refers to, or [Int.MAX_VALUE] for anything that + * does not parse — which reads as "past the end", so an unrecognised key is closed + * rather than kept open forever. + */ + internal fun subscriptionIndex(key: String) = + key.substringAfterLast(GROUP_SUB_ID_PREFIX).toIntOrNull() ?: Int.MAX_VALUE } /** @@ -160,6 +223,11 @@ class LiveSubscriptionManager( } } + /** + * Everything below inherits the caller's dispatcher rather than pinning Dispatchers.IO. + * `SynchronizationViewModel` already launches [observe] on IO, so nothing has moved — but + * a coroutine that picks its own dispatcher cannot be driven by a test scheduler. + */ private suspend fun runWhileForeground(publicKey: HexKey, keyPair: KeyPair): Unit = coroutineScope { logger.i("Foregrounded; opening live subscriptions for $publicKey") @@ -171,10 +239,10 @@ class LiveSubscriptionManager( // Live subscriptions cover the window we are online for; this covers the gap we were // not. Neither subsumes the other. - launch(Dispatchers.IO) { queueCatchUpSynchronization(publicKey) } + launch { queueCatchUpSynchronization(publicKey) } Relays.DefaultDMRelayList.forEach { relay -> - launch(Dispatchers.IO) { + launch { runLiveSubscription( subId = GIFT_WRAP_SUB_ID, relayUrl = relay.url, @@ -184,7 +252,7 @@ class LiveSubscriptionManager( } } - launch(Dispatchers.IO) { followGroupMembership(publicKey = publicKey, keyPair = keyPair) } + launch { followGroupMembership(publicKey = publicKey, keyPair = keyPair) } } /** @@ -241,37 +309,7 @@ class LiveSubscriptionManager( } private suspend fun liveGroupIds(publicKey: HexKey): List = - chatRepository.getChatRoomListByPublicKey(publicKey).toLiveGroupIds() - - /** - * Every gift wrap addressed to us: direct messages, and the Marmot Welcome events that - * make us a member of a group. - * - * There is deliberately no `since`. NIP-59 randomizes a wrap's `created_at` into the past - * — our own outbound path stamps them with `TimeUtils.randomWithTwoDays()` — so a wrap - * published right now can carry a timestamp two days old, and a `since` anywhere near the - * present would silently drop it. "Some messages just never arrive" is the worst failure - * mode to debug, and re-receiving a wrap costs one no-op `storeNostrEvent`. - */ - private fun giftWrapFilter(publicKey: HexKey) = Filter( - kinds = listOf(GiftWrapEvent.KIND), - tags = mapOf("p" to listOf(publicKey)), - limit = INITIAL_HISTORY_LIMIT, - ) - - /** - * Marmot group messages for a chunk of the groups we belong to. - * - * Unlike gift wraps these carry honest timestamps (`MarmotOutboundDao` stamps them with - * `TimeUtils.now()`), so a `since` watermark would be safe here. It is still not used: - * `limit` already bounds the initial burst, and a watermark would have to be recomputed - * every time the chunk's membership changed. - */ - private fun groupFilter(groupIds: List) = Filter( - kinds = listOf(GroupEvent.KIND), - tags = mapOf("h" to groupIds), - limit = INITIAL_GROUP_HISTORY_LIMIT, - ) + groupIdsFrom(chatRepository.getChatRoomListByPublicKey(publicKey)) /** * Keeps the group subscriptions matching the groups we are actually in. @@ -291,7 +329,7 @@ class LiveSubscriptionManager( val subscriptions = mutableMapOf() chatRepository.observeChatRoomListByPublicKey(publicKey) - .map { rooms -> rooms.toLiveGroupIds() } + .map { rooms -> groupIdsFrom(rooms) } .distinctUntilChanged() .debounce(GROUP_CHANGE_DEBOUNCE) .collect { groupIds -> @@ -318,7 +356,7 @@ class LiveSubscriptionManager( keyPair: KeyPair, scope: CoroutineScope, ) { - val chunks = groupIds.chunked(MAX_GROUPS_PER_SUBSCRIPTION) + val chunks = groupChunks(groupIds) groupIdChunks = chunks logger.i("Live group membership: ${groupIds.size} group(s) over ${chunks.size} subscription(s)") @@ -327,7 +365,7 @@ class LiveSubscriptionManager( val relayUrl = relay.url chunks.forEachIndexed { index, chunk -> - val subId = "$GROUP_SUB_ID_PREFIX$index" + val subId = groupSubId(index) val key = subscriptionKey(relayUrl = relayUrl, subId = subId) if (subscriptions[key]?.isActive == true) { @@ -336,7 +374,7 @@ class LiveSubscriptionManager( relayUrl = relayUrl, ) } else { - subscriptions[key] = scope.launch(Dispatchers.IO) { + subscriptions[key] = scope.launch { runLiveSubscription( subId = subId, relayUrl = relayUrl, @@ -369,26 +407,6 @@ class LiveSubscriptionManager( } } - /** - * An MLS group is a room with group state; a NIP-17 room has none and is served by the - * gift wrap subscription instead. A room we have left or deleted keeps its history - * locally but must stop pulling new messages. - * - * Sorted so the same membership in a different row order is the same value, and a - * re-emit that changes nothing costs nothing downstream. - */ - private fun List.toLiveGroupIds() = - this - .filter { it.chatRoom.mlsGroupState != null } - .filter { it.chatRoom.leftGroupAt == null && it.chatRoom.deletedAt == null } - .map { it.chatRoom.id } - .sorted() - - private fun subscriptionKey(relayUrl: String, subId: String) = "$relayUrl|$subId" - - private fun subscriptionIndex(key: String) = - key.substringAfterLast(GROUP_SUB_ID_PREFIX).toIntOrNull() ?: Int.MAX_VALUE - /** * Keeps one subscription open on one relay, re-opening it if the relay ends it. * @@ -476,10 +494,7 @@ class LiveSubscriptionManager( relayUrl = relayUrl, ).transformWhile { message -> emit(message) - // CLOSED is the only message that ends a live subscription. EOSE emphatically - // does not: it is the boundary between the stored history and the live tail, - // and treating it as an end is exactly what makes a sync a poll. - message !is NostrIncomingMessage.ClosedMessage + !message.endsLiveSubscription() }.collect { message -> when (message) { is NostrIncomingMessage.EventMessage -> { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/LiveSubscriptionTransport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/LiveSubscriptionTransport.kt new file mode 100644 index 00000000..a27defe2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/LiveSubscriptionTransport.kt @@ -0,0 +1,28 @@ +package press.mantra.compose.network.relays + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.network.sockets.NostrIncomingMessage + +/** + * The slice of the relay layer a subscription that stays open needs. + * + * Narrower than [RelaysSocketManager] on purpose. That class observes the active wallet and + * starts collecting relay lists in its `init`, so it cannot be stood up in a test at all — + * which would otherwise leave the reconcile loop that keeps group subscriptions matching group + * membership with no coverage but the pure helpers underneath it. + */ +interface LiveSubscriptionTransport { + + /** @see RelayPool.openLiveSubscription */ + suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow + + /** @see RelayPool.updateLiveSubscription */ + suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) + + /** @see RelayPool.closeLiveSubscription */ + suspend fun closeLiveSubscription(subId: String, relayUrl: String) + + /** @see RelayPool.reconnectAll */ + suspend fun reconnectAll() +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt index d4a1be20..a260a1d7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt @@ -42,6 +42,12 @@ import kotlin.time.Duration.Companion.milliseconds class RelayPool( private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, + /** + * Where this pool's own fire-and-forget work runs: status updates, the socket closes that + * a relay-list change triggers, and the subscription replay a reconnect triggers. + * Injectable so a test can drive that work deterministically rather than racing it. + */ + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), ) { val logger = Logger.withTag("RelayPool") @@ -49,8 +55,6 @@ class RelayPool( const val PUBLISH_TIMEOUT = 30_000 } - private val scope = CoroutineScope(Dispatchers.IO) - val relays: MutableSet = mutableSetOf() private val relayMutex = Mutex() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index 8b18428f..d901402a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -33,7 +33,7 @@ class RelaysSocketManager( private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, private val relayRepository: press.mantra.compose.repository.RelayRepository, -) { +) : LiveSubscriptionTransport { val logger = Logger.withTag("RelaysSocketManager") private val scope = CoroutineScope(Dispatchers.IO) private val relayPoolsMutex = Mutex() @@ -126,8 +126,7 @@ class RelaysSocketManager( ) } - /** @see RelayPool.reconnectAll */ - suspend fun reconnectAll() = relayPool.reconnectAll() + override suspend fun reconnectAll() = relayPool.reconnectAll() fun tryConnectingToAllRelays() { relayPool.relays.forEach { @@ -147,24 +146,21 @@ class RelaysSocketManager( } - /** @see RelayPool.openLiveSubscription */ - suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow { + override suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow { return relayPool.openLiveSubscription( reqCommand = reqCommand, relayUrl = relayUrl ) } - /** @see RelayPool.updateLiveSubscription */ - suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { + override suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { return relayPool.updateLiveSubscription( reqCommand = reqCommand, relayUrl = relayUrl ) } - /** @see RelayPool.closeLiveSubscription */ - suspend fun closeLiveSubscription(subId: String, relayUrl: String) { + override suspend fun closeLiveSubscription(subId: String, relayUrl: String) { return relayPool.closeLiveSubscription( subId = subId, relayUrl = relayUrl diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt index 153a6d4e..d876c11b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt @@ -28,6 +28,19 @@ fun NostrIncomingMessage.isTerminalFor(id: String): Boolean = (this is NostrIncomingMessage.ClosedMessage && subscriptionId == id) || (this is NostrIncomingMessage.NegentropyError && subscriptionId == id) +/** + * True for the message that ends a subscription meant to stay OPEN. + * + * Only CLOSED. EOSE emphatically does not end one: it is the boundary between the stored + * history a relay had and the live tail it will now stream, and treating it as an end is + * exactly what makes a subscription a poll. Compare [isTerminalFor], which is the rule for a + * one-shot request, where EOSE is the whole point. + * + * No subscription-id argument, unlike [isTerminalFor]: a live collector is already filtered by + * id before it gets here. + */ +fun NostrIncomingMessage.endsLiveSubscription(): Boolean = this is NostrIncomingMessage.ClosedMessage + /** * Completes the flow once the subscription is over, emitting the terminal * message first so callers still see the EOSE/CLOSED/NEG-ERR that ended it. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt index d751989f..99262b0d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt @@ -19,7 +19,25 @@ internal val defaultSocketsHttpClient by lazy { } -object NostrSocketClientFactory { +/** + * How [press.mantra.compose.network.relays.RelayPool] gets a socket for a relay. + * + * An interface rather than the object directly, so the pool's subscription bookkeeping — what + * it retains, when it replays, when it lets a socket stop reconnecting — can be exercised + * against a fake instead of against a real websocket. + */ +interface NostrSocketClientFactory { + + fun create( + wssUrl: String, + incomingCompressionEnabled: Boolean = false, + onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, + onSocketConnectionClosed: SocketConnectionClosedCallback? = null, + onSocketConnectionReopened: SocketConnectionReopenedCallback? = null, + ): NostrSocketClient +} + +object DefaultNostrSocketClientFactory : NostrSocketClientFactory { fun create( wssUrl: String, @@ -39,12 +57,12 @@ object NostrSocketClientFactory { ) } - fun create( + override fun create( wssUrl: String, - incomingCompressionEnabled: Boolean = false, - onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, - onSocketConnectionClosed: SocketConnectionClosedCallback? = null, - onSocketConnectionReopened: SocketConnectionReopenedCallback? = null, + incomingCompressionEnabled: Boolean, + onSocketConnectionOpened: SocketConnectionOpenedCallback?, + onSocketConnectionClosed: SocketConnectionClosedCallback?, + onSocketConnectionReopened: SocketConnectionReopenedCallback?, ) = create( httpClient = defaultSocketsHttpClient, wssUrl = wssUrl, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt index 23740028..2a6fb364 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt @@ -32,10 +32,6 @@ import okio.buffer import okio.use import kotlin.concurrent.Volatile import kotlin.coroutines.cancellation.CancellationException -import kotlin.math.min -import kotlin.math.pow -import kotlin.random.Random -import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -58,17 +54,7 @@ internal class NostrSocketClientImpl( private val MAX_RECONNECT_DELAY = 60.seconds - /** - * Bounds the exponent so a socket that has been failing for hours cannot overflow - * the doubling into `Infinity`, which `Duration * Double` rejects outright. - */ - private const val MAX_RECONNECT_EXPONENT = 16 - - /** - * Up to this fraction of the delay is added at random. Every relay in the pool - * drops at once when the network does, and without jitter they would all come - * back in lockstep for as long as the outage lasts. - */ + /** Fraction of the delay added at random. See [reconnectDelay]. */ private const val RECONNECT_JITTER = 0.25 /** @@ -276,7 +262,12 @@ internal class NostrSocketClientImpl( reconnectJob = scope.launch { while (isActive && autoReconnect && !closedByClient) { val attempt = ++reconnectAttempts - val wait = reconnectDelay(attempt) + val wait = reconnectDelay( + attempt = attempt, + initialDelay = INITIAL_RECONNECT_DELAY, + maxDelay = MAX_RECONNECT_DELAY, + jitterFraction = RECONNECT_JITTER, + ) logger.i { "Reconnecting to $socketUrl in $wait (attempt $attempt)" } delay(wait) @@ -291,13 +282,6 @@ internal class NostrSocketClientImpl( } } - private fun reconnectDelay(attempt: Int): Duration { - val doublings = 2.0.pow(min(attempt - 1, MAX_RECONNECT_EXPONENT)) - val backoff = minOf(INITIAL_RECONNECT_DELAY * doublings, MAX_RECONNECT_DELAY) - - return backoff + backoff * RECONNECT_JITTER * Random.nextDouble() - } - override suspend fun close() { val session = wsMutex.withLock { closedByClient = true diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/ReconnectBackoff.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/ReconnectBackoff.kt new file mode 100644 index 00000000..7adc6ceb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/ReconnectBackoff.kt @@ -0,0 +1,37 @@ +package press.mantra.compose.network.sockets + +import kotlin.math.min +import kotlin.math.pow +import kotlin.random.Random +import kotlin.time.Duration + +/** + * Bounds the exponent so a socket that has been failing for hours cannot overflow the doubling + * into `Infinity`, which `Duration * Double` rejects outright — turning a reconnect loop into a + * crash loop at roughly the point the network is least likely to come back on its own. + */ +internal const val MAX_RECONNECT_EXPONENT = 16 + +/** + * How long to wait before reconnect [attempt]: [initialDelay] doubled once per previous + * failure, capped at [maxDelay], plus up to [jitterFraction] of that again at random. + * + * The jitter is not decoration. Every relay in the pool drops at the same moment when the + * network does, so without it they all come back in lockstep for as long as the outage lasts, + * and each round of that is a simultaneous burst of connection attempts. + * + * [jitter] is a parameter rather than an inline `Random.nextDouble()` so the arithmetic can be + * pinned in a test; callers leave it defaulted. + */ +internal fun reconnectDelay( + attempt: Int, + initialDelay: Duration, + maxDelay: Duration, + jitterFraction: Double, + jitter: Double = Random.nextDouble(), +): Duration { + val doublings = 2.0.pow(min(attempt - 1, MAX_RECONNECT_EXPONENT).coerceAtLeast(0)) + val backoff = minOf(initialDelay * doublings, maxDelay) + + return backoff + backoff * jitterFraction * jitter +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index fab0fb71..c4fe428c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -14,7 +14,7 @@ import press.mantra.compose.network.relays.RelayPool import press.mantra.compose.network.relays.RelaysSocketManager import press.mantra.compose.network.relays.isRelayBackPressure import press.mantra.compose.network.sockets.NostrIncomingMessage -import press.mantra.compose.network.sockets.NostrSocketClientFactory +import press.mantra.compose.network.sockets.DefaultNostrSocketClientFactory import press.mantra.compose.repository.CachingImportRepository import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository @@ -68,7 +68,7 @@ class SynchronizationViewModel( val relaysSocketManager = RelaysSocketManager( activeWalletStateFlow = activeWalletStateFlow, - nostrSocketClientFactory = NostrSocketClientFactory, + nostrSocketClientFactory = DefaultNostrSocketClientFactory, cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, relayRepository = relayRepository, ) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt new file mode 100644 index 00000000..8761f337 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt @@ -0,0 +1,175 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GROUP_SUB_ID_PREFIX +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.giftWrapFilter +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupChunks +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupFilter +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupIdsFrom +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionIndex +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionKey +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.network.sockets.endsLiveSubscription +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class LiveSubscriptionPlanTest { + + private val me = "a".repeat(64) + + // --- filters ---------------------------------------------------------------------- + + /** + * The one that will look like an oversight to whoever reads it next. + * + * NIP-59 randomizes a gift wrap's `created_at` into the past, and our own outbound path + * stamps them with `TimeUtils.randomWithTwoDays()` — so a wrap published this second can + * carry a timestamp two days old. A `since` anywhere near the present silently drops a + * large share of genuinely new messages, and the symptom is "some DMs just never arrive": + * no error, no log, nothing to grep for. + */ + @Test + fun `the gift wrap filter carries no since`() { + assertNull(giftWrapFilter(me).since) + } + + @Test + fun `the gift wrap filter asks for wraps addressed to us`() { + val filter = giftWrapFilter(me) + + assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds) + assertEquals(mapOf("p" to listOf(me)), filter.tags) + assertEquals(100, filter.limit) + } + + /** + * `limit` is not a cap on the subscription — NIP-01 scopes it to the stored events a relay + * sends before EOSE, explicitly not to the stream after it. It bounds what a reconnect + * costs; it does not bound what arrives live. + */ + @Test + fun `the group filter asks for the given groups, and also carries no since`() { + val filter = groupFilter(listOf("group-a", "group-b")) + + assertEquals(listOf(GroupEvent.KIND), filter.kinds) + assertEquals(mapOf("h" to listOf("group-a", "group-b")), filter.tags) + assertEquals(500, filter.limit) + assertNull(filter.since) + } + + // --- which groups are watched ----------------------------------------------------- + + @Test + fun `only MLS rooms are watched`() { + val rooms = listOf( + room(id = "mls", mlsGroupState = "state"), + // A NIP-17 room has no group state; its messages arrive as gift wraps instead, so + // h-tag subscribing to it would ask for events that do not exist. + room(id = "nip17", mlsGroupState = null), + ) + + assertEquals(listOf("mls"), groupIdsFrom(rooms)) + } + + /** + * A room we left keeps its history locally and must stop pulling new messages. Getting + * this wrong is not merely wasteful: it means still receiving from a group we are no + * longer a member of. + */ + @Test + fun `rooms we have left or deleted are not watched`() { + val rooms = listOf( + room(id = "here", mlsGroupState = "state"), + room(id = "left", mlsGroupState = "state", leftGroupAt = Instant.fromEpochSeconds(10)), + room(id = "gone", mlsGroupState = "state", deletedAt = Instant.fromEpochSeconds(10)), + ) + + assertEquals(listOf("here"), groupIdsFrom(rooms)) + } + + /** + * Sorted, so the same membership in a different row order is the same value and the + * `distinctUntilChanged` upstream of the reconcile does not re-send a filter that has not + * actually changed. + */ + @Test + fun `group ids come out sorted`() { + val rooms = listOf("c", "a", "b").map { room(id = it, mlsGroupState = "state") } + + assertEquals(listOf("a", "b", "c"), groupIdsFrom(rooms)) + } + + // --- chunking and subscription ids ------------------------------------------------- + + @Test + fun `groups are chunked to bound the size of one filter's tag array`() { + val chunks = groupChunks((1..250).map { "group-$it" }) + + assertEquals(3, chunks.size) + assertEquals(listOf(100, 100, 50), chunks.map { it.size }) + } + + @Test + fun `no groups means no subscriptions`() { + assertTrue(groupChunks(emptyList()).isEmpty()) + } + + @Test + fun `a subscription key round-trips back to its chunk index`() { + val key = subscriptionKey(relayUrl = "wss://relay.example.com", subId = groupSubId(3)) + + assertEquals("wss://relay.example.com|${GROUP_SUB_ID_PREFIX}3", key) + assertEquals(3, subscriptionIndex(key)) + } + + /** + * `subscriptionIndex` decides which subscriptions get closed when membership shrinks — + * anything at or past the new chunk count goes. Unparseable therefore has to read as "past + * the end", so a key nobody recognises is closed rather than kept open forever. + */ + @Test + fun `an unrecognisable key sorts past the end so it gets closed`() { + assertEquals(Int.MAX_VALUE, subscriptionIndex("wss://relay.example.com|$GIFT_WRAP_SUB_ID")) + assertEquals(Int.MAX_VALUE, subscriptionIndex("nonsense")) + } + + // --- what ends a live subscription ------------------------------------------------- + + @Test + fun `EOSE does not end a live subscription, CLOSED does`() { + assertFalse(NostrIncomingMessage.EoseMessage(subscriptionId = "s").endsLiveSubscription()) + assertFalse(NostrIncomingMessage.EventMessage(subscriptionId = "s").endsLiveSubscription()) + assertFalse(NostrIncomingMessage.NoticeMessage(message = "hi").endsLiveSubscription()) + + assertTrue( + NostrIncomingMessage.ClosedMessage(subscriptionId = "s", message = "rate-limited") + .endsLiveSubscription() + ) + } + + private fun room( + id: String, + mlsGroupState: String?, + leftGroupAt: Instant? = null, + deletedAt: Instant? = null, + ) = LocalChatRoom( + chatRoom = ChatRoom( + id = id, + userPublicKey = me, + subject = null, + description = null, + mlsGroupState = mlsGroupState, + leftGroupAt = leftGroupAt, + deletedAt = deletedAt, + ) + ) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionReconcileTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionReconcileTest.kt new file mode 100644 index 00000000..0eb46c50 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionReconcileTest.kt @@ -0,0 +1,428 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId +import press.mantra.compose.network.relays.LiveSubscriptionTransport +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * The requirement this whole change exists to meet: the group subscription has to follow group + * membership, without anyone remembering to call a subscribe function when a group is joined. + * + * The failure this guards against is entirely silent. A group whose id never makes it into the + * `#h` filter is not an error anywhere — it is a conversation that simply never delivers, on a + * screen that looks exactly like an empty one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LiveSubscriptionReconcileTest { + + private val keyPair = KeyPair() + private val me = keyPair.pubKey.toHexString() + + @Test + fun `opens a group subscription for the groups we are in`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b"))) + val running = start(transport, rooms) + + settle() + + val opened = transport.opened.single { it.reqCommand.subId == groupSubId(0) } + assertEquals( + mapOf("h" to listOf("group-a", "group-b")), + opened.reqCommand.filters.single().tags, + ) + + running.cancelAndJoin() + } + + /** + * The loop that closes: a Welcome arrives on the gift wrap subscription, a ChatRoom row is + * written, the room list re-emits, and the filter widens — with no chat screen involved. + * + * It has to widen *in place*. Closing and re-opening would drop the live tail of every + * group already in that chunk for as long as the round trip takes, so joining one group + * would briefly stop delivery on all the others. + */ + @Test + fun `joining a group widens the filter without reopening the subscription`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"))) + val running = start(transport, rooms) + settle() + + val opensBefore = transport.opened.count { it.reqCommand.subId == groupSubId(0) } + + rooms.value = listOf(room("group-a"), room("group-new")) + settle() + + val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) } + assertEquals( + mapOf("h" to listOf("group-a", "group-new")), + updated.reqCommand.filters.single().tags, + ) + assertEquals( + opensBefore, + transport.opened.count { it.reqCommand.subId == groupSubId(0) }, + "widening must not re-open the subscription", + ) + + running.cancelAndJoin() + } + + @Test + fun `leaving a group drops it from the filter`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b"))) + val running = start(transport, rooms) + settle() + + rooms.value = listOf(room("group-a"), room("group-b", leftGroupAt = Instant.fromEpochSeconds(1))) + settle() + + val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) } + assertEquals(mapOf("h" to listOf("group-a")), updated.reqCommand.filters.single().tags) + + running.cancelAndJoin() + } + + @Test + fun `leaving every group closes the subscription`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"))) + val running = start(transport, rooms) + settle() + + rooms.value = emptyList() + settle() + + assertTrue(transport.closed.any { it.subId == groupSubId(0) }) + + running.cancelAndJoin() + } + + /** + * Membership churn inside the debounce window must collapse. Joining a group writes the + * room, its participants and placeholder profiles in quick succession, each of which + * re-emits the list — so without this a single join re-sends the filter several times. + */ + @Test + fun `rapid membership changes collapse into one update`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"))) + val running = start(transport, rooms) + settle() + + val updatesBefore = transport.updated.size + + rooms.value = listOf(room("group-a"), room("group-b")) + advanceTimeBy(100) + rooms.value = listOf(room("group-a"), room("group-b"), room("group-c")) + advanceTimeBy(100) + rooms.value = listOf(room("group-a"), room("group-b"), room("group-c"), room("group-d")) + settle() + + assertEquals(1, transport.updated.size - updatesBefore, "expected one update, not three") + + running.cancelAndJoin() + } + + @Test + fun `a NIP-17 room never becomes a group subscription`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("dm-room", mlsGroupState = null))) + val running = start(transport, rooms) + settle() + + assertTrue(transport.opened.none { it.reqCommand.subId.startsWith("live-groups-") }) + + running.cancelAndJoin() + } + + // --- what arrives on a subscription ------------------------------------------------- + + @Test + fun `events are stored against the relay they arrived from`() = runTest { + val transport = FakeTransport() + val nostr = RecordingNostrRepository() + val running = start(transport, MutableStateFlow(emptyList()), nostr) + settle() + + val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID } + subscription.messages.emit( + NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("ev-1")) + ) + settle() + + val saved = nostr.saved.single() + assertEquals("ev-1", saved.first) + assertEquals(subscription.relayUrl, saved.second) + + running.cancelAndJoin() + } + + /** EOSE is a marker, not an end: what follows it is the whole point. */ + @Test + fun `an event after EOSE is still stored`() = runTest { + val transport = FakeTransport() + val nostr = RecordingNostrRepository() + val running = start(transport, MutableStateFlow(emptyList()), nostr) + settle() + + val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID } + subscription.messages.emit(NostrIncomingMessage.EoseMessage(subscriptionId = GIFT_WRAP_SUB_ID)) + subscription.messages.emit( + NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("after-eose")) + ) + settle() + + assertEquals(listOf("after-eose"), nostr.saved.map { it.first }) + + running.cancelAndJoin() + } + + @Test + fun `a relay that closes the subscription gets it re-opened`() = runTest { + val transport = FakeTransport() + val running = start(transport, MutableStateFlow(emptyList())) + settle() + + val first = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID } + first.messages.emit( + NostrIncomingMessage.ClosedMessage(subscriptionId = GIFT_WRAP_SUB_ID, message = "shutting down") + ) + + settle(1.seconds) + assertEquals( + 1, + transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID }, + "should still be inside the back-off, not hammering the relay", + ) + + settle(30.seconds) + assertEquals( + 2, + transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID }, + "a CLOSED subscription should have been re-opened once the back-off elapsed", + ) + + running.cancelAndJoin() + } + + /** + * Back-pressure is the one refusal that must NOT be answered promptly — opening another + * subscription is exactly what the relay just asked us to stop doing. + */ + @Test + fun `a rate-limited close waits far longer before re-opening`() = runTest { + val transport = FakeTransport() + val running = start(transport, MutableStateFlow(emptyList())) + settle() + + transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }.messages.emit( + NostrIncomingMessage.ClosedMessage( + subscriptionId = GIFT_WRAP_SUB_ID, + message = "rate-limited: too many concurrent REQs", + ) + ) + settle(30.seconds) + assertEquals( + 1, + transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID }, + "must not re-open within the window a plain failure would have", + ) + + // ...but it is a back-off, not a give-up. + settle(5.minutes) + assertEquals( + 2, + transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID }, + "the subscription should come back once the relay has had its breathing room", + ) + + running.cancelAndJoin() + } + + // --- lifecycle ----------------------------------------------------------------------- + + @Test + fun `backgrounding closes the subscriptions and foregrounding rebuilds them`() = runTest { + val transport = FakeTransport() + val foreground = MutableStateFlow(true) + val running = start(transport, MutableStateFlow(emptyList()), isForeground = foreground) + settle() + + val openedWhileForeground = transport.opened.size + assertTrue(openedWhileForeground > 0) + + foreground.value = false + settle() + assertTrue(transport.closed.any { it.subId == GIFT_WRAP_SUB_ID }, "should have said goodbye") + + foreground.value = true + settle() + assertTrue(transport.opened.size > openedWhileForeground, "should have re-subscribed") + + running.cancelAndJoin() + } + + /** + * A live subscription covers the window we are connected for; it cannot answer for the gap + * we were away. That is negentropy's job, and returning to the foreground is precisely + * when it needs asking. + */ + @Test + fun `foregrounding reconnects and queues a catch-up reconciliation`() = runTest { + val transport = FakeTransport() + val nostr = RecordingNostrRepository() + val running = start(transport, MutableStateFlow(listOf(room("group-a"))), nostr) + settle() + + assertEquals(1, transport.reconnects, "the socket must not be trusted after a gap") + + val purposes = nostr.queued.map { it.purpose }.toSet() + assertTrue("chat" in purposes, "gift wraps should be reconciled") + assertTrue("mlsMessages" in purposes, "group events should be reconciled") + + running.cancelAndJoin() + } + + // --- harness ------------------------------------------------------------------------- + + /** + * Advances virtual time by exactly [duration] and runs what that makes due. + * + * Deliberately not `advanceUntilIdle()`: that runs until nothing is scheduled at all, + * which means it fast-forwards through *any* pending delay — including the five-minute + * back-off this suite needs to assert has NOT elapsed. A test that cannot tell "waited" + * from "did not wait" cannot test a back-off at all. + */ + private fun kotlinx.coroutines.test.TestScope.settle(duration: Duration = 1.seconds) { + advanceTimeBy(duration) + runCurrent() + } + + private fun kotlinx.coroutines.test.TestScope.start( + transport: FakeTransport, + rooms: MutableStateFlow>, + nostr: RecordingNostrRepository = RecordingNostrRepository(), + isForeground: MutableStateFlow = MutableStateFlow(true), + ) = launch { + LiveSubscriptionManager( + relaysSocketManager = transport, + nostrRepository = nostr, + chatRepository = FakeChatRepository(rooms), + isForeground = isForeground, + ).observe(keyPair) + } + + private fun room( + id: String, + mlsGroupState: String? = "state", + leftGroupAt: Instant? = null, + ) = LocalChatRoom( + chatRoom = ChatRoom( + id = id, + userPublicKey = me, + subject = null, + description = null, + mlsGroupState = mlsGroupState, + leftGroupAt = leftGroupAt, + ) + ) + + private fun event(id: String) = NostrEvent( + id = id, + pubKey = me, + createdAt = Instant.fromEpochSeconds(1_000), + kind = 1059, + tags = emptyArray(), + content = "", + sig = "", + ) +} + +private class OpenedSubscription(val reqCommand: ReqCmd, val relayUrl: String) { + val messages = MutableSharedFlow(extraBufferCapacity = 32) +} + +private class FakeTransport : LiveSubscriptionTransport { + val opened = mutableListOf() + val updated = mutableListOf() + val closed = mutableListOf() + var reconnects = 0 + + data class ClosedSubscription(val subId: String, val relayUrl: String) + + override suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow = + OpenedSubscription(reqCommand, relayUrl).also { opened += it }.messages.asSharedFlow() + + override suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { + updated += OpenedSubscription(reqCommand, relayUrl) + } + + override suspend fun closeLiveSubscription(subId: String, relayUrl: String) { + closed += ClosedSubscription(subId, relayUrl) + } + + override suspend fun reconnectAll() { + reconnects++ + } +} + +private class FakeChatRepository( + private val rooms: StateFlow>, +) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + + override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> = rooms + + override suspend fun getChatRoomListByPublicKey(publicKey: String): List = rooms.value +} + +private class RecordingNostrRepository : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + /** (event id, relay it arrived from) */ + val saved = mutableListOf>() + val queued = mutableListOf() + + override suspend fun saveNostrEvent( + nostrEvent: NostrEvent, + relayURL: String, + synchronizationRelayURLs: List, + level: Int, + activeKeyPair: KeyPair, + ) { + saved += nostrEvent.id to relayURL + } + + override suspend fun queueNegentropySynchronizeRequest( + negentropySynchronizeRequests: List, + ) { + queued += negentropySynchronizeRequests + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayBackPressureTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayBackPressureTest.kt new file mode 100644 index 00000000..25a13e0d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayBackPressureTest.kt @@ -0,0 +1,58 @@ +package press.mantra.compose.network.relays + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins which CLOSED reasons mean "ease off". + * + * The classification decides what happens next and is expensive to get wrong in both + * directions: a refusal read as a transient failure is answered by opening another + * subscription, which is how one "too many concurrent REQs" becomes a flood of them, while an + * unsupported filter read as back-pressure leaves a subscription shut for five minutes over a + * problem no amount of waiting fixes. + */ +class RelayBackPressureTest { + + @Test + fun `recognises the NIP-01 machine-readable prefix`() { + assertTrue(isRelayBackPressure("rate-limited: slow down there chief")) + } + + @Test + fun `recognises the free-text forms relays actually send`() { + listOf( + "ERROR: too many concurrent REQs", + "rate limit exceeded", + "Please slow down", + "TOO MANY SUBSCRIPTIONS", + "maximum concurrent subscriptions reached", + ).forEach { reason -> + assertTrue(isRelayBackPressure(reason), "expected back-pressure: $reason") + } + } + + @Test + fun `does not read a refusal we cannot wait out as back-pressure`() { + listOf( + "auth-required: we can't serve DMs to unauthenticated users", + "unsupported: filter contains unknown tag", + "invalid: filter is empty", + "error: negentropy disabled", + "blocked: you are not allowed to write here", + ).forEach { reason -> + assertFalse(isRelayBackPressure(reason), "did not expect back-pressure: $reason") + } + } + + /** + * A CLOSED with no reason at all is common. Treating it as back-pressure would mean the + * least informative refusal produced the longest possible outage. + */ + @Test + fun `a missing or empty reason is not back-pressure`() { + assertFalse(isRelayBackPressure(null)) + assertFalse(isRelayBackPressure("")) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt new file mode 100644 index 00000000..decc69cd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt @@ -0,0 +1,392 @@ +package press.mantra.compose.network.relays + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import press.mantra.compose.network.dto.RelayDTO +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.network.sockets.NostrSocketClient +import press.mantra.compose.network.sockets.NostrSocketClientFactory +import press.mantra.compose.network.sockets.SocketConnectionClosedCallback +import press.mantra.compose.network.sockets.SocketConnectionOpenedCallback +import press.mantra.compose.network.sockets.SocketConnectionReopenedCallback +import press.mantra.compose.repository.CachingImportRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The pool's half of surviving a dropped socket. + * + * A subscription that outlives its socket only works if the pool remembers what that socket was + * carrying and hands it back on reconnect. None of that is observable from the outside — no + * return value changes, nothing throws — so a regression here looks like "messages stopped + * arriving after a tunnel", hours later, on someone else's phone. + * + * The other half is that a relay answers a repeated REQ on an existing subscription id by + * replacing that subscription's filter, which is what makes replay a send rather than a + * close-and-reopen. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class RelayPoolSubscriptionTest { + + private val relayUrl = "wss://relay.example.com" + private val otherRelayUrl = "wss://other.example.com" + + private fun req(subId: String, kind: Int = 1) = + ReqCmd(subId = subId, filters = listOf(Filter(kinds = listOf(kind)))) + + @Test + fun `a query is retained, and replayed when its socket comes back`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + + val socket = factory.only() + assertEquals(1, socket.sent.size, "the REQ should have gone out once") + assertTrue(socket.autoReconnect, "a relay carrying a subscription is worth reconnecting") + + socket.sent.clear() + factory.reopen(relayUrl) + + assertEquals(1, socket.sent.size, "the REQ should have been replayed") + assertTrue(socket.sent.single().contains("sub-1")) + } + + @Test + fun `a closed query is forgotten, and the socket stops reconnecting for it`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl) + + val socket = factory.only() + assertFalse(socket.autoReconnect, "nothing is open on this relay any more") + + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.isEmpty(), "a closed subscription must not come back on reconnect") + } + + /** + * Closing one of two must not drop the other — the bug this guards is a shared socket + * quietly losing its remaining subscription because a sibling finished first. + */ + @Test + fun `closing one subscription leaves the rest of that relay's alone`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + pool.query(req("sub-2", kind = 7), relayUrl) + pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl) + + val socket = factory.only() + assertTrue(socket.autoReconnect, "sub-2 is still open") + + socket.sent.clear() + factory.reopen(relayUrl) + + assertEquals(1, socket.sent.size) + assertTrue(socket.sent.single().contains("sub-2")) + } + + /** + * Negentropy is stateful: NEG-OPEN carries a fingerprint of the local set and every round + * depends on the last. Replaying one mid-exchange would reconcile against a conversation + * the relay is no longer having, so an interrupted exchange is abandoned and re-queued + * instead. + */ + @Test + fun `a negentropy exchange is never replayed`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.negentropySync( + NegOpenCmd(subId = "neg-1", filter = Filter(kinds = listOf(1)), initialMessage = "6100"), + relayUrl, + ) + + val socket = factory.only() + assertEquals(1, socket.sent.size, "NEG-OPEN still goes out") + + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.isEmpty(), "a half-finished reconciliation must not be resumed") + } + + @Test + fun `a live subscription is retained like any other`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.openLiveSubscription(req("live-giftwrap"), relayUrl) + + val socket = factory.only() + assertTrue(socket.autoReconnect) + + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.single().contains("live-giftwrap")) + } + + /** + * Widening a live subscription has to replace what gets replayed, not just what is on the + * wire now. Retaining the old filter would mean a reconnect quietly restored a + * subscription the caller had already moved on from — a group you just joined going + * silent the first time you walked through a tunnel. + */ + @Test + fun `updating a live subscription replaces what a reconnect replays`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl) + pool.updateLiveSubscription( + ReqCmd( + subId = "live-groups-0", + filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))), + ), + relayUrl, + ) + + val socket = factory.only() + socket.sent.clear() + factory.reopen(relayUrl) + + val replayed = socket.sent.single() + assertTrue(replayed.contains("group-b"), "expected the current filter, got: $replayed") + } + + /** + * An update that cannot be sent must still be recorded. Throwing instead would leave the + * previous filter retained, which is the one outcome worse than not sending at all. + */ + @Test + fun `an update whose send fails is still retained for the reconnect`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl) + + val socket = factory.only() + socket.failSends = true + pool.updateLiveSubscription( + ReqCmd( + subId = "live-groups-0", + filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))), + ), + relayUrl, + ) + + socket.failSends = false + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.single().contains("group-b")) + } + + @Test + fun `dropping a relay forgets what it was carrying`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + pool.changeRelays(listOf(RelayDTO(url = otherRelayUrl, read = true, write = true))) + + val dropped = factory.forUrl(relayUrl) + dropped.sent.clear() + factory.reopen(relayUrl) + + assertTrue(dropped.sent.isEmpty(), "a relay we removed should not be re-subscribed") + } + + @Test + fun `closing the pool forgets everything`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + pool.closePool() + + val socket = factory.only() + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.isEmpty()) + } + + /** Retention is per relay: one relay's reconnect must not re-send another's REQ. */ + @Test + fun `replay is scoped to the relay that reconnected`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-here"), relayUrl) + pool.query(req("sub-there"), otherRelayUrl) + + factory.forUrl(relayUrl).sent.clear() + factory.forUrl(otherRelayUrl).sent.clear() + + factory.reopen(relayUrl) + + assertTrue(factory.forUrl(relayUrl).sent.single().contains("sub-here")) + assertTrue(factory.forUrl(otherRelayUrl).sent.isEmpty()) + } + + /** + * The semantic the whole change rests on. For a one-shot request EOSE is the end; for a + * live one it is only the boundary between the history a relay had stored and the tail it + * will now stream. Ending there is precisely what made every chat sync a poll. + */ + @Test + fun `a live subscription keeps delivering after EOSE`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + val received = mutableListOf() + val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl) + val collecting = launch(UnconfinedTestDispatcher(testScheduler)) { + flow.collect { received += it } + } + + val socket = factory.only() + socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "live-giftwrap")) + socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "live-giftwrap")) + + assertEquals(2, received.size, "the event after EOSE should still have arrived") + assertTrue(collecting.isActive, "a live subscription ends when its collector stops, not at EOSE") + + collecting.cancel() + } + + /** The contrast, so the two rules cannot silently converge. */ + @Test + fun `a one-shot query still ends at EOSE`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + val received = mutableListOf() + val flow = pool.query(req("sub-1"), relayUrl) + val collecting = launch(UnconfinedTestDispatcher(testScheduler)) { + flow.collect { received += it } + } + + val socket = factory.only() + socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "sub-1")) + socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "sub-1")) + + assertEquals(1, received.size, "nothing should arrive after the EOSE that ended it") + assertFalse(collecting.isActive, "the collector should have completed") + } + + /** + * A NOTICE carries no subscription id, so the socket hands it to every collector. It is + * admitted deliberately — it is the only signal some relays give for "negentropy disabled" + * — but it must stay advisory, never terminal, or one relay's complaint would tear down + * every unrelated subscription on that socket. + */ + @Test + fun `a NOTICE reaches a live subscription without ending it`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + val received = mutableListOf() + val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl) + val collecting = launch(UnconfinedTestDispatcher(testScheduler)) { + flow.collect { received += it } + } + + factory.only().deliver(NostrIncomingMessage.NoticeMessage(message = "restricted: slow")) + + assertEquals(1, received.size) + assertTrue(collecting.isActive) + + collecting.cancel() + } + + private fun kotlinx.coroutines.test.TestScope.pool(factory: FakeSocketClientFactory) = + RelayPool( + nostrSocketClientFactory = factory, + cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, + // Unconfined so the pool's launched work — status updates, and the replay a + // reconnect triggers — has run by the time the call that started it returns. + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) +} + +private class FakeSocketClientFactory : NostrSocketClientFactory { + private val clients = mutableMapOf() + private val reopenCallbacks = mutableMapOf() + + override fun create( + wssUrl: String, + incomingCompressionEnabled: Boolean, + onSocketConnectionOpened: SocketConnectionOpenedCallback?, + onSocketConnectionClosed: SocketConnectionClosedCallback?, + onSocketConnectionReopened: SocketConnectionReopenedCallback?, + ): NostrSocketClient { + reopenCallbacks[wssUrl] = onSocketConnectionReopened + + return FakeNostrSocketClient(wssUrl).also { clients[wssUrl] = it } + } + + fun only(): FakeNostrSocketClient = clients.values.single() + + fun forUrl(url: String): FakeNostrSocketClient = clients.getValue(url) + + /** Stands in for a socket that dropped and re-established its session. */ + fun reopen(url: String) { + reopenCallbacks.getValue(url)?.invoke(url) + } +} + +private class FakeNostrSocketClient(override val socketUrl: String) : NostrSocketClient { + val sent = mutableListOf() + var failSends = false + + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) + override val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow() + + override var autoReconnect: Boolean = false + + override suspend fun close() = Unit + + override suspend fun ensureSocketConnectionOrThrow() = Unit + + override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) { + if (failSends) throw IllegalStateException("socket is down") + sent += text + } + + override suspend fun sendAUTH(signedEvent: JsonObject) = Unit + + override suspend fun sendCLOSE(subscriptionId: String) = Unit + + override suspend fun sendCOUNT(data: JsonObject): String = "unused" + + override suspend fun sendEVENT(signedEvent: JsonObject) = Unit + + override suspend fun sendREQ(subscriptionId: String, data: JsonObject) = Unit + + /** Stands in for a message arriving on the wire. */ + suspend fun deliver(message: NostrIncomingMessage) { + _incomingMessages.emit(message) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt new file mode 100644 index 00000000..0c4cb389 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt @@ -0,0 +1,73 @@ +package press.mantra.compose.network.sockets + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class ReconnectBackoffTest { + + private val initial = 1.seconds + private val max = 60.seconds + + private fun delay(attempt: Int, jitter: Double = 0.0) = + reconnectDelay( + attempt = attempt, + initialDelay = initial, + maxDelay = max, + jitterFraction = 0.25, + jitter = jitter, + ) + + @Test + fun `doubles once per previous failure`() { + assertEquals(1.seconds, delay(attempt = 1)) + assertEquals(2.seconds, delay(attempt = 2)) + assertEquals(4.seconds, delay(attempt = 3)) + assertEquals(8.seconds, delay(attempt = 4)) + } + + @Test + fun `stops growing at the cap`() { + assertEquals(max, delay(attempt = 7)) + assertEquals(max, delay(attempt = 50)) + } + + /** + * The reason [MAX_RECONNECT_EXPONENT] exists. `2.0.pow(4000)` is `Infinity`, and + * `Duration * Double` throws on it — so without the clamp a socket that had been failing + * for long enough turned its own reconnect loop into a crash loop, at roughly the moment + * the network was least likely to recover unaided. + */ + @Test + fun `survives an attempt count large enough to overflow the doubling`() { + assertEquals(max, delay(attempt = 4_000)) + assertEquals(max, delay(attempt = Int.MAX_VALUE)) + } + + /** Defensive: a caller that starts counting at zero should still get a usable delay. */ + @Test + fun `treats a non-positive attempt as the first one`() { + assertEquals(initial, delay(attempt = 0)) + assertEquals(initial, delay(attempt = -3)) + } + + @Test + fun `adds at most the jitter fraction on top, never subtracts`() { + assertEquals(4.seconds, delay(attempt = 3, jitter = 0.0)) + assertEquals(5.seconds, delay(attempt = 3, jitter = 1.0)) + + val midway = delay(attempt = 3, jitter = 0.5) + assertTrue(midway > 4.seconds && midway < 5.seconds, "expected 4s..5s, got $midway") + } + + /** + * Jitter is applied after the cap, so a capped delay still spreads: relays all drop at the + * same moment when the network does, and identical waits would bring them back in lockstep + * for as long as the outage lasted. + */ + @Test + fun `jitter still spreads a capped delay`() { + assertTrue(delay(attempt = 50, jitter = 1.0) > max) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e51ecfde..9d846a9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } From c8cbd936f1609fa6e0628acfa5660219098bb585 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:32:53 +0200 Subject: [PATCH 26/33] docs: record where the sync's safety net is, and where it is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two updates after the test pass. long-running-sync.md gains a section naming what each test file pins and, more usefully, the three things they cannot reach: NostrSocketClientImpl's reconnect loop and ordered inbound (exercised only through their extracted arithmetic — covering them wants a fake WebSocketSession), everything downstream of saveNostrEvent (Room-backed, and there is no sqlite driver on the JVM test classpath), and the app on a device. The manual checks stay the manual checks. It also records that the tests were verified by mutation rather than by passing, so the next person knows the assertions were confirmed to bite. dead-code.md's line references are refreshed — the testability seams shifted most of them — and it now says which commit they were correct at and to confirm with the grep rather than trusting them. One entry added: the DefaultNostrSocketClientFactory overload taking an explicit HttpClient has no caller now that everything goes through the interface method. Co-Authored-By: Claude Opus 5 --- docs/dead-code.md | 26 +++++++++++++++----------- docs/long-running-sync.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/docs/dead-code.md b/docs/dead-code.md index e8daa3c3..828838c2 100644 --- a/docs/dead-code.md +++ b/docs/dead-code.md @@ -19,6 +19,9 @@ every `.kt` file outside `build/` and counts occurrences: grep -rn "\bidentifierName\b" --include=*.kt composeApp/src | grep -v '/build/' ``` +Line numbers below were correct at `f5eb744` and drift with every edit to those +files; treat them as a hint and confirm with the grep. + Two things that method cannot see, so each item was also read in context: - **Room DAOs** are called through generated code as `database.xDao().method()`, @@ -54,16 +57,17 @@ Each of these predates the sync work. | what | where | note | |---|---|---| -| `RelayPool.removeRelays` | [RelayPool.kt:119](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:119) | never called; `changeRelays` and `closePool` cover every path that removes a relay | -| `RelayPool.hasRelays` | [RelayPool.kt:167](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:167) | never called | -| `RelayPool.transformWhileEventsAreIncoming` | [RelayPool.kt:482](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:482) | private, never called. Superseded by `completeOnSubscriptionEnd`, which ends a flow on EOSE/CLOSED/NEG-ERR rather than on "the last message was not an event" | -| the commented-out publish gate | [RelayPool.kt:543-556](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:543) | the only thing keeping `import kotlinx.coroutines.flow.transform` ([:30](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:30)) alive | +| `RelayPool.removeRelays` | [RelayPool.kt:123](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:123) | never called; `changeRelays` and `closePool` cover every path that removes a relay | +| `RelayPool.hasRelays` | [RelayPool.kt:171](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:171) | never called | +| `RelayPool.transformWhileEventsAreIncoming` | [RelayPool.kt:486](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:486) | private, never called. Superseded by `completeOnSubscriptionEnd`, which ends a flow on EOSE/CLOSED/NEG-ERR rather than on "the last message was not an event" | +| the commented-out publish gate | [RelayPool.kt:547-560](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:547) | the only thing keeping `import kotlinx.coroutines.flow.transform` ([:30](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:30)) alive | | `RelaysSocketManager.clearRelayPools` | [RelaysSocketManager.kt:100](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:100) | private, never called. Nothing tears the pool down on sign-out | -| `RelaysSocketManager.tryConnectingToAllRelays` | [RelaysSocketManager.kt:132](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:132) | never called; the only caller of `RelayPool.tryConnectingToRelay`, which is otherwise dead too | -| the commented `tryConnectingToUserRelay` | [RelaysSocketManager.kt:140](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:140) | | +| `RelaysSocketManager.tryConnectingToAllRelays` | [RelaysSocketManager.kt:131](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:131) | never called; the only caller of `RelayPool.tryConnectingToRelay`, which is otherwise dead too | +| the commented `tryConnectingToUserRelay` | [RelaysSocketManager.kt:139](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:139) | | | `NostrIncomingMessage?.verifyOrThrow` | [NostrIncomingMessage.kt:59](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessage.kt:59) | never called. It also treats any NOTICE as a failure, which is wrong for the reason `filterByEventId` documents — a NOTICE has no subscription id and reaches every collector | | `String?.decodeFromJsonStringOrNull` | [CommonJson.kt:37](composeApp/src/commonMain/kotlin/press/mantra/compose/network/serialization/CommonJson.kt:37) | never called | -| `NostrSocketClientImpl.compressMessage` | [NostrSocketClientImpl.kt:384](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:384) | already carries `@Suppress("unused")`. Outgoing compression is not a thing any relay here asks for | +| `NostrSocketClientImpl.compressMessage` | [NostrSocketClientImpl.kt:368](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:368) | already carries `@Suppress("unused")`. Outgoing compression is not a thing any relay here asks for | +| `DefaultNostrSocketClientFactory.create(httpClient = …)` | [NostrSocketClientFactory.kt](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt) | the overload taking an explicit `HttpClient` has no caller; everything goes through the interface method and `defaultSocketsHttpClient` | ### `RelaysSocketManager.userRelays` is a field that is never written @@ -98,10 +102,10 @@ command with `OptimizedJsonMapper` and goes through `sendMESSAGE`. | declaration | impl | |---|---| -| `sendREQ` [:37](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:37) | [:349](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:349) | -| `sendCLOSE` [:31](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:31) | [:361](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:361) | -| `sendCOUNT` [:33](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:33) | [:354](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:354) | -| `sendAUTH` [:29](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:29) | [:365](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:365) | +| `sendREQ` [:37](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:37) | [:349](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:333) | +| `sendCLOSE` [:31](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:31) | [:361](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:345) | +| `sendCOUNT` [:33](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:33) | [:354](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:338) | +| `sendAUTH` [:29](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:29) | [:365](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:349) | Removing them also orphans `buildNostrREQMessage`, `buildNostrCOUNTMessage`, `buildNostrCLOSEMessage` and `buildNostrAUTHMessage` in diff --git a/docs/long-running-sync.md b/docs/long-running-sync.md index 9ed04e0e..56f4e5da 100644 --- a/docs/long-running-sync.md +++ b/docs/long-running-sync.md @@ -199,6 +199,41 @@ and its indexes, so two of those interleaving is a lost update — survivable wh a single queue was the only writer, not survivable with a live subscription writing alongside a backfill. +## What is tested, and what is not + +Every failure mode in this subsystem is silent — a filter that drops messages, a +subscription that stops being replayed, a group id that never reaches an `#h` +tag. None of them throws, and all of them present as "some messages didn't +arrive", later, on someone else's phone. So the tests are aimed at the decisions +rather than at the plumbing: + +| file | pins | +|---|---| +| `RelayPoolSubscriptionTest` | retain on query, replay on reconnect, release on close, never replay negentropy, per-relay scoping — and that a live subscription keeps delivering after EOSE while a one-shot query still ends at it | +| `LiveSubscriptionReconcileTest` | the group filter following membership: widening in place rather than reopening, dropping a left group, closing when the last one goes, collapsing churn inside the debounce, and the CLOSED/back-pressure reopen behaviour | +| `LiveSubscriptionPlanTest` | the filter shapes, led by `since` being absent from the gift wrap filter | +| `RelayBackPressureTest`, `ReconnectBackoffTest` | the two pure decisions: which CLOSED reasons mean "ease off", and the backoff arithmetic including the exponent clamp | + +They were checked by mutation rather than by passing: reintroducing a `since`, +making EOSE terminal, dropping the `leftGroupAt` filter, removing retention from +`query()`, reconciling by close-and-reopen, removing the debounce and removing +the back-pressure branch each produce failures. + +Three things this does **not** cover, and could not without a real relay or a +database: + +- **`NostrSocketClientImpl` itself.** The reconnect loop, the session-identity + check on teardown, and the ordered inbound emission are exercised only through + their extracted arithmetic. Testing them wants a fake `WebSocketSession`. +- **Anything downstream of `saveNostrEvent`.** Indexing, gift wrap unwrapping, + Welcome handling and MLS decryption are Room-backed, and there is no sqlite + driver on the JVM test classpath (see the note in `build-verification-commands`). + The tests assert the event reaches the repository with the right relay and + level, and stop there. +- **The app on a device.** Nothing here proves a DM lands with the chat list + closed; that still wants the two manual checks — send yourself a message from + another client, and have a second device add you to a group. + ## Not done - **Connectivity changes.** A network switch mid-foreground is only noticed by From a909108300920865bb05a1dcee183cc43831c980 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:36:34 +0200 Subject: [PATCH 27/33] feat: announce which key a room signs with, instead of rederiving it A signer holds a different secret share under every ceremony it took part in, and signing with the wrong one produces a partial signature that cannot aggregate. Nothing said which was which: FrostSigningManager found a room's key by walking every ceremony this device holds a share for and rederiving each one's room id until one matched. That search can only find rooms derived at the one path the constant names. SharedKeyDerivation.parsePath was written to lift that limit and was never called, so a room derived anywhere else was invisible to signing. So the coordinator now says it. GroupKeyStateEvent (kind 30326) carries the threshold public key, the ceremony that made it and the path the room's id came from, posted into the room as its first application message and filed as a GroupKeyState row. completedKey reads that row first and follows it to the share. Nothing secret travels. Every member of the room can read the event, so a share on it would be each member holding everyone else's -- a 1-of-n key wearing a t-of-n's clothes. The event names the ceremony; the share stays in DkgSession.secretShare on the device that generated it. The coordinator is untrusted, as everywhere else in the ceremony, so a state is verified rather than believed: the room's id *is* the threshold key derived at the path, and one that does not rederive its own room is dropped. That is the same guarantee the rederivation gave, kept rather than traded for a lookup. The old scan stays behind it for rooms that predate the table. Announced after the members are added, which is the only order that works -- adding them commits a new epoch and MLS will not let a member read what was encrypted before the one they joined at. A member invited later still misses it and falls back to the scan, which is where every member was before this existed. Replacement is this app's job. These are rumors inside a Marmot group event, so no relay applies the 3xxxx rule, and the DAO keeps the newest announcement per room so a backfill cannot walk a room backwards. Co-Authored-By: Claude Opus 5 --- .../7.json | 5379 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 15 +- .../compose/database/dao/GroupKeyStateDao.kt | 56 + .../mantra/compose/database/dao/NostrDao.kt | 33 +- .../compose/database/model/ChatMessage.kt | 8 + .../compose/database/model/GroupKeyState.kt | 106 + .../repository/DatabaseDkgRepository.kt | 27 + .../compose/managers/FrostSigningManager.kt | 31 +- .../compose/managers/GroupKeyStateManager.kt | 177 + .../compose/managers/SharedKeyDerivation.kt | 32 +- .../compose/nostr/frost/FrostSigningEvents.kt | 5 + .../compose/nostr/frost/GroupKeyStateEvent.kt | 108 + .../frost/tags/FrostDerivationPathTag.kt | 42 + .../compose/repository/DkgRepository.kt | 20 + .../ui/view/model/DkgRitualViewModel.kt | 25 +- .../compose/managers/GroupKeyStateTest.kt | 245 + 16 files changed, 6288 insertions(+), 21 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json new file mode 100644 index 00000000..02a05a59 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json @@ -0,0 +1,5379 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "4b5d394b56639e56d8de1e8f9a7f6faf", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `stage` TEXT NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signerIds` TEXT, `signature` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4b5d394b56639e56d8de1e8f9a7f6faf')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index 29cd80e7..873526bb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -20,6 +20,7 @@ import press.mantra.compose.database.dao.FrostSigningSessionDao import press.mantra.compose.database.dao.GiftWrapMessageDao import press.mantra.compose.database.dao.GiftWrapPayloadDao import press.mantra.compose.database.dao.GiftWrapSealDao +import press.mantra.compose.database.dao.GroupKeyStateDao import press.mantra.compose.database.dao.InReplyToRelationDao import press.mantra.compose.database.dao.MantraArtifactDao import press.mantra.compose.database.dao.MantraArtifactVersionDao @@ -71,6 +72,7 @@ import press.mantra.compose.database.model.Connection import press.mantra.compose.database.model.GiftWrapMessage import press.mantra.compose.database.model.GiftWrapPayload import press.mantra.compose.database.model.GiftWrapSeal +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.InReplyToRelation import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion @@ -132,6 +134,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) GiftWrapMessage::class, GiftWrapSeal::class, GiftWrapPayload::class, + GroupKeyState::class, InReplyToRelation::class, MantraArtifact::class, MantraArtifactVersion::class, @@ -169,7 +172,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 6, + version = 7, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -194,7 +197,13 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // both shapes Room can migrate itself. A ceremony that completed before // this reads back null, and signing falls back to not cross-checking // shares rather than refusing to run. - AutoMigration(from = 5, to = 6) + AutoMigration(from = 5, to = 6), + // v7 adds the GroupKeyState table, which records what shared key a room + // signs with instead of leaving it to be rederived. A new table is a + // shape Room migrates itself. Rooms created before this have no row and + // fall back to the rederivation scan in FrostSigningManager.completedKey, + // which is why that scan stays. + AutoMigration(from = 6, to = 7) ] ) @ColumnTypeConverters(MantraConverters::class) @@ -216,6 +225,8 @@ abstract class MantraDatabase: RoomDatabase() { abstract fun frostSigningSessionDao(): FrostSigningSessionDao + abstract fun groupKeyStateDao(): GroupKeyStateDao + abstract fun connectionDao(): ConnectionDao abstract fun giftWrapMessageDao(): GiftWrapMessageDao diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt new file mode 100644 index 00000000..05c43121 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt @@ -0,0 +1,56 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Transaction +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.database.model.GroupKeyState + +@Dao +abstract class GroupKeyStateDao { + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract suspend fun getByChatRoomId(chatRoomId: String): GroupKeyState? + + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract fun observeByChatRoomId(chatRoomId: String): Flow + + /** Every room that signs with one ceremony's key. One, today. */ + @Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId") + abstract suspend fun getByDkgSessionId(dkgSessionId: String): List + + @Upsert + abstract suspend fun upsert(groupKeyState: GroupKeyState) + + /** + * Files a state, keeping the newest announcement per room. + * + * This is where the event's replaceable semantics actually happen. Relays + * never see a `GroupKeyStateEvent` -- it is a rumor inside a Marmot group + * event -- so nothing upstream applies the 3xxxx replacement rule, and an + * announcement that arrives twice would otherwise be two rows racing for + * one primary key. + * + * Older announcements are dropped rather than applied, so a redelivery from + * a relay backfill cannot walk the room back to a state it has already + * moved past. An announcement at the same instant is kept as a no-op: two + * members announcing the same true thing agree by construction, since both + * derived it from the room they are standing in. + * + * Returns the state now on file. + */ + @Transaction + open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState { + val known = getByChatRoomId(groupKeyState.chatRoomId) + + if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known + + val stamped = groupKeyState.copy( + createdAt = known?.createdAt ?: groupKeyState.createdAt, + updatedAt = groupKeyState.createdAt + ) + upsert(stamped) + + return stamped + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 6dc36dfd..20efb407 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -32,7 +32,9 @@ import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MlsGroupCache import press.mantra.compose.managers.MarmotInboundManager import co.touchlab.kermit.Logger @@ -469,16 +471,29 @@ abstract class NostrDao( // itself. The manager is idempotent, so a redelivered // message re-runs a step it has already taken. if (groupEventResult is GroupEventResult.ApplicationMessage) { - Event.fromJsonOrNull(groupEventResult.innerEventJson) - ?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) } - ?.let { innerEvent -> - FrostSigningManager.processSigningPayload( - database = database, - localChatRoom = localChatRoom, - innerEvent = innerEvent, - userPublicKey = activeKeyPair.pubKey.toHex() - ) + Event.fromJsonOrNull(groupEventResult.innerEventJson)?.let { innerEvent -> + when { + FrostSigningEvents.isFrostSigningKind(innerEvent.kind) -> + FrostSigningManager.processSigningPayload( + database = database, + localChatRoom = localChatRoom, + innerEvent = innerEvent, + userPublicKey = activeKeyPair.pubKey.toHex() + ) + + // What key this room signs with. Filed + // rather than acted on, and only after + // the room rederives from the key it + // names -- the manager drops anything + // that does not, whoever sent it. + GroupKeyStateEvent.isGroupKeyStateKind(innerEvent.kind) -> + GroupKeyStateManager.record( + database = database, + chatRoomId = localChatRoom.chatRoom.id, + innerEvent = innerEvent + ) } + } } } } else { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index feba4e8c..8393be8f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -18,6 +18,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -705,6 +706,13 @@ data class ChatMessage( // account of the same thing. in FrostSigningEvents.ALL -> null + // The room saying what key it signs with. Standing state rather + // than something that happened, and the room's own id already + // says it to anyone who can derive -- so there is nothing here a + // reader of the transcript needs told. Falling through to + // "unsupported" would put the raw announcement in the chat. + GroupKeyStateEvent.KIND -> null + else -> { ChatMessage( giftWrapPayloadId = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt new file mode 100644 index 00000000..48acc1b5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.time.Clock +import kotlin.time.Instant +import press.mantra.compose.database.model.traits.LocalStoreEntity +import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.managers.SharedKeyDerivation + +/** + * Which shared key a room signs with, as this device has been told. + * + * Written from a `GroupKeyStateEvent` -- the room's own announcement of the + * ceremony behind it -- and read when a signing request arrives, to pick the + * secret share out of the right [DkgSession]. A device that took part in more + * than one ceremony holds more than one share, and they are not + * interchangeable: a partial signature made with the wrong one cannot + * aggregate. + * + * ### Why this is a row and not a rederivation + * + * `FrostSigningManager.completedKey` found the key by walking every ceremony + * this device holds a share for and rederiving each one's room id until one + * matched. That works, and it stays as the fallback for rooms made before this + * table existed, but it can only find rooms derived at the *default* path -- + * the one path the constant names. A room derived anywhere else was invisible + * to it. [derivationPath] is what fixes that, which is also the reason the path + * is stored rather than assumed. + * + * ### Nothing secret lives here + * + * [thresholdPublicKey] is the key signatures verify against, not the secret + * behind it, and [dkgSessionId] is a pointer. The share itself never leaves + * `DkgSession.secretShare` on the device that generated it. + * + * One row per room: a room is derived from one key, and a group that re-runs + * its ceremony derives a different room rather than re-keying this one. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("dkgSessionId"), + ], +) +data class GroupKeyState( + /** The Marmot room this is the key state for. Its id is the derived key. */ + @PrimaryKey + val chatRoomId: String, + + /** + * The ceremony that made the key, and so the row holding this device's + * share of it. + * + * Not a foreign key on purpose. A member can be in the room without + * holding a share -- they were added after the ceremony, or reinstalled -- + * and the state is still worth keeping: it says what the room signs with, + * which is what tells them they cannot. + */ + val dkgSessionId: String, + + /** The group's ChillDKG threshold public key, 33-byte compressed hex. */ + val thresholdPublicKey: HexKey, + + /** The path [chatRoomId] was derived at, `m/9420/0/0` style. */ + val derivationPath: String, + + /** Who announced it. Kept for the transcript; the derivation is what vouches for it. */ + val announcedBy: HexKey, + + /** The announcement's own timestamp, so the newest state per room wins. */ + val announcedAt: Instant, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt, + override val savedAt: Instant = createdAt, +): TimestampedEntity, LocalStoreEntity { + /** [derivationPath] as indices, or null if it is not a walkable path. */ + fun pathIndices(): List? = SharedKeyDerivation.parsePathString(derivationPath) + + /** + * Whether this state actually describes the room it claims to. + * + * The room's id is the threshold key derived at the path, so this is the + * whole of the trust model: a state that does not rederive its own room was + * announced by somebody pointing the room at a key it was not made from. + * Checked before the row is written and cheap enough to check again. + */ + fun verifies(): Boolean { + val path = pathIndices() ?: return false + + return runCatching { + SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) == chatRoomId + }.getOrDefault(false) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index bd1eeb6f..daacde83 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -3,9 +3,11 @@ package press.mantra.compose.database.repository import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.managers.ChillDkgRitualManager +import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.repository.DkgRepository import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -48,6 +50,31 @@ class DatabaseDkgRepository( override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = ChillDkgRitualManager.pendingApproval(database, session) + override suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? { + val thresholdPublicKey = session.thresholdPublicKey ?: return null + + return try { + GroupKeyStateManager.announce( + database = database, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + dkgSessionId = session.id, + thresholdPublicKey = thresholdPublicKey + ) + } catch (e: Throwable) { + // The room not deriving from the key it is about to announce is a + // bug rather than a condition, but it is not worth failing the room + // over: the group still has a working chat, and signing simply falls + // back to the rederivation scan it used before there was a state. + logger.e("Error announcing the key state for $chatRoomId", e) + null + } + } + override suspend fun approve( localChatRoom: LocalChatRoom, sessionId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 2bca8145..5fbdbbaf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -26,6 +26,7 @@ import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSignerMessage import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgRitualStage @@ -789,16 +790,32 @@ object FrostSigningManager { * to be outside of -- while a group event needs an MLS one, so the two * cannot be the same room. * - * They are still bound together, and by construction rather than by a - * column: the #admins room's id *is* the key, derived from it by - * [SharedKeyDerivation.marmotGroupId]. Rederiving is what finds the key - * here, which means a room cannot be pointed at a key it was not derived - * from. + * They are bound together by the room's [GroupKeyState]: the announcement + * the room opened with, naming the ceremony behind it. That is a lookup + * rather than a search, and it carries the derivation path, so a room + * derived anywhere other than [SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH] + * is findable at all -- which the rederivation below cannot manage, since it + * can only rederive at the one path the constant names. * - * Falls back to a ceremony held in this very room, which is not how the app - * wires things today but costs one lookup to keep honest. + * The state buys none of its authority from being written down. It is only + * ever stored having rederived the room it describes, so what actually binds + * a room to a key is still that the room's id *is* the key, and a room still + * cannot be pointed at a key it was not derived from. + * + * Two fallbacks behind it, both for rooms that predate the table: the + * original scan over every ceremony this device holds a share for, and then + * a ceremony held in this very room, which is not how the app wires things + * today but costs one lookup to keep honest. */ suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? { + GroupKeyStateManager.keyStateFor(database, chatRoomId)?.let { state -> + database.dkgSessionDao().getSessionById(state.dkgSessionId)?.takeIf { key -> + key.stage == DkgRitualStage.COMPLETE && + key.secretShare != null && + key.thresholdPublicKey == state.thresholdPublicKey + }?.let { return it } + } + database.dkgSessionDao().getKeyHoldingSessions().firstOrNull { session -> session.stage == DkgRitualStage.COMPLETE && session.thresholdPublicKey?.let { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt new file mode 100644 index 00000000..81ccbbad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -0,0 +1,177 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.time.Clock +import kotlin.time.Instant +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.MarmotInnerEvent +import press.mantra.compose.nostr.frost.GroupKeyStateEvent + +/** + * Announces and files what key a room signs with. + * + * The coordinator [announce]s once, as the new room's first message; every + * other member [record]s what arrives. Both ends land on the same + * [GroupKeyState] row, which is what a signing request is resolved against -- + * see `FrostSigningManager.completedKey`. + * + * Nothing here is trusted on the strength of who said it. A state is kept only + * if the room's id rederives from the key it names, which is the same check + * `completedKey` used to make by scanning, and the reason a coordinator cannot + * point a room at a key it was not made from. + */ +object GroupKeyStateManager { + private const val TAG = "GroupKeyStateManager" + + private val logger = Logger.withTag(TAG) + + /** The key state a room signs under, or null while it has none. */ + suspend fun keyStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? = + database.groupKeyStateDao().getByChatRoomId(chatRoomId) + + /** + * Says what the freshly made room signs with, and files it locally. + * + * Called once, by the member who created the room, before anybody has been + * added to it -- the announcement is the room's first message, so a member + * arriving on a welcome finds it waiting rather than having to be told + * separately. + * + * Queued before it is recorded, matching the signing pipeline: a crash + * between the two costs a duplicate announcement, which [record] folds + * away, rather than a room whose key nobody ever named. + * + * Refuses to announce a state that does not describe the room, because a + * state that fails [GroupKeyState.verifies] here is this device having + * derived the room from one key and announced another -- a bug worth + * failing on rather than broadcasting. + */ + suspend fun announce( + database: MantraDatabase, + chatRoomId: String, + userPublicKey: HexKey, + dkgSessionId: String, + thresholdPublicKey: HexKey, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, + createdAt: Long = Clock.System.now().epochSeconds + ): GroupKeyState { + val state = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + thresholdPublicKey = thresholdPublicKey, + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = userPublicKey, + announcedAt = Instant.fromEpochSeconds(createdAt) + ) + + check(state.verifies()) { + "Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}" + } + + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + path = path + ) + + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + // The rumor id the outbound pipeline will recompute from these + // same fields when it assembles the event to encrypt. + id = EventHasher.hashId( + pubKey = userPublicKey, + createdAt = createdAt, + tags = tags, + content = thresholdPublicKey, + kind = GroupKeyStateEvent.KIND + ), + publicKey = userPublicKey, + kind = GroupKeyStateEvent.KIND, + createdAt = Instant.fromEpochSeconds(createdAt), + tags = tags, + content = thresholdPublicKey, + chatRoomId = chatRoomId + ) + ) + + logger.i("Announcing key ${state.thresholdPublicKey} for room $chatRoomId at ${state.derivationPath}") + + return database.groupKeyStateDao().replace(state) + } + + /** + * Files an inbound announcement, or drops it and says why. + * + * Storing is all this adds to [stateFrom], which is where the deciding + * happens -- kept apart so the check a member's safety rests on can be + * exercised without standing up a database. + */ + suspend fun record( + database: MantraDatabase, + chatRoomId: String, + innerEvent: Event + ): GroupKeyState? = + stateFrom(chatRoomId, innerEvent)?.let { database.groupKeyStateDao().replace(it) } + + /** + * The state an announcement amounts to, or null if it amounts to none. + * + * Every reason to return null is a reason the announcement does not describe + * this room, and none of them are about who sent it: a member with no share, + * or none of the ceremony at all, can announce a true state and it is still + * true. What cannot be tolerated is a state naming a key the room was not + * derived from, because acting on one means signing with a share that will + * not aggregate -- or, worse, treating a key the group does not hold as the + * key the group holds. + */ + fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? { + val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) + if (announced != null && announced != chatRoomId) { + logger.w("Key state for room $announced arrived in $chatRoomId; dropping") + return null + } + + val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content) + if (thresholdPublicKey == null) { + logger.w("Key state in $chatRoomId carries no threshold key; dropping") + return null + } + + val dkgSessionId = GroupKeyStateEvent.parseDkgSessionId(innerEvent.tags) + if (dkgSessionId == null) { + logger.w("Key state in $chatRoomId names no ceremony; dropping") + return null + } + + val path = GroupKeyStateEvent.parsePath(innerEvent.tags) + if (path == null) { + logger.w("Key state in $chatRoomId carries no walkable derivation path; dropping") + return null + } + + val state = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + thresholdPublicKey = thresholdPublicKey, + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = innerEvent.pubKey, + announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt) + ) + + // The whole trust model, in one line. Anybody may say what this room + // signs with; only the truth rederives the room they said it in. + if (!state.verifies()) { + logger.w( + "Key state from ${innerEvent.pubKey} names $thresholdPublicKey at " + + "${state.derivationPath}, which does not derive room $chatRoomId; dropping" + ) + return null + } + + return state + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt index 1a138c00..cde305fe 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt @@ -115,12 +115,40 @@ object SharedKeyDerivation { ?.firstOrNull { it.trimStart().startsWith(PATH_MARKER) } ?: return null - val path = line.trimStart().removePrefix(PATH_MARKER).trim() + return parsePathString(line.trimStart().removePrefix(PATH_MARKER).trim()) + } + + /** The indices [tweakScalar] can actually tell apart: a BIP32-shaped uint32. */ + private val INDEX_RANGE = 0L..0xFFFFFFFFL + + /** + * A bare `m/9420/0/0` as indices, or null if it is not one. + * + * Split out from [parsePath] because a path also travels on its own, in a + * `GroupKeyStateEvent`'s [press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag], + * where there is no description to dig it out of. Both spellings have to + * agree on what a path is, so there is only one reader of one. + * + * Indices outside a uint32 are rejected, which matters because a path now + * arrives from the wire rather than only from [MARMOT_ADMIN_GROUP_PATH]. + * [tweakScalar] serialises an index as its low four bytes, so without this + * `m/4294967296/0/0` walks to the same key as `m/0/0/0` and a room could be + * described by a path nobody would write. Nothing is stolen by that -- a + * state still has to derive the room it names -- but it would make + * [formatPath] a lossy round trip and leave two spellings of one path for + * any later code to disagree over. Negative indices go the same way: they + * are not a thing a path has. + */ + fun parsePathString(path: String): List? { if (!path.startsWith("m/")) return null return path.removePrefix("m/") .split("/") - .map { segment -> segment.toLongOrNull() ?: return null } + .map { segment -> + val index = segment.toLongOrNull() ?: return null + if (index !in INDEX_RANGE) return null + index + } .takeIf { it.isNotEmpty() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt index c61a5a71..04b99d1d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -24,6 +24,11 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag * anyone --[ 30325 failure ]-> everyone abandon + blame * ``` * + * [GroupKeyStateEvent] sits just past them on 30326. It is not part of a + * session -- it is the standing fact a session is opened against, saying which + * key the room signs with -- so it is deliberately outside [ALL], which is what + * the inbound path dispatches a session message on. + * * ### Why 3032x * * These share the inner-event space with the nip30303 document kinds, which run diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt new file mode 100644 index 00000000..a6a59b73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt @@ -0,0 +1,108 @@ +package press.mantra.compose.nostr.frost + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag +import press.mantra.compose.nostr.frost.tags.FrostKeyTag + +/** + * What key a Marmot room signs with, announced into the room itself. + * + * The coordinator posts one as the room's first message, right after creating + * it. Content is the group's ChillDKG threshold public key; the tags name the + * ceremony that produced it and the path the room's id was derived at. + * + * ``` + * coordinator --[ 30326 group key state ]-> everyone "this room signs with K, at m/9420/0/0" + * ``` + * + * ### What it is for + * + * A signer holds a different secret share under every ceremony it took part in, + * and signing with the wrong one produces a partial signature that cannot + * aggregate. This is the record that says which. A member reads the state, + * follows [FrostKeyTag] to the `DkgSession` row their own device already holds, + * and takes the share from there -- the association travels, the share does not. + * + * Nothing secret is in here, and that is not an accident. Every member of the + * room can read it, so a share put on this event would be every member holding + * every other member's share, which is a 1-of-n key wearing a t-of-n's clothes. + * + * ### Trusted no further than it can be checked + * + * The coordinator posts it, and the coordinator is untrusted by construction. + * A receiver therefore verifies rather than believes: the room's id *is* the + * threshold key derived at the path, so + * `SharedKeyDerivation.marmotGroupId(content, path) == chatRoomId` has to hold + * or the state is dropped. That is the same check + * `FrostSigningManager.completedKey` made by rederiving, kept rather than + * replaced -- this event makes the association explicit and cheap to look up, + * not easier to forge. + * + * ### Replaceable, by this app rather than by a relay + * + * Like every kind in [FrostSigningEvents] this is a rumor inside a Marmot group + * event, so no relay ever sees it and the addressable semantics of the 3xxxx + * range never fire. [DTag] is the room id and the newest state per room wins, + * which the local store enforces on its own. Being able to say it twice is what + * matters in practice: a redelivered announcement, or a second member saying the + * same true thing, folds away instead of accumulating. + * + * One room only ever names one key today. A group that re-runs its ceremony + * derives a *different* room from the new key, so rotation in place does not + * arise -- and if it ever does, the verification above is what has to change + * first, because a rotated key no longer derives the room it is announced in. + */ +object GroupKeyStateEvent { + /** + * Sits with the signing family in the Marmot inner-event space. 30320-30325 + * are a signing session; this is the standing fact a session is opened + * against, so it is adjacent rather than inside. + */ + val KIND: Kind = 30326 + + fun isGroupKeyStateKind(kind: Kind): Boolean = kind == KIND + + /** + * The tags for a state naming [dkgSessionId], for the room derived at [path]. + * + * The room id goes on as the `d` tag so the event is self-addressing: a + * reader can tell which room a state belongs to without the envelope it + * arrived in, which is what makes dropping a state announced into the wrong + * room a check rather than an assumption. + */ + fun assembleTags( + chatRoomId: String, + dkgSessionId: String, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ): Array> = arrayOf( + DTag.assemble(chatRoomId), + FrostKeyTag.assemble(dkgSessionId), + FrostDerivationPathTag.assemble(path) + ) + + /** The room this state is about, or null if it names none. */ + fun parseChatRoomId(tags: Array>): String? = + tags.firstOrNull { it.size > 1 && it[0] == DTag.TAG_NAME }?.get(1)?.ifBlank { null } + + /** The ceremony whose share signs for this room, or null if it names none. */ + fun parseDkgSessionId(tags: Array>): String? = + tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId + + /** The derivation path, or null if it carries none or an unwalkable one. */ + fun parsePath(tags: Array>): List? = + tags.firstNotNullOfOrNull(FrostDerivationPathTag::parse)?.path + + /** + * The threshold public key a state announces, or null when the content is + * not one. + * + * Shape only -- 33 compressed bytes of hex. Whether it is *the* key for the + * room is settled by rederiving the room id from it, not by looking at it. + */ + fun parseThresholdPublicKey(content: String): HexKey? = + content.trim() + .takeIf { it.length == 66 && it.all { char -> char.isDigit() || char in 'a'..'f' || char in 'A'..'F' } } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt new file mode 100644 index 00000000..ad6d570f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt @@ -0,0 +1,42 @@ +package press.mantra.compose.nostr.frost.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure +import press.mantra.compose.managers.SharedKeyDerivation + +/** + * The path the room's id was derived at, `m/9420/0/0` style. + * + * Recorded rather than assumed. `SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH` + * is the only path anything walks today, but a room derived at a second one + * would be unfindable by a lookup that hardcodes the first, and the path is + * also what rebuilds the `TweakCache` a signing session needs. + * + * Hardened indices are rejected on parse: hardened derivation needs the parent + * private key, which in a threshold group nobody has, so a path carrying one + * was never walked. + */ +class FrostDerivationPathTag( + val path: List, +) { + fun toTagArray() = assemble(path = path) + + companion object { + const val TAG_NAME = "frost_path" + + fun parse(tag: Array): FrostDerivationPathTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val path = SharedKeyDerivation.parsePathString(tag[1]) ?: return null + + return FrostDerivationPathTag(path = path) + } + + fun assemble(path: List): Array = + arrayOf(TAG_NAME, SharedKeyDerivation.formatPath(path)) + + fun assemble(frostDerivationPathTag: FrostDerivationPathTag) = + assemble(path = frostDerivationPathTag.path) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index fcecf161..2024cd8a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -2,6 +2,7 @@ package press.mantra.compose.repository import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgApprovalStep import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -42,6 +43,19 @@ interface DkgRepository { nostrPrivateKey: ByteArray ) + /** + * Tells [chatRoomId] which ceremony's key it signs with, as its first message. + * + * Called once by whoever creates the room. Null if the ritual has produced + * no key yet, or if the room does not derive from the one it produced -- + * both of which mean there is nothing true to announce. + */ + suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? + companion object { val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository { override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow = flowOf(null) @@ -65,6 +79,12 @@ interface DkgRepository { step: DkgApprovalStep, nostrPrivateKey: ByteArray ) = Unit + + override suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? = null } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 8ae7816f..81f26cf0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -251,7 +251,8 @@ class DkgRitualViewModel( if (isActionPending.value) return val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return - val thresholdPublicKey = loaded.session?.thresholdPublicKey ?: return + val session = loaded.session ?: return + val thresholdPublicKey = session.thresholdPublicKey ?: return val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() if (nostrPrivateKey == null) { @@ -383,6 +384,28 @@ class DkgRitualViewModel( logger.e("Failed to add members to admin group $groupId", it) }.getOrElse { addable.map { (publicKey, _) -> publicKey } } + // The room's first message: which ceremony's key it signs with, and + // the path its id was derived at. What a signer reaches for when a + // signing request arrives and it has to pick one of its shares. + // + // After the members are added rather than before, which is the only + // order that works: adding them commits a new epoch, and MLS will not + // let a member read what was encrypted before the epoch they joined + // at. Announced first, the announcement would reach nobody but its + // author. It is still the room's first *application* message -- what + // comes before it is handshake. + // + // A member invited later still misses it for the same reason, and is + // left where every member was before this event existed: falling back + // to FrostSigningManager.completedKey's rederivation. Re-announcing + // on invite is the fix, and is cheap because a repeat announcement + // folds away rather than accumulating. + dkgRepository.announceGroupKeyState( + chatRoomId = localChatRoom.chatRoom.id, + userPublicKey = activeUserPublicKey, + session = session + ) + isActionPending.value = false if (notAdded.isNotEmpty()) { diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt new file mode 100644 index 00000000..854d78d6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -0,0 +1,245 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.secp256k1.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.frost.GroupKeyStateEvent + +/** + * What a room's key-state announcement is allowed to convince a member of. + * + * The announcement is made by the coordinator, and the coordinator is untrusted + * by construction -- the same assumption every other part of the ceremony is + * written under. So the interesting cases here are all the ones where a state + * is *wrong*: a member who acts on a state naming a key their room was not made + * from signs with a share that cannot aggregate, or worse, treats a key the + * group does not hold as the key the group holds. + * + * `GroupKeyStateManager` needs a database and so cannot be stood up here. What + * can be is the check it defers to, which is where the whole trust model lives. + */ +class GroupKeyStateTest { + /** Stands in for a ceremony's output. Any valid point will do. */ + private val thresholdPublicKey = PrivateKey( + Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ).publicKey().value.toHex() + + /** A second group's key, for the states that name the wrong one. */ + private val otherKey = PrivateKey( + Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2") + ).publicKey().value.toHex() + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) + + private fun state( + chatRoomId: String = this.chatRoomId, + thresholdPublicKey: String = this.thresholdPublicKey, + derivationPath: String = SharedKeyDerivation.formatPath(path) + ) = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + thresholdPublicKey = thresholdPublicKey, + derivationPath = derivationPath, + announcedBy = "c00rd1na70r", + announcedAt = Instant.fromEpochSeconds(1_700_000_000) + ) + + @Test + fun `a state describing the room it was announced in verifies`() { + assertTrue(state().verifies()) + } + + @Test + fun `a state naming another group's key does not verify`() { + // The attack this is here for: a coordinator pointing the room at a key + // the group never made, so that everything signed in it is signed by + // whoever holds that key instead. + assertFalse(state(thresholdPublicKey = otherKey).verifies()) + } + + @Test + fun `a state naming the right key at the wrong path does not verify`() { + // The path is half the derivation, so getting it wrong reaches a + // different room just as surely as getting the key wrong does. + assertFalse(state(derivationPath = "m/9420/0/1").verifies()) + } + + @Test + fun `a state for one room does not verify against another`() { + assertFalse(state(chatRoomId = otherKey).verifies()) + } + + @Test + fun `a state carrying an unwalkable path does not verify`() { + // Hardened derivation needs the parent private key, which nobody in a + // threshold group has, so a hardened path was never walked to anything. + assertFalse(state(derivationPath = "m/9420'/0/0").verifies()) + assertFalse(state(derivationPath = "9420/0/0").verifies()) + assertFalse(state(derivationPath = "").verifies()) + } + + @Test + fun `the tags a state is announced on read back as they were written`() { + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path + ) + + assertEquals(chatRoomId, GroupKeyStateEvent.parseChatRoomId(tags)) + assertEquals("ceremony-1", GroupKeyStateEvent.parseDkgSessionId(tags)) + assertEquals(path, GroupKeyStateEvent.parsePath(tags)) + } + + @Test + fun `a threshold key is only read out of content that is one`() { + assertEquals(thresholdPublicKey, GroupKeyStateEvent.parseThresholdPublicKey(thresholdPublicKey)) + + // 32 bytes is an x-only key, not the 33-byte compressed point a ceremony + // reports; anything else is not a key at all. + assertNull(GroupKeyStateEvent.parseThresholdPublicKey(chatRoomId)) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("")) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("not a key")) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("z".repeat(66))) + } + + @Test + fun `a path survives the trip through a tag`() { + val deep = listOf(9420L, 7L, 0L, 1L) + val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", deep) + + assertEquals(deep, GroupKeyStateEvent.parsePath(tags)) + } + + @Test + fun `a room derived at a path other than the default still verifies at it`() { + // The reason the path is announced rather than assumed: a lookup that + // hardcodes MARMOT_ADMIN_GROUP_PATH cannot find this room at all. + val sibling = listOf(9420L, 0L, 1L) + val siblingRoom = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, sibling) + + assertTrue( + state( + chatRoomId = siblingRoom, + derivationPath = SharedKeyDerivation.formatPath(sibling) + ).verifies() + ) + } + + // ---- The announcement as it actually arrives ------------------------- + // + // Everything above checks the verdict on a state already assembled. These + // check the assembling: a real Event, with the tags and content an + // announcement is carried on, through the function the inbound path calls. + + private fun announcement( + content: String = thresholdPublicKey, + tags: Array> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path), + pubKey: String = "c00rd1na70r", + createdAt: Long = 1_700_000_000 + ) = Event( + id = "an-id", + pubKey = pubKey, + createdAt = createdAt, + kind = GroupKeyStateEvent.KIND, + tags = tags, + content = content, + sig = "" + ) + + @Test + fun `an announcement of the room it arrives in is taken`() { + val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement()) + + assertEquals(chatRoomId, state?.chatRoomId) + assertEquals("ceremony-1", state?.dkgSessionId) + assertEquals(thresholdPublicKey, state?.thresholdPublicKey) + assertEquals("m/9420/0/0", state?.derivationPath) + // Attribution and ordering come off the event, not off the clock. + assertEquals("c00rd1na70r", state?.announcedBy) + assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt) + } + + @Test + fun `an announcement naming another group's key is dropped`() { + // The one that matters: a coordinator pointing the room at a key the + // group never made. Everything else here is malformed input; this is + // well-formed input that lies. + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = otherKey))) + } + + @Test + fun `an announcement addressed to another room is dropped`() { + val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path) + + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere))) + } + + @Test + fun `an announcement missing any of what it has to say is dropped`() { + val full = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path) + + // No ceremony to reach a share through. + assertNull( + GroupKeyStateManager.stateFrom( + chatRoomId, + announcement(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray()) + ) + ) + // No path, so nothing to rebuild a TweakCache from. + assertNull( + GroupKeyStateManager.stateFrom( + chatRoomId, + announcement(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray()) + ) + ) + // No key. + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = ""))) + } + + @Test + fun `an announcement carrying no d tag is judged on its derivation alone`() { + // The d tag is a convenience for a reader holding the event on its own. + // Dropping it loses nothing that matters, because the room it arrived in + // plus the derivation still settle the question. + val undirected = arrayOf( + arrayOf("frost_key", "ceremony-1"), + arrayOf("frost_path", "m/9420/0/0") + ) + + assertEquals( + chatRoomId, + GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId + ) + } + + @Test + fun `a path index wider than a uint32 is not a path`() { + // tweakScalar serialises an index as its low four bytes, so m/4294967296 + // would otherwise walk exactly where m/0 does -- one room, two spellings, + // both verifying. Rejected at the parser so formatPath stays a round trip. + assertNull(SharedKeyDerivation.parsePathString("m/4294967296/0/0")) + assertNull(SharedKeyDerivation.parsePathString("m/-1/0/0")) + + assertEquals(listOf(4294967295L), SharedKeyDerivation.parsePathString("m/4294967295")) + assertEquals(listOf(0L), SharedKeyDerivation.parsePathString("m/0")) + } + + @Test + fun `a state whose path indices are out of range does not verify`() { + // Reachable only by constructing the row directly; the parser above + // refuses to build one. Checked because verifies() is what everything + // else defers to, and it should not be the thing that trusts its input. + assertFalse(state(derivationPath = "m/4294967296/0/0").verifies()) + } +} From 65e4a3acc05de77bfb35c0474632789ae1c3b3b2 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 00:40:31 +0200 Subject: [PATCH 28/33] fix: seal the Welcome, the one gift wrap an MLS room must publish No invite to a Marmot room has been delivered since 1700e6d. The Welcome was built, hashed, queued and logged exactly as before -- and then refused one step short of the relay, by a guard that had no idea it was looking at one. **The guard.** 1700e6d ("send a direct message into the group, wrapped for one member") added a backstop at the top of sealGiftWrapPayload: val chatRoom = database.chatRoomDao().findChatRoomById(giftWrapPayload.chatRoomId) if (chatRoom?.chatRoom?.mlsGroupState != null) { log; return } Its reasoning is sound and still is: a Marmot direct message is a genuine, correctly signed NIP-59 gift wrap, indistinguishable from one this path would be right to publish, and the only thing keeping it off a relay is that it never becomes a GiftWrapPayload row. Refusing at the seal as well means a future caller cannot walk one onto a relay by accident. **Why it caught the Welcome.** MarmotOutboundDao.deliveryWelcome writes a GiftWrapPayload with chatRoomId = nostrGroupId -- the MLS room's own id, which by construction has mlsGroupState set. Every Welcome therefore matched a refusal keyed on mlsGroupState alone. There is no Welcome that does not: the tag identifying the room is the whole point of the event. The kind:444 arm further down -- the one that wraps only for the participant who published the referenced key package -- became unreachable, which is why nothing in the logs said "welcome" at all. **Why it went unnoticed.** The room reaches the correct state on the inviter's side whether or not the Welcome goes out: addMember advances the epoch, the group state is persisted, the Participant row exists, and "Invited X to chat" is written to the transcript. From the coordinator's side an invitee who never heard anything is indistinguishable from one who joined -- recorded as a known gap in docs/shared-key-ceremony.md, and this is what was behind it. **The second-order damage.** observeUnsealedGiftWrapPayloads is SELECT * FROM GiftWrapPayload WHERE publicKey = :p AND giftWrapSealId IS NULL collected as a Flow -- one row at a time. giftWrapSealId is only ever set inside persistAndBroadcastGiftWrap, which the guard returns before reaching, so a refused payload stays unsealed forever and sits at the head of that queue. The first Welcome a user queued blocked every gift wrap behind it, in every room, for the life of the install. **The fix.** Kind first, room second. 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 deliveryWelcome queues one on purpose. Every other kind is refused exactly as before. sendChatMessage already branches on mlsGroupState before writing a payload, so an MLS room's messages never arrive here anyway; the guard stays as the backstop it was meant to be. docs/marmot-direct-messages.md claimed "An MLS room should never produce a NIP-17 gift wrap for any reason". That premise is what made the guard look complete, so it is corrected rather than merely amended, with the test to apply when adding a kind to the exemption: can its recipient read a kind:445? If so, it does not belong on this path. **Not fixed, deliberately.** A refusal still leaves the payload unsealed and head-blocking. That is now unreachable -- nothing else can queue a payload against an MLS room -- but it remains a trap for whatever gets refused next. Marking a payload refused needs a state the schema does not have, so it is left for its own change rather than smuggled in here. Verified: :composeApp:compileDebugKotlinAndroid succeeds. No test covers this -- DatabaseChatRepository is Room-backed, and Room-backed code has no unit test harness in this project. Co-Authored-By: Claude Opus 5 --- .../repository/DatabaseChatRepository.kt | 37 ++++++++++++------- docs/marmot-direct-messages.md | 19 ++++++++-- 2 files changed, 39 insertions(+), 17 deletions(-) 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 | From 02117643c415e33cc3f16bc0dc43cb942110a0d1 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 01:06:05 +0200 Subject: [PATCH 29/33] fix: send a group event because it was queued, not because the chat mentions it No FROST signing message has ever reached another participant. The proposal was built, MLS-encrypted, wrapped under the exporter secret, signed as a kind:445, written to NostrEvent and MarmotGroupEvent, and its queue row marked processed -- and then never handed to a relay, by a branch that was never about delivery at all. **The gate.** The tail of MarmotOutboundDao.encryptAndSendMarmotInnerEvent looked up the transcript row for the queued rumor and did everything else inside it: val chatMessageOrNull = database.chatMessageDao() .getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id) chatMessageOrNull?.let { chatMessage -> ... relation, marmotGroupEventId ... val ids = database.broadcastNostrEventRequestDao().insert(...) } The BroadcastNostrEventRequest rows are the only thing that puts a kind:445 on a relay -- observeBroadcastNostrEventRequestsByStatus("pending") is what the broadcaster watches, and nothing else inserts them for this path. So the question "does the chat have a line for this?" was silently answering the question "should the group receive this?". **Why FROST always lost.** A signing message has no ChatMessage by design. FrostSigningManager.broadcast queues the rumor alone, and announce() writes its milestone lines with marmotInnerEventId = null on purpose: each device writes its own transcript from the messages it has already received, so the lines cost no traffic and cannot disagree with the session they describe. The inbound half states the same intent from the other side -- ChatMessage.applyInnerEvent returns null for every FrostSigningEvents kind, because a row there would be a second, worse account of what the manager already narrates. That is every kind in the family, not just the proposal: nonces, the signer set, partial signatures, the finished signature and the failure notice all go through the same broadcast(). A session could not have completed even if a proposal had somehow arrived. **GroupKeyStateManager.announce had it too.** Same shape, same silence: a room's kind:30326 announcement of which key it signs with was queued, encrypted and dropped. a909108 added it so members would stop rederiving; no member has ever received one. **Why the neighbours worked, and hid it.** The DKG rides NIP-17 gift wraps through a different path entirely, so a room could finish a ceremony, hold a real shared key, and report canSign() == true with the signing transport dead beneath it. nip30303 submissions work because MantraDao.sendMarmotInnerEvent pairs every queued rumor with a ChatMessage carrying its id -- not as a delivery mechanism, just because a submission is also something a member did. FROST was the first traffic to use the group path without a chat line, which is why this reads as a FROST bug and is not one. **Why it went unnoticed.** Nothing failed. The coordinator's own device is fully convinced: proposeSigning writes the session, announceStarted puts a line in the chat, advance() runs, and publishOwn records the coordinator's own nonce and announces that step too. From the proposer's side a session nobody else can see is indistinguishable from one waiting on slow peers. Unlike 65e4a3a, the queue did not block. marmotGroupEventId is set before the transcript lookup, so the row left the queue cleanly and the next one was picked up. Every message was lost individually, in silence, with no backlog to notice. **The fix.** The broadcast insert is hoisted out of the branch, and the decision it was tangled with is lifted into MarmotDelivery.plan: given a group event, a relay list, and a chat message or null, what has to be written. A group event is sent because it was queued; a chat line is linked because a member said something. The DAO now computes that plan and executes it, with the insert as a plain unconditional statement ahead of the bookkeeping that legitimately does depend on there being a line. The extraction is not decoration. encryptAndSendMarmotInnerEvent is Room-backed and cannot be stood up in a unit test, which is exactly how the gate survived; separating the decision from the filing of it is the same move MarmotDirectMessage.classify exists for, and for the same stated reason. **Tests.** MarmotDeliveryTest, six of them. The two that matter are "a signing proposal goes out, though nothing in the chat points at it" and "the send does not depend on the transcript", which asserts the broadcast list is identical with and without a chat message. The rest pin the supporting facts: one request per relay naming the event, every request written pending because that is the only status the broadcaster looks at, the linkage that does depend on a chat line, and an empty relay list as the sole legitimate way to produce an empty broadcast list -- so that an empty list always reads as "nowhere to send it" and never as "nothing to send". **Not covered, deliberately.** These pin the decision, not the call site. Re- nesting the insert inside chatMessageOrNull?.let would leave MarmotDelivery correct and every test passing. Closing that needs the DAO itself under test: BundledSQLiteDriver is on the classpath and getInMemoryDatabaseBuilder exists, but its android actual wants a real Context, testDebugUnitTest is plain JVM, and there is no androidUnitTest source set or Robolectric. That is its own change, not one to smuggle in here. Verified: :composeApp:compileDebugKotlinAndroid succeeds; 160 tests pass, 154 before these six. The inbound half was read rather than assumed -- NostrDao dispatches FrostSigningEvents kinds to processSigningPayload, inbound rumors are stored with marmotGroupEventId set so they cannot re-enter the outbound queue, and the out-of-order replay path is intact. Outbound was the only break. That two participants now actually see a proposal is inference from the code, not an observation: it wants two devices. Co-Authored-By: Claude Opus 5 --- .../compose/database/dao/MarmotOutboundDao.kt | 30 ++--- .../mantra/compose/nostr/MarmotDelivery.kt | 60 ++++++++++ .../compose/nostr/MarmotDeliveryTest.kt | 107 ++++++++++++++++++ 3 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDelivery.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index 653d12ee..e6c5049f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -22,6 +22,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.exporterSecret import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW +import press.mantra.compose.nostr.MarmotDelivery import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import co.touchlab.kermit.Logger @@ -653,13 +654,24 @@ abstract class MarmotOutboundDao( // Keyed on the queued row, not on `innerEvent.id`. For a direct message those // differ -- the row is the rumor, the wire event is the wrap built around it -- - // and the wrap's id matches no ChatMessage, so this lookup would come back null, - // the message would never be linked, and no BroadcastNostrEventRequest would ever - // be inserted. Encrypted, stored, and silently never sent. They are the same value - // for every other kind of message. + // and the wrap's id matches no ChatMessage, so this lookup comes back null. That + // costs only the transcript linkage, not the send. val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id) logger.d("chatMessageOrNull: $chatMessageOrNull") + + val delivery = MarmotDelivery.plan( + groupEventId = groupEvent.id, + relays = Relays.DefaultDMRelayList, // TODO: Get these from localChatRoom... + chatMessage = chatMessageOrNull, + ) + + // Sync broadcast to all the required relays. Unconditional, and before any + // transcript bookkeeping: see MarmotDelivery for what gating it on a chat line + // cost the signing sessions that have none. + val broadcastNostrEventRequestIds = + database.broadcastNostrEventRequestDao().insert(delivery.broadcasts) + chatMessageOrNull?.let { chatMessage -> database.chatMessageNostrEventRelationDao().upsert( ChatMessageNostrEventRelation( @@ -674,16 +686,6 @@ abstract class MarmotOutboundDao( ) ) - // Sync broadcast to all the required relays... - val broadcastNostrEventRequestIds = database.broadcastNostrEventRequestDao().insert( - Relays.DefaultDMRelayList.map { // TODO: Get these from localChatRoom... - BroadcastNostrEventRequest( - nostrEventId = groupEvent.id, - relayURL = it.url - ) - } - ) - broadcastNostrEventRequestIds.forEach { broadcastNostrEventRequestId -> database.chatMessageBroadcastNostrEventRequestRelationDao().upsert( ChatMessageBroadcastNostrEventRequestRelation( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDelivery.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDelivery.kt new file mode 100644 index 00000000..c41d8a76 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDelivery.kt @@ -0,0 +1,60 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import press.mantra.compose.database.model.BroadcastNostrEventRequest +import press.mantra.compose.database.model.ChatMessage + +/** + * What has to be written once a queued inner event has been encrypted into a + * group event: where it is sent, and which transcript row -- if any -- the send + * belongs to. + * + * The two are independent, and this exists to say so. A group event is sent + * because it was queued; a chat line is linked because a member said something. + * Letting the second decide the first is what silenced FROST signing: the + * broadcast rows were written inside `chatMessageOrNull?.let { }`, and protocol + * traffic has no ChatMessage -- `ChatMessage.applyInnerEvent` returns null for + * every [press.mantra.compose.nostr.frost.FrostSigningEvents] kind and for + * [press.mantra.compose.nostr.frost.GroupKeyStateEvent], and + * `FrostSigningManager` queues its messages without one. So every signing + * proposal was MLS-encrypted, wrapped, persisted and marked processed, and then + * never sent to a relay. Nothing failed; the other participants simply never saw + * it. + * + * Pulled out of `MarmotOutboundDao.encryptAndSendMarmotInnerEvent` because that + * function is Room-backed and cannot be unit tested, which is precisely how the + * gate survived. The decision is separated from the filing of it for the same + * reason [MarmotDirectMessage.classify] is. + */ +data class MarmotDelivery( + /** + * One request per relay, always. An empty list here means the event is on + * disk and going nowhere. + */ + val broadcasts: List, + /** + * The chat line this send belongs to, or null when the row is protocol + * traffic -- something the room did rather than something a member said. + */ + val transcriptChatMessageId: Long?, +) { + /** True when nothing in the chat points at this event, which is not a reason to withhold it. */ + val isProtocolTraffic: Boolean get() = transcriptChatMessageId == null + + companion object { + fun plan( + groupEventId: HexKey, + relays: Collection, + chatMessage: ChatMessage?, + ): MarmotDelivery = MarmotDelivery( + broadcasts = relays.map { relay -> + BroadcastNostrEventRequest( + nostrEventId = groupEventId, + relayURL = relay.url, + ) + }, + transcriptChatMessageId = chatMessage?.id, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt new file mode 100644 index 00000000..5df982dc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt @@ -0,0 +1,107 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.ChatMessage +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Whether an encrypted group event actually leaves the device. + * + * The bug this pins did not throw, log, or fail a build. `MarmotOutboundDao` + * wrote the broadcast rows inside `chatMessageOrNull?.let { }`, so an event with + * no chat line pointing at it was MLS-encrypted, wrapped, persisted, its queue + * row marked processed -- and never sent. FROST signing proposals and the room's + * key announcement are exactly that: traffic the room generates, which + * deliberately writes no ChatMessage of its own. Every proposal was silently + * delivered to nobody. + * + * The DAO around this is Room-backed and cannot be stood up here, which is how + * the gate survived unnoticed in the first place. So the decision is tested + * where it can be seen, and the DAO does nothing with it but write what it says. + */ +class MarmotDeliveryTest { + private val groupEventId = "a".repeat(64) + private val relays = Relays.DefaultDMRelayList + + private val chatLine = ChatMessage( + id = 42L, + senderPublicKey = "b".repeat(64), + isUserMessage = true, + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = "c".repeat(64), + chatRoomId = "room", + content = "the vote is at six", + ) + + @Test + fun `a signing proposal goes out, though nothing in the chat points at it`() { + // The regression. A FROST message has no ChatMessage by design -- the manager + // writes its own transcript lines from what arrives, so a row here would be a + // second, worse account of the same thing -- and that must not be the reason the + // group never hears about it. + val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null) + + assertTrue(delivery.isProtocolTraffic) + assertEquals(relays.size, delivery.broadcasts.size) + assertTrue(delivery.broadcasts.isNotEmpty(), "an event on disk and going nowhere") + } + + @Test + fun `the send does not depend on the transcript`() { + // Said as directly as it can be said: the two questions are independent. Anything + // that makes a broadcast conditional on a chat line fails here. + val protocol = MarmotDelivery.plan(groupEventId, relays, chatMessage = null) + val spoken = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine) + + assertContentEquals( + protocol.broadcasts.map { it.relayURL }, + spoken.broadcasts.map { it.relayURL }, + ) + assertEquals(protocol.broadcasts.size, spoken.broadcasts.size) + } + + @Test + fun `every relay gets a request, naming the event`() { + val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine) + + assertContentEquals( + relays.map { it.url }, + delivery.broadcasts.map { it.relayURL }, + ) + assertTrue(delivery.broadcasts.all { it.nostrEventId == groupEventId }) + } + + @Test + fun `requests are queued pending, which is all the broadcaster looks at`() { + // `observeBroadcastNostrEventRequestsByStatus("pending")` is the only thing that + // picks these up. A request written in any other state is as unsent as no request. + val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null) + + assertTrue(delivery.broadcasts.all { it.status == "pending" }) + } + + @Test + fun `a member's message is linked to its chat line`() { + // The bookkeeping that legitimately does depend on there being a chat line: the + // transcript needs to know which event carried the words, so a sent message can + // be shown as sent. + val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine) + + assertFalse(delivery.isProtocolTraffic) + assertEquals(chatLine.id, delivery.transcriptChatMessageId) + } + + @Test + fun `no relays is the only way an event stays home`() { + // Worth pinning as the single legitimate empty case, so an empty broadcast list + // is always read as "nowhere to send it" and never as "nothing to send". + val delivery = MarmotDelivery.plan(groupEventId, relays = emptyList(), chatMessage = chatLine) + + assertTrue(delivery.broadcasts.isEmpty()) + assertEquals(chatLine.id, delivery.transcriptChatMessageId) + } +} From e22a8ae4cdf9f48590fb8919d794c69faf0ba405 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 01:13:42 +0200 Subject: [PATCH 30/33] fix: stop asking a member to review a signature the group has settled The transcript's "Review" affordance is a promise: tapping it leads to a decision still there to be made. For a FROST signing proposal it was only ever withdrawn one way -- and a proposal can be processed three. **How a request was closed.** RitualNotice drops the tint and the call to action when the request is answered, and a request counts as answered when the step it asked for has since been published by this device: FROST_REQUEST_FULFILMENTS = mapOf(TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE) Approving publishes a nonce, so approving closes it. Nothing else does. **Declining.** decline() fails the session and broadcasts a FAILURE. It publishes nothing of the member's own, by design -- a refusal is a refusal. So no fulfilment line is ever written, and the request went on asking, in primary tint, for a decision the member had already made. Tapping it reached a screen with no buttons on it, which was the screen being right. **A quorum that did not need them.** A t-of-n key finishes without everybody. The coordinator takes the first t nonces, and a member whose phone was in a pocket is simply not among them -- but advance() returned at the approval gate on their device, so the arriving SIGNATURE was stored and nothing was done with it. Their session sat at COLLECTING_NONCES forever. The request stayed lit, the screen still offered Sign and Don't sign, and both answers were wrong: a nonce nobody was waiting for, or a refusal that would flip a COMPLETE session to FAILED on every device and announce "Nothing was signed" to a group holding the signature. fail() writes the stage with update() rather than moveTo(), so that last one was reachable. **The transcript.** A request is now closed by being *answered* or by being *settled* -- a frostComplete or frostFailed line after it. The two are kept apart deliberately. Answered keeps the tick; settled does not, because the member never answered and crediting them with a signature they refused, or were never asked for, is worse than the summons was. Both rules moved out of the composable onto ChatMessage, where they are stated once and tested. Settlement is signing-only: a ceremony step can only be taken or waited for, so a DKG request has no equivalent and reading one from a signing session's end would drop a summons the ritual is still stalled on. **The session.** The transcript alone could not close the third case: the device that never approved wrote no terminal line to read. advance() now completes on a signature that has already arrived, ahead of the approval gate rather than below it. That gate is there to keep this device's own material off the wire, and finishing puts none there -- it verifies the aggregate, applies the event and announces, all from what is already stored. Everything it now skips on that path is work the signature made pointless anyway: a late nonce, a partial signature nobody will aggregate. Three things follow. isAwaitingApproval reports false, so FrostSigningScreen hides the buttons -- it now asks the manager rather than re-deriving the rule, which had drifted into a second copy of it. A late "Don't sign" cannot abandon a signature that exists. And the signed event finally lands locally for a member who never approved: applySignedEvent sat below the gate and was being skipped, so a dialect the group signed without them never reached their store. Verified: :composeApp:compileDebugKotlinAndroid succeeds, and :composeApp:testDebugUnitTest passes -- 165 tests, 16 of them new. Eight cover the transcript rules against a hand-built row list; eight cover isAwaitingApproval, including the settled-signature case. What stays uncovered is advance() itself, which is Room-backed. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 58 ++++++++ .../compose/managers/FrostSigningManager.kt | 105 +++++++++----- .../ui/composable/FrostSigningScreen.kt | 9 +- .../ui/view/model/ChatMessageListViewModel.kt | 39 +++--- .../model/TranscriptRequestStateTest.kt | 131 ++++++++++++++++++ .../compose/managers/FrostSigningRoundTest.kt | 35 +++++ 6 files changed, 313 insertions(+), 64 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 00104db4..ddb88a63 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -241,6 +241,21 @@ data class ChatMessage( /** The signing lines that ask rather than report. */ val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED) + /** + * The signing lines that end a session, whichever way it went. + * + * A request usually stops being one by being answered, but that is not + * the only way. A member who declines publishes nothing, and a quorum + * that signs without them asks them for nothing further -- from the + * transcript both look the same, nothing of the reader's own either + * side of the request, so the session's own ending is the only thing + * left to read it from. + * + * A ceremony has no equivalent because a ceremony step can only be + * taken or waited for. Signing is the one thing a member can refuse. + */ + val FROST_SETTLEMENTS = setOf(TYPE_FROST_COMPLETE, TYPE_FROST_FAILED) + /** Every signing line, for rendering them as system lines rather than bubbles. */ val FROST_TYPES = setOf( TYPE_FROST_STARTED, @@ -278,6 +293,49 @@ data class ChatMessage( TYPE_DKG_FAILED, ) + /** + * The request lines this device has since answered, by row id. + * + * A request is answered when the step it asked for has been published by + * this device -- which is exactly what approving it does. The transcript + * already records that as an authored line, so the answer is read from + * the list rather than from the session, and it stays right for a room + * that has run more than one ceremony. + */ + fun answeredRequests(messages: List): Set = messages + .mapNotNull { request -> + val published = (DKG_REQUEST_FULFILMENTS + FROST_REQUEST_FULFILMENTS)[request.messageType] + ?: return@mapNotNull null + + val done = messages.any { + it.messageType == published && + it.isUserMessage && + it.createdAt >= request.createdAt + } + + request.id.takeIf { done } + } + .toSet() + + /** + * The signing requests that are no longer open, by row id. + * + * Not the same thing as [answeredRequests], and deliberately kept apart + * from it: these are requests the reader never answered, so they have + * earned no tick and claiming otherwise would credit them with a + * signature they refused or were never needed for. What they have run + * out of is a decision to make -- see [FROST_SETTLEMENTS]. + */ + fun settledRequests(messages: List): Set = messages + .filter { request -> + request.messageType in FROST_REQUEST_TYPES && + messages.any { + it.messageType in FROST_SETTLEMENTS && it.createdAt >= request.createdAt + } + } + .map { it.id } + .toSet() + /** * Files one direct message, from whichever side of it this device is on. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 5fbdbbaf..09a7f085 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -464,6 +464,19 @@ object FrostSigningManager { ?: return try { + // A signature the group has already made settles this session whether + // or not its owner ever answered: a t-of-n key does not need everybody, + // so a quorum can finish while one member's phone is still in a pocket. + // Ahead of the gate below because that gate is about keeping this + // device's own material off the wire, and finishing puts none of it + // there -- while going on to ask would be asking for a decision that + // can no longer change anything, and would let a late "don't sign" + // abandon a signature that exists. + if (session.signature != null) { + complete(database, session) + return + } + // Nothing of this device's own goes out before its owner has said so. // Returns rather than throws: the session is not failing, it is waiting // on a person, and everything received stays stored so it resumes the @@ -580,43 +593,7 @@ object FrostSigningManager { announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey) } - val signature = session.signature ?: return - - // The payoff: a signature that verifies is one the group made, whoever - // relayed it. Checking rather than trusting is what keeps a faulty or - // dishonest coordinator from passing off something that will be - // rejected by every relay it reaches. - val signedEvent = signedEvent(session, signature) - val verified = Nip01Crypto.verify( - signature = signature.hexToByteArray(), - hash = session.eventId.hexToByteArray(), - pubKey = signedEvent.pubKey.hexToByteArray() - ) - if (!verified) { - throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}") - } - - update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } - - // A signature exists to be used. Every device has the event and the - // signature by now, so each applies the result itself rather than - // waiting to be sent something it can already build -- the same - // reasoning the transcript lines are written on. Nothing goes on the - // wire: a signed event authored by the threshold key cannot travel as - // an inner event anyway, because the outbound pipeline re-authors - // rumors as their sender and would strip the group's signature off. - applySignedEvent(database, session, signedEvent) - - announce( - database = database, - session = session, - messageType = ChatMessage.TYPE_FROST_COMPLETE, - content = "The group signed the event. It took ${session.threshold} of " + - "${session.participantCount} members.", - actor = session.coordinatorPublicKey - ) - - logger.i("Signing session $sessionId complete") + complete(database, session) } catch (e: CancellationException) { // The sync was torn down mid-step, which says nothing about the session. throw e @@ -633,6 +610,54 @@ object FrostSigningManager { } } + /** + * Verifies the group's signature, uses the event, and closes the session. + * + * The payoff: a signature that verifies is one the group made, whoever + * relayed it. Checking rather than trusting is what keeps a faulty or + * dishonest coordinator from passing off something that will be rejected by + * every relay it reaches. + * + * Reached only with the session still open -- [advance] returns above this + * on a settled one -- so the milestone is written once by construction, + * like every other line in the transcript. + */ + private suspend fun complete(database: MantraDatabase, session: FrostSigningSession) { + val signature = session.signature ?: return + + val signedEvent = signedEvent(session, signature) + val verified = Nip01Crypto.verify( + signature = signature.hexToByteArray(), + hash = session.eventId.hexToByteArray(), + pubKey = signedEvent.pubKey.hexToByteArray() + ) + if (!verified) { + throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}") + } + + update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } + + // A signature exists to be used. Every device has the event and the + // signature by now, so each applies the result itself rather than + // waiting to be sent something it can already build -- the same + // reasoning the transcript lines are written on. Nothing goes on the + // wire: a signed event authored by the threshold key cannot travel as + // an inner event anyway, because the outbound pipeline re-authors + // rumors as their sender and would strip the group's signature off. + applySignedEvent(database, session, signedEvent) + + announce( + database = database, + session = session, + messageType = ChatMessage.TYPE_FROST_COMPLETE, + content = "The group signed the event. It took ${session.threshold} of " + + "${session.participantCount} members.", + actor = session.coordinatorPublicKey + ) + + logger.i("Signing session ${session.id} complete") + } + /** * Turns the signed event into whatever it is: a dialect, an artifact, a * chapter. @@ -974,6 +999,12 @@ object FrostSigningManager { return false } + // The group signed it without needing this member, and [advance] closes + // the session on its next pass without asking them anything. Offering the + // decision anyway would be offering two bad answers: a nonce nobody is + // waiting for, or a refusal that abandons a signature already made. + if (session.signature != null) return false + return session.signApprovedAt == null } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 38282f75..a495ad9c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -48,6 +48,7 @@ import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent @@ -223,10 +224,10 @@ fun FrostSigningScreen( } } - if (session.signApprovedAt == null && - session.stage != FrostSigningStage.COMPLETE && - session.stage != FrostSigningStage.FAILED - ) { + // Asked rather than re-derived: the manager owns when a session + // is still waiting on its owner, and a second copy of that rule + // here is a second copy to keep in step. + if (FrostSigningManager.isAwaitingApproval(session)) { HorizontalDivider() Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 2a9c0b93..4ba70a91 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -283,30 +283,17 @@ class ChatMessageListViewModel( ) } else { - // A request line is answered when the step it asked for - // has since been published by this device -- which is - // exactly what approving it does. The transcript already - // records that as an authored line, so the answer is here - // in the list rather than in the session, and it stays - // right for a room that has run more than one ceremony. - val answeredRequests = chatRoomDetailMessageListUIState + // Which request lines are still asking something of the + // reader. Read off the transcript rather than the session + // -- the rows are what a line rendered days later has -- + // and both rules live on ChatMessage, where they can be + // stated once and tested. + val messages = chatRoomDetailMessageListUIState .chatMessageList - .mapNotNull { request -> - val published = ( - ChatMessage.DKG_REQUEST_FULFILMENTS + - ChatMessage.FROST_REQUEST_FULFILMENTS - )[request.chatMessage.messageType] - ?: return@mapNotNull null + .map { it.chatMessage } - val done = chatRoomDetailMessageListUIState.chatMessageList.any { - it.chatMessage.messageType == published && - it.chatMessage.isUserMessage && - it.chatMessage.createdAt >= request.chatMessage.createdAt - } - - request.chatMessage.id.takeIf { done } - } - .toSet() + val answeredRequests = ChatMessage.answeredRequests(messages) + val settledRequests = ChatMessage.settledRequests(messages) LazyColumn( modifier = Modifier.fillMaxWidth().padding(5.dp), @@ -366,6 +353,7 @@ class ChatMessageListViewModel( RitualNotice( localChatMessage = localChatMessage, isAnswered = localChatMessage.chatMessage.id in answeredRequests, + isSettled = localChatMessage.chatMessage.id in settledRequests, onClick = onOpenSharedKey ) return@items @@ -394,6 +382,7 @@ class ChatMessageListViewModel( RitualNotice( localChatMessage = localChatMessage, isAnswered = localChatMessage.chatMessage.id in answeredRequests, + isSettled = localChatMessage.chatMessage.id in settledRequests, onClick = onOpenSigning ) return@items @@ -663,6 +652,7 @@ private fun PrivateMessageNotice( private fun RitualNotice( localChatMessage: LocalChatMessage, isAnswered: Boolean, + isSettled: Boolean, onClick: () -> Unit, ) { val chatMessage = localChatMessage.chatMessage @@ -704,10 +694,13 @@ private fun RitualNotice( // quiet; these are not. // An answered request is history, not a summons: it keeps its stage's icon so // the step is still recognisable, but drops the colour and the call to action. + // So is a settled one -- declined, or signed by a quorum that did not need this + // member. Nothing was answered there, so it gets no tick, but offering to + // review it would be offering a decision that has already gone by. val isRequest = ( chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES || chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES - ) && !isAnswered + ) && !isAnswered && !isSettled val tint = when { chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt new file mode 100644 index 00000000..4086c3d1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt @@ -0,0 +1,131 @@ +package press.mantra.compose.database.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * When a request line in a transcript stops asking for something. + * + * The transcript renders a request with a tint and a "Review" affordance, and + * that is a promise: tapping it leads to a decision still there to be made. + * Keeping the promise means knowing when the decision has gone, and the rows are + * all there is to know it from -- a line rendered days later has no session to + * ask, and the room may have signed several things since. + * + * Two ways for a request to be over, and they are not the same. Answering it + * leaves a line of the reader's own and earns the tick. A session ending + * underneath it leaves nothing of theirs at all: a member who declined published + * nothing, and a quorum that signed without them wanted nothing. Both must drop + * the summons; neither may claim the member signed. + */ +class TranscriptRequestStateTest { + private val user = "u".repeat(64) + private val other = "o".repeat(64) + + private var lastId = 0L + + /** One transcript row, with only the four fields either rule reads. */ + private fun line( + type: String, + at: Long, + sender: String = user + ) = ChatMessage( + id = ++lastId, + senderPublicKey = sender, + isUserMessage = sender == user, + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = null, + chatRoomId = "room", + content = "", + messageType = type, + createdAt = Instant.fromEpochSeconds(at) + ) + + @Test + fun `a signing request nobody has acted on is still asking`() { + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(line(ChatMessage.TYPE_FROST_STARTED, at = 9, sender = other), request) + + assertEquals(emptySet(), ChatMessage.answeredRequests(transcript)) + assertEquals(emptySet(), ChatMessage.settledRequests(transcript)) + } + + @Test + fun `publishing the nonce answers the request that asked for it`() { + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11)) + + assertEquals(setOf(request.id), ChatMessage.answeredRequests(transcript)) + } + + @Test + fun `somebody else's nonce answers nothing`() { + // The fulfilment has to be this device's own: a transcript is full of other + // members taking the step this reader has yet to take. + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11, sender = other)) + + assertEquals(emptySet(), ChatMessage.answeredRequests(transcript)) + } + + @Test + fun `declining settles the request without claiming it was signed`() { + // Declining publishes nothing, so there is no fulfilment to find. The + // failure the refusal writes is the only trace, and it has to be enough -- + // otherwise the line goes on offering a decision already made. + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(request, line(ChatMessage.TYPE_FROST_FAILED, at = 11)) + + assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript)) + assertEquals(emptySet(), ChatMessage.answeredRequests(transcript)) + } + + @Test + fun `a group that signs without this member settles their request`() { + // A t-of-n key does not need everybody. Nothing of this member's is in the + // signature and nothing of theirs was ever published, so answered stays + // empty -- but there is no longer anything for them to decide. + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(request, line(ChatMessage.TYPE_FROST_COMPLETE, at = 12, sender = other)) + + assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript)) + assertEquals(emptySet(), ChatMessage.answeredRequests(transcript)) + } + + @Test + fun `an earlier session's ending does not close a later request`() { + // Rooms sign more than once, and the previous session's last line sits + // above this one's first. + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 20) + val transcript = listOf(line(ChatMessage.TYPE_FROST_COMPLETE, at = 9, sender = other), request) + + assertEquals(emptySet(), ChatMessage.settledRequests(transcript)) + } + + @Test + fun `a ceremony step is settled by nothing`() { + // Signing is the one thing a member can refuse, so it is the only place a + // request can be over without them having answered it. A ceremony step is + // either taken or still waited on, and reading either ending as the end of + // one would drop a summons the ritual is still stalled on. + val request = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 10) + val transcript = listOf( + request, + line(ChatMessage.TYPE_FROST_FAILED, at = 11), + line(ChatMessage.TYPE_DKG_FAILED, at = 12, sender = other) + ) + + assertEquals(emptySet(), ChatMessage.settledRequests(transcript)) + } + + @Test + fun `each ceremony step is answered only by its own`() { + val hostKey = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY, at = 10) + val roundOne = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 12) + val transcript = listOf(hostKey, line(ChatMessage.TYPE_DKG_HOST_KEY, at = 11), roundOne) + + assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 0d4fc4c5..391c5998 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -14,11 +14,13 @@ import fr.acinq.bitcoin.crypto.frost.Session import fr.acinq.bitcoin.crypto.frost.TweakCache import fr.acinq.secp256k1.Hex import kotlin.test.Test +import kotlin.time.Instant import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.frost.FrostSigningEvents @@ -217,6 +219,39 @@ class FrostSigningSessionTest { signerIds = signerIds ) + @Test + fun `a session waits on its owner until they answer`() { + val open = session(signerId = 2, signerIds = null) + + assertTrue(FrostSigningManager.isAwaitingApproval(open)) + assertFalse( + FrostSigningManager.isAwaitingApproval( + open.copy(signApprovedAt = Instant.fromEpochSeconds(1)) + ) + ) + } + + @Test + fun `a session that has settled asks its owner nothing`() { + val open = session(signerId = 2, signerIds = null) + + assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE))) + assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED))) + } + + @Test + fun `a signature the group already made asks its owner nothing either`() { + // A t-of-n key does not need everybody, so a quorum can finish while one + // member's phone is still in a pocket. The session stays at its opening + // stage on their device until it next advances, and offering them the + // decision in that window offers two bad answers: a nonce nobody is + // waiting for, or a refusal that abandons a signature that exists. + val signedWithoutThem = session(signerId = 2, signerIds = "0,1") + .copy(signature = "a".repeat(128)) + + assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem)) + } + @Test fun `a member left out of the signer set is not a signer`() { assertTrue(session(signerId = 1, signerIds = "0,1").isSigner()) From 024da9940434fdc1c201553e7982342a1198de78 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 01:26:52 +0200 Subject: [PATCH 31/33] test: pin what a member who never took part needs to finish a session e22a8ae hoisted completion above the approval gate on the strength of one claim: closing a session needs nothing secret, and nothing the member would have had to publish. Were that false -- were the aggregated nonce, the signer set or a share needed to check the result -- the gate would have to stay where it was, and the member a quorum did not need would go on being asked to sign something already signed. Nothing checked the claim. FrostSigningCompletionTest builds a real 2-of-3 signature from members 0 and 1, then works entirely from member 2's row: never approved, not in the signer set, aggregatedNonce and signerIds deliberately null. From that alone it pins that they can verify what the group signed, that the finished event is the proposed one unaltered rather than rebuilt or rehashed, that there is no finished event before the signature arrives, that the arrived signature is what stops the session asking, and that a signature over a different event is refused -- which is what the check in complete() is for. Both new assertions about isAwaitingApproval were mutation-checked: with the `signature != null` guard removed, exactly two tests fail and the rest of the suite still passes, so they guard the change rather than restating it. Not covered, and not coverable here: advance() itself -- that the branch fires on an inbound SIGNATURE rather than stopping at the gate. It is Room-backed, and this project has no harness for that (no Robolectric, and the in-memory builder's android actual needs a Context). The pure half of the claim is what this pins instead. Also corrects e22a8ae's message, which said sixteen new tests. It was eleven: eight in TranscriptRequestStateTest and three added to FrostSigningSessionTest, which has eight in total. Verified: :composeApp:compileDebugKotlinAndroid succeeds, and :composeApp:testDebugUnitTest passes -- 176 tests across 24 classes. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningRoundTest.kt | 175 +++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 391c5998..ca9d3690 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -1,5 +1,6 @@ package press.mantra.compose.managers +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -14,10 +15,10 @@ import fr.acinq.bitcoin.crypto.frost.Session import fr.acinq.bitcoin.crypto.frost.TweakCache import fr.acinq.secp256k1.Hex import kotlin.test.Test -import kotlin.time.Instant import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Instant import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.types.FrostSigningStage @@ -311,3 +312,175 @@ class FrostSigningSessionTest { ) } } + +/** + * What a device needs on its row to finish a session it never took part in. + * + * `FrostSigningManager.advance` completes on an arrived signature ahead of the + * approval gate, and that hoist rests on one claim: closing a session needs + * nothing secret and nothing the member would have had to publish. Were it + * false -- were the aggregated nonce, the signer set or a share needed to check + * the result -- the gate would have to stay where it was, and a member the + * quorum did not need would be stuck being asked to sign something already + * signed. + * + * So the claim is spelled out here against a real 2-of-3 signature, from the + * row of the member who was left out of it. + */ +class FrostSigningCompletionTest { + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("2decade0000000000000000000000000000000000000000000000000000000b2") + ), + nParticipants = 3, + threshold = 2 + ) + + private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey) + + /** The group's nostr identity, exactly as `unsignedEventOf` derives it. */ + private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex() + + /** The event the group is asked to sign, built the way the manager builds it. */ + private val unsignedEvent = Event( + id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(), + content = "a dialect the group agreed on" + ), + pubKey = groupPubKey, + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(), + content = "a dialect the group agreed on", + sig = "" + ) + + /** A real signature from members 0 and 1. Member 2 is not in it and never was. */ + private val signature: String = run { + val message = ByteVector(unsignedEvent.id.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("c".repeat(63) + "${signerId + 1}"), + secretShare = keyMaterial.secretShares[signerId], + publicShare = keyMaterial.publicShares[signerId], + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val session = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { keyMaterial.publicShares[it] }, + nParticipants = 3, + threshold = 2, + tweakCache = tweakCache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!! + } + + session.aggregateSigs(partials).right!!.toHex() + } + + /** + * Member 2's row, as it stands when the signature reaches them: they never + * approved, so nothing of theirs was ever published, and the coordinator + * never named them. Every column the completion path reads is here; the ones + * it must not need are deliberately left null. + */ + private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession( + id = "s".repeat(64), + chatRoomId = "room", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + dkgSessionId = "k".repeat(64), + threshold = 2, + participantCount = 3, + signerId = 2, + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + nonceRandom = "f".repeat(64), + aggregatedNonce = null, + signerIds = null, + signature = signature, + signApprovedAt = null + ) + + @Test + fun `a member who never took part can still check what the group signed`() { + val session = leftOutMemberSession(signature) + val signed = FrostSigningManager.signedEvent(session)!! + + assertTrue( + Nip01Crypto.verify( + signature = signed.sig.hexToByteArray(), + hash = session.eventId.hexToByteArray(), + pubKey = signed.pubKey.hexToByteArray() + ), + "completing must need only the row: the event, its id and the signature" + ) + } + + @Test + fun `the finished event is the one that was proposed, with a signature on it`() { + // Not rebuilt and not rehashed: the id a session is pinned to is the id + // the signature is over, so anything that changed here would produce an + // event whose signature verifies against nothing. + val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!! + + assertEquals(unsignedEvent.id, signed.id) + assertEquals(unsignedEvent.pubKey, signed.pubKey) + assertEquals(unsignedEvent.createdAt, signed.createdAt) + assertEquals(unsignedEvent.kind, signed.kind) + assertEquals(unsignedEvent.content, signed.content) + assertEquals(signature, signed.sig) + } + + @Test + fun `there is no finished event until the signature arrives`() { + assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession())) + } + + @Test + fun `the arrived signature is what stops the session asking`() { + // The pair that matters to the screen and the transcript: the same row, + // before and after the group finished without this member. + assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession())) + assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature))) + } + + @Test + fun `a signature over a different event is refused`() { + // What the check is for. A coordinator passing off something else must not + // get it applied and announced as the group's, and the row is all there is + // to catch it with. + val other = leftOutMemberSession(signature).copy( + eventId = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(), + content = "something else entirely" + ) + ) + + val signed = FrostSigningManager.signedEvent(other)!! + + assertFalse( + Nip01Crypto.verify( + signature = signed.sig.hexToByteArray(), + hash = other.eventId.hexToByteArray(), + pubKey = signed.pubKey.hexToByteArray() + ) + ) + } +} From 786c0602daec147f4be6e54c64b8d43706a53353 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 01:37:58 +0200 Subject: [PATCH 32/33] feat: sign an artifact into the library instead of submitting one Adding an artifact no longer creates one. It opens a signing session over an ArtifactEvent, and the artifact appears -- on every member's device at once, authored by the group's shared key rather than by whoever typed it -- when enough members have signed. The same trade the dialects made: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social, while a signature is the group saying it and it takes a quorum to say. A library is the group's. **The first version.** This is the part the dialect had no answer for. An artifact was creating an initial ArtifactVersion as a second submitted event, and that cannot survive the change: a chapter attaches to a version rather than to an artifact, so an artifact without one is inert, but a version cannot be submitted before the artifact it points at exists, cannot have its own quorum without costing a second signing session per form, and cannot be invented locally -- an invented id differs on every device, so members would silently disagree about which version a chapter hangs off while every screen showed the same artifact. So the label rides on the artifact as an `artifactVersion` tag and the row is derived from the signed artifact's own fields when it is applied. Same bytes in, same row out, everywhere. It is a rumor, because nobody signed it; what the group signed is the artifact that declares it. **What went away.** MantraDao.addArtifact and its way up through the repository. Nothing called it once the screen proposed instead, and leaving a path that authors an artifact under a member's key while the UI insists on a quorum would have double-created the version besides. **Tests.** Three files, and each was checked against a broken implementation rather than only against a working one: deriving the version from the clock, dropping the label from the proposal, authoring the derived row as its reader, and losing the signature on the way out of the session are all caught. SignedArtifactTest runs a real 2-of-3 quorum over an actual proposal, because the claim worth holding -- the row is the group's, and carries proof of it -- is invisible when it breaks. Not covered: applyInnerEvent's two upserts, which need a database no test here stands up, and AddArtifactViewModel, which is plumbing across two dispatchers over a template the tests already pin. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/MantraDao.kt | 58 ----- .../compose/database/model/ChatMessage.kt | 35 ++- .../database/model/MantraArtifactVersion.kt | 37 +++ .../repository/DatabaseMantraRepository.kt | 22 -- .../compose/nostr/nip30303/ArtifactEvent.kt | 14 ++ .../compose/repository/MantraRepository.kt | 22 -- .../ui/composable/AddArtifactScreen.kt | 48 ++-- .../ui/composable/FrostSigningScreen.kt | 10 +- .../ui/composable/navigation/MantraNavHost.kt | 7 +- .../ui/view/model/AddArtifactViewModel.kt | 117 +++++---- .../ui/view/state/AddArtifactUIState.kt | 8 +- .../model/InitialArtifactVersionTest.kt | 144 +++++++++++ .../database/model/RumorIdAgreementTest.kt | 3 +- .../compose/managers/SignedArtifactTest.kt | 227 ++++++++++++++++++ .../nostr/nip30303/ArtifactEventTest.kt | 118 +++++++++ 15 files changed, 695 insertions(+), 175 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt index ac55e82e..bd8f5f82 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt @@ -9,7 +9,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.ChatMessage -import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk @@ -19,7 +18,6 @@ import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent @@ -156,62 +154,6 @@ abstract class MantraDao( } - @Transaction - open suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String = DEFAULT_VISIBILITY, - license: String = DEFAULT_LICENSE, - ): MantraArtifact? { - // TODO: Verify the active user is an admin of the chat room before allowing this. - val artifactEventTemplate = ArtifactEvent.build( - name = name, - url = url, - visibility = visibility, - license = license, - dialectId = dialectId, - ) - - val mantraArtifact = MantraArtifact.fromArtifactEventTemplate( - artifactEventTemplate = artifactEventTemplate, - chatRoomId = localChatRoom.chatRoom.id, - userPublicKey = userPublicKey, - ) ?: return null - - // Persist the artifact locally and submit it to the group. - // - // This covers the "private" visibility case. Permissioned artifacts - // (published as a PublicMessage) and public artifacts (published as a - // plain nostr event) are not implemented yet. - return try { - database.mantraArtifactDao().upsert(mantraArtifact) - - submitToGroup( - chatRoomId = mantraArtifact.chatRoomId, - submitterPublicKey = userPublicKey, - payload = rumorOf(artifactEventTemplate, userPublicKey), - text = "Added $name to artifacts", - ) - - // Every artifact starts with an initial version. - addArtifactVersionInternal( - localChatRoom = localChatRoom, - artifactId = mantraArtifact.id, - versionLabel = versionLabel, - userPublicKey = userPublicKey, - ) - - mantraArtifact - } catch (error: Throwable) { - logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error) - null - } - } - @Transaction open suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 00104db4..e250ab80 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -602,15 +602,17 @@ data class ChatMessage( ) } ArtifactEvent.KIND -> { + val artifactEvent = ArtifactEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + MantraArtifact.fromArtifactEvent( - artifactEvent = ArtifactEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig, - ), + artifactEvent = artifactEvent, chatRoomId = groupId, )?.let { mantraArtifact -> database.mantraArtifactDao().upsert( @@ -619,6 +621,23 @@ data class ChatMessage( ) ) + // An artifact arrives with the version it starts life + // with, derived here rather than sent, so that every + // device holding the artifact holds the same first + // version. Nothing else can hang off an artifact until + // one exists -- a chapter attaches to a version, not to + // an artifact -- so an artifact without one is inert. + MantraArtifactVersion.initialVersionOf( + artifactEvent = artifactEvent, + chatRoomId = groupId, + )?.let { initialVersion -> + database.mantraArtifactVersionDao().upsert( + initialVersion.copy( + marmotGroupEventId = marmotGroupEventId, + ) + ) + } + ChatMessage( giftWrapPayloadId = null, messageType = "artifact", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index 1e7435ef..6cd5c209 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -10,6 +10,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag @@ -83,6 +84,42 @@ data class MantraArtifactVersion( } companion object { + /** + * The version an artifact starts life with, derived from the artifact. + * + * The group signs an artifact; it does not sign this. So the first + * version cannot be an event proposed on its own -- that would cost a + * second quorum for one form -- and it cannot be invented by whichever + * device notices the artifact first, because an invented id differs on + * every device holding the same artifact and none of them would agree + * about which version a chapter hangs off. Deriving it from the signed + * artifact's own fields gives every device the same row from the same + * bytes, which is the only property that matters here. + * + * It is a rumor -- empty signature -- because nobody signed it. What the + * group signed is the artifact that declares it. + * + * Null when the artifact declares no version, which is every artifact + * written before it did. + */ + fun initialVersionOf( + artifactEvent: ArtifactEvent, + chatRoomId: HexKey, + ): MantraArtifactVersion? { + val versionLabel = artifactEvent.versionLabel() ?: return null + + return fromArtifactVersionEventTemplate( + artifactVersionEventTemplate = ArtifactVersionEvent.build( + content = versionLabel, + createdAt = artifactEvent.createdAt, + ) { + addUnique(ArtifactIdTag.assemble(artifactEvent.id)) + }, + chatRoomId = chatRoomId, + userPublicKey = artifactEvent.pubKey, + ) + } + fun fromArtifactVersionEventTemplate( artifactVersionEventTemplate: EventTemplate, chatRoomId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index eb573bcf..26b4ddf6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -131,28 +131,6 @@ class DatabaseMantraRepository( ) } - override suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String, - license: String, - ): MantraArtifact? { - return database.mantraDao().addArtifact( - localChatRoom = localChatRoom, - name = name, - url = url, - versionLabel = versionLabel, - dialectId = dialectId, - userPublicKey = userPublicKey, - visibility = visibility, - license = license - ) - } - override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt index 6d0ebb96..1aaa6558 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt @@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag import press.mantra.compose.nostr.nip30303.tags.DialectIdTag import press.mantra.compose.nostr.nip30303.tags.LicenseTag import press.mantra.compose.nostr.nip30303.tags.UrlTag @@ -31,6 +32,17 @@ class ArtifactEvent( fun dialectIdReference() = tags.firstNotNullOfOrNull(DialectIdTag::parse)?.ref fun dialectId() = dialectIdReference()?.eventId + /** + * The label of the version this artifact starts life with. + * + * Carried on the artifact rather than in an event of its own because the + * group signs the artifact and nothing else. A first version proposed + * separately would need either a second quorum for one form or an id each + * device invents for itself, and invented ids differ on every device holding + * the same artifact. Null on artifacts written before this was declared. + */ + fun versionLabel() = tags.firstNotNullOfOrNull(ArtifactVersionMetadataTag::parse)?.versionLabel + companion object { const val KIND = 30300 const val ALT_DESCRIPTION = "Artifact" @@ -41,6 +53,7 @@ class ArtifactEvent( visibility: String, license: String, dialectId: String, + versionLabel: String, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, name, createdAt) { @@ -51,6 +64,7 @@ class ArtifactEvent( addUnique( DialectIdTag.assemble(dialectId) ) + addUnique(ArtifactVersionMetadataTag.assemble(versionLabel)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 9d0b5d88..5605c335 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -92,17 +92,6 @@ interface MantraRepository { userPublicKey: HexKey, ): MantraDialect? - suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String = DEFAULT_VISIBILITY, - license: String = DEFAULT_LICENSE, - ): MantraArtifact? - suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, @@ -176,17 +165,6 @@ interface MantraRepository { userPublicKey: HexKey, ): MantraDialect? = null - override suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String, - license: String, - ): MantraArtifact? = null - override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 5d451f8d..10dc902c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route @@ -62,7 +63,7 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.repository.MantraRepository -import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.view.model.AddArtifactViewModel import press.mantra.compose.ui.view.state.AddArtifactUIState @@ -77,6 +78,7 @@ fun AddArtifactScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, ) { @@ -88,7 +90,8 @@ fun AddArtifactScreen( nostrRepository = nostrRepository, chatRepository = chatRepository, activeUserPublicKey = activeUserPublicKey, - mantraRepository = mantraRepository + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository ) ) @@ -115,11 +118,12 @@ fun AddArtifactScreen( // one is picked; dialects are defined from the group detail screen. var selectedDialectId: String? by remember { mutableStateOf(null) } - // Of the required fields this is the only one the screen cannot ask - // for again: a dialect has to already exist, and nothing here can - // create one. So an unpicked dialect is a dead end rather than - // something to submit and be told about, and the button says so. - val canAddArtifact = selectedDialectId != null + // The two things the form cannot ask for again. A dialect has to + // already exist and nothing here can create one; a shared key has to + // have been ceremonied and this is not where that happens. Either + // missing is a dead end rather than something to propose and be told + // about afterwards, and the button says so. + val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign // M3 gives a FAB no `enabled`, so borrow the disabled colours every // other button in the app uses rather than inventing a shade here. @@ -168,15 +172,18 @@ fun AddArtifactScreen( urlField = urlFieldState, versionLabelField = versionLabelFieldState, dialectId = selectedDialectId, - onSuccess = { artifactId -> - // Open the newly created artifact, removing this - // add screen from the back stack. + onSuccess = { sessionId -> + // Onto the session rather than to the + // artifact. Nothing has been created yet + // -- the artifact appears when enough + // members sign -- so a detail screen for + // a row that does not exist would read as + // a failure. onNavigateToRouteAndPopUpInclusive.invoke( - ArtifactDetailRoute( + FrostSigningRoute( activeUserPublicKey = activeUserPublicKey, - artifactId = artifactId, chatRoomId = chatRoomId, - relayHint = relayHint + sessionId = sessionId ) ) }, @@ -191,9 +198,9 @@ fun AddArtifactScreen( ) { Icon( Icons.Default.Add, - contentDescription = "Add artifact" + contentDescription = "Propose artifact" ) - Text("Add Artifact") + Text("Propose Artifact") } } ) @@ -209,6 +216,16 @@ fun AddArtifactScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text("Add Artifact to the group library") + + if (!addArtifactUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign an " + + "artifact into its library. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + OutlinedTextField( modifier = Modifier.fillMaxWidth() .background(BottomAppBarDefaults.containerColor), @@ -385,6 +402,7 @@ private fun AddArtifactScreenPreview() { nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 38282f75..0ee8f15f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -304,7 +304,15 @@ private fun WhatIsBeingSigned(event: Event?) { .joinToString(" · ") } - ArtifactEvent.KIND -> "New artifact" to event.content + ArtifactEvent.KIND -> "New artifact" to ArtifactEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { artifact -> + // The url is the substance of an artifact -- signing one is putting + // the group's name to what it points at -- so it goes next to the + // name rather than being left for the detail screen afterwards. + listOfNotNull(event.content, artifact.versionLabel(), artifact.url()) + .joinToString(" · ") + } ChapterEvent.KIND -> "New chapter" to ChapterEvent( event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index cc29ee3e..9332b751 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -830,9 +830,12 @@ fun MantraNavHost( nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, mantraRepository = databaseMantraRepository, - onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. navController.navigate( - route = chatRoomDetailRoute + route = signingRoute ) { popUpTo(route) { inclusive = true diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index 98a02974..582698f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.view.state.AddArtifactUIState @@ -30,6 +32,7 @@ class AddArtifactViewModel( val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var addArtifactUIState: AddArtifactUIState by mutableStateOf(initialAddArtifactUIState) @@ -52,11 +55,25 @@ class AddArtifactViewModel( AddArtifactUIState.Loaded( localChatRoom = localChatRoom, dialects = mantraRepository.getDialects(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), ) } } } + /** + * Asks the group to sign an artifact into its library. + * + * The artifact is not created here and does not exist yet. What goes out is + * a proposal to sign it, and the artifact appears -- on every member's + * device at once, authored by the group's shared key rather than by whoever + * typed it -- when enough members have signed. + * + * That is the difference from submitting one. A submission says "I am + * putting this in front of the group" and the group's only recourse + * afterwards is social. A signature is the group saying it, and it takes a + * quorum to say. A library is the group's, so the second is the honest one. + */ fun addArtifact( localChatRoom: LocalChatRoom, nameField: TextFieldState, @@ -65,57 +82,65 @@ class AddArtifactViewModel( dialectId: HexKey?, visibility: String = MantraRepository.DEFAULT_VISIBILITY, license: String = MantraRepository.DEFAULT_LICENSE, - onSuccess: (artifactId: String) -> Unit, + onSuccess: (sessionId: String) -> Unit, onFailure: () -> Unit ) { - localChatRoom.chatRoom.toMlsGroup()?.let { mlsGroup -> - val name = nameField.text.toString() - val url = urlField.text.toString() - val versionLabel = versionLabelField.text.toString() + val name = nameField.text.toString() + val url = urlField.text.toString() + val versionLabel = versionLabelField.text.toString() - // addArtifact requires an existing source dialect; they are defined - // from the group detail screen, not here. - if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) { - onFailure.invoke() - return - } + // An artifact requires an existing source dialect; they are signed into + // existence from the group detail screen, not here. + if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) { + onFailure.invoke() + return + } - // Guard against double submits from repeated FAB taps. - if (isActionPending.value) return - isActionPending.value = true + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true - viewModelScope.launch(Dispatchers.IO) { - val artifact = runCatching { - mantraRepository.addArtifact( - localChatRoom = localChatRoom, - name = name, - url = url, - versionLabel = versionLabel, - dialectId = dialectId, - userPublicKey = activeUserPublicKey, - visibility = visibility, - license = license, - ) - }.onFailure { error -> - logger.e("Failed to add artifact", error) - }.getOrNull() + viewModelScope.launch(Dispatchers.IO) { + // The version label rides on the artifact rather than following it as + // a second event. The group signs the artifact; a first version + // proposed on its own would cost a second quorum for one form, and + // every device derives the same first version from what was signed. + val artifactEventTemplate = ArtifactEvent.build( + name = name, + url = url, + visibility = visibility, + license = license, + dialectId = dialectId, + versionLabel = versionLabel, + ) - if (artifact != null) { - nameField.clearText() - urlField.clearText() - versionLabelField.clearText() + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = artifactEventTemplate.kind, + tags = artifactEventTemplate.tags, + content = artifactEventTemplate.content, + ) + }.onFailure { error -> + logger.e("Failed to propose an artifact for signing", error) + }.getOrNull() - viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke(artifact.id) - } - } else { - viewModelScope.launch(Dispatchers.Main) { - onFailure.invoke() - } + if (session != null) { + nameField.clearText() + urlField.clearText() + versionLabelField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(session.id) + } + } else { + viewModelScope.launch(Dispatchers.Main) { + onFailure.invoke() } - - isActionPending.value = false } + + isActionPending.value = false } } @@ -129,7 +154,8 @@ class AddArtifactViewModel( initialAddArtifactUIState: AddArtifactUIState = AddArtifactUIState.Loading, nostrRepository: NostrRepository, chatRepository: ChatRepository, - mantraRepository: MantraRepository + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { AddArtifactViewModel( @@ -139,9 +165,10 @@ class AddArtifactViewModel( initialAddArtifactUIState = initialAddArtifactUIState, nostrRepository = nostrRepository, chatRepository = chatRepository, - mantraRepository = mantraRepository + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository ) } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt index af593edb..32fe947f 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt @@ -7,6 +7,13 @@ sealed interface AddArtifactUIState { data class Loaded( val localChatRoom: LocalChatRoom, val dialects: List = emptyList(), + + /** + * Whether the group holds a shared key. An artifact is signed into + * existence now rather than submitted, so a group without one cannot + * add one here at all. + */ + val canSign: Boolean = false, ): AddArtifactUIState data class Error( @@ -15,4 +22,3 @@ sealed interface AddArtifactUIState { data object Loading: AddArtifactUIState } - diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt new file mode 100644 index 00000000..c59e65ff --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt @@ -0,0 +1,144 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag + +/** + * The first version of an artifact is derived, not delivered. + * + * The group signs an artifact and nothing else, so the version it starts life + * with is not an event anybody sent: every device builds the row for itself out + * of the artifact it already holds. That only works while every device builds + * the *same* row, and nothing about the ids would show it if they stopped — + * they are content hashes, opaque hex either way. What would show is a group + * that quietly disagrees about which version a chapter hangs off, with the + * artifact looking identical on every screen. + */ +class InitialArtifactVersionTest { + private val groupKey = "a".repeat(64) + private val dialectId = "b".repeat(64) + private val chatRoomId = "room" + + private fun signedArtifact( + name: String = "In Detention", + versionLabel: String = "1.0", + createdAt: Long = 1_700_000_000, + ): ArtifactEvent { + val template = ArtifactEvent.build( + name = name, + url = "example.com", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = createdAt, + ) + + // Hashed rather than made up, so two fixtures that differ are two + // different artifacts here for the same reason they would be in the app. + return ArtifactEvent( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupKey, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "d".repeat(128) + ) + } + + @Test + fun `the derived version is a function of the artifact and nothing else`() { + // Every input has to come off the artifact. Reading the clock here would + // still agree with itself twice in a row -- and disagree between two + // devices that applied the same artifact minutes apart, which is the + // case nobody can reproduce on demand. So the timestamp is checked + // against the artifact's rather than against a second derivation. + val artifact = signedArtifact(createdAt = 1_700_000_000) + + val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertNotNull(version) + assertEquals(1_700_000_000, version.createdAt.epochSeconds) + } + + @Test + fun `two devices derive the same first version from the same artifact`() { + val artifact = signedArtifact() + + val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertNotNull(mine) + assertEquals(mine.id, theirs?.id) + assertEquals(mine.createdAt, theirs?.createdAt) + } + + @Test + fun `an artifact signed at a different moment derives a different version`() { + // The artifact's own timestamp is bound into the derived id, so two + // proposals identical but for when they were made stay two artifacts + // with two first versions rather than colliding on one row. + val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId) + val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId) + + assertNotNull(first) + assertNotNull(second) + assertNotEquals(first.id, second.id) + } + + @Test + fun `the derived version hangs off the artifact and carries what it declared`() { + val artifact = signedArtifact(versionLabel = "First Edition") + + val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertEquals(artifact.id, version?.artifactId) + assertEquals("First Edition", version?.versionLabel) + // Authored by whoever authored the artifact -- the group, once signed -- + // and unsigned, because nobody signed this. + assertEquals(groupKey, version?.publicKey) + assertEquals("", version?.signature) + } + + @Test + fun `the label is bound into the id rather than hung beside it`() { + // Two artifacts alike but for the label must not derive one version + // between them: the id has to come from the whole event, or a group + // renaming a version would leave the row it replaces in place. + val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId) + val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId) + + assertNotNull(first) + assertNotNull(second) + assertNotEquals(first.id, second.id) + } + + @Test + fun `an artifact that declares no version derives none`() { + // Artifacts written before the artifact carried its first version. + val declared = signedArtifact() + val silent = ArtifactEvent( + id = declared.id, + pubKey = declared.pubKey, + createdAt = declared.createdAt, + tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + .toTypedArray(), + content = declared.content, + sig = declared.sig + ) + + assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt index ea0437ab..eb5572f0 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt @@ -66,7 +66,8 @@ class RumorIdAgreementTest { url = "example.com", visibility = "private", license = "cc", - dialectId = other + dialectId = other, + versionLabel = "1.0" ) val entity = MantraArtifact.fromArtifactEventTemplate( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt new file mode 100644 index 00000000..f585390b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -0,0 +1,227 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import fr.acinq.bitcoin.crypto.frost.TweakCache +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.nip30303.ArtifactEvent + +/** + * An artifact the group signed, from proposal to rows, against real FROST. + * + * Adding an artifact used to write a row and submit it; the row said the + * submitter wrote it, because they had. Now the group signs it, and the claim + * this test exists to hold is that the artifact every device ends up with is + * the group's: authored by the threshold key, carrying a signature that + * verifies, with an id every member arrives at independently. + * + * None of that is visible when it breaks. A row whose author is the proposer + * looks exactly like a row whose author is the group -- both are opaque hex -- + * and a group that disagrees about the id has two artifacts that look like one. + */ +class SignedArtifactTest { + private val participants = 3 + private val threshold = 2 + private val chatRoomId = "room" + + /** The member who filled in the form. Nothing they own should end up on the row. */ + private val proposer = "9".repeat(64) + private val dialectId = "b".repeat(64) + + /** Stands in for a completed ceremony; the test is about what gets signed, not the DKG. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey) + + /** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */ + private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex() + + private fun proposalTemplate(versionLabel: String = "1.0") = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = 1_700_000_000L, + ) + + /** + * Exactly what `FrostSigningManager.unsignedEventOf` does, and it must stay + * exactly that: the proposer's fields re-authored under the group's key. + */ + private fun unsignedEventOf(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "" + ) + + private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( + id = "s".repeat(64), + chatRoomId = chatRoomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + dkgSessionId = "k".repeat(64), + threshold = threshold, + participantCount = participants, + signerId = 0, + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + nonceRandom = "f".repeat(64) + ) + + /** A quorum signing the session's event, in the manager's order. */ + private fun groupSignature(session: FrostSigningSession): String { + val message = ByteVector(session.eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = keyMaterial.secretShares[signerId], + publicShare = keyMaterial.publicShares[signerId], + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { keyMaterial.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = tweakCache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + keyMaterial.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } + + /** Everything from the form to the row a device holds afterwards. */ + private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent { + val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel))) + val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + + return ArtifactEvent( + signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig + ) + } + + @Test + fun `the artifact the group signs is authored by the group, not the proposer`() { + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertNotNull(artifact) + assertEquals(groupPubKey, artifact.publicKey) + assertNotEquals(proposer, artifact.publicKey) + } + + @Test + fun `the row's id is the id the group put its signature to`() { + // Every device builds this row from the same signed event, so the id has + // to be the one that was signed rather than anything recomputed from the + // proposer. Otherwise members converge on nothing and each holds its own + // copy of what is meant to be one artifact. + val session = sessionOver(unsignedEventOf(proposalTemplate())) + val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + + val artifact = MantraArtifact.fromArtifactEvent( + ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig), + chatRoomId + ) + + assertEquals(session.eventId, artifact?.id) + } + + @Test + fun `the signature on the row verifies against the row's own id and author`() { + // The payoff of signing rather than submitting: the row carries proof the + // group made it, checkable by anybody holding it. + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertNotNull(artifact) + assertTrue( + Nip01Crypto.verify( + signature = artifact.signature.hexToByteArray(), + hash = artifact.id.hexToByteArray(), + pubKey = artifact.publicKey.hexToByteArray() + ), + "an artifact row should carry a signature the group's key made over its own id" + ) + } + + @Test + fun `what the form asked for is what the group signed`() { + // The fields travel as tags through a session that knows nothing about + // artifacts. Anything dropped in there is signed away silently. + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertEquals("In Detention", artifact?.name) + assertEquals("https://example.com/in-detention", artifact?.url) + assertEquals("private", artifact?.visibility) + assertEquals("cc", artifact?.license) + assertEquals(dialectId, artifact?.dialectId) + } + + @Test + fun `the artifact arrives with the first version hanging off it`() { + // Nothing sends this row: each device derives it from the artifact it + // just applied. A chapter attaches to a version rather than to an + // artifact, so an artifact that arrives without one is inert. + val signed = signedArtifactEvent(versionLabel = "First Edition") + + val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId) + val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId) + + assertNotNull(version) + assertEquals(artifact?.id, version.artifactId) + assertEquals("First Edition", version.versionLabel) + assertEquals(groupPubKey, version.publicKey) + // Derived, not signed: the group signed the artifact that declares it. + assertEquals("", version.signature) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt new file mode 100644 index 00000000..db212a4a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt @@ -0,0 +1,118 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag + +/** + * What the form collects has to reach the members deciding whether to sign it. + * + * An artifact proposal leaves the proposer's device as a kind, a tag array and + * a string, and everything a member is shown before signing -- and every row + * built afterwards -- is read back out of those. A field that does not survive + * the trip is not a visible failure: the artifact still appears, just without + * a url, or a source dialect, or a first version, on every device but the one + * that typed it. + */ +class ArtifactEventTest { + private val groupKey = "a".repeat(64) + private val dialectId = "b".repeat(64) + + /** The event a member actually receives: bytes, with no template behind it. */ + private fun readBack(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate) = + ArtifactEvent( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = groupKey, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "c".repeat(128), + ) + + private fun proposal(versionLabel: String = "1.0") = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = 1_700_000_000L, + ) + + @Test + fun `every field the form collects survives the trip through the tags`() { + val artifact = readBack(proposal()) + + assertEquals("In Detention", artifact.content) + assertEquals("https://example.com/in-detention", artifact.url()) + assertEquals("private", artifact.visibility()) + assertEquals("cc", artifact.license()) + assertEquals(dialectId, artifact.dialectId()) + assertEquals("1.0", artifact.versionLabel()) + } + + @Test + fun `the proposal is the artifact's kind, so the signing screen can describe it`() { + // The session carries a kind and nothing else to go on. Get this wrong + // and members are asked to sign "event of kind 30300" -- a question + // nobody can answer. + assertEquals(ArtifactEvent.KIND, proposal().kind) + } + + @Test + fun `an artifact declares one version, whatever an initializer adds`() { + // addUnique, not add: two labels would leave receivers deriving two + // different first versions depending on which one they read first. + val template = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = "1.0", + ) { + addUnique(ArtifactVersionMetadataTag.assemble("2.0")) + } + + assertEquals( + 1, + template.tags.count { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + ) + } + + @Test + fun `an artifact from before the label existed reads back as declaring none`() { + // Not an error: it is what every artifact submitted the old way looks + // like, and they have to keep parsing rather than failing to load. + val template = proposal() + val older = Event( + id = "d".repeat(64), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags + .filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + .toTypedArray(), + content = template.content, + sig = "", + ) + + val artifact = ArtifactEvent( + older.id, older.pubKey, older.createdAt, older.tags, older.content, older.sig + ) + + assertNull(artifact.versionLabel()) + // Everything else still reads, so the artifact itself is unharmed. + assertEquals(dialectId, artifact.dialectId()) + assertEquals("https://example.com/in-detention", artifact.url()) + } +} From 925099125b90c41155da3bae36ec21cea0185598 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 01:49:57 +0200 Subject: [PATCH 33/33] feat: read a room's group events again when they arrived out of order Relays impose no ordering, so a kind:445 can turn up before the group can read it: an application message encrypted under an epoch whose commit has not landed, or a commit for an epoch ahead of the local one. Both are stored and then dropped -- MarmotInboundManager refuses an out-of-epoch commit precisely so it does not half-mutate the group -- and nothing goes back for them once the missing event fills the gap. The message is on disk, readable, and never read. A "Reindex Events" button at the bottom of the group's detail screen is that second look. Only events with nothing to show for them are replayed: no chat line at all, or one of the two placeholder types. A room where nothing went wrong is left exactly as it was, which is what makes the button safe to press on a hunch. Passes repeat while a pass recovers something, because created_at order is not epoch order and a commit recovered by one pass is what lets the next read the messages that were waiting on it. **Replaying was not safe as it stood.** Every row the path writes is keyed on an event id and upserts in place -- MarmotGroupEvent, MarmotInnerEvent, and the nip30303 entities -- with one exception. ChatMessage's primary key is autogenerated, so writing a freshly built line always inserts, and a re-read would have left the room showing each recovered message twice, once as "Undecryptable Message" and once as itself. ChatMessage.reconcileMarmotLine matches on the group event id instead, so a re-read is an update, and refuses to let a placeholder overwrite a line that says something. That last rule is what protects the line this device wrote on the way out for a message it sent: our own kind:445 cannot be read back, since the sender ratchet has consumed the generation, and without the rule a replay would have replaced our words with "Undecryptable Message". The MLS group itself was already safe to replay against, which is worth saying because it is the part that looks dangerous: a commit behind the current epoch is rejected as a duplicate before it touches the group, one ahead is refused, and a consumed ratchet generation throws before mutating anything. The exception was quartz's EpochCommitTracker, which does not dedupe and only empties when a commit applies -- so replaying a held commit just grew the list and left it pending forever. forgetPendingCommits drops the room's entries first, and the sweep feeds the events back in the order CommitOrdering picks a winner in, so a contested epoch resolves the same way it would have on every other device. **What is testable, and what is not.** The DAO is not: testDebugUnitTest is plain JVM and Room's in-memory builder wants an Android Context. So the two pieces carrying decisions are lifted out where they can be run without one -- MarmotReindexSweep for the stopping rule, and reconcileMarmotLine for which of two lines wins -- and the DAO is left as query, sweep, write. The filter tests pin why the query's `tags LIKE` is a prefilter and not a test: an event belonging to another room can mention this one in a q tag, and its own h tag is what rejects it. **Not recovered by any of this.** A message whose key is gone -- one the ratchet has already advanced past, or one from an epoch predating this device's join. And events that never reached disk at all: storeNostrEvent is a single transaction, so a kind:445 arriving before its room exists rolls back its own insert along with the failed indexing, and only a re-sync brings it back. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 4 + .../compose/database/dao/ChatMessageDao.kt | 20 + .../mantra/compose/database/dao/NostrDao.kt | 348 ++++++++++++------ .../compose/database/dao/NostrEventDao.kt | 20 + .../compose/database/model/ChatMessage.kt | 67 +++- .../model/types/MarmotReindexReport.kt | 27 ++ .../repository/DatabaseChatRepository.kt | 18 + .../compose/managers/MarmotInboundManager.kt | 25 ++ .../compose/managers/MarmotReindexSweep.kt | 95 +++++ .../compose/repository/ChatRepository.kt | 19 + .../ui/composable/ChatRoomDetailScreen.kt | 90 +++++ .../ui/view/model/ChatRoomDetailViewModel.kt | 55 +++ .../model/MarmotChatLineReconciliationTest.kt | 161 ++++++++ .../model/MarmotGroupEventRoomFilterTest.kt | 85 +++++ .../managers/MarmotReindexSweepTest.kt | 173 +++++++++ gradle/libs.versions.toml | 1 + 16 files changed, 1098 insertions(+), 110 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotChatLineReconciliationTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotGroupEventRoomFilterTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotReindexSweepTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 71501a81..a951896c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -121,6 +121,10 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + // runTest: the DAO, model and relay layers are all suspending, and there is no + // runBlocking in a common source set — so anything worth asserting about them + // needs a coroutine, and a scheduler, to assert it in. + implementation(libs.kotlinx.coroutinesTest) } jvmMain.dependencies { implementation(compose.desktop.currentOs) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt index daa45b01..d0657be0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt @@ -30,6 +30,26 @@ interface ChatMessageDao { @Query("SELECT COUNT(*) FROM ChatMessage WHERE chatRoomId = :chatRoomId AND senderPublicKey = :senderPublicKey") fun countChatMessagesBySenderPublicKey(chatRoomId: HexKey, senderPublicKey: HexKey): Int + /** + * The room's group events that already have something to show for them. + * + * One query so a replay can skip the events it has no business touching without + * a lookup per event -- `marmotGroupEventId` carries no index, so each of those + * is a scan of the whole table. + * + * [unresolvedTypes] is [press.mantra.compose.database.model.ChatMessage.UNRESOLVED_MARMOT_TYPES]: + * lines that stand in for a message still to come rather than reporting one. + */ + @Query( + "SELECT marmotGroupEventId FROM ChatMessage " + + "WHERE chatRoomId = :chatRoomId AND marmotGroupEventId IS NOT NULL " + + "AND messageType NOT IN (:unresolvedTypes)" + ) + suspend fun getResolvedMarmotGroupEventIds( + chatRoomId: String, + unresolvedTypes: Collection + ): List + @Upsert suspend fun upsert(chatMessage: press.mantra.compose.database.model.ChatMessage): Long } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 0aaba9be..f3344e5b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -19,6 +19,7 @@ import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.Post import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.MarmotReindexReport import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.exceptions.GiftWrapImpersonationException import press.mantra.compose.exceptions.GiftWrapSealDecryptionException @@ -35,12 +36,15 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.MlsGroupCache import press.mantra.compose.managers.MarmotInboundManager +import press.mantra.compose.managers.MarmotReindexSweep import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.CommitOrdering +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle @@ -386,114 +390,11 @@ abstract class NostrDao( logger.d("groupEvent: ${groupEvent.toJson()}") groupEvent.groupId()?.let { chatRoomId -> - val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId) - - if (localChatRoom != null) { - // Through the cache rather than rebuilt here, so the secret - // tree's skipped-generation keys survive from one message to - // the next. Two events published in the same instant arrive in - // whatever order the relay feels like, and rebuilding between - // them loses the earlier one for good -- see MlsGroupCache. - val handled = MlsGroupCache.withGroup( - chatRoomId = chatRoomId, - storedStateHex = localChatRoom.chatRoom.mlsGroupState, - build = { localChatRoom.chatRoom.toMlsGroup() }, - save = { stateHex -> - database.chatRoomDao().upsert( - localChatRoom.chatRoom.copy(mlsGroupState = stateHex) - ) - } - ) { mlsGroup -> - val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) -> - - val pubkey = when (val cred = leafNode.credential) { - is Credential.Basic -> cred.identity.toHexKey() - else -> null - } - - logger.d("leafIndex=$leafIndex pubKey=$pubkey") - pubkey - } - - if (memberPubkeys.contains(activeKeyPair.pubKey.toHex())) { - database.marmotGroupEventDao().upsert( - MarmotGroupEvent( - id = groupEvent.id, - userPublicKey = activeKeyPair.pubKey.toHex(), - publicKey = groupEvent.pubKey, - encryptedContent = groupEvent.encryptedContent(), - chatRoomId = chatRoomId, - createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), - expiresAt = groupEvent.expiration()?.let { Instant.fromEpochSeconds(it) }, - signature = groupEvent.sig - ) - ) - MarmotInboundManager.processGroupEvent( - database = database, - activeKeyPair = activeKeyPair, - localChatRoom = localChatRoom, - mlsGroup = mlsGroup, - groupEvent = groupEvent - )?.let { groupEventResult -> - logger.d("groupEventResult: $groupEventResult") - - when(groupEventResult) { - is GroupEventResult.CommitProcessed -> { - MarmotInboundManager.processGroupMembershipChanges( - database = database, - mlsGroup = mlsGroup, - localChatRoom = localChatRoom - ) - } - else -> { - logger.i("No app logic to handle: $groupEventResult") - } - } - - ChatMessage.fromGroupEventResult( - database = database, - activeKeyPair = activeKeyPair, - groupEvent = groupEvent, - groupEventResult = groupEventResult, - )?.let { chatMessage -> - logger.d("chatMessage: $chatMessage") - database.chatMessageDao().upsert( - chatMessage - ) - } - - // A FROST signing message for this group. Driven from - // here rather than from ChatMessage because the - // manager needs the room to publish its own replies - // into, and because it writes its transcript lines - // itself. The manager is idempotent, so a redelivered - // message re-runs a step it has already taken. - if (groupEventResult is GroupEventResult.ApplicationMessage) { - Event.fromJsonOrNull(groupEventResult.innerEventJson) - ?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) } - ?.let { innerEvent -> - FrostSigningManager.processSigningPayload( - database = database, - localChatRoom = localChatRoom, - innerEvent = innerEvent, - userPublicKey = activeKeyPair.pubKey.toHex() - ) - } - } - } - } else { - throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}") - } - } - - // Null means the room has no usable group state, which is what - // a failed toMlsGroup() meant before the cache existed. - if (handled == null) { - throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for $nostrEvent") - } - } else { - throw MarmotMissingChatGroupException("Couldn't find chatRoom for $nostrEvent") - } + indexMarmotGroupEvent( + groupEvent = groupEvent, + chatRoomId = chatRoomId, + activeKeyPair = activeKeyPair, + ) } } @@ -1279,6 +1180,236 @@ abstract class NostrDao( } } + /** + * Reads one kind:445 against the room's MLS group and files whatever it turns + * out to be: a message, a commit that advances the group, an entity for the + * library, or a placeholder saying it could not be read yet. + * + * Its own function so a replay can run exactly what a first delivery ran -- + * see [reindexMarmotGroupEvents]. Throws for a room this device cannot process + * the event against at all, which the caller decides what to do about: a first + * delivery lets it roll back its transaction, a replay logs it and moves on. + */ + private suspend fun indexMarmotGroupEvent( + groupEvent: GroupEvent, + chatRoomId: String, + activeKeyPair: KeyPair, + ) { + val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId) + ?: throw MarmotMissingChatGroupException("Couldn't find chatRoom for ${groupEvent.id}") + + // Through the cache rather than rebuilt here, so the secret + // tree's skipped-generation keys survive from one message to + // the next. Two events published in the same instant arrive in + // whatever order the relay feels like, and rebuilding between + // them loses the earlier one for good -- see MlsGroupCache. + val handled = MlsGroupCache.withGroup( + chatRoomId = chatRoomId, + storedStateHex = localChatRoom.chatRoom.mlsGroupState, + build = { localChatRoom.chatRoom.toMlsGroup() }, + save = { stateHex -> + database.chatRoomDao().upsert( + localChatRoom.chatRoom.copy(mlsGroupState = stateHex) + ) + } + ) { mlsGroup -> + val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) -> + + val pubkey = when (val cred = leafNode.credential) { + is Credential.Basic -> cred.identity.toHexKey() + else -> null + } + + logger.d("leafIndex=$leafIndex pubKey=$pubkey") + pubkey + } + + if (memberPubkeys.contains(activeKeyPair.pubKey.toHex())) { + database.marmotGroupEventDao().upsert( + MarmotGroupEvent( + id = groupEvent.id, + userPublicKey = activeKeyPair.pubKey.toHex(), + publicKey = groupEvent.pubKey, + encryptedContent = groupEvent.encryptedContent(), + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), + expiresAt = groupEvent.expiration()?.let { Instant.fromEpochSeconds(it) }, + signature = groupEvent.sig + ) + ) + MarmotInboundManager.processGroupEvent( + database = database, + activeKeyPair = activeKeyPair, + localChatRoom = localChatRoom, + mlsGroup = mlsGroup, + groupEvent = groupEvent + )?.let { groupEventResult -> + logger.d("groupEventResult: $groupEventResult") + + when(groupEventResult) { + is GroupEventResult.CommitProcessed -> { + MarmotInboundManager.processGroupMembershipChanges( + database = database, + mlsGroup = mlsGroup, + localChatRoom = localChatRoom + ) + } + else -> { + logger.i("No app logic to handle: $groupEventResult") + } + } + + ChatMessage.fromGroupEventResult( + database = database, + activeKeyPair = activeKeyPair, + groupEvent = groupEvent, + groupEventResult = groupEventResult, + )?.let { chatMessage -> + logger.d("chatMessage: $chatMessage") + persistMarmotChatMessage(chatMessage) + } + + // A FROST signing message for this group. Driven from + // here rather than from ChatMessage because the + // manager needs the room to publish its own replies + // into, and because it writes its transcript lines + // itself. The manager is idempotent, so a redelivered + // message re-runs a step it has already taken. + if (groupEventResult is GroupEventResult.ApplicationMessage) { + Event.fromJsonOrNull(groupEventResult.innerEventJson) + ?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) } + ?.let { innerEvent -> + FrostSigningManager.processSigningPayload( + database = database, + localChatRoom = localChatRoom, + innerEvent = innerEvent, + userPublicKey = activeKeyPair.pubKey.toHex() + ) + } + } + } + } else { + throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}") + } + } + + // Null means the room has no usable group state, which is what + // a failed toMlsGroup() meant before the cache existed. + if (handled == null) { + throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for ${groupEvent.id}") + } + } + + /** + * Files the chat line a kind:445 produced, replacing the line that event + * already has rather than adding a second one. The rule for which of the two + * wins is [ChatMessage.reconcileMarmotLine]. + */ + private suspend fun persistMarmotChatMessage(chatMessage: ChatMessage) { + val existing = chatMessage.marmotGroupEventId?.let { + database.chatMessageDao().getChatMessagesByMarmotGroupEventId(it) + } + + val row = ChatMessage.reconcileMarmotLine(fresh = chatMessage, existing = existing) + + if (row == null) { + logger.d("Keeping ${existing?.messageType} line for ${chatMessage.marmotGroupEventId}") + return + } + + database.chatMessageDao().upsert(row) + } + + /** + * Reads a room's stored kind:445 events again, oldest first. + * + * Relays impose no ordering, so a group event can arrive before the group is + * able to read it: an application message encrypted under an epoch whose commit + * has not landed yet, or a commit for an epoch ahead of the local one. Both are + * stored and then dropped -- `MarmotInboundManager` refuses to apply an + * out-of-epoch commit precisely so it does not half-mutate the group -- and + * nothing revisits them once the missing event fills the gap. This is the + * revisit, driven from the group's detail screen. + * + * Only events with nothing to show for them are replayed: no chat line at all, + * or one of [ChatMessage.UNRESOLVED_MARMOT_TYPES]. Everything already read is + * left untouched, which is what keeps the replay from disturbing a room where + * there was nothing wrong. Within that set a replay is safe to repeat: the ids + * of every row it writes are derived from the events themselves, commits that + * are behind the current epoch are rejected as duplicates before they can touch + * the group, and the one row with a generated id is reconciled by + * [persistMarmotChatMessage]. + * + * Passes repeat while a pass recovers something -- see [MarmotReindexSweep] -- + * because created_at order is not necessarily epoch order, and a commit + * recovered by one pass can be what lets the next read the messages that were + * waiting on it. + * + * What this cannot do is recover a message whose key is gone: an application + * message the ratchet has already advanced past, or one from an epoch that + * predates this device joining. Those stay unreadable however often they are + * replayed. + */ + open suspend fun reindexMarmotGroupEvents( + chatRoomId: String, + activeKeyPair: KeyPair, + ): MarmotReindexReport { + val userPublicKey = activeKeyPair.pubKey.toHex() + + // The query's LIKE only narrows; the h tag is what decides the room. Sorted + // by CommitOrdering's own comparator rather than left in createdAt order, so + // that where two commits compete for an epoch the first one replayed is the + // one every other member also picked. + val groupEvents = database.nostrEventDao() + .getMarmotGroupNostrEventsByChatRoomId(chatRoomId) + .mapNotNull { storedNostrEvent -> + storedNostrEvent + .toGroupEvent(userPublicKey = userPublicKey) + ?.takeIf { it.groupId() == chatRoomId } + } + .sortedWith(CommitOrdering.comparator) + + logger.i("Reindex $chatRoomId: ${groupEvents.size} stored group event(s)") + + // Held commits are what a replay is most often for, and they are only ever + // cleared by one applying -- see MarmotInboundManager.forgetPendingCommits. + MarmotInboundManager.forgetPendingCommits(chatRoomId) + + val resolved = database.chatMessageDao().getResolvedMarmotGroupEventIds( + chatRoomId = chatRoomId, + unresolvedTypes = ChatMessage.UNRESOLVED_MARMOT_TYPES, + ).toSet() + + return MarmotReindexSweep.run( + stored = groupEvents.size, + unresolved = groupEvents.filterNot { it.id in resolved }, + replay = { groupEvent -> + indexMarmotGroupEvent( + groupEvent = groupEvent, + chatRoomId = chatRoomId, + activeKeyPair = activeKeyPair, + ) + }, + isUnresolved = { groupEvent -> isUnresolvedMarmotGroupEvent(groupEvent.id) }, + ).also { + logger.i("Reindex $chatRoomId: $it") + } + } + + /** + * Whether one group event still has nothing to show for it, asked again after + * a replay to see whether that replay achieved anything. + * + * Some events can never leave this state and will be swept every time: a commit + * rejected as a duplicate writes no line, and neither does a commit this device + * sent, whose line was written against the send rather than the event. Both cost + * one refused decrypt per sweep, which is cheaper than a bookkeeping column that + * would have to be migrated in. + */ + private suspend fun isUnresolvedMarmotGroupEvent(groupEventId: String): Boolean { + val chatMessage = database.chatMessageDao().getChatMessagesByMarmotGroupEventId(groupEventId) + return chatMessage == null || chatMessage.messageType in ChatMessage.UNRESOLVED_MARMOT_TYPES + } /** * Files an arriving NIP-17 chat message so it shows up in the room's feed. @@ -1425,4 +1556,5 @@ abstract class NostrDao( } } } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt index 1066c814..f354e547 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt @@ -143,6 +143,26 @@ interface NostrEventDao { now: Instant = Clock.System.now(), ): List + /** + * Every kind:445 held locally that mentions [chatRoomId], oldest first. + * + * The `LIKE` is a prefilter over the serialised tags, not the test: it can match + * a group id that happens to appear in some other tag, so callers must confirm + * the event's own `h` tag before treating it as this room's. Ordered ascending + * because a replay has to apply commits in the order they were sent. + * + * Deliberately not a join on `MarmotGroupEvent`: an event whose indexing failed + * part way may have been stored without ever reaching that table, and those are + * exactly the ones worth replaying. + */ + @Transaction + @Query( + "SELECT * FROM NostrEvent WHERE kind = ${GroupEvent.KIND} " + + "AND tags LIKE '%' || :chatRoomId || '%' " + + "ORDER BY createdAt ASC" + ) + fun getMarmotGroupNostrEventsByChatRoomId(chatRoomId: HexKey): List + @Transaction @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND pubKey in (:authors) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") fun getAuthoredNostrEvents( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index feba4e8c..f6df2e83 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -249,6 +249,69 @@ data class ChatMessage( TYPE_DKG_FAILED, ) + /** + * A kind:445 arrived that the group could not read at the time. + * + * Both are placeholders for a message still to come: the outer layer was + * encrypted under an epoch whose exporter secret we did not hold yet, or + * the commit is one of several competing for an epoch and none has been + * applied. Neither says anything a member wrote. + */ + const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer" + const val TYPE_PENDING_COMMIT = "pendingCommit" + + /** + * The lines a reindex is allowed to replace. + * + * A group event that left one of these behind was never read, so replaying + * it can only improve on what is there. Every other line -- a message, an + * applied commit, anything this device sent -- is the final word on its + * group event and a replay must leave it alone. + */ + val UNRESOLVED_MARMOT_TYPES = setOf( + TYPE_UNDECRYPTABLE_OUTER_LAYER, + TYPE_PENDING_COMMIT, + ) + + /** + * The row to write for a group event that was just read, given the line that + * event already has. Null means leave what is there alone. + * + * [ChatMessage.id] is autogenerated, so writing [fresh] as it comes always + * inserts. That is right the first time a group event is read and wrong every + * time after: a replay would leave the room showing each recovered message + * twice, once as "Undecryptable Message" and once as itself. A group event has + * exactly one line and a deterministic id, so [existing] is what makes a + * re-read an update instead. + * + * A placeholder never overwrites a line that says something. A group event + * this device sent already has its line, written on the way out, and a replay + * that cannot read back our own ratchet-consumed message must not replace it + * with "Undecryptable Message". + * + * [viewedAt] and [savedAt] stay with [existing]: when a line was first seen is + * a fact about the reader, not about the event, and a re-read is not a new + * arrival. Its id stays too, so the broadcast and nostr-event relations + * pointing at that row keep pointing at it. + */ + fun reconcileMarmotLine( + fresh: ChatMessage, + existing: ChatMessage?, + ): ChatMessage? { + if (existing == null) return fresh + + val freshIsPlaceholder = fresh.messageType in UNRESOLVED_MARMOT_TYPES + val existingIsPlaceholder = existing.messageType in UNRESOLVED_MARMOT_TYPES + + if (freshIsPlaceholder && !existingIsPlaceholder) return null + + return fresh.copy( + id = existing.id, + savedAt = existing.savedAt, + viewedAt = existing.viewedAt, + ) + } + suspend fun fromGroupEventResult( database: MantraDatabase, activeKeyPair: KeyPair, @@ -326,7 +389,7 @@ data class ChatMessage( giftWrapPayloadId = null, marmotGroupEventId = groupEvent.id, marmotInnerEventId = null, - messageType = "pendingCommit", + messageType = TYPE_PENDING_COMMIT, senderPublicKey = groupEvent.pubKey, isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupEventResult.groupId, @@ -392,7 +455,7 @@ data class ChatMessage( giftWrapPayloadId = null, marmotGroupEventId = groupEvent.id, marmotInnerEventId = null, - messageType = "undecryptableOuterLayer", + messageType = TYPE_UNDECRYPTABLE_OUTER_LAYER, senderPublicKey = groupEvent.pubKey, isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupEventResult.groupId, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt new file mode 100644 index 00000000..0933e6da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt @@ -0,0 +1,27 @@ +package press.mantra.compose.database.model.types + +/** + * What a replay of a room's stored kind:445 events did. + * + * Nothing here is persisted -- it exists so the screen that asked for the replay + * can say what happened rather than leaving the user to guess from the message + * list whether anything moved. + * + * @param stored every kind:445 held locally for the room. + * @param unresolved how many of those had nothing to show for them, or only a + * placeholder line -- the ones a replay was allowed to touch. + * @param recovered how many of [unresolved] came out with something to show: + * a message, an applied commit, an entity added to the group's library. + * @param failed how many threw while being replayed. Expected to be non-zero + * on a room with events from before this device joined, whose epoch secrets it + * never held and never will. + */ +data class MarmotReindexReport( + val stored: Int = 0, + val unresolved: Int = 0, + val recovered: Int = 0, + val failed: Int = 0, +) { + /** Nothing was left to try, so the replay was a no-op. */ + val isNoOp: Boolean get() = unresolved == 0 +} 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 b12e3723..663336a0 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 @@ -10,9 +10,11 @@ import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.MarmotReindexReport import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -407,6 +409,22 @@ class DatabaseChatRepository( onCompletion.invoke() } + /** + * Only the public key is needed: reading a group event is done with the MLS + * group's own secrets, held in the room's stored state, and the identity is + * used for nothing but deciding whether this device is a member and whether a + * line is its own. So a read-only [KeyPair] is enough, and the nostr private + * key stays where it is. + */ + override suspend fun reindexMarmotGroupEvents( + chatRoomId: String, + userPublicKey: HexKey + ): MarmotReindexReport = + database.nostrDao().reindexMarmotGroupEvents( + chatRoomId = chatRoomId, + activeKeyPair = KeyPair(pubKey = userPublicKey.hexToByteArray()), + ) + companion object { const val TAG = "DatabaseChatRepository" } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt index c7be3dba..f2f7ffa6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt @@ -413,6 +413,31 @@ object MarmotInboundManager { } } + /** + * Forget the commits being held for [nostrGroupId], so a replay decides its + * epochs again from the events it is about to feed back in. + * + * [handleCommitEvent] holds a commit whenever a second one turns up for an + * epoch, and only [CommitOrdering.EpochCommitTracker.clearEpoch] on a + * successful apply ever empties that. Nothing applies while more than one is + * held, so the set is a dead end: replaying those commits without clearing + * first just adds them again and leaves every one of them pending, and the + * list grows with each attempt. + * + * Safe to drop because the tracker is a scratch pad, not a record -- the + * commits themselves are on disk, and a replay hands them back oldest first, + * which is the order [CommitOrdering.comparator] picks a winner in. + */ + suspend fun forgetPendingCommits(nostrGroupId: HexKey) { + commitTracker + .pendingGroupEpochs() + .filter { it.groupId == nostrGroupId } + .forEach { pending -> + logger.d("Forgetting pending commits for $nostrGroupId epoch ${pending.epoch}") + commitTracker.clearEpoch(pending.groupId, pending.epoch) + } + } + private suspend fun applyCommit( database: MantraDatabase, mlsGroup: MlsGroup, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt new file mode 100644 index 00000000..47071e5d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt @@ -0,0 +1,95 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import press.mantra.compose.database.model.types.MarmotReindexReport + +/** + * Reads a backlog of group events again, repeatedly, until repeating stops helping. + * + * The whole reason a replay is worth anything is that the events depend on each + * other: a commit nobody could apply is what a room's later messages were waiting + * on, and applying it makes them readable. So one pass in order is not enough -- + * a pass that recovers something has changed what the next pass can do. + * + * Ordering is the caller's business; this only decides how many times to go round. + * A pass that recovers nothing ends it, because everything left is waiting on + * something no later pass will produce either. + * + * Kept apart from the database so the stopping rule can be read, and tested, on + * its own -- it is the part with something to get wrong. + */ +object MarmotReindexSweep { + private const val TAG = "MarmotReindexSweep" + + private val logger = Logger.withTag(TAG) + + /** + * How many times a sweep will go round. + * + * A pass that recovers nothing already ends the sweep, so this only bounds the + * pathological shape where every pass recovers exactly one event and the sweep + * turns quadratic. A chain of five events each waiting on the last is already + * far past what relay reordering produces. + */ + const val DEFAULT_MAX_PASSES = 5 + + /** + * @param stored how many group events the room holds in total, for the report. + * @param unresolved those with nothing to show for them, in the order to replay + * them. Anything already read must be left out: a replay is only ever allowed + * to touch events it cannot make worse. + * @param replay reads one event again. Its failures are expected -- a message + * whose key is gone throws every time -- so they are logged, not raised. + * @param isUnresolved asked again after [replay] to see whether it achieved + * anything. This, rather than [replay]'s return, is what counts a recovery: + * an event is recovered when the room has something to show for it, which is + * a fact about the database and not about the call that just ran. + */ + suspend fun run( + stored: Int, + unresolved: List, + maxPasses: Int = DEFAULT_MAX_PASSES, + replay: suspend (T) -> Unit, + isUnresolved: suspend (T) -> Boolean, + ): MarmotReindexReport { + var remaining = unresolved + var recovered = 0 + var pass = 0 + + while (remaining.isNotEmpty() && pass < maxPasses) { + pass++ + val stillUnresolved = mutableListOf() + + for (candidate in remaining) { + try { + replay(candidate) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.w("Replay of $candidate failed", e) + } + + if (isUnresolved(candidate)) { + stillUnresolved.add(candidate) + } else { + recovered++ + } + } + + val madeProgress = stillUnresolved.size < remaining.size + remaining = stillUnresolved + + if (!madeProgress) break + } + + return MarmotReindexReport( + stored = stored, + unresolved = unresolved.size, + recovered = recovered, + failed = remaining.size, + ).also { + logger.i("Swept ${unresolved.size} unresolved event(s) in $pass pass(es): $it") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 4669fae9..34174c20 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -6,6 +6,7 @@ import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.MarmotReindexReport import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -106,6 +107,19 @@ interface ChatRepository { onCompletion: () -> Unit ) + /** + * Reads the room's stored marmot group events again, so ones the group could + * not make sense of when they arrived get another chance now that the events + * they were waiting on have landed. + * + * Only events with nothing to show for them are replayed, so a room where + * nothing went wrong is left exactly as it was. + */ + suspend fun reindexMarmotGroupEvents( + chatRoomId: String, + userPublicKey: HexKey + ): MarmotReindexReport + companion object { val NO_OP_CHAT_REPOSITORY = object: ChatRepository { override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { @@ -232,6 +246,11 @@ interface ChatRepository { ) { TODO("Not yet implemented") } + + override suspend fun reindexMarmotGroupEvents( + chatRoomId: String, + userPublicKey: HexKey + ): MarmotReindexReport = MarmotReindexReport() } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index ba444544..bc19ed8e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -7,12 +7,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.LibraryBooks @@ -23,6 +25,7 @@ import androidx.compose.material.icons.filled.Unsubscribe import androidx.compose.material.icons.filled.WaterfallChart import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider @@ -493,6 +496,22 @@ fun ChatRoomDetailScreen( Text("Delete Group") } } + + // Only MLS rooms have group events to read again. A NIP-17 + // room's messages are gift wraps, which carry no ordering + // for anything to go wrong with. + if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { + item { + HorizontalDivider() + } + + item { + ReindexMarmotGroupEventsButton( + reindexState = chatRoomDetailViewModel.reindexState, + onReindex = chatRoomDetailViewModel::reindexMarmotGroupEvents + ) + } + } } } } @@ -539,6 +558,77 @@ fun ChatRoomDetailScreen( } } +/** + * Reads the room's stored marmot group events again. + * + * Sits at the very bottom, below leaving and deleting, because it is a repair + * rather than something anyone should need in the ordinary course of using the + * group. It says what it found afterwards -- a replay that recovered nothing + * looks identical to one that was never pressed, and the difference between + * "nothing was broken" and "nothing could be fixed" is worth telling. + */ +@Composable +private fun ReindexMarmotGroupEventsButton( + reindexState: ChatRoomDetailViewModel.ReindexState, + onReindex: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + TextButton( + enabled = reindexState !is ChatRoomDetailViewModel.ReindexState.Running, + onClick = onReindex + ) { + if (reindexState is ChatRoomDetailViewModel.ReindexState.Running) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Default.Autorenew, + contentDescription = "Reindex group events" + ) + } + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text("Reindex Events") + } + + when (reindexState) { + is ChatRoomDetailViewModel.ReindexState.Done -> { + val report = reindexState.report + Text( + text = when { + report.isNoOp -> "Nothing to reindex · ${report.stored} event(s) all read" + report.recovered > 0 && report.failed > 0 -> + "Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable" + report.recovered > 0 -> "Recovered ${report.recovered} of ${report.unresolved} event(s)" + else -> "${report.failed} event(s) still unreadable" + }, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + is ChatRoomDetailViewModel.ReindexState.Failed -> { + Text( + text = reindexState.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + else -> Unit + } + } +} + @Preview @Composable private fun ChatRoomMessagingScreenPreview() { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 9e1d7068..b2cff93a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -18,9 +18,11 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import press.mantra.compose.database.model.types.MarmotReindexReport class ChatRoomDetailViewModel( val chatRoomId: String, @@ -35,6 +37,26 @@ class ChatRoomDetailViewModel( var chatRoomDetailUIState: ChatRoomDetailUIState by mutableStateOf(initialChatRoomDetailUIState) private set + /** + * Where the room's replay has got to, for the button that started it. + * + * Held here rather than in [ChatRoomDetailUIState] because a replay does not + * change what the screen is showing -- the message list is observed elsewhere + * and updates itself -- it only changes what the button has to say for itself. + */ + var reindexState: ReindexState by mutableStateOf(ReindexState.Idle) + private set + + sealed interface ReindexState { + data object Idle : ReindexState + + data object Running : ReindexState + + data class Done(val report: MarmotReindexReport) : ReindexState + + data class Failed(val message: String) : ReindexState + } + private val logger = Logger.withTag(TAG) @@ -81,6 +103,39 @@ class ChatRoomDetailViewModel( } } + /** + * Reads the room's stored marmot group events again. + * + * Nostr relays impose no ordering, so a group event can arrive before the group + * can read it -- a message from an epoch whose commit has not landed, a commit + * for an epoch ahead of the local one. Nothing revisits those once the gap + * fills, which is what this is for. + * + * Only events with nothing to show for them are replayed, so pressing this on a + * room where nothing went wrong changes nothing. The feed is observed, so + * anything recovered appears in the message list without a refresh. + */ + fun reindexMarmotGroupEvents() { + if (reindexState is ReindexState.Running) return + + reindexState = ReindexState.Running + viewModelScope.launch(Dispatchers.IO) { + reindexState = try { + ReindexState.Done( + chatRepository.reindexMarmotGroupEvents( + chatRoomId = chatRoomId, + userPublicKey = activeUserPublicKey, + ) + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.e("Failed to reindex $chatRoomId", e) + ReindexState.Failed(e.message ?: "Reindex failed") + } + } + } + fun softDeleteGroup( localChatRoom: LocalChatRoom, onPopBackToRoute: (Route) -> Unit diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotChatLineReconciliationTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotChatLineReconciliationTest.kt new file mode 100644 index 00000000..cd898372 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotChatLineReconciliationTest.kt @@ -0,0 +1,161 @@ +package press.mantra.compose.database.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.time.Instant + +/** + * What happens to a room's chat line when its group event is read a second time. + * + * [ChatMessage.id] is autogenerated, so writing a freshly built line always + * inserts. That is correct exactly once. Reading the same kind:445 again -- which + * is what a reindex does -- would otherwise leave the room showing the recovered + * message twice, once as the placeholder that was written when it could not be + * read and once as itself. And the other way round matters just as much: a group + * event this device sent already has its line, and a replay that cannot read our + * own ratchet-consumed message must not replace it with "Undecryptable Message". + * + * Both directions are decided by [ChatMessage.reconcileMarmotLine], which is why + * they are asserted rather than left to the shape of the calling code. + */ +class MarmotChatLineReconciliationTest { + + private val groupEventId = "a".repeat(64) + private val sender = "b".repeat(64) + private val room = "c".repeat(64) + + private fun line( + id: Long = 0, + messageType: String, + content: String, + viewedAt: Instant? = null, + savedAt: Instant = Instant.fromEpochSeconds(1_000), + ) = ChatMessage( + id = id, + senderPublicKey = sender, + isUserMessage = false, + giftWrapPayloadId = null, + marmotGroupEventId = groupEventId, + marmotInnerEventId = null, + chatRoomId = room, + content = content, + messageType = messageType, + savedAt = savedAt, + viewedAt = viewedAt, + ) + + @Test + fun `a group event read for the first time is written as it comes`() { + val fresh = line(messageType = "message", content = "hello") + + assertSame(fresh, ChatMessage.reconcileMarmotLine(fresh = fresh, existing = null)) + } + + @Test + fun `a recovered message replaces the placeholder rather than joining it`() { + val placeholder = line( + id = 42, + messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, + content = "Undecryptable Message", + ) + val recovered = line(messageType = "message", content = "hello") + + val row = ChatMessage.reconcileMarmotLine(fresh = recovered, existing = placeholder) + + assertEquals(42, row?.id, "reusing the row id is what makes this an update, not a second line") + assertEquals("hello", row?.content) + assertEquals("message", row?.messageType) + } + + @Test + fun `a placeholder never overwrites a line that says something`() { + val sent = line(id = 7, messageType = "message", content = "hello") + val placeholder = line( + messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, + content = "Undecryptable Message", + ) + + assertNull( + ChatMessage.reconcileMarmotLine(fresh = placeholder, existing = sent), + "a replay that cannot read our own message must leave its line alone", + ) + } + + @Test + fun `a commit still waiting may replace the placeholder it already wrote`() { + val pending = line( + id = 3, + messageType = ChatMessage.TYPE_PENDING_COMMIT, + content = "Pending Commit in epoch 4", + ) + val stillPending = line( + messageType = ChatMessage.TYPE_PENDING_COMMIT, + content = "Pending Commit in epoch 5", + ) + + val row = ChatMessage.reconcileMarmotLine(fresh = stillPending, existing = pending) + + assertEquals(3, row?.id, "one placeholder replacing another is still one line") + assertEquals("Pending Commit in epoch 5", row?.content) + } + + /** + * When a line was first seen, and when it was first stored, are facts about + * the reader rather than about the event. Reading the event again is not the + * message arriving again, so neither may be reset. + */ + @Test + fun `replacing a line keeps when it was first saved and seen`() { + val seenAt = Instant.fromEpochSeconds(2_000) + val storedAt = Instant.fromEpochSeconds(1_500) + + val placeholder = line( + id = 9, + messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, + content = "Undecryptable Message", + viewedAt = seenAt, + savedAt = storedAt, + ) + val recovered = line( + messageType = "message", + content = "hello", + viewedAt = null, + savedAt = Instant.fromEpochSeconds(9_999), + ) + + val row = ChatMessage.reconcileMarmotLine(fresh = recovered, existing = placeholder) + + assertEquals(seenAt, row?.viewedAt, "a re-read must not mark a read message unread") + assertEquals(storedAt, row?.savedAt) + } + + /** + * The lines a replay is allowed to replace are exactly the ones that stand in + * for a message still to come. Adding a type to the transcript without adding + * it here silently makes those events unrecoverable; adding one here that + * reports something real makes them overwritable. + */ + @Test + fun `only the placeholder types count as unresolved`() { + assertEquals( + setOf(ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, ChatMessage.TYPE_PENDING_COMMIT), + ChatMessage.UNRESOLVED_MARMOT_TYPES, + ) + + listOf("message", "processedCommit", "proposalStaged", "artifact", "dialect") + .forEach { messageType -> + assertNull( + ChatMessage.reconcileMarmotLine( + fresh = line( + messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, + content = "Undecryptable Message", + ), + existing = line(id = 1, messageType = messageType, content = "something"), + ), + "a $messageType line reports something and must survive a replay", + ) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotGroupEventRoomFilterTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotGroupEventRoomFilterTest.kt new file mode 100644 index 00000000..509d803b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotGroupEventRoomFilterTest.kt @@ -0,0 +1,85 @@ +package press.mantra.compose.database.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * Which room a stored kind:445 belongs to. + * + * `NostrEventDao.getMarmotGroupNostrEventsByChatRoomId` finds a room's group + * events with `tags LIKE '%' || :chatRoomId || '%'`, because the tags are one + * serialised column and there is nothing better to match on. That is a prefilter + * and not a test: a group id can appear in a tag that is not the `h` tag, and an + * event matched that way belongs to a different room entirely. Feeding one of + * those to the replay would read another group's event against this group's MLS + * state. + * + * So the reindex confirms `groupId()` in Kotlin after the query. These pin the + * cases that confirmation is there for. + */ +class MarmotGroupEventRoomFilterTest { + + private val room = "a".repeat(64) + private val otherRoom = "d".repeat(64) + private val ephemeralSender = "e".repeat(64) + + private fun storedGroupEvent( + tags: Array>, + kind: Int = 445, + ) = NostrEvent( + id = "1".repeat(64), + pubKey = ephemeralSender, + kind = kind, + tags = tags, + content = "bm9uY2UrY2lwaGVydGV4dA==", + sig = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + @Test + fun `the h tag is what names the room`() { + val stored = storedGroupEvent(arrayOf(arrayOf("h", room))) + + assertEquals(room, stored.toGroupEvent(userPublicKey = ephemeralSender)?.groupId()) + } + + /** + * The case the `LIKE` cannot tell apart: this event is another room's, and + * only mentions ours in a tag that says nothing about routing. + */ + @Test + fun `an event mentioning the room outside its h tag belongs to the other room`() { + val stored = storedGroupEvent( + arrayOf( + arrayOf("h", otherRoom), + arrayOf("q", room), + ) + ) + + val groupId = stored.toGroupEvent(userPublicKey = ephemeralSender)?.groupId() + + assertEquals(otherRoom, groupId) + assertNotEquals(room, groupId, "the LIKE would match this event; groupId() is what rejects it") + } + + @Test + fun `an event with no h tag names no room`() { + val stored = storedGroupEvent(arrayOf(arrayOf("q", room))) + + assertNull(stored.toGroupEvent(userPublicKey = ephemeralSender)?.groupId()) + } + + /** + * Only kind:445 is a group event. The query pins the kind in SQL, and this is + * the other half of that: nothing else can be read as one by mistake. + */ + @Test + fun `an event of another kind is not a group event`() { + val stored = storedGroupEvent(tags = arrayOf(arrayOf("h", room)), kind = 9) + + assertNull(stored.toGroupEvent(userPublicKey = ephemeralSender)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotReindexSweepTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotReindexSweepTest.kt new file mode 100644 index 00000000..51a0f4b0 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotReindexSweepTest.kt @@ -0,0 +1,173 @@ +package press.mantra.compose.managers + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The stopping rule a reindex sweep runs on. + * + * A replay is worth doing at all because the events depend on each other: the + * commit nobody could apply is what the room's later messages were waiting on. + * That is also what makes one ordered pass insufficient, and what makes "keep + * going" dangerous — a room full of events whose keys are gone would be swept + * forever. Both halves of that are pinned here. + * + * The sweep never sees a database. What counts as recovered is whatever the + * `isUnresolved` probe says afterwards, so these fakes are the real contract and + * not a stand-in for one. + */ +class MarmotReindexSweepTest { + + /** + * A backlog where each event is unlocked by the one before it, which is the + * shape a chain of out-of-order commits arrives in. + * + * [unlockedBy] is the event that has to be replayed successfully first; + * null means it can be read straight away. + */ + private class Backlog(unlocks: Map) { + private val unlockedBy = unlocks + private val resolved = mutableSetOf() + + /** Every event replayed, in order, across every pass. */ + val replayed = mutableListOf() + + suspend fun replay(id: String) { + replayed.add(id) + val blocker = unlockedBy.getValue(id) + if (blocker == null || blocker in resolved) { + resolved.add(id) + } + } + + suspend fun isUnresolved(id: String): Boolean = id !in resolved + } + + @Test + fun `a chain of events unlocking each other is read in as many passes as it takes`() = runTest { + // c waits on b, b waits on a. One pass in this order recovers only a. + val backlog = Backlog(mapOf("c" to "b", "b" to "a", "a" to null)) + + val report = MarmotReindexSweep.run( + stored = 10, + unresolved = listOf("c", "b", "a"), + replay = backlog::replay, + isUnresolved = backlog::isUnresolved, + ) + + assertEquals(3, report.recovered, "every event in the chain should have been recovered") + assertEquals(0, report.failed) + assertEquals(3, report.unresolved, "unresolved is what the sweep started with") + assertEquals(10, report.stored, "stored is reported as handed in") + } + + @Test + fun `an event that can never be read stops the sweep after one pass`() = runTest { + // Nothing unlocks these: the keys are gone, replaying achieves nothing. + val backlog = Backlog(mapOf("a" to "never", "b" to "never")) + + val report = MarmotReindexSweep.run( + stored = 2, + unresolved = listOf("a", "b"), + replay = backlog::replay, + isUnresolved = backlog::isUnresolved, + ) + + assertEquals(listOf("a", "b"), backlog.replayed, "a pass that recovers nothing must not repeat") + assertEquals(0, report.recovered) + assertEquals(2, report.failed) + } + + @Test + fun `events that can be read are still recovered alongside ones that cannot`() = runTest { + val backlog = Backlog(mapOf("stuck" to "never", "b" to "a", "a" to null)) + + val report = MarmotReindexSweep.run( + stored = 3, + unresolved = listOf("stuck", "b", "a"), + replay = backlog::replay, + isUnresolved = backlog::isUnresolved, + ) + + assertEquals(2, report.recovered) + assertEquals(1, report.failed, "the unreadable one is reported, not retried forever") + assertEquals(report.unresolved, report.recovered + report.failed, "every event is accounted for") + } + + /** + * The pass cap only bites on the shape where each pass recovers exactly one + * event, which is the only way the sweep can go quadratic. Six events in a + * chain replayed worst-first need six passes and get five. + */ + @Test + fun `the pass cap bounds a chain longer than it`() = runTest { + val chain = listOf("f", "e", "d", "c", "b", "a") + val backlog = Backlog( + mapOf("f" to "e", "e" to "d", "d" to "c", "c" to "b", "b" to "a", "a" to null) + ) + + val report = MarmotReindexSweep.run( + stored = chain.size, + unresolved = chain, + replay = backlog::replay, + isUnresolved = backlog::isUnresolved, + ) + + assertEquals(MarmotReindexSweep.DEFAULT_MAX_PASSES, report.recovered) + assertEquals(1, report.failed, "what the cap cut short is reported as still unread") + } + + @Test + fun `a replay that throws is survived by the rest of the sweep`() = runTest { + val resolved = mutableSetOf() + + val report = MarmotReindexSweep.run( + stored = 3, + unresolved = listOf("throws", "a", "b"), + replay = { id -> + if (id == "throws") error("no key for this epoch") + resolved.add(id) + }, + isUnresolved = { id -> id !in resolved }, + ) + + assertEquals(2, report.recovered, "a throw must not abandon the events behind it") + assertEquals(1, report.failed) + } + + @Test + fun `nothing unresolved does no work at all`() = runTest { + var replays = 0 + + val report = MarmotReindexSweep.run( + stored = 40, + unresolved = emptyList(), + replay = { replays++ }, + isUnresolved = { true }, + ) + + assertEquals(0, replays, "a room where nothing went wrong must be left alone") + assertTrue(report.isNoOp) + assertEquals(40, report.stored) + } + + /** + * The probe, not the replay call, is what counts a recovery: a group event can + * be replayed without error and still leave the room with nothing to show for + * it -- a commit rejected as a duplicate does exactly that. + */ + @Test + fun `a replay that raises nothing but resolves nothing is not counted as recovered`() = runTest { + val report = MarmotReindexSweep.run( + stored = 1, + unresolved = listOf("duplicateCommit"), + replay = { /* applies cleanly, writes no line */ }, + isUnresolved = { true }, + ) + + assertEquals(0, report.recovered) + assertEquals(1, report.failed) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e51ecfde..9d846a9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" }