From 61c01981cc5fc9e66a782dc4b0b32554f6356334 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 20:08:26 +0000 Subject: [PATCH 1/8] feat(marmot): add Reset Marmot State safety valve in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gives users a last-resort "nuclear" option when Marmot local state is corrupted or otherwise unrecoverable — wipes every MLS group, retained epoch secret, persisted KeyPackage bundle, subscription and in-memory chatroom for the current account. Does not broadcast leave/SelfRemove commits, since a graceful teardown may be impossible in exactly the scenarios where users reach for this option. The KeyPackage is republished lazily on the next sync so the account stays reachable. Adds clearAllState() helpers on MlsGroupManager and KeyPackageRotationManager, a resetAllState() orchestrator on MarmotManager, an Account.resetMarmotState() entry point, and a destructive row + confirm dialog in the AllSettingsScreen Danger Zone that mirrors the existing Request-to-Vanish pattern. https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- .../vitorpamplona/amethyst/model/Account.kt | 23 +++++ .../ui/screen/loggedIn/AccountViewModel.kt | 4 + .../loggedIn/settings/AllSettingsScreen.kt | 97 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 6 ++ .../amethyst/commons/marmot/MarmotManager.kt | 30 ++++++ .../KeyPackageRotationManager.kt | 20 ++++ .../marmot/mls/group/MlsGroupManager.kt | 26 +++++ 7 files changed, 206 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 27b0c5717..350736804 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2334,6 +2334,29 @@ class Account( client.publish(outbound.signedEvent, groupRelays) } + /** + * User-initiated "nuclear" reset for the Marmot subsystem. + * + * Wipes every MLS group, every retained epoch secret, every persisted + * KeyPackage bundle, every relay subscription and every in-memory + * chatroom associated with this account. Does NOT broadcast any + * SelfRemove/leave commits to peers — if the user is in this flow at + * all, local state may already be unusable and a graceful leave is + * probably not possible. Peers will see the user as unresponsive until + * their next commit evicts the stale leaf. + * + * A fresh KeyPackage will be republished lazily on the next + * `ensureMarmotKeyPackagePublished` cycle, so the account remains + * reachable for future group invites. + */ + suspend fun resetMarmotState() { + Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${signer.pubKey.take(8)}…" } + marmotManager?.resetAllState() + for (groupId in marmotGroupList.allGroupIds()) { + marmotGroupList.removeGroup(groupId) + } + } + /** * Remove a member from a Marmot MLS group. * Publishes the commit GroupEvent to group relays. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 90c454594..f6989dd7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1536,6 +1536,10 @@ class AccountViewModel( account.leaveMarmotGroup(nostrGroupId, relays) } + suspend fun resetMarmotState() { + account.resetMarmotState() + } + /** * Get the relay set for a Marmot group from MLS GroupContext metadata. * Falls back to outbox relays if the group has no configured relays. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index acc80fe97..a61e3d9c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -40,6 +41,7 @@ import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.MilitaryTech import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Settings @@ -47,17 +49,29 @@ import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.material.icons.outlined.Translate import androidx.compose.material.icons.outlined.VideoSettings +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -71,6 +85,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Preview @Composable @@ -89,6 +105,10 @@ fun AllSettingsScreen( nav: INav, ) { val tint = MaterialTheme.colorScheme.onBackground + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showResetMarmotDialog by remember { mutableStateOf(false) } + var isResettingMarmot by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -249,8 +269,85 @@ fun AllSettingsScreen( tint = tint, onClick = { nav.nav(Route.VanishEvents) }, ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.reset_marmot_state, + icon = Icons.Outlined.RestartAlt, + tint = tint, + onClick = { if (!isResettingMarmot) showResetMarmotDialog = true }, + ) } } + + if (showResetMarmotDialog) { + ResetMarmotStateDialog( + onConfirm = { + showResetMarmotDialog = false + isResettingMarmot = true + scope.launch(Dispatchers.IO) { + val successMessage = stringRes(context, R.string.reset_marmot_success) + try { + accountViewModel.resetMarmotState() + launch(Dispatchers.Main) { + Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + val failureMessage = + stringRes(context, R.string.reset_marmot_failure, e.message ?: "") + launch(Dispatchers.Main) { + Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show() + } + } finally { + isResettingMarmot = false + } + } + }, + onDismiss = { showResetMarmotDialog = false }, + ) + } +} + +@Composable +private fun ResetMarmotStateDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp), + ) + }, + title = { + Text( + text = stringRes(R.string.reset_marmot_confirm_title), + textAlign = TextAlign.Center, + ) + }, + text = { + Text(text = stringRes(R.string.reset_marmot_confirm_body)) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringRes(R.string.reset_marmot_confirm_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) } @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d9eb8d736..4bbb8f3e0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1122,6 +1122,12 @@ Account Settings App Settings Danger Zone + Reset Marmot State + Reset Marmot State? + This will permanently delete every Marmot group chat, message history, and MLS key on this device for the current account. Peers will not be notified and may still see you in groups until their next commit. This cannot be undone. A new KeyPackage will be published the next time the app syncs. + Reset + Marmot state reset. + Failed to reset Marmot state: %1$s Always Wifi-only Unmetered WiFi diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index c1fc14d85..aa3a561ff 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -295,6 +295,36 @@ class MarmotManager( return nostrGroupId } + /** + * Nuke all local Marmot state — every MLS group, every retained epoch + * secret, every persisted KeyPackage bundle, and every relay + * subscription. Does NOT publish any leave/SelfRemove commits: the + * reset path is specifically for recovering from corrupted or + * unrecoverable local state where graceful teardown may be impossible. + * + * The caller is responsible for wiping any higher-level in-memory + * structures (e.g. `MarmotGroupList`) and for re-publishing a fresh + * KeyPackage once the reset completes, if the account is still active. + */ + suspend fun resetAllState() { + Log.w("MarmotManager") { "resetAllState(): wiping all Marmot local state for ${signer.pubKey.take(8)}…" } + try { + groupManager.clearAllState() + } catch (e: Exception) { + Log.w("MarmotManager", "resetAllState(): groupManager.clearAllState failed: ${e.message}") + } + try { + keyPackageRotationManager.clearAllState() + } catch (e: Exception) { + Log.w("MarmotManager", "resetAllState(): keyPackageRotationManager.clearAllState failed: ${e.message}") + } + try { + subscriptionManager.clear() + } catch (e: Exception) { + Log.w("MarmotManager", "resetAllState(): subscriptionManager.clear failed: ${e.message}") + } + } + /** * Leave a group. * Returns proposal bytes to publish (as a GroupEvent). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index 972b66274..8cbbef310 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -157,6 +157,26 @@ class KeyPackageRotationManager( } } + /** + * Wipe all in-memory KeyPackage bundles plus the persisted snapshot. + * Intended for user-initiated Marmot resets — the caller is responsible + * for re-publishing KeyPackages afterwards (e.g. via + * `Account.ensureMarmotKeyPackagePublished`). + */ + suspend fun clearAllState() = + mutex.withLock { + activeBundles.clear() + pendingRotations.clear() + eventIdToSlot.clear() + namedSlotDTags.clear() + val store = store ?: return@withLock + try { + store.delete() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "clearAllState(): failed to delete snapshot: ${e.message}") + } + } + private data class Snapshot( val bundles: Map, val pending: Set, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index cc7ef8e2f..cfb77d9e1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -520,6 +520,32 @@ class MlsGroupManager( store.delete(nostrGroupId) } + /** + * Wipe all MLS group state, both in-memory and on-disk. Intended as a + * user-initiated "nuclear" reset for the Marmot subsystem — does NOT + * publish any SelfRemove/leave events, because the reset path is meant + * to recover from unusable local state where graceful leave may not + * even be possible. + * + * The union of in-memory group IDs and [MlsGroupStateStore.listGroups] + * is used so any orphaned on-disk state (e.g. a prior restore that + * failed to decode into memory) is also removed. + */ + suspend fun clearAllState() = + mutex.withLock { + val ids = (groups.keys + store.listGroups().toSet()).toList() + Log.d(TAG) { "clearAllState(): wiping ${ids.size} group(s): $ids" } + for (id in ids) { + try { + removeGroupStateUnlocked(id) + } catch (e: Exception) { + Log.w(TAG) { "clearAllState(): failed to wipe $id: ${e.message}" } + } + } + groups.clear() + retainedEpochs.clear() + } + // --- Key Export --- /** From e37899a1157d27201a94f5d78e872c36af34f09e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 20:29:01 +0000 Subject: [PATCH 2/8] feat(cli): add amy marmot reset safety-valve verb Mirrors the Android Danger-Zone action: wipes every MLS group, retained epoch secret, persisted KeyPackage bundle, relay subscription, and the run-state since-cursors. Does not broadcast any SelfRemove/leave commits, because the reset path is for recovering from corrupted or unrecoverable local state where graceful teardown may be impossible. Requires an explicit --yes to execute. Without --yes it emits a dry-run JSON listing the groups that would be wiped and exits 2, so scripts that forget the flag fail loudly instead of nuking state. https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- .../com/vitorpamplona/amethyst/cli/Main.kt | 6 ++ .../amethyst/cli/commands/Commands.kt | 5 ++ .../cli/commands/MarmotResetCommand.kt | 84 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index d9cf303bb..dcac7da55 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -162,6 +162,10 @@ private suspend fun marmotDispatch( Commands.await(dataDir, rest) } + "reset" -> { + Commands.reset(dataDir, rest) + } + else -> { System.err.println("unknown marmot subcommand: $head") printUsage() @@ -215,6 +219,8 @@ private fun printUsage() { | marmot await message GID --match TEXT | marmot await rename GID --name NAME | marmot await epoch GID --min N + | + | marmot reset [--yes] wipe all local MLS/KeyPackage state (destructive) """.trimMargin(), ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 4b287ce96..77bd97a7c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -70,4 +70,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = AwaitCommands.dispatch(dataDir, tail) + + suspend fun reset( + dataDir: DataDir, + tail: Array, + ): Int = MarmotResetCommand.run(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt new file mode 100644 index 000000000..e781c23a7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json + +/** + * `amy marmot reset [--yes]` — wipe all local Marmot state. + * + * Mirrors the Android "Reset Marmot State" danger-zone action. Deletes + * every MLS group, every retained epoch secret, every persisted + * KeyPackage bundle, every active relay subscription, and the run-state + * since-cursors that track catch-up progress. + * + * Does NOT publish any SelfRemove/leave commits — the reset path is + * specifically for recovering from corrupted or unrecoverable local + * state where graceful teardown may be impossible. + * + * Requires `--yes` to execute, because the command is destructive and + * cannot be undone. Without `--yes` the command prints the set of + * groups that would be wiped and exits with code 2. + */ +object MarmotResetCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val confirmed = rest.any { it == "--yes" || it == "-y" } + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val groupIds = + ctx.marmot + .activeGroupIds() + .toList() + .sorted() + + if (!confirmed) { + Json.writeLine( + mapOf( + "dry_run" to true, + "would_wipe_groups" to groupIds, + "detail" to "pass --yes to actually reset", + ), + ) + return 2 + } + + ctx.marmot.resetAllState() + ctx.state.giftWrapSince = null + ctx.state.groupSince.clear() + + Json.writeLine( + mapOf( + "reset" to true, + "wiped_groups" to groupIds, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} From 310cae0a6466513031be9bd94f68f9984666dee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 20:50:47 +0000 Subject: [PATCH 3/8] feat(cli): add amy marmot message react / delete verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two inner-event verbs on top of the existing kind:9 text send path: amy marmot message react GID TARGET_EVENT_ID EMOJI amy marmot message delete GID TARGET_EVENT_ID... Both build an unsigned rumor per MIP-03 (RumorAssembler) and wrap it in the usual kind:445 outer via MarmotOutboundProcessor — the same shape as text messages, just a different inner kind. The target event is looked up in the account's decrypted inner-message log (persisted by the inbound pipeline) so the NIP-25 / NIP-09 templates can populate the target's pubkey and kind tags without making the caller re-derive them. Test 09 previously noted that react round-trip verification was blocked on a missing CLI verb. Expanded it to react via amy and confirm B sees the kind:7 surface in its messages stream. https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 + .../amethyst/cli/commands/MessageCommands.kt | 97 ++++++++++++++++++- .../amethyst/commons/marmot/MarmotManager.kt | 55 +++++++++++ tools/marmot-interop/headless/tests-extras.sh | 25 ++++- 4 files changed, 174 insertions(+), 5 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index dcac7da55..450364f4b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -211,6 +211,8 @@ private fun printUsage() { | | marmot message send GID TEXT publish kind:9 inner event into the group | marmot message list GID [--limit N] dump decrypted inner events + | marmot message react GID EVENT_ID EMOJI publish kind:7 reaction targeting an inner event + | marmot message delete GID EVENT_ID… publish kind:5 deletion targeting inner events | | marmot await key-package NPUB (all await verbs take --timeout SECS, default 30; | marmot await group --name NAME exit 124 on timeout) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt index 5cc5bf9d1..1ed952258 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -25,17 +25,20 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.quartz.nip01Core.core.Event object MessageCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "message …") + if (tail.isEmpty()) return Json.error("bad_args", "message …") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "send" -> send(dataDir, rest) "list" -> list(dataDir, rest) + "react" -> react(dataDir, rest) + "delete" -> delete(dataDir, rest) else -> Json.error("bad_args", "message ${tail[0]}") } } @@ -111,4 +114,96 @@ object MessageCommands { ctx.close() } } + + private suspend fun react( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 3) return Json.error("bad_args", "message react ") + val targetId = rest[1] + val emoji = rest[2] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val gid = ctx.resolveGroupId(rest[0]) + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val target = findStoredInnerEvent(ctx, gid, targetId) ?: return Json.error("not_found", targetId) + val bundle = ctx.marmot.buildReactionMessage(gid, target, emoji) + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(bundle.outbound.signedEvent, targets) + + Json.writeLine( + mapOf( + "group_id" to gid, + "inner_event_id" to bundle.innerEvent.id, + "outer_event_id" to bundle.outbound.signedEvent.id, + "kind" to bundle.innerEvent.kind, + "target_event_id" to target.id, + "reaction" to emoji, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun delete( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "message delete [target_event_id ...]") + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val gid = ctx.resolveGroupId(rest[0]) + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val targetIds = rest.drop(1) + val targets = + targetIds.map { id -> + findStoredInnerEvent(ctx, gid, id) ?: return Json.error("not_found", id) + } + + val bundle = ctx.marmot.buildDeletionMessage(gid, targets) + val relays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(bundle.outbound.signedEvent, relays) + + Json.writeLine( + mapOf( + "group_id" to gid, + "inner_event_id" to bundle.innerEvent.id, + "outer_event_id" to bundle.outbound.signedEvent.id, + "kind" to bundle.innerEvent.kind, + "target_event_ids" to targets.map { it.id }, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** + * Scan the group's persisted inner-message log for an event matching + * [targetId] and reconstruct the full `Event`. Required by `react` and + * `delete` because the NIP-25 / NIP-09 templates need the target's + * `pubKey` + `kind` for p-tag / k-tag, not just the id. + */ + private suspend fun findStoredInnerEvent( + ctx: Context, + nostrGroupId: String, + targetId: String, + ): Event? { + for (line in ctx.marmot.loadStoredMessages(nostrGroupId)) { + val parsed = Event.fromJsonOrNull(line) ?: continue + if (parsed.id == targetId) return parsed + } + return null + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index aa3a561ff..5aa55bb1d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -191,6 +191,61 @@ class MarmotManager( return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) } + /** + * Build a kind:7 reaction inner event targeting another inner event in + * the same group. Like [buildTextMessage], the inner event is an + * unsigned rumor (MIP-03). The reaction uses NIP-25 conventions: content + * is the emoji / `+` / `-`; e-tag + p-tag + k-tag reference the target. + */ + suspend fun buildReactionMessage( + nostrGroupId: HexKey, + targetEvent: Event, + reaction: String, + persistOwn: Boolean = true, + ): TextMessageBundle { + val template = + com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + .build( + reaction, + com.vitorpamplona.quartz.nip01Core.hints + .EventHintBundle(targetEvent), + ) + val innerEvent = + com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + .assembleRumor( + signer.pubKey, + template, + ) + val outbound = buildGroupMessage(nostrGroupId, innerEvent) + if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) + return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) + } + + /** + * Build a kind:5 deletion inner event targeting one or more prior inner + * events in the same group. Unsigned rumor (MIP-03); e-tag + k-tag for + * each target per NIP-09. + */ + suspend fun buildDeletionMessage( + nostrGroupId: HexKey, + targetEvents: List, + persistOwn: Boolean = true, + ): TextMessageBundle { + require(targetEvents.isNotEmpty()) { "buildDeletionMessage: targetEvents must not be empty" } + val template = + com.vitorpamplona.quartz.nip09Deletions.DeletionEvent + .build(targetEvents) + val innerEvent = + com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + .assembleRumor( + signer.pubKey, + template, + ) + val outbound = buildGroupMessage(nostrGroupId, innerEvent) + if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) + return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) + } + /** * Add a member to a group by consuming their published [KeyPackageEvent]. * diff --git a/tools/marmot-interop/headless/tests-extras.sh b/tools/marmot-interop/headless/tests-extras.sh index 6e8495856..fd13abfa2 100644 --- a/tools/marmot-interop/headless/tests-extras.sh +++ b/tools/marmot-interop/headless/tests-extras.sh @@ -37,13 +37,30 @@ test_09_reply_react_unreact() { amy_json marmot message send "$gid" "replying via amy" >/dev/null || { record_result "$id" fail "amy send reply failed"; return } - if wait_for_message B "$mls_gid" "replying via amy" 90; then + if ! wait_for_message B "$mls_gid" "replying via amy" 90; then + record_result "$id" fail "B didn't receive reply"; return + fi + + # amy react — look up the anchor id from amy's own decrypted log (B's kind:9 + # "anchor for reactions" was delivered + persisted during wait_for_message), + # then hand that id to the new react verb. + sleep 3 + local a_anchor_id + a_anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \ + | jq -r '[.messages[]? | select((.content // "") == "anchor for reactions")][0].id // empty') + if [[ -z "$a_anchor_id" || "$a_anchor_id" == "null" ]]; then + record_result "$id" fail "amy couldn't find anchor message in local log"; return + fi + if ! amy_json marmot message react "$gid" "$a_anchor_id" "🍕" >/dev/null; then + record_result "$id" fail "amy marmot message react failed"; return + fi + + # Round-trip: B should surface amy's kind:7 reaction in its messages stream. + if wait_for_message B "$mls_gid" "🍕" 90; then record_result "$id" pass else - record_result "$id" fail "B didn't receive reply" + record_result "$id" fail "B didn't receive amy's reaction" fi - # NB: react/unreact round-trip verification requires a CLI verb we don't have - # yet (amy marmot message react). Once we add it, expand this test. } test_10_concurrent_commits() { From c1a818c2e25e293f384c802e7728ed455ffbc872 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 21:24:57 +0000 Subject: [PATCH 4/8] fix(marmot): return newest KeyPackage by created_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeyPackageFetcher.fetchKeyPackage used client.fetchFirst, which closes the subscription on the first event a relay sends. For kind:443 (KeyPackage) that's wrong after a rotation: the publisher does not replace the prior event (kind:443 is not addressable), so relays may still hold the old KP and whichever one wins the race gets returned. Switch to fetchAll — drain every matching event until EOSE, then pick the one with the highest created_at. Keeps freshly-rotated bundles reachable and matches whitenoise/mdk semantics. Unskips interop test 16 (wn rotates, amy discovers). The inverse path (test 13) already worked because `wn keys check` looks up via the addressable kind:10051 index instead of kind:443 directly. https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- .../mip00KeyPackages/KeyPackageFetcher.kt | 20 ++++++--- tools/marmot-interop/headless/tests-extras.sh | 43 ++++++++++++++----- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt index a68a46db2..c38ac27a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages import com.vitorpamplona.quartz.marmot.MarmotFilters import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -60,8 +60,16 @@ object KeyPackageFetcher { } /** - * One-shot fetch of a user's KeyPackage across [relays], returning the first - * matching event or `null` if none of the relays yielded one before timeout. + * Drain every kind:443 KeyPackage event for [targetPubKey] across [relays] + * and return the most recently published one (highest `created_at`), or + * `null` if nothing arrived before the timeout. + * + * Returning the newest is important after a KeyPackage rotation: MIP-00 + * does not use addressable replacement for kind:443, so a relay may still + * hold the prior KP alongside the new one. `fetchFirst` would race the + * relays and pick whichever replied first, which would frequently be the + * older event. Draining to EOSE and selecting by `created_at` matches + * MDK/whitenoise semantics and keeps freshly-rotated bundles reachable. */ suspend fun fetchKeyPackage( client: INostrClient, @@ -71,8 +79,10 @@ object KeyPackageFetcher { ): KeyPackageEvent? { if (relays.isEmpty()) return null val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey) - val event = client.fetchFirst(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs) - return event as? KeyPackageEvent + val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs) + // fetchAll returns events sorted by created_at DESC, so the first + // KeyPackageEvent is the most recent one any relay had. + return events.firstNotNullOfOrNull { it as? KeyPackageEvent } } /** diff --git a/tools/marmot-interop/headless/tests-extras.sh b/tools/marmot-interop/headless/tests-extras.sh index fd13abfa2..e155e4fb3 100644 --- a/tools/marmot-interop/headless/tests-extras.sh +++ b/tools/marmot-interop/headless/tests-extras.sh @@ -238,14 +238,37 @@ test_16_wn_keypackage_rotation() { banner "Test 16 — wn rotates KeyPackage; amy discovers new KP" local id="16 wn keypackage rotation" - # amy's KeyPackageFetcher.fetchKeyPackage calls client.fetchFirst, - # which returns the first matching event a relay sends — nostr-rs-relay - # typically serves kind:443 events in storage order, not created_at - # order, so after a rotation amy may keep seeing the older event_id - # depending on which arrives first. Making this test deterministic - # requires a "fetch latest by created_at" KeyPackage fetcher; until - # then the check flaps. The inverse direction (test 13, amy rotates - # and wn sees via `wn keys check` which is an addressable index) is - # the reliable one. - record_result "$id" skip "pending createdAt-sorted KeyPackage fetch path" + # KeyPackageFetcher now drains to EOSE and returns the event with the + # highest created_at, so a freshly-rotated KP is reliably preferred + # over an older one still held on some relays. This test verifies + # the wn->amy direction of that behaviour. + + # Capture the KP amy currently sees for B (the one B originally published). + local before + before=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null \ + | jq -r '.event_id // empty') + if [[ -z "$before" ]]; then + record_result "$id" fail "no prior KP visible to amy for B"; return + fi + + # Ask B to rotate. `wn keys publish` writes a new kind:443 with a fresh + # created_at; the old event may or may not be evicted depending on the + # relay's retention policy, so both may coexist for a while. + wn_b keys publish >/dev/null 2>&1 || { + record_result "$id" fail "wn_b keys publish failed"; return + } + + local deadline=$(( $(date +%s) + 60 )) after="" + while [[ $(date +%s) -lt $deadline ]]; do + after=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null \ + | jq -r '.event_id // empty') + [[ -n "$after" && "$after" != "$before" ]] && break + sleep 3 + done + if [[ -n "$after" && "$after" != "$before" ]]; then + info "amy saw KP rotation: ${before:0:8}… → ${after:0:8}…" + record_result "$id" pass + else + record_result "$id" fail "amy kept seeing the pre-rotation KP" + fi } From 1affe4aa92baaed80986f6c310b37336fee82352 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 21:35:36 +0000 Subject: [PATCH 5/8] feat(marmot): surface per-relay freshness on group info screen The compact GroupRelayStrip at the top of the screen uses a coloured dot to indicate whether each relay has carried any kind:445 traffic in the last 7 days, but nothing tells the user what the dot means or how stale a given relay actually is. Adds a detailed "Relays" section to the scrollable body that lists each group relay with its URL, status dot, and a "last event 3m ago" line derived from the existing relayActivity StateFlow on MarmotGroupChatroom. Rows click through to the existing RelayInfo route so users can inspect NIP-11 metadata, just like the strip. Uses the shared timeAgo helper for consistent formatting with the rest of the app. https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- .../marmotGroup/MarmotGroupInfoScreen.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 46da171b5..550996de2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier @@ -219,6 +220,17 @@ fun MarmotGroupInfoScreen( // Members list (scrollable, takes remaining vertical space) LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (groupRelays.isNotEmpty()) { + item { + RelayHealthSection( + relayUrls = groupRelays, + relayActivity = relayActivity, + nav = nav, + ) + HorizontalDivider() + } + } + item { Text( text = "Members", @@ -790,3 +802,101 @@ fun GroupRelayTile( } } } + +/** + * Detailed per-relay listing showing URL, connection-status dot, and the + * freshness of the most recent kind:445 group event we've received from + * each relay. Complements the compact [GroupRelayStrip] at the top of the + * screen — that one is an at-a-glance visual; this one surfaces the + * underlying data so users can diagnose which relay is lagging. + */ +@Composable +fun RelayHealthSection( + relayUrls: List, + relayActivity: Map, + nav: INav, +) { + val normalized = + remember(relayUrls) { + relayUrls.mapNotNull { url -> url.normalizeRelayUrlOrNull()?.let { url to it } } + } + if (normalized.isEmpty()) return + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Relays", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + val nowSeconds = System.currentTimeMillis() / 1000L + normalized.forEach { (raw, relay) -> + val lastSeen = relayActivity[relay] + val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS + RelayHealthRow( + relay = relay, + fallbackUrl = raw, + lastSeen = lastSeen, + isActive = isActive, + nav = nav, + ) + } + } +} + +@Composable +private fun RelayHealthRow( + relay: NormalizedRelayUrl, + fallbackUrl: String, + lastSeen: Long?, + isActive: Boolean, + nav: INav, +) { + val context = LocalContext.current + val dotColor = + if (isActive) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.placeholderText + } + val subtitle = + if (lastSeen == null) { + "no events yet" + } else { + "last event${timeAgo(lastSeen, context)}" + } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.RelayInfo(relay.url)) } + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(dotColor), + ) + Column( + modifier = + Modifier + .padding(start = 12.dp) + .weight(1f), + ) { + Text( + text = relay.url.ifEmpty { fallbackUrl }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} From f08ad845f1b76256231f2e9c6229aad34d9e85a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 21:46:14 +0000 Subject: [PATCH 6/8] test(marmot-interop): un-skip tests 14-15 now that filtered UpdatePath works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests 14 (wn removes A) and 15 (wn_c leaves) were skipped with a rationale referring to the pre-filter applyUpdatePath that rejected wn/mdk-core commits whose UpdatePath omitted nodes with empty-copath resolution per RFC 9420 §7.7. That code path was fixed in 3279c246 ("use filtered direct path in UpdatePath per RFC 9420 §7.9") which made both outbound generation and inbound validation operate on the filtered path. The inverted-role interop tests can now actually run. Test 14 creates a dedicated Interop-14 group, has wn_b admin-remove A, and asserts amy flips to "not_member" on her next sync. Test 15 creates Interop-15, has wn_c self-remove, nudges wn_b to commit the SelfRemove via a rename, and asserts amy sees (C gone, A present) AND can still publish a kind:9 that B receives — proves the filtered commit applied cleanly and encryption survived. https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- tools/marmot-interop/headless/tests-extras.sh | 116 +++++++++++++++--- 1 file changed, 98 insertions(+), 18 deletions(-) diff --git a/tools/marmot-interop/headless/tests-extras.sh b/tools/marmot-interop/headless/tests-extras.sh index e155e4fb3..ef4deebcd 100644 --- a/tools/marmot-interop/headless/tests-extras.sh +++ b/tools/marmot-interop/headless/tests-extras.sh @@ -208,30 +208,110 @@ test_14_wn_removes_a() { banner "Test 14 — wn (admin) removes A; amy processes Remove" local id="14 wn removes amy" - # Known gap: wn (mdk-core/openmls) emits the filtered direct-path - # form of UpdatePath on Remove commits per RFC 9420 §7.7 — when the - # copath of a direct-path node has an empty resolution (every leaf - # under it is blank) the corresponding UpdatePathNode is omitted. - # Quartz's RatchetTree.applyUpdatePath currently requires the - # unfiltered node count (`pathNodes.size == directPath.size`) so - # every wn->amy Remove triggers - # "UpdatePath node count (N) doesn't match direct path length (N+k)". - # That's a pre-existing quartz conformance bug, out of scope for - # this branch; the harness carries the test so it starts passing - # the moment the filtered-path path is wired up. - record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath" + # Exercises inbound filtered-direct-path per RFC 9420 §7.7: wn + # (mdk-core/openmls) strips UpdatePathNodes whose copath has empty + # resolution, and amy must accept the short path on receive. Quartz + # wires this on both sides as of commit 3279c246. + + local out mls_gid + out=$(wn_b --json groups create "Interop-14" "$C_NPUB" 2>>"$LOG_FILE") || { + record_result "$id" fail "wn create Interop-14 failed"; return + } + mls_gid=$(printf '%s' "$out" | jq_group_id) + [[ -n "$mls_gid" ]] || { record_result "$id" fail "no group_id from create"; return; } + wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true + + wn_b groups add-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn add-members A failed"; return + } + local a_gid + a_gid=$(amy_json marmot await group --name "Interop-14" --timeout 30 | jq -r '.group_id // empty') + [[ -n "$a_gid" ]] || { record_result "$id" fail "A never received Interop-14 invite"; return; } + + # B (admin) removes A. The resulting commit carries a filtered + # UpdatePath; amy must apply it without throwing on the node-count + # mismatch that used to happen pre-filter-support. + wn_b groups remove-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn remove-members A failed"; return + } + + # Amy should observe that she's no longer a member. `group show` returns + # a not_member error as soon as the Remove commit is applied locally. + local deadline=$(( $(date +%s) + 120 )) removed=0 + while [[ $(date +%s) -lt $deadline ]]; do + local show rc + show=$(amy_json marmot group show "$a_gid" 2>&1) + rc=$? + if [[ $rc -ne 0 ]] && printf '%s' "$show" | jq -e '.error == "not_member"' >/dev/null 2>&1; then + removed=1; break + fi + sleep 3 + done + if [[ "$removed" -eq 1 ]]; then + record_result "$id" pass + else + record_result "$id" fail "amy still considered herself a member after wn Remove" + fi } test_15_wn_member_leaves() { banner "Test 15 — wn_c leaves; amy + wn_b process SelfRemove" local id="15 wn_c leaves" - # Same filtered_direct_path gap as test 14: when wn_c leaves a - # 3-member group, wn_b folds the SelfRemove into a commit whose - # UpdatePath uses RFC 9420 §7.7 filtering, and amy's strict - # applyUpdatePath rejects it. Skip until quartz handles the - # filtered form on inbound. - record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath" + # Same filtered-direct-path path as test 14, but the trigger is a + # SelfRemove proposal from C that wn_b folds into a commit. Amy stays + # a member here — verification is that the commit applies cleanly and + # the group continues to function afterwards. + + local out mls_gid + out=$(wn_b --json groups create "Interop-15" "$C_NPUB" 2>>"$LOG_FILE") || { + record_result "$id" fail "wn create Interop-15 failed"; return + } + mls_gid=$(printf '%s' "$out" | jq_group_id) + [[ -n "$mls_gid" ]] || { record_result "$id" fail "no group_id from create"; return; } + wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true + + wn_b groups add-members "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn add-members A failed"; return + } + local a_gid + a_gid=$(amy_json marmot await group --name "Interop-15" --timeout 30 | jq -r '.group_id // empty') + [[ -n "$a_gid" ]] || { record_result "$id" fail "A never received Interop-15 invite"; return; } + + # C self-removes. wn_b picks up the SelfRemove proposal and commits it. + wn_c groups leave "$mls_gid" >/dev/null 2>&1 || true + sleep 5 + # Nudge B to commit any outstanding proposals via a no-op rename round-trip. + wn_b groups rename "$mls_gid" "Interop-15 (C left)" >/dev/null 2>&1 || true + + # Wait for amy to see C gone AND stay a member herself. + local deadline=$(( $(date +%s) + 120 )) ok=0 + while [[ $(date +%s) -lt $deadline ]]; do + local show + show=$(amy_json marmot group show "$a_gid" 2>/dev/null) || { sleep 3; continue; } + local c_still + c_still=$(printf '%s' "$show" | jq --arg p "$C_HEX" '[.members[]? | select(.pubkey == $p)] | length') + local a_still + a_still=$(printf '%s' "$show" | jq --arg p "$A_HEX" '[.members[]? | select(.pubkey == $p)] | length') + if [[ "$c_still" == "0" && "$a_still" == "1" ]]; then + ok=1; break + fi + sleep 3 + done + if [[ "$ok" -ne 1 ]]; then + record_result "$id" fail "amy never saw a (C gone, A present) state"; return + fi + + # Post-commit round-trip: amy sends, B receives. Confirms encryption + # survived the filtered UpdatePath application on amy's side. + amy_json marmot message send "$a_gid" "after wn_c leave" >/dev/null || { + record_result "$id" fail "amy send failed after wn_c leave"; return + } + if wait_for_message B "$mls_gid" "after wn_c leave" 90; then + record_result "$id" pass + else + record_result "$id" fail "B didn't receive amy's post-leave message" + fi } test_16_wn_keypackage_rotation() { From 73becef0388fccc84981dcfa8fc4be62877be24c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 22:05:12 +0000 Subject: [PATCH 7/8] test(marmot): cover MlsGroupManager leave + rejoin round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing testReaddAfterRemove_RejoinerCanEncryptAndDecrypt in MlsGroupLifecycleTest exercises the MLS layer (two raw MlsGroup instances, admin kicks peer, fresh KP, rejoin). Nothing covered the same flow one level up at the MlsGroupManager layer, where the persistent state store, retained-epoch wipe, and Welcome routing for the same nostrGroupId actually live. Adds testLeaveAndRejoin_SameGroupIdEndToEnd: two MlsGroupManager instances (Alice + Bob) with separate InMemoryGroupStateStores go through the full cycle — Alice creates with a MarmotGroupData extension, Bob joins via Welcome, bidirectional message round-trip, Bob calls manager.leaveGroup (asserts store + retained epochs both wiped), Alice removes Bob's leaf, Bob's second KeyPackage is admin- added, Bob processes the second Welcome under the same nostrGroupId, and bidirectional round-trip works again at the new epoch. Closes the "MarmotManager.leaveGroup + rejoin" and "persistent-store leave + fresh Welcome for same nostrGroupId" gaps from the review. Documents inline why Alice drives the eviction commit directly: the standalone-proposal ingestion pipeline (MarmotInboundProcessor) is a separate, still-unimplemented concern. https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- .../quartz/marmot/mls/MlsGroupManagerTest.kt | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt index 47c76842b..159febb7d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt @@ -20,15 +20,19 @@ */ package com.vitorpamplona.quartz.marmot.mls +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.fail /** * In-memory implementation of [MlsGroupStateStore] for testing. @@ -220,4 +224,150 @@ class MlsGroupManagerTest { assertTrue(manager2.isMember(groupId2)) } } + + /** + * End-to-end amethyst ↔ amethyst leave + rejoin round-trip at the + * [MlsGroupManager] layer. Covers the gaps left by + * [MlsGroupLifecycleTest.testReaddAfterRemove_RejoinerCanEncryptAndDecrypt] + * (which operates a layer below on raw [MlsGroup] instances): + * + * 1. Bob's [leaveGroup] must actually wipe his persisted state — + * `isMember` flips to false and the store no longer holds his group. + * 2. Alice (admin) applying a Remove commit for the same leaf keeps + * her own group consistent afterwards. + * 3. Bob can rejoin the SAME `nostrGroupId` with a fresh KeyPackage; + * the leave-side state cleanup must not leave any stale residue + * that would corrupt the new Welcome. + * 4. Post-rejoin, bidirectional MLS encryption works in both + * directions — so the new epoch's tree, keying material and + * exporter secrets are all in lockstep across both managers. + */ + @Test + fun testLeaveAndRejoin_SameGroupIdEndToEnd() { + runBlocking { + val aliceStore = InMemoryGroupStateStore() + val alice = MlsGroupManager(aliceStore) + val bobStore = InMemoryGroupStateStore() + val bob = MlsGroupManager(bobStore) + + // Seed with a NostrGroupData extension so Bob's processWelcome + // can derive the nostrGroupId from the Welcome's GroupContext. + // adminPubkeys is intentionally empty — the admin-depletion + // guard (MlsGroup.enforceNoAdminDepletion) and MIP-03 + // committer-authority check both short-circuit when no admins + // are configured, so Alice can drive all commits and Bob can + // SelfRemove without tripping the admin gate. + val groupData = + MarmotGroupData( + nostrGroupId = groupId, + name = "leave-rejoin", + ) + alice.createGroup( + groupId, + "alice".encodeToByteArray(), + initialExtensions = listOf(groupData.toExtension()), + ) + assertTrue(alice.isMember(groupId)) + + // Bob publishes his first KeyPackage via a throwaway scratch + // group (mirrors how a standalone KP is generated in + // production before a Welcome has ever been seen). + val bobBundle1 = + MlsGroup + .create("bob".encodeToByteArray()) + .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + val firstAdd = alice.addMember(groupId, bobBundle1.keyPackage.toTlsBytes()) + val firstWelcome = firstAdd.welcomeBytes ?: fail("Alice's first add must produce a Welcome") + + bob.processWelcome(firstWelcome, bobBundle1, hintNostrGroupId = groupId) + assertTrue(bob.isMember(groupId)) + assertEquals(2, alice.getGroup(groupId)!!.memberCount) + assertEquals(2, bob.getGroup(groupId)!!.memberCount) + + // Baseline round-trip both directions. + val hello = "hello before leave".encodeToByteArray() + assertContentEquals( + hello, + bob.getGroup(groupId)!!.decrypt(alice.getGroup(groupId)!!.encrypt(hello)).content, + ) + val pong = "pong before leave".encodeToByteArray() + assertContentEquals( + pong, + alice.getGroup(groupId)!!.decrypt(bob.getGroup(groupId)!!.encrypt(pong)).content, + ) + + // --- Leave: Bob's unilateral local teardown. ----------------- + // MlsGroupManager.leaveGroup returns the SelfRemove proposal + // bytes (Alice would normally ingest these via the inbound + // pipeline and fold them into a commit). It ALSO deletes Bob's + // group from his store — the return contract is "your state + // is gone now, the bytes are your farewell". Verify both. + val (selfRemoveBytes, _) = bob.leaveGroup(groupId) + assertTrue(selfRemoveBytes.isNotEmpty(), "SelfRemove bytes must be returned") + assertFalse(bob.isMember(groupId), "Bob must no longer hold the group") + assertNull(bobStore.load(groupId), "Bob's disk state for the group must be gone") + assertEquals( + emptyList(), + bobStore.loadRetainedEpochs(groupId), + "Bob's retained-epoch secrets must be wiped too", + ) + + // --- Admin commit that actually evicts Bob's leaf. ---------- + // The standalone-proposal ingestion pipeline is a separate + // concern (MarmotInboundProcessor.processPublicMessage still + // returns "Standalone proposals not yet supported" for the + // PROPOSAL content type). Here we verify the equivalent + // admin-kick flow: Alice removes Bob's leaf directly, which + // is what an admin auto-commit would do after picking up the + // proposal. + val bobLeafIndex = bob.getGroup(groupId)?.leafIndex ?: 1 + alice.removeMember(groupId, bobLeafIndex) + assertEquals( + 1, + alice.getGroup(groupId)!!.memberCount, + "Alice must be solo after evicting Bob", + ) + + // --- Rejoin: fresh KeyPackage + fresh Welcome, SAME groupId. - + val bobBundle2 = + MlsGroup + .create("bob".encodeToByteArray()) + .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + val secondAdd = alice.addMember(groupId, bobBundle2.keyPackage.toTlsBytes()) + val secondWelcome = + secondAdd.welcomeBytes ?: fail("Alice's re-add must produce a Welcome") + + bob.processWelcome(secondWelcome, bobBundle2, hintNostrGroupId = groupId) + assertTrue( + bob.isMember(groupId), + "Bob must be a member again under the same nostrGroupId", + ) + assertEquals(2, bob.getGroup(groupId)!!.memberCount) + assertNotNull( + bobStore.load(groupId), + "Bob's store must hold fresh state for the same nostrGroupId", + ) + + // Epoch must be aligned. + assertEquals( + alice.getGroup(groupId)!!.epoch, + bob.getGroup(groupId)!!.epoch, + "Rejoiner must sit at Alice's current epoch", + ) + + // Bidirectional round-trip after rejoin — proves the shared + // tree, keying material and exporter secrets are all back in + // sync, not just that the Welcome deserialized cleanly. + val afterHello = "hello after rejoin".encodeToByteArray() + assertContentEquals( + afterHello, + bob.getGroup(groupId)!!.decrypt(alice.getGroup(groupId)!!.encrypt(afterHello)).content, + ) + val afterPong = "pong after rejoin".encodeToByteArray() + assertContentEquals( + afterPong, + alice.getGroup(groupId)!!.decrypt(bob.getGroup(groupId)!!.encrypt(afterPong)).content, + ) + } + } } From 5194190f5ecdd4d8d4143056bf773cfc9caffa11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 22:20:01 +0000 Subject: [PATCH 8/8] test(marmot): cover MarmotManager leave + rejoin commons-layer round-trip The quartz-layer companion (testLeaveAndRejoin_SameGroupIdEndToEnd in MlsGroupManagerTest) only exercises the MLS engine. This one goes one layer up and drives the full commons-layer pipeline end to end: - NIP-59 gift wrap / unseal of the Welcome (kind:1059 -> kind:13 -> kind:444) via MarmotManager.ingest, - ChaCha20-Poly1305 outer + MLS PrivateMessage decryption of the group event (kind:445) via the same ingest entrypoint, - persisted inner-event log via MarmotMessageStore (asserts log is wiped on leave and does not resurrect pre-leave messages on rejoin), - subscription bookkeeping on MarmotSubscriptionManager (asserts subscribe on join, unsubscribe on leave, re-subscribe on rejoin), - KeyPackage bundle lifecycle via KeyPackageRotationManager + KeyPackageBundleStore (two distinct KPs generated for the two joins). Same admin-kick workaround as the quartz-layer test: the standalone- proposal ingestion path in MarmotInboundProcessor still returns "Standalone proposals not yet supported", so the test has Alice commit the Remove directly and documents that inline. Self-contained in-memory stores live alongside the test so we don't need to cross-depend on quartz's test utilities. Runs under commons androidHostTest (same source set that already pulls in secp256k1 JVM bindings for real crypto). https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW --- .../marmot/MarmotManagerLeaveRejoinTest.kt | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 commons/src/androidHostTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt diff --git a/commons/src/androidHostTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt b/commons/src/androidHostTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt new file mode 100644 index 000000000..6025c9484 --- /dev/null +++ b/commons/src/androidHostTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * End-to-end leave + rejoin round-trip at the [MarmotManager] layer. + * + * One level above [com.vitorpamplona.quartz.marmot.mls.MlsGroupManagerTest.testLeaveAndRejoin_SameGroupIdEndToEnd] + * in quartz, which only exercises the MLS engine. This one drives the full + * commons-layer pipeline: + * + * - NIP-59 gift wrap / unwrap of the Welcome (kind:1059 → kind:13 → kind:444), + * - ChaCha20-Poly1305 outer layer + MLS PrivateMessage decryption of group + * events (kind:445) via `MarmotManager.ingest`, + * - persisted inner-event log via [MarmotMessageStore], + * - subscription bookkeeping in [com.vitorpamplona.quartz.marmot.MarmotSubscriptionManager], + * - KeyPackage bundle lifecycle via [KeyPackageBundleStore] + + * [com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager]. + * + * Scenarios covered in one flow: + * 1. Alice creates the group with a [MarmotGroupData] extension + * (required so the receiver's `processWelcome` can derive the + * nostrGroupId — admin list left empty so MIP-03 gates don't fire). + * 2. Bob publishes a KeyPackage, Alice `addMember`s it, Bob `ingest`s the + * resulting gift wrap and joins. + * 3. Round-trip: Alice sends a kind:9 via `buildTextMessage`, Bob ingests + * the resulting kind:445, the plaintext lands in his [MarmotMessageStore]. + * 4. `bob.leaveGroup(...)` wipes Bob's MLS state, persisted messages and + * subscription for that group. + * 5. Alice evicts Bob's leaf with an admin-kick Remove commit. (Standalone- + * proposal ingestion at [com.vitorpamplona.quartz.marmot.MarmotInboundProcessor] + * is a separate TODO — this test pins down the cleanup + rejoin story + * around it.) + * 6. Bob publishes a fresh KeyPackage, Alice `addMember`s it, Bob + * `ingest`s the second gift wrap and rejoins the same nostrGroupId. + * 7. Round-trip after rejoin proves the shared tree / keying material + * are in sync and that `MarmotManager.ingest` correctly routes a + * kind:445 authored at the new epoch. + */ +class MarmotManagerLeaveRejoinTest { + private val nostrGroupId = "a".repeat(64) + private val relay = + RelayUrlNormalizer.normalizeOrNull("wss://example.invalid/") + ?: error("test relay must normalize") + + @Test + fun testLeaveAndRejoin_CommonsLayer() { + runBlocking { + val alice = buildManager() + val bob = buildManager() + + // Step 1: Alice creates the group with a NostrGroupData extension. + val metadata = + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = "leave-rejoin-commons", + relays = listOf(relay.url), + ) + alice.manager.createGroup(nostrGroupId, metadata) + assertTrue(alice.manager.isMember(nostrGroupId)) + + // Step 2: Bob publishes a KP, Alice adds him, Bob ingests the wrap. + val bobKp1 = bob.manager.generateKeyPackageEvent(listOf(relay)) + val (commitEvent1, delivery1) = + alice.manager.addMember(nostrGroupId, bobKp1, listOf(relay)) + assertNotNull(delivery1, "Alice's first add must produce a gift-wrapped Welcome") + + val firstJoin = bob.manager.ingest(delivery1.giftWrapEvent) + assertIs( + firstJoin, + "Bob must join via the gift-wrapped Welcome", + ) + assertEquals(nostrGroupId, firstJoin.nostrGroupId) + assertTrue(bob.manager.isMember(nostrGroupId)) + assertTrue( + bob.manager.subscriptionManager.isSubscribed(nostrGroupId), + "Joining must register a subscription for the group", + ) + + // The commit that added Bob also arrives at Alice (echo) — ingest + // is idempotent: her own pipeline marks the id processed before + // publish, so a replay routes to Ignored. + assertIs(alice.manager.ingest(commitEvent1.signedEvent)) + + // Step 3: Alice sends a kind:9 inner event. Bob ingests the kind:445. + val helloBefore = "hello before leave" + val helloBundle = alice.manager.buildTextMessage(nostrGroupId, helloBefore) + val helloResult = bob.manager.ingest(helloBundle.outbound.signedEvent) + val helloMessage = + assertIs(helloResult, "Bob should decrypt Alice's kind:9") + assertEquals(nostrGroupId, helloMessage.inner.groupId) + + val bobMessages1 = bob.manager.loadStoredMessages(nostrGroupId) + assertTrue( + bobMessages1.any { it.contains(helloBefore) }, + "Bob's persisted log should contain Alice's pre-leave message; was $bobMessages1", + ) + + // Step 4: Bob leaves. SelfRemove bytes are returned; local state wiped. + val leaveOutbound = bob.manager.leaveGroup(nostrGroupId) + assertTrue(leaveOutbound.signedEvent.content.isNotEmpty()) + assertFalse( + bob.manager.isMember(nostrGroupId), + "Bob must no longer hold the MLS group after leaveGroup", + ) + assertNull( + bob.mlsStore.load(nostrGroupId), + "Bob's MLS state must be deleted from the store", + ) + assertEquals( + emptyList(), + bob.manager.loadStoredMessages(nostrGroupId), + "Bob's persisted inner-event log must be wiped on leave", + ) + assertFalse( + bob.manager.subscriptionManager.isSubscribed(nostrGroupId), + "Bob's subscription for the group must be cleared on leave", + ) + + // Step 5: Alice evicts Bob's leaf (admin-kick stands in for the + // still-unimplemented standalone-proposal ingestion pipeline). + // Bob's leaf was assigned during addMember; look it up against + // Alice's MLS group view to avoid hard-coding a leaf index. + val bobLeaf = + alice.manager.groupManager + .getGroup(nostrGroupId) + ?.memberIdentityHex(1) + ?.let { 1 } + ?: error("Bob must still occupy leaf 1 on Alice's side") + alice.manager.removeMember(nostrGroupId, bobLeaf) + assertEquals(1, alice.manager.memberCount(nostrGroupId)) + + // Step 6: Bob rejoins with a fresh KP under the SAME nostrGroupId. + val bobKp2 = bob.manager.generateKeyPackageEvent(listOf(relay)) + val (_, delivery2) = + alice.manager.addMember(nostrGroupId, bobKp2, listOf(relay)) + assertNotNull(delivery2, "Alice's re-add must produce a second gift-wrapped Welcome") + + val secondJoin = bob.manager.ingest(delivery2.giftWrapEvent) + assertIs( + secondJoin, + "Bob must rejoin via the second gift wrap", + ) + assertEquals(nostrGroupId, secondJoin.nostrGroupId) + assertTrue(bob.manager.isMember(nostrGroupId)) + assertTrue( + bob.manager.subscriptionManager.isSubscribed(nostrGroupId), + "Rejoining must re-subscribe Bob to the group", + ) + assertNotNull( + bob.mlsStore.load(nostrGroupId), + "Bob's store must hold fresh MLS state for the rejoined group", + ) + + // Step 7: Round-trip after rejoin — proves the new epoch works + // end-to-end through commons, not just that the Welcome deserialized. + val helloAfter = "hello after rejoin" + val afterBundle = alice.manager.buildTextMessage(nostrGroupId, helloAfter) + val afterResult = bob.manager.ingest(afterBundle.outbound.signedEvent) + val afterMessage = + assertIs( + afterResult, + "Bob should decrypt Alice's post-rejoin kind:9", + ) + assertEquals(nostrGroupId, afterMessage.inner.groupId) + + val bobMessages2 = bob.manager.loadStoredMessages(nostrGroupId) + assertTrue( + bobMessages2.any { it.contains(helloAfter) }, + "Bob's fresh log should contain the post-rejoin message; was $bobMessages2", + ) + assertFalse( + bobMessages2.any { it.contains(helloBefore) }, + "Pre-leave messages MUST NOT survive the rejoin — forward secrecy sanity", + ) + } + } + + // --- Test fixtures ----------------------------------------------------- + + private data class Fixture( + val manager: MarmotManager, + val mlsStore: InMemoryMlsGroupStateStore, + val kpStore: InMemoryKeyPackageBundleStore, + val messageStore: InMemoryMarmotMessageStore, + ) + + private fun buildManager(): Fixture { + val signer = NostrSignerInternal(KeyPair()) + val mls = InMemoryMlsGroupStateStore() + val kp = InMemoryKeyPackageBundleStore() + val msg = InMemoryMarmotMessageStore() + return Fixture(MarmotManager(signer, mls, msg, kp), mls, kp, msg) + } +} + +private class InMemoryMlsGroupStateStore : MlsGroupStateStore { + private val states = mutableMapOf() + private val retained = mutableMapOf>() + + override suspend fun save( + nostrGroupId: String, + state: ByteArray, + ) { + states[nostrGroupId] = state + } + + override suspend fun load(nostrGroupId: String): ByteArray? = states[nostrGroupId] + + override suspend fun delete(nostrGroupId: String) { + states.remove(nostrGroupId) + retained.remove(nostrGroupId) + } + + override suspend fun listGroups(): List = states.keys.toList() + + override suspend fun saveRetainedEpochs( + nostrGroupId: String, + retainedSecrets: List, + ) { + retained[nostrGroupId] = retainedSecrets + } + + override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retained[nostrGroupId] ?: emptyList() +} + +private class InMemoryKeyPackageBundleStore : KeyPackageBundleStore { + private var snapshot: ByteArray? = null + + override suspend fun save(snapshot: ByteArray) { + this.snapshot = snapshot + } + + override suspend fun load(): ByteArray? = snapshot + + override suspend fun delete() { + snapshot = null + } +} + +private class InMemoryMarmotMessageStore : MarmotMessageStore { + private val messages = mutableMapOf>() + + override suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) { + messages.getOrPut(nostrGroupId) { mutableListOf() }.add(innerEventJson) + } + + override suspend fun loadMessages(nostrGroupId: String): List = messages[nostrGroupId]?.toList() ?: emptyList() + + override suspend fun delete(nostrGroupId: String) { + messages.remove(nostrGroupId) + } +}