Merge pull request #4073 from vitorpamplona/claude/marmot-protocol-mdk-sync-bubfqq

feat(marmot): Marmot/MLS group messaging, interoperable with White Noise
This commit is contained in:
Vitor Pamplona
2026-09-11 08:11:29 -04:00
committed by GitHub
289 changed files with 44793 additions and 2114 deletions
+8 -1
View File
@@ -17,7 +17,9 @@ relay-server code; smaller modules are `benchmark` (Android macrobenchmarks),
`relayBench` (head-to-head relay benchmark — boots geode, strfry and other
relay binaries, replays a shared deterministic corpus, measures ingest/query/
NIP-77 sync; `./relayBench/run.sh`, see `relayBench/README.md`) and
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `marmotQuic` is the Marmot raw-QUIC transport
binding for agent text stream previews (`transports/quic.md`) on top of
`:quic` — its own ALPNs and framing, not WebTransport. `nestsClient` runs
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
production listener AND speaker paths both run on moq-lite to interop with the
@@ -78,6 +80,11 @@ amethyst/
KMP project that needs MoQ. Has no Android-framework dependencies.
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio.
- `marmotQuic/` = Marmot's raw-QUIC binding for agent text stream previews.
Takes `:quic` for the connection and `:quartz` for the record/envelope
codecs. Not WebTransport — the binding has its own ALPNs and writes frames
straight onto QUIC streams, so it deliberately does not reuse
`nestsClient`'s `WebTransportSession`.
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
+1 -1
View File
@@ -40,7 +40,7 @@ locally and tick the box. If your change can't possibly affect them
(docs-only, UI-only on unrelated screens, etc.), tick "N/A". -->
- [ ] N/A — change can't affect wire bytes / decoded audio / MLS state / DM envelopes
- [ ] Marmot / MLS — `cli/tests/marmot/marmot-interop-headless.sh` (NIP-EE / `whitenoise-rs`)
- [ ] Marmot / MLS — `cli/tests/marmot/marmot-interop-headless.sh` (Marmot / MDK `wn`)
- [ ] NIP-17 DM — `cli/tests/dm/dm-interop-headless.sh`
- [ ] Audio rooms manual — `cli/tests/nests/nests-interop.sh` (Amethyst ↔ nostrnests.com)
- [ ] MoQ-lite hang-tier — `:nestsClient:jvmTest -DnestsHangInterop=true`
+4
View File
@@ -402,6 +402,10 @@ dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":nestsClient"))
// Agent text stream previews: the raw-QUIC binding plus the QUIC
// stack under it (for the certificate validator it requires).
implementation(project(":marmotQuic"))
implementation(project(":quic"))
implementation(project(":nappletHost"))
// Compose Multiplatform resources runtime, so app-side screens that share a
// string with a commons renderer can read commons' generated `Res` directly
@@ -32,6 +32,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.marmot.MarmotPublisher
import com.vitorpamplona.amethyst.commons.marmot.MarmotPushCoordinator
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
@@ -174,6 +176,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.marmotquic.QuicAgentTextStreamTransport
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
@@ -203,6 +206,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent
import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.image
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -212,6 +216,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
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.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -338,6 +343,7 @@ import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import com.vitorpamplona.quartz.utils.containsAny
import com.vitorpamplona.quic.tls.JdkCertificateValidator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -373,6 +379,24 @@ class Account(
val mlsGroupStateStore: MlsGroupStateStore? = null,
val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null,
val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null,
/**
* Durable publish obligations. Null means publish-before-apply does not
* survive a restart, so a commit interrupted mid-publish is replaced by a
* fresh one for the same epoch — a fork against the peers that took the
* first.
*/
val marmotPublishObligationStore: com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore? = null,
/**
* Durable "already decided" markers for inbound events. Null means every
* backdated gift wrap is re-unwrapped on every sync.
*/
val marmotIngestDedupStore: com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore? = null,
/**
* Durable push token records, stamps and tombstones. Null means a restart
* forgets every tombstone, so a relayed but revoked token record can win
* once and start waking a device its owner asked to be forgotten.
*/
val marmotPushStateStore: com.vitorpamplona.quartz.marmot.mip05PushNotifications.MarmotPushStateStore? = null,
val powQueue: () -> PoWPublishQueue? = { null },
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
@@ -920,7 +944,56 @@ class Account(
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) }
val marmotManager: MarmotManager? =
mlsGroupStateStore?.let {
MarmotManager(
signer,
it,
marmotMessageStore,
marmotKeyPackageStore,
// Publish-before-apply: a group-state change becomes canonical
// only once a relay in the group's own scope returns OK true.
// `publishAndConfirm` is exactly that "at least one
// acknowledged accept" rule; a plain `publish` would report
// success for bytes nobody took.
MarmotPublisher { event, relays -> client.publishAndConfirm(event, relays) },
marmotPublishObligationStore,
marmotIngestDedupStore,
scope = scope,
)
}
/**
* Push token gossip (`features/push-notifications.md`) for the groups this
* account is in.
*
* Present whenever Marmot itself is, because CONSUMING gossip costs nothing
* and is what lets this client answer a peer's kind:447 later. Producing a
* record of our own is a separate decision: it needs a device token and a
* notification server public key, neither of which the protocol discovers.
*/
val marmotPushCoordinator: MarmotPushCoordinator? =
marmotManager?.let {
marmotPushStateStore?.let { store -> MarmotPushCoordinator(it, store) } ?: MarmotPushCoordinator(it)
}
/**
* Raw QUIC for agent text stream previews (`transports/quic.md`).
*
* Only the live preview needs it. A device that cannot open a QUIC
* connection still participates fully — it reads every stream's
* authoritative kind:9 like ordinary chat — which is why this is a
* separate optional piece rather than part of [marmotManager].
*/
val marmotStreamTransport: MarmotQuicTransport by lazy {
QuicAgentTextStreamTransport(
parentScope = scope,
// Preview brokers are commonly self-signed and the binding expects
// that; the platform trust store is still the default answer, and
// a deployment that pins does it here.
certificateValidator = JdkCertificateValidator(),
)
}
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
@@ -3716,6 +3789,26 @@ class Account(
// Restore Marmot MLS group state on startup
if (marmotManager != null) {
// Derived kind:1210 rows go straight into the conversation. Only
// DERIVED rows arrive here — one received over the wire is an
// assertion by its sender and is dropped at ingest — so these are
// safe to render with attribution.
marmotManager.onSystemRowDerived = { groupId, row ->
cache.justConsume(row, null, true)
val note = cache.getOrCreateNote(row.id)
note.event = row
marmotGroupList.addMessage(groupId, note)
}
// A disappearing message that is gone from disk but still on screen
// has not disappeared. Drop it from the conversation as it expires,
// rather than waiting for the next read to omit it.
marmotManager.onMessagesExpired = { groupId, expiredIds ->
expiredIds.forEach { id ->
cache.getNoteIfExists(id)?.let { marmotGroupList.removeMessage(groupId, it) }
}
}
scope.launch(Dispatchers.IO) {
marmotManager.restoreAll()
@@ -20,7 +20,15 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -52,7 +60,7 @@ class AccountMarmotActions(
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
account.marmotManager
?.groupMetadata(nostrGroupId)
?.groupView(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -135,8 +143,11 @@ class AccountMarmotActions(
?.toSet()
.orEmpty()
val fetchRelays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
KeyPackageFetcher.fetchRelaysFor(
targetOutbox = memberOutbox,
myOutbox = myOutbox,
targetKeyPackageRelays = memberKeyPackageRelays,
)
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
@@ -214,15 +225,24 @@ class AccountMarmotActions(
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}… " +
val commit =
commitEvent?.let { "kind=${it.signedEvent.kind} id=${it.signedEvent.id.take(8)}…" }
?: "none (founding add, merged locally)"
"addMarmotGroupMember: built commit $commit " +
"welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}"
}
// Publish commit first (critical ordering)
// Nothing to publish here either way. A normal commit was already
// published by the manager, which only advances the group once a relay
// acknowledged it (publish-before-apply); publishing it again would
// just duplicate the event. A FOUNDING add has no commit at all — the
// creator was the group's only member, so it merges locally under the
// empty publication obligation and the invitee gets epoch 1 from the
// Welcome.
Log.d("MarmotDbg") {
"addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
commitEvent?.let { "addMarmotGroupMember: commit kind:${it.signedEvent.kind} published to ${groupRelays.size} relay(s)" }
?: "addMarmotGroupMember: founding add merged locally, no commit published"
}
account.client.publish(commitEvent.signedEvent, groupRelays.toSet())
// Then send the Welcome gift wrap to the new member.
//
@@ -266,11 +286,16 @@ class AccountMarmotActions(
/**
* Relays where this account publishes kind:30443 KeyPackage events.
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
*
* The NIP-65 write set is the discovery rule now — the spec removed the
* dedicated kind:10051 KeyPackage relay list. The account's own 10051 is
* still unioned in so peers that have not migrated keep finding us.
*/
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.publishRelaysFor(account.keyPackageRelayList.flow.value, account.outboxRelays.flow.value)
KeyPackageFetcher.publishRelaysFor(
myOutbox = account.outboxRelays.flow.value,
legacyKeyPackageRelayList = account.keyPackageRelayList.flow.value,
)
/**
* Publish or rotate KeyPackage events.
@@ -376,12 +401,40 @@ class AccountMarmotActions(
}
/**
* Create a new Marmot MLS group.
* Create a new Marmot MLS group under the CURRENT profile.
*
* Not the legacy `0xF2EE` shape. A current-profile peer refuses a leaf
* with no account identity proof, and a legacy group cannot be upgraded
* into one afterwards — its existing leaves have no proofs to add — so the
* profile is decided here, once, and never migrated. Groups made the old
* way are joinable only by other legacy clients.
*
* The name, description and avatar arrive later through
* `updateMarmotGroupMetadata`; the routing component has to exist from
* epoch 0 because it carries the `nostr_group_id` every kind-445 event in
* this group is addressed to.
*/
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
suspend fun createMarmotGroup(
nostrGroupId: HexKey,
name: String = "",
description: String = "",
/**
* Disappearing messages (`0x8005`), or null for off. Fixed at creation:
* promoting a component to required later needs its state installed by
* a prior commit, which this path does not make.
*/
disappearingMessageSecs: ULong? = null,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
manager.createGroup(nostrGroupId)
manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays =
account.outboxRelays.flow.value
.map { it.url },
profile = if (name.isEmpty() && description.isEmpty()) null else GroupProfileV1(name, description),
retention = disappearingMessageSecs?.let { MessageRetentionV1(it) },
)
// Creator owns the group — mark it as "known" immediately so it
// doesn't appear under "New Requests" before the first message.
account.marmotGroupList.markAsKnown(nostrGroupId)
@@ -406,9 +459,9 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId)
if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
val view = manager.groupView(nostrGroupId)
if (view != null && view.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = view.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
// MIP-03 also rejects any GCE commit that leaves the group with zero
// admins. If we're the only one, promote an arbitrary non-self
// member to admin before stepping down.
@@ -421,9 +474,7 @@ class AccountMarmotActions(
if (heir != null) remaining.add(heir)
}
if (remaining.isNotEmpty()) {
val demoted = metadata.copy(adminPubkeys = remaining)
val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted)
account.client.publish(demoteCommit.signedEvent, groupRelays)
manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList())
}
}
@@ -483,17 +534,16 @@ class AccountMarmotActions(
return
}
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex, groupRelays.toList())
Log.d("MarmotDbg") {
"removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…"
}
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}… " +
"to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
"removeMarmotGroupMember: commit id=${outbound.signedEvent.id.take(8)}… " +
"published to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
@@ -508,13 +558,112 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.updateGroupMetadata(nostrGroupId, metadata)
// The MLS commit has already been applied locally — surface the new
// metadata in the chatroom now so the UI reflects it without waiting
// for the relay round-trip.
manager.updateGroupMetadata(nostrGroupId, metadata, groupRelays.toList())
// The commit was published and acknowledged before it became canonical,
// so the local state is already the one peers will see — surface it now
// rather than waiting for our own event to loop back.
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
}
/**
* Disband a Marmot MLS group (`marmot.group.lifecycle.v1`, `0x800c`).
*
* Irreversible and absorbing: every member's copy terminalizes when they
* apply the commit, and there is no commit that walks it back. The caller
* MUST have confirmed with a human first — this layer only refuses what is
* structurally impossible (a non-admin, a legacy group, a second disband),
* which is not the same as asking.
*
* Deliberately NOT silent on failure the way the other actions here are: a
* disband that did not happen must not look like one that did, so the
* exception propagates to the caller's error path.
*
* It is no longer terminal the moment it is published, either: the Commit
* is admitted as a convergence candidate and only a SELECTED one moves the
* group to `Disbanded`, so between the two the request sits behind a
* durable `Disbanding` gate. Reporting that distinction is the whole point
* of the return value — announcing "group disbanded" for a request that is
* still pending is the one thing a terminal action must never do.
*
* @return true when the group is terminal now; false when the request is
* durable and unresolved, which is not a failure.
*/
suspend fun disbandMarmotGroup(
nostrGroupId: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
): Boolean {
val manager = account.marmotManager ?: return false
if (!account.isWriteable()) return false
manager.disbandGroup(nostrGroupId, groupRelays.toList())
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
return manager.lifecycle(nostrGroupId) == GroupLifecycleState.DISBANDED
}
/**
* Commit the `encrypted-media-v2` policy (`0x800b`) for a group.
*
* Creation deliberately leaves this off — `CurrentProfileGroupFactory`
* explains why: carrying it at epoch 0 would make our GroupContext differ
* from the reference's for the same inputs, and would force every joiner to
* advertise `0x800b` before it could be added. The spec's answer is that "a
* group that wants a media policy commits one", and until now nothing on
* Android could, so `marmotUsesEncryptedMediaV2` was false for every group
* this app created and attachments always fell back to MIP-04.
*
* Enable-only on purpose. Changing the policy later is the same commit;
* REMOVING it is a different question the component does not answer, and
* inventing a removal that strands members mid-upload is not something to
* guess at.
*
* The endpoints come from the account's own Blossom server list, because a
* policy naming servers the uploader does not use would describe a group
* nobody can actually post media to.
*/
suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: HexKey) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val servers = account.blossomServers.flow.value
require(servers.isNotEmpty()) {
"Cannot enable encrypted media without at least one Blossom server configured"
}
val policy =
EncryptedMediaPolicyV2(
allowedLocatorKinds = listOf(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND),
defaultBlobEndpoints =
servers.map {
BlobStoreEndpointV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, it)
},
)
manager.setEncryptedMediaPolicy(nostrGroupId, policy, marmotGroupRelays(nostrGroupId).toList())
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
}
/**
* Set or clear the group's plain-`https` avatar link
* (`marmot.group.avatar-url.v1`, `0x8007`).
*
* The lightweight avatar carrier: no Blossom upload, no key material, just
* a URL every Marmot client can render. A blank [url] clears it, which
* falls the group back to its encrypted Blossom image if it has one — the
* two carriers coexist and this one wins while it is set.
*/
suspend fun setMarmotGroupAvatarUrl(
nostrGroupId: HexKey,
url: String,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val avatar = url.trim().takeIf { it.isNotEmpty() }?.let { GroupAvatarUrlV1(MarmotWebUrl.normalize(it, label = "avatar URL")) }
manager.setGroupAvatarUrl(nostrGroupId, avatar, groupRelays.toList())
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
@@ -534,17 +683,10 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (metadata.adminPubkeys.contains(targetPubKey)) return
val view = manager.groupView(nostrGroupId) ?: return
if (view.adminPubkeys.contains(targetPubKey)) return
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
manager.setGroupAdmins(nostrGroupId, view.adminPubkeys + targetPubKey, groupRelays.toList())
}
/**
@@ -561,20 +703,13 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (!metadata.adminPubkeys.contains(targetPubKey)) return
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
val view = manager.groupView(nostrGroupId) ?: return
if (!view.adminPubkeys.contains(targetPubKey)) return
val remaining = view.adminPubkeys.filter { it != targetPubKey }
check(remaining.isNotEmpty()) {
"Cannot revoke the last admin from a Marmot group (MIP-03)"
}
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = remaining)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList())
}
}
@@ -32,9 +32,12 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.marmot.AndroidIngestDedupStore
import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore
import com.vitorpamplona.amethyst.model.marmot.AndroidPushStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -266,6 +269,45 @@ class AccountCacheState(
null
}
val marmotPublishObligationStore =
try {
AndroidPublishObligationStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidPublishObligationStore " +
"(a Marmot commit interrupted mid-publish will NOT be retried after a restart)",
e,
)
null
}
val marmotIngestDedupStore =
try {
AndroidIngestDedupStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidIngestDedupStore " +
"(every backdated gift wrap will be re-decided on each sync)",
e,
)
null
}
val marmotPushStateStore =
try {
AndroidPushStateStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidPushStateStore " +
"(a revoked push token could be resurrected by a relayed token list after a restart)",
e,
)
null
}
// Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one
// account never leaks into another (the store used to be a single app-wide file).
val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir)
@@ -291,6 +333,9 @@ class AccountCacheState(
mlsGroupStateStore = mlsStore,
marmotMessageStore = marmotMessageStore,
marmotKeyPackageStore = marmotKeyPackageStore,
marmotPublishObligationStore = marmotPublishObligationStore,
marmotIngestDedupStore = marmotIngestDedupStore,
marmotPushStateStore = marmotPushStateStore,
powQueue = powQueue,
relayAuthPermissionStore = relayAuthPermissionStore,
signerPermissionStore = signerPermissionStore,
@@ -0,0 +1,90 @@
/*
* 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.model.marmot
import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
/**
* Android implementation of [MarmotIngestDedupStore] — one hex event id per
* line under `<rootDir>/marmot_ingested.ids`.
*
* Deliberately NOT encrypted: the file holds public relay event ids and no key
* material, and a marker lost to a decryption failure would silently cost a
* re-decision rather than fail loudly.
*
* Capped and trimmed oldest-first. The worst case for a forgotten marker is
* one wasted NIP-59 unwrap on the next sync, so bounding growth is worth more
* than remembering every id forever.
*/
class AndroidIngestDedupStore(
private val rootDir: File,
private val maxEntries: Int = 20_000,
) : MarmotIngestDedupStore {
private val mutex = Mutex()
private fun file(): File = File(rootDir, "marmot_ingested.ids")
override suspend fun mark(eventId: HexKey) =
withContext(Dispatchers.IO) {
mutex.withLock {
val target = file()
try {
target.parentFile?.mkdirs()
target.appendText(eventId + "\n")
if (target.length() > maxEntries.toLong() * 65L) {
val kept = target.readLines().filter { it.isNotBlank() }.takeLast(maxEntries / 2)
target.writeText(kept.joinToString("\n") + "\n")
}
} catch (e: Exception) {
Log.w(TAG, "could not record ingest marker: ${e.message}", e)
}
Unit
}
}
override suspend fun loadAll(): Set<HexKey> =
withContext(Dispatchers.IO) {
mutex.withLock {
try {
file()
.takeIf { it.exists() }
?.readLines()
?.filter { it.isNotBlank() }
?.toSet()
.orEmpty()
} catch (e: Exception) {
Log.w(TAG, "could not read ingest markers: ${e.message}", e)
emptySet()
}
}
}
companion object {
private const val TAG = "AndroidIngestDedupStore"
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
@@ -111,16 +112,218 @@ class AndroidMarmotMessageStore(
override suspend fun delete(nostrGroupId: String) {
withContext(Dispatchers.IO) {
writeMutex.withLock {
val file = messagesFile(nostrGroupId)
if (file.exists() && !file.delete()) {
Log.w(TAG) { "delete($nostrGroupId): failed to remove ${file.absolutePath}" }
for (file in listOf(messagesFile(nostrGroupId), epochsFile(nostrGroupId), snapshotFile(nostrGroupId), expiriesFile(nostrGroupId), epochRetentionsFile(nostrGroupId))) {
if (file.exists() && !file.delete()) {
Log.w(TAG) { "delete($nostrGroupId): failed to remove ${file.absolutePath}" }
}
}
}
}
}
private fun readAll(nostrGroupId: String): List<String> {
val file = messagesFile(nostrGroupId)
private fun epochsFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "epochs")
/**
* Which MLS epoch delivered an inner event. Agent text streams bind the
* epoch into their record key context, so a receiver needs the epoch that
* carried the stream's kind:1200 anchor rather than the group's current
* one — a commit landing in between would otherwise derive a different key
* and render nothing.
*
* Stored through the same encrypted codec as the messages: the ids are as
* sensitive as the payloads they point at.
*/
override suspend fun recordEpoch(
nostrGroupId: String,
innerEventId: String,
epoch: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
try {
val line = "$innerEventId $epoch"
val existing = readAllFrom(epochsFile(nostrGroupId)).toMutableList()
if (line in existing) return@withLock
existing.add(line)
writeAllTo(epochsFile(nostrGroupId), existing)
} catch (e: Exception) {
Log.e(TAG, "recordEpoch($nostrGroupId) FAILED: ${e.message}", e)
}
}
}
override suspend fun loadEpochs(nostrGroupId: String): Map<String, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(epochsFile(nostrGroupId))
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
val epoch = parts[1].toLongOrNull() ?: return@mapNotNull null
parts[0] to epoch
}.toMap()
} catch (e: Exception) {
Log.e(TAG, "loadEpochs($nostrGroupId) FAILED: ${e.message}", e)
emptyMap()
}
}
private fun expiriesFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "expiries")
/**
* When a message stops being displayable, for a group that expires them.
*
* Encrypted like the messages: an expiry names an inner event id and says
* roughly when it was sent, which is conversation metadata.
*
* First write wins. The expiry is pinned to the retention of the message's
* own source epoch, so re-persisting the same message after a restart —
* which happens, because the ratchet rewinds and relays replay — must not
* re-time it under whatever the setting has since become.
*/
override suspend fun recordExpiry(
nostrGroupId: String,
innerEventId: String,
expiresAtSecs: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
try {
val existing = readAllFrom(expiriesFile(nostrGroupId)).toMutableList()
if (existing.any { it.substringBefore(' ') == innerEventId }) return@withLock
existing.add("$innerEventId $expiresAtSecs")
writeAllTo(expiriesFile(nostrGroupId), existing)
} catch (e: Exception) {
Log.e(TAG, "recordExpiry($nostrGroupId) FAILED: ${e.message}", e)
}
}
}
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(expiriesFile(nostrGroupId))
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
val at = parts[1].toLongOrNull() ?: return@mapNotNull null
parts[0] to at
}.toMap()
} catch (e: Exception) {
Log.e(TAG, "loadExpiries($nostrGroupId) FAILED: ${e.message}", e)
emptyMap()
}
}
/**
* Delete messages and forget their expiries, rewriting both logs.
*
* A rewrite rather than a tombstone: the point of a disappearing message
* is that the plaintext is gone from disk, and this store holds the only
* copy — the ratchet moved past the ciphertext it came from long ago.
*/
override suspend fun removeMessages(
nostrGroupId: String,
innerEventIds: Set<String>,
) = withContext(Dispatchers.IO) {
if (innerEventIds.isEmpty()) return@withContext
writeMutex.withLock {
try {
val kept =
readAll(nostrGroupId).filter { json ->
val id = Event.fromJsonOrNull(json)?.id
id == null || id !in innerEventIds
}
writeAll(nostrGroupId, kept)
val keptExpiries =
readAllFrom(expiriesFile(nostrGroupId)).filter { it.substringBefore(' ') !in innerEventIds }
writeAllTo(expiriesFile(nostrGroupId), keptExpiries)
} catch (e: Exception) {
Log.e(TAG, "removeMessages($nostrGroupId) FAILED: ${e.message}", e)
}
}
}
private fun epochRetentionsFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "epoch_retentions")
/**
* What retention this group required at each epoch.
*
* First write wins per epoch: an epoch's required components are fixed the
* moment it exists, so a second answer for the same epoch would be a bug
* rather than an update.
*/
override suspend fun recordEpochRetention(
nostrGroupId: String,
epoch: Long,
retentionSecs: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
try {
val existing = readAllFrom(epochRetentionsFile(nostrGroupId)).toMutableList()
if (existing.any { it.substringBefore(' ') == epoch.toString() }) return@withLock
existing.add("$epoch $retentionSecs")
writeAllTo(epochRetentionsFile(nostrGroupId), existing)
} catch (e: Exception) {
Log.e(TAG, "recordEpochRetention($nostrGroupId) FAILED: ${e.message}", e)
}
}
}
override suspend fun loadEpochRetentions(nostrGroupId: String): Map<Long, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(epochRetentionsFile(nostrGroupId))
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
val epoch = parts[0].toLongOrNull() ?: return@mapNotNull null
val secs = parts[1].toLongOrNull() ?: return@mapNotNull null
epoch to secs
}.toMap()
} catch (e: Exception) {
Log.e(TAG, "loadEpochRetentions($nostrGroupId) FAILED: ${e.message}", e)
emptyMap()
}
}
private fun snapshotFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "snapshot")
/**
* The group state the last kind:1210 rows were derived from.
*
* Encrypted like everything else here: it names members and admins, which
* is the group's membership written down.
*
* A single entry rather than an append log — this is one baseline, not a
* history, and the previous one is worthless the moment rows are derived
* against it.
*/
override suspend fun recordGroupSnapshot(
nostrGroupId: String,
snapshotJson: String,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
try {
writeAllTo(snapshotFile(nostrGroupId), listOf(snapshotJson))
} catch (e: Exception) {
Log.e(TAG, "recordGroupSnapshot($nostrGroupId) FAILED: ${e.message}", e)
}
}
}
override suspend fun loadGroupSnapshot(nostrGroupId: String): String? =
withContext(Dispatchers.IO) {
try {
readAllFrom(snapshotFile(nostrGroupId)).firstOrNull()
} catch (e: Exception) {
Log.e(TAG, "loadGroupSnapshot($nostrGroupId) FAILED: ${e.message}", e)
null
}
}
private fun readAll(nostrGroupId: String): List<String> = readAllFrom(messagesFile(nostrGroupId))
private fun readAllFrom(file: File): List<String> {
if (!file.exists()) return emptyList()
val encrypted = file.readBytes()
val plain = encryption.decrypt(encrypted) ?: return emptyList()
@@ -151,8 +354,12 @@ class AndroidMarmotMessageStore(
private fun writeAll(
nostrGroupId: String,
messages: List<String>,
) = writeAllTo(messagesFile(nostrGroupId), messages)
private fun writeAllTo(
file: File,
messages: List<String>,
) {
val file = messagesFile(nostrGroupId)
file.parentFile?.mkdirs()
val encodedEntries = messages.map { it.encodeToByteArray() }
@@ -0,0 +1,189 @@
/*
* 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.model.marmot
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
/**
* Android implementation of [MarmotPublishObligationStore], encrypted at rest
* with [KeyStoreEncryption] like the group-state and KeyPackage stores.
*
* ```
* <rootDir>/marmot_obligations/<obligationId>.obligation
* ```
*
* Publish-before-apply only means anything if the record outlives the process.
* A commit is recorded, published, and only then applied; a crash inside that
* window has to leave a trace, or the next launch mints a REPLACEMENT commit
* for the same epoch and forks this device against every peer that accepted
* the first one. Android kills apps mid-work routinely, so "in memory" here is
* not a simplification — it is the common case.
*
* One file per obligation rather than one appended log: two groups can publish
* concurrently and resolve out of order, so removing one record must not
* rewrite another's.
*/
class AndroidPublishObligationStore(
private val rootDir: File,
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
) : MarmotPublishObligationStore {
private val mutex = Mutex()
private fun dir(): File = File(rootDir, "marmot_obligations")
private fun file(obligationId: String) = File(dir(), "$obligationId.obligation")
override suspend fun save(
obligationId: HexKey,
bytes: ByteArray,
) = withContext(Dispatchers.IO) {
mutex.withLock {
val target = file(obligationId)
try {
target.parentFile?.mkdirs()
val encrypted = encryption.encrypt(bytes)
val tmp = File(target.parentFile, "${target.name}.tmp")
tmp.writeBytes(encrypted)
if (!tmp.renameTo(target)) {
tmp.copyTo(target, overwrite = true)
if (!tmp.delete()) Log.w(TAG) { "could not delete temp file ${tmp.absolutePath}" }
}
} catch (e: Exception) {
// Failing to record is worse than failing to publish: an
// unrecorded commit that peers accept is a fork we cannot
// detect. Surface it rather than continuing to the publish.
Log.e(TAG, "save($obligationId) FAILED", e)
throw e
}
}
}
override suspend fun delete(obligationId: HexKey) =
withContext(Dispatchers.IO) {
mutex.withLock {
val target = file(obligationId)
if (target.exists() && !target.delete()) {
Log.w(TAG) { "could not delete resolved obligation ${target.absolutePath}" }
}
Unit
}
}
private fun gateDir(): File = File(rootDir, "marmot_gates")
private fun gateFile(groupId: String) = File(gateDir(), "$groupId.gate")
/**
* Outbound gates are durable for the same reason obligations are, and the
* `Disbanding` one more than any: the component requires it to survive
* "publication failure, restart, and a losing branch", and Android kills
* apps mid-work routinely. An in-memory gate loses an admin's irreversible
* request to the crash between raising it and a relay taking the Commit,
* and the next launch offers the group as ordinarily live.
*
* Not encrypted, unlike an obligation: the value is one enum name and the
* filename is a group id this device already stores in the clear
* everywhere else. There is no key material and no message content here.
*/
override suspend fun saveGate(
groupId: HexKey,
gate: String,
) = withContext(Dispatchers.IO) {
mutex.withLock {
val target = gateFile(groupId)
try {
target.parentFile?.mkdirs()
val tmp = File(target.parentFile, "${target.name}.tmp")
tmp.writeBytes(gate.encodeToByteArray())
if (!tmp.renameTo(target)) {
tmp.copyTo(target, overwrite = true)
if (!tmp.delete()) Log.w(TAG) { "could not delete temp file ${tmp.absolutePath}" }
}
} catch (e: Exception) {
// Same rule as an obligation: failing to record the gate is
// worse than failing to act, because the request is the part
// that cannot be reconstructed.
Log.e(TAG, "saveGate($groupId) FAILED", e)
throw e
}
}
}
override suspend fun deleteGate(groupId: HexKey) =
withContext(Dispatchers.IO) {
mutex.withLock {
val target = gateFile(groupId)
if (target.exists() && !target.delete()) {
Log.w(TAG) { "could not delete cleared gate ${target.absolutePath}" }
}
Unit
}
}
override suspend fun loadGates(): Map<HexKey, String> =
withContext(Dispatchers.IO) {
mutex.withLock {
gateDir()
.listFiles { f -> f.isFile && f.name.endsWith(".gate") }
?.mapNotNull { file ->
try {
file.name.removeSuffix(".gate") to file.readText().trim()
} catch (e: Exception) {
Log.w(TAG, "could not read gate ${file.absolutePath}", e)
null
}
}?.toMap()
.orEmpty()
}
}
override suspend fun loadAll(): List<ByteArray> =
withContext(Dispatchers.IO) {
mutex.withLock {
dir()
.listFiles { f -> f.isFile && f.name.endsWith(".obligation") }
?.sortedBy { it.name }
?.mapNotNull { file ->
try {
encryption.decrypt(file.readBytes())
} catch (e: Exception) {
// One unreadable record must not cost us the
// others; the gate treats a missing obligation as
// "never confirmed", which is the safe direction.
Log.w(TAG, "unreadable obligation ${file.name}: ${e.message}", e)
null
}
}.orEmpty()
}
}
companion object {
private const val TAG = "AndroidPublishObligationStore"
}
}
@@ -0,0 +1,116 @@
/*
* 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.model.marmot
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.MarmotPushStateStore
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
/**
* Android implementation of [MarmotPushStateStore] — one JSON file per group
* under `<rootDir>/marmot_push/<group id>.json`.
*
* Durability is the whole point of this class. A push tombstone is the only
* lasting record that a token was revoked: any current member can re-emit a
* revoked-but-still-signed record in a fresh kind `448` at any later epoch, and
* a client that forgot the tombstone would accept it and start waking a device
* its owner asked to be forgotten. So this survives restarts, and it is not
* bounded by any wall clock, `owner_ts` or epoch count — a key is cleared only
* by a strictly newer registration, or by its leaf leaving the group.
*
* Not encrypted, deliberately: the file holds tokens already encrypted to a
* notification server this device cannot read, plus public routing. A failure
* to decrypt would cost a tombstone, which is worse than the file being
* readable by a process that has already broken out of the app sandbox.
*/
class AndroidPushStateStore(
private val rootDir: File,
) : MarmotPushStateStore {
private val mutex = Mutex()
private fun dir(): File = File(rootDir, "marmot_push")
/**
* A group id is 32 hex characters from the protocol, but it reaches here as
* a plain string, so anything that is not hex is refused rather than turned
* into a path.
*/
private fun file(nostrGroupId: HexKey): File? {
if (nostrGroupId.isEmpty() || !nostrGroupId.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null
return File(dir(), "$nostrGroupId.json")
}
override suspend fun load(nostrGroupId: HexKey): String? =
withContext(Dispatchers.IO) {
mutex.withLock {
try {
file(nostrGroupId)?.takeIf { it.exists() }?.readText()
} catch (e: Exception) {
Log.w(TAG, "could not read push state for $nostrGroupId: ${e.message}", e)
null
}
}
}
override suspend fun save(
nostrGroupId: HexKey,
state: String,
) = withContext(Dispatchers.IO) {
mutex.withLock {
val target = file(nostrGroupId) ?: return@withLock
try {
target.parentFile?.mkdirs()
// Write-then-rename: a half-written state file would silently
// drop tombstones, and a lost tombstone is exactly the failure
// this store exists to prevent.
val temp = File(target.parentFile, "${target.name}.tmp")
temp.writeText(state)
if (!temp.renameTo(target)) {
target.writeText(state)
temp.delete()
}
} catch (e: Exception) {
Log.w(TAG, "could not persist push state for $nostrGroupId: ${e.message}", e)
}
}
}
override suspend fun clear(nostrGroupId: HexKey) =
withContext(Dispatchers.IO) {
mutex.withLock {
try {
file(nostrGroupId)?.delete()
} catch (e: Exception) {
Log.w(TAG, "could not clear push state for $nostrGroupId: ${e.message}", e)
}
Unit
}
}
companion object {
private const val TAG = "AndroidPushStateStore"
}
}
@@ -77,7 +77,7 @@ class MarmotGroupEventsEoseManager(
} ?: continue
// Use group-specific relays from MLS metadata; fall back to home relays
val metadata = manager.groupMetadata(groupId)
val metadata = manager.groupView(groupId)
val groupRelays =
metadata
?.relays
@@ -115,11 +115,13 @@ import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2
import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.hints.EventHintBundle
@@ -2452,10 +2454,50 @@ class AccountViewModel(
fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId)
suspend fun createMarmotGroup(nostrGroupId: String) {
account.marmot.createMarmotGroup(nostrGroupId)
/**
* True when this group carries the `encrypted-media-v2` policy (`0x800b`)
* and a sender should therefore produce v2 references.
*
* A group without it is not a licence to reinterpret the frozen v1 policy
* at `0x8008` as v2 — they are different components — so this is a plain
* "does the group say v2", and the sender falls back to MIP-04 when it
* does not.
*/
fun marmotUsesEncryptedMediaV2(nostrGroupId: String): Boolean = account.marmotManager?.encryptedMediaPolicy(nostrGroupId) != null
/** True when this account has somewhere to upload a group's encrypted media. */
fun hasBlossomServers(): Boolean =
account.blossomServers.flow.value
.isNotEmpty()
suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) {
account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId)
}
/** Post the kind:9 carrying an `encrypted-media-v2` attachment. */
suspend fun sendMarmotGroupEncryptedMediaV2(
nostrGroupId: String,
reference: EncryptedMediaReferenceV2,
caption: String,
) {
val manager = account.marmotManager ?: return
val bundle = manager.buildMediaMessage(nostrGroupId, reference, caption, persistOwn = false)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
}
suspend fun createMarmotGroup(
nostrGroupId: String,
name: String = "",
description: String = "",
disappearingMessageSecs: ULong? = null,
) {
account.marmot.createMarmotGroup(nostrGroupId, name, description, disappearingMessageSecs)
}
/** This group's disappearing-message duration in seconds; 0 is off. */
fun marmotRetentionSeconds(nostrGroupId: String): Long = account.marmotManager?.retentionSeconds(nostrGroupId) ?: 0L
suspend fun publishMarmotKeyPackage() {
account.marmot.publishMarmotKeyPackage()
}
@@ -2488,6 +2530,27 @@ class AccountViewModel(
account.marmot.leaveMarmotGroup(nostrGroupId, relays)
}
/**
* Disband the group for everyone. Irreversible — the caller is responsible
* for confirming with the user before this is reached.
*
* @return true when the group is terminal now, false when the request is
* still pending convergence, which is not a failure.
*/
suspend fun disbandMarmotGroup(nostrGroupId: String): Boolean {
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
return account.marmot.disbandMarmotGroup(nostrGroupId, relays)
}
/** Set (or, with a blank string, clear) the group's plain-https avatar link. */
suspend fun setMarmotGroupAvatarUrl(
nostrGroupId: String,
url: String,
) {
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.setMarmotGroupAvatarUrl(nostrGroupId, url, relays)
}
suspend fun resetMarmotState() {
account.marmot.resetMarmotState()
}
@@ -2549,35 +2612,26 @@ class AccountViewModel(
// overlap, so kind:445 messages never reach the other side. The
// welcome carries the metadata, so the invitee learns the relays at
// join time.
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
val baseMetadata =
currentMetadata
?.copy(name = name, description = description)
?.withMergedRelays(outboxRelayStrings)
?: MarmotGroupData.bootstrap(
nostrGroupId = nostrGroupId,
creatorPubKey = account.signer.pubKey,
outboxRelays = outboxRelayStrings,
name = name,
description = description,
)
val updatedMetadata =
when (icon) {
is MarmotGroupIconChange.Keep -> baseMetadata
is MarmotGroupIconChange.Clear -> baseMetadata.withoutImage()
is MarmotGroupIconChange.Set ->
baseMetadata.withImage(
imageHash = icon.upload.imageHash,
val manager = account.marmotManager ?: return
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
manager.setGroupProfile(nostrGroupId, name, description, relays.toList())
when (icon) {
is MarmotGroupIconChange.Keep -> Unit
is MarmotGroupIconChange.Clear -> manager.setGroupImage(nostrGroupId, null, relays.toList())
is MarmotGroupIconChange.Set ->
manager.setGroupImage(
nostrGroupId,
GroupBlossomImageV1(
imageHash = icon.upload.imageHash.hexToByteArray(),
imageKey = icon.upload.imageKey,
imageNonce = icon.upload.imageNonce,
imageUploadKey = icon.upload.imageUploadKey,
)
}
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
mediaType = icon.upload.mediaType,
),
relays.toList(),
)
}
}
override fun onCleared() {
@@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
import com.vitorpamplona.quartz.marmot.WelcomeResult
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -675,9 +677,65 @@ class GroupEventHandler(
cache.copyRelaysFromTo(outerNote, innerEvent.id)
}
// Track the message in the Marmot group chatroom
// A kind:1009 edit is anchored to the message it replaces,
// exactly like a Concord edit or a reaction: the bubble reads
// `Note.edits`, and holding the edit as a hard-referenced
// child of its target is what keeps it alive as long as that
// target is. A Marmot inner event is decrypted exactly once —
// the ratchet has moved on by the time anyone could re-fetch
// it — so an edit left orphaned in the soft cache could be
// collected and never come back.
//
// The overlay's own rules (author-only, latest wins) are
// applied at render time by `Note.latestMarmotEdit`, not here:
// the target's author is not necessarily known yet when the
// edit arrives, and a link is not an endorsement.
if (innerEvent.kind == MarmotAppEvent.KIND_EDIT) {
MarmotMessageEdit.fromAppEvent(MarmotAppEvent.fromEvent(innerEvent))?.let { edit ->
cache.getOrCreateNote(edit.targetId).addEdit(innerNote)
}
}
// Push token gossip (kinds 447/448/449) is routing data for
// a notification server, addressed to the other members'
// clients rather than to the people in the room. It still
// reaches the feed's dedupe and cache paths above like any
// inner event — `MarmotGroupList` is what keeps it off the
// screen — but its meaning is applied here.
//
// Everything this call does is advisory: a malformed entry,
// a signature that does not verify, a list that lost its
// ordering race are all dropped on their own and none of
// them may reach the validity of the kind:445 that carried
// them. That is why it neither throws nor is checked.
account.marmotPushCoordinator?.let { push ->
push.apply(result.groupId, innerEvent)
// A peer asking for records gets our view, once. We
// answer with the records we hold — including other
// members' — with their owner signatures untouched, so
// a member who has been offline can be caught up by
// whoever happens to be around.
if (push.isTokenRequest(innerEvent) && innerEvent.pubKey != account.signer.pubKey) {
push.buildTokenList(result.groupId)?.let { response ->
account.marmot.sendMarmotGroupMessage(
result.groupId,
response,
account.marmot.marmotGroupRelays(result.groupId),
)
}
}
}
// Track the message in the Marmot group chatroom. A
// peer-sent kind:1210 is dropped inside addMessage — see
// `MarmotGroupList.isDisplayableFeedMessage`.
account.marmotGroupList.addMessage(result.groupId, innerNote)
// Traffic is the natural clock for disappearing messages: a
// group being read is a group whose expired messages should
// already be gone.
manager.pruneExpiredMessages(result.groupId)
// Persist the decrypted plaintext so the message
// survives an app restart. Marmot/MLS application
// messages cannot be re-decrypted once the ratchet
@@ -713,6 +771,13 @@ class GroupEventHandler(
// Sync MIP-01 metadata after epoch advance (extensions may have changed)
val chatroom = account.marmotGroupList.getOrCreateGroup(result.groupId)
manager.syncMetadataTo(result.groupId, chatroom)
// The epoch just advanced, so whatever this commit changed
// is now canonical state — which is exactly what a kind:1210
// row is derived from. Deriving here covers OTHER members'
// commits; our own are derived by `commitAndPublish`. Both
// reach the feed through `onSystemRowDerived`.
manager.recordRetentionForCurrentEpoch(result.groupId)
manager.syncGroupSystemRows(result.groupId)
// Epoch just advanced — drain any kind:445 events that
// previously failed as UndecryptableOuterLayer for this
// group. See `pendingUndecryptable` for the scenario.
@@ -764,6 +829,29 @@ class GroupEventHandler(
}
}
is GroupEventResult.AppMessageOnCandidateBranch -> {
// Decrypted on a branch that is not canonical. Not shown:
// the canonical state contradicts it. If convergence later
// selects that branch the message arrives again through
// the normal path, so nothing is lost by not rendering it
// now.
Log.d("MarmotDbg") {
"GroupEventHandler.add: app payload on candidate branch for group=${result.groupId.take(8)}… " +
"epoch=${result.epoch} witness=${result.countedAsWitness}"
}
}
is GroupEventResult.RefusedByLifecycle -> {
// Disbanded is absorbing and Unrecoverable needs a repair
// before anything more may be applied, so this input was
// refused before decryption. Nothing to render, nothing to
// retain, and nothing the user can do about it here.
Log.d("MarmotDbg") {
"GroupEventHandler.add: refused for group=${result.groupId.take(8)}… " +
"lifecycle=${result.lifecycle}"
}
}
is GroupEventResult.Error -> {
Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" }
}
@@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -51,6 +52,23 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.launch
/**
* A caller's own rendering for feed rows that are not chat bubbles.
*
* Marmot's kind:1210 group system rows are the case this exists for: they sit
* in the conversation in chronological order but are captions about group
* state, not messages, and rendering one as a bubble would show a reader raw
* JSON attributed to whoever committed the change.
*/
@Immutable
interface ChatFeedRowRenderer {
/** True when this renderer takes the row instead of the normal bubble. */
fun claims(note: Note): Boolean
@Composable
fun Render(note: Note)
}
@Composable
fun RefreshingChatroomFeedView(
feedContentState: FeedContentState,
@@ -81,6 +99,9 @@ fun RefreshingChatroomFeedView(
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
// Optional per-row override for rows the caller renders itself rather than as
// a chat bubble. Null for every surface whose feed is only messages.
rowRenderer: ChatFeedRowRenderer? = null,
) {
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
listStateObserver(listState)
@@ -99,6 +120,7 @@ fun RefreshingChatroomFeedView(
jumpToNoteId,
onJumpHandled,
onWantsToEditChatMessage,
rowRenderer,
)
}
}
@@ -119,6 +141,7 @@ fun RenderChatFeedView(
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
rowRenderer: ChatFeedRowRenderer? = null,
) {
val feedState by feed.feedContent.collectAsStateWithLifecycle()
@@ -152,6 +175,7 @@ fun RenderChatFeedView(
jumpToNoteId,
onJumpHandled,
onWantsToEditChatMessage,
rowRenderer,
)
}
}
@@ -174,6 +198,7 @@ fun ChatFeedLoaded(
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
rowRenderer: ChatFeedRowRenderer? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -256,20 +281,30 @@ fun ChatFeedLoaded(
older?.event?.createdAt,
)
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
shouldHighlight = highlightedNoteId.value == item.idHex,
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
onWantsToEditChatMessage = onWantsToEditChatMessage,
)
// A claimed row is rendered by the caller instead of as a
// bubble. The date divisor above still applies — a system
// row belongs under the day it happened on like anything
// else — which is why the claim is checked here and not
// around the whole item.
val claimed = rowRenderer?.takeIf { it.claims(item) }
if (claimed != null) {
claimed.Render(item)
} else {
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
shouldHighlight = highlightedNoteId.value == item.idHex,
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
onWantsToEditChatMessage = onWantsToEditChatMessage,
)
}
}
}
}
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
import com.vitorpamplona.amethyst.commons.model.latestConcordEdit
import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit
import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -67,11 +68,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChan
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderConcordEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedMediaV2
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
@@ -81,6 +84,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
@@ -620,15 +624,24 @@ fun NoteRow(
note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
// `encrypted-media-v2` is a separate branch because its imeta has
// no `url` field for the NIP-92 parser above to anchor on.
hasEncryptedMediaV2(note.event) -> RenderEncryptedMediaV2(note, bgColor, accountViewModel, nav)
else -> {
// Concord and Buzz channels overlay edits on their messages (kind-3302 and
// kind-40003): when one exists, render the newest edit's content instead of the
// stale original. One observer for both — a message is only ever one kind, so a
// single edits-flow collector per row covers both (and is null for other surfaces).
// Concord, Buzz and Marmot all overlay edits on their messages (kinds 3302,
// 40003 and 1009): when one exists, render the winning edit's content instead of
// the stale original. One observer for all three — a message is only ever one
// kind, so a single edits-flow collector per row covers them (and is null for
// other surfaces).
val edit = observeChatEdit(note)
when (edit?.event) {
is ConcordChatEditEvent -> RenderConcordEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
is StreamMessageEditEvent -> RenderBuzzEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
val editEvent = edit?.event
when {
editEvent is ConcordChatEditEvent -> RenderEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
editEvent is StreamMessageEditEvent -> RenderBuzzEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
// A Marmot edit is a plain inner app event, not a typed
// class, so it is matched on its kind rather than its type.
editEvent != null && editEvent.kind == MarmotAppEvent.KIND_EDIT ->
RenderEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
}
}
@@ -646,7 +659,7 @@ fun observeChatEdit(note: Note): Note? {
val latest by
produceState<Note?>(initialValue = null, note.idHex) {
note.flow().edits.stateFlow.collect {
value = note.latestConcordEdit() ?: note.latestBuzzEdit()
value = note.latestConcordEdit() ?: note.latestBuzzEdit() ?: note.latestMarmotEdit()
}
}
return latest
@@ -40,12 +40,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* A Concord chat message whose content has been superseded by a kind-3302 edit:
* renders the NEWEST edit's content (never the stale original) plus an "(edited)"
* marker, matching the Concord reference client's last-write-wins presentation.
* A chat message whose content has been superseded by an edit: renders the
* WINNING edit's content (never the stale original) plus an "(edited)" marker.
*
* Which edit wins is decided per surface before this is called — Concord by
* CORD-02 send time, Marmot by `created_at` with an event-id tie-break — and is
* always author-only. The rendering itself has nothing surface-specific in it:
* an edit is a body plus its own tags, so the content, the custom emoji and the
* mentions all come off the edit rather than the original.
*/
@Composable
fun RenderConcordEditedNote(
fun RenderEditedNote(
note: Note,
editNote: Note,
canPreview: Boolean,
@@ -42,10 +42,15 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2Cipher
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04Cipher
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaMeta
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.toMip04MediaMeta
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -60,6 +65,114 @@ fun hasMip04Media(event: Event?): Boolean {
return imetas.any { it.toMip04MediaMeta() != null }
}
/**
* The `encrypted-media-v2` reference on an event, or null.
*
* Deliberately NOT routed through [imetas]: NIP-92 parsing anchors on a `url`
* field, and a v2 tag has no `url` — it carries `locator <kind> <value>` pairs
* instead, because the same ciphertext may live at several places and none of
* them is privileged. Reading v2 through the NIP-92 parser silently produced
* nothing, so a v2 attachment rendered as its caption with the image missing
* and no error.
*/
fun encryptedMediaV2Of(event: Event?): EncryptedMediaReferenceV2? {
if (event == null) return null
return event.tags.firstNotNullOfOrNull { tag ->
if (tag.size > 1 && tag[0] == "imeta") EncryptedMediaV2.parseImetaTagOrNull(tag) else null
}
}
fun hasEncryptedMediaV2(event: Event?): Boolean = encryptedMediaV2Of(event) != null
/**
* Renders an `encrypted-media-v2` attachment.
*
* Same shape as the MIP-04 path — register a cipher against the URL the blob
* will be fetched from, then hand a [BaseMediaContent] to [ZoomableContentView]
* — with two differences that come from the format:
*
* - The URL comes from the FIRST `blossom-v1` locator rather than a `url`
* field. Later locators are mirrors of the same ciphertext; trying them in
* turn would need the download layer to report failure back here, which it
* does not, so this renders the first and leaves fallback for when it can.
* - The nonce and plaintext hash come off the tag, so the cipher is built
* through its receiving constructor.
*/
@Composable
fun RenderEncryptedMediaV2(
note: Note,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event ?: return
val reference = remember(event) { encryptedMediaV2Of(event) }
val url =
remember(reference) {
reference
?.locators
?.firstOrNull { it.kind == EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND }
?.value
}
val nostrGroupId = remember(note) { findGroupIdForNote(note, accountViewModel) }
val mediaSecret =
remember(nostrGroupId) {
nostrGroupId?.let { accountViewModel.marmotMediaExporterSecret(it) }
}
if (reference == null || url == null || mediaSecret == null) {
RenderDecryptionError(note, bgColor, accountViewModel, nav)
return
}
val cipher = remember(reference, mediaSecret) { EncryptedMediaV2Cipher(mediaSecret, reference) }
Amethyst.instance.keyCache.add(url, cipher, reference.mediaType)
val description = event.alt()
val dim = reference.dim?.let { DimensionTag.parse(it) }
val content by remember(reference) {
mutableStateOf<BaseMediaContent>(
if (reference.mediaType.startsWith("image/")) {
EncryptedMediaUrlImage(
url = url,
description = description,
hash = reference.plaintextSha256.toHexKey(),
dim = dim,
uri = note.toNostrUri(),
mimeType = reference.mediaType,
encryptionAlgo = EncryptedMediaV2.VERSION,
encryptionKey = mediaSecret,
encryptionNonce = reference.nonce,
thumbhash = reference.thumbhash,
)
} else {
EncryptedMediaUrlVideo(
url = url,
description = description,
hash = reference.plaintextSha256.toHexKey(),
dim = dim,
uri = note.toNostrUri(),
authorName = note.author?.toBestDisplayName(),
mimeType = reference.mediaType,
encryptionAlgo = EncryptedMediaV2.VERSION,
encryptionKey = mediaSecret,
encryptionNonce = reference.nonce,
thumbhash = reference.thumbhash,
)
},
)
}
ZoomableContentView(
content,
persistentListOf(content),
roundedCorner = true,
contentScale = ContentScale.FillWidth,
accountViewModel,
)
}
/**
* Renders MIP-04 encrypted media in a Marmot group chat message.
*
@@ -0,0 +1,112 @@
/*
* 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.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.marmot.AgentStreamPreview
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStatus
/**
* The live agent-preview row, shown between the transcript and the composer.
*
* The whole point of this row is that it is NOT the transcript. Preview text
* is provisional until the durable kind:9 lands and its transcript hash agrees
* with what we folded — every record can open individually and the stream
* still be wrong, if one was dropped, reordered or injected. So it renders in
* italic on a tinted surface with an explicit label, and it disappears the
* moment the real message arrives (confirmed) or is contradicted (dropped).
*
* `ProgressDelta` and `Status` records never reach [AgentStreamPreview.text] —
* the spec keeps them out of preview text, notifications, indexes and
* automation input — so they render here only as a separate, quieter line.
*/
@Composable
fun AgentStreamPreviewBanner(
preview: AgentStreamPreview?,
modifier: Modifier = Modifier,
) {
// An aborted preview produces no durable text at all: the publisher
// withdrew it, so there is nothing honest left to show.
val visible = preview != null && preview.status != PreviewStatus.ABORTED && !preview.isConfirmed
AnimatedVisibility(visible = visible) {
if (preview == null) return@AnimatedVisibility
Column(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
when (preview.status) {
PreviewStatus.UNVERIFIABLE -> "Live preview (incomplete)"
else -> "Live preview"
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
preview.statusLabel?.let {
Text(
text = " · $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (preview.text.isNotEmpty()) {
Text(
text = preview.text,
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
preview.progressLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -73,6 +73,10 @@ fun CreateGroupScreen(
var groupName by remember { mutableStateOf("") }
var groupDescription by remember { mutableStateOf("") }
var pickedIcon by remember { mutableStateOf<SelectedMedia?>(null) }
// Disappearing messages (`0x8005`). Chosen here and only here: promoting a
// component to required later needs its state installed by a prior commit,
// which this screen does not make.
var disappearing by remember { mutableStateOf(MarmotRetentionChoice.OFF) }
// Stable seed for the placeholder avatar shown before an icon is picked. The real
// group id is generated per creation attempt (so retries don't collide), so this is
// a separate cosmetic seed rather than "".
@@ -82,12 +86,28 @@ fun CreateGroupScreen(
val scope = rememberCoroutineScope()
val context = LocalContext.current
fun proceedWithCreate() {
/**
* Create the group, optionally after [prepare].
*
* [prepare] runs *inside* the same coroutine and is awaited, which is the whole point: the
* KeyPackage relay list it writes is what the creation below depends on. Launching the two
* side by side raced them, and the loser was silent -- `isCreating` had already latched true,
* so the top bar's `isActive` gate left Create inert with no error and no group, and only
* Cancel could leave the screen. Awaiting also puts a failure to save on the same Toast path
* as a failure to create, instead of dropping it in a coroutine nobody reads.
*/
fun proceedWithCreate(prepare: (suspend () -> Unit)? = null) {
isCreating = true
scope.launch(Dispatchers.IO) {
try {
prepare?.invoke()
val nostrGroupId = RandomInstance.bytes(32).toHexKey()
accountViewModel.createMarmotGroup(nostrGroupId)
accountViewModel.createMarmotGroup(
nostrGroupId,
groupName.trim(),
groupDescription.trim(),
disappearing.seconds,
)
// Encrypt + upload the picked icon (if any) before the metadata commit,
// so its parameters land in the group's MarmotGroupData extension.
val iconChange =
@@ -189,6 +209,14 @@ fun CreateGroupScreen(
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(16.dp))
MarmotRetentionPicker(
selected = disappearing,
onSelect = { disappearing = it },
enabled = !isCreating,
)
Text(
stringRes(Res.string.marmot_create_group_footer),
modifier = Modifier.padding(top = 12.dp),
@@ -202,10 +230,7 @@ fun CreateGroupScreen(
MissingKeyPackageRelayListDialog(
onConfirm = {
showKeyPackageRelayDialog = false
scope.launch(Dispatchers.IO) {
accountViewModel.saveKeyPackageRelayListFromOutbox()
}
proceedWithCreate()
proceedWithCreate { accountViewModel.saveKeyPackageRelayListFromOutbox() }
},
onDismiss = {
showKeyPackageRelayDialog = false
@@ -43,10 +43,14 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url
import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url_footer
import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url_placeholder
import com.vitorpamplona.amethyst.commons.resources.marmot_edit_info_footer
import com.vitorpamplona.amethyst.commons.resources.marmot_group_description_placeholder
import com.vitorpamplona.amethyst.commons.resources.marmot_group_name
import com.vitorpamplona.amethyst.commons.resources.marmot_group_name_placeholder
import com.vitorpamplona.amethyst.commons.resources.marmot_legacy_group_no_avatar_url
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -71,9 +75,12 @@ fun EditGroupInfoScreen(
val currentName by chatroom.displayName.collectAsStateWithLifecycle()
val currentDescription by chatroom.description.collectAsStateWithLifecycle()
val currentImage by chatroom.image.collectAsStateWithLifecycle()
val currentAvatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle()
val isCurrentProfile by chatroom.isCurrentProfile.collectAsStateWithLifecycle()
var name by remember(currentName) { mutableStateOf(currentName ?: "") }
var description by remember(currentDescription) { mutableStateOf(currentDescription ?: "") }
var avatarUrl by remember(currentAvatarUrl) { mutableStateOf(currentAvatarUrl?.url.orEmpty()) }
var pickedIcon by remember { mutableStateOf<SelectedMedia?>(null) }
var removeIcon by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
@@ -81,7 +88,12 @@ fun EditGroupInfoScreen(
val context = LocalContext.current
val iconChanged = pickedIcon != null || removeIcon
val hasChanges = name != (currentName ?: "") || description != (currentDescription ?: "") || iconChanged
val avatarUrlChanged = avatarUrl.trim() != currentAvatarUrl?.url.orEmpty()
val hasChanges =
name != (currentName ?: "") ||
description != (currentDescription ?: "") ||
iconChanged ||
avatarUrlChanged
Scaffold(
topBar = {
@@ -104,6 +116,18 @@ fun EditGroupInfoScreen(
description = description.trim(),
icon = iconChange,
)
// A separate component (`0x8007`) and therefore a
// separate commit — only made when it actually
// changed, so saving a rename does not also
// rewrite the avatar state.
// `isCurrentProfile` is belt-and-braces: the field
// is not shown on a legacy group, so the value
// cannot have changed. Guarding the call as well
// means a future edit to the form cannot turn a
// hidden field into a refused commit on save.
if (avatarUrlChanged && isCurrentProfile) {
accountViewModel.setMarmotGroupAvatarUrl(nostrGroupId, avatarUrl.trim())
}
launch(Dispatchers.Main) {
Toast
.makeText(context, stringRes(context, R.string.marmot_group_info_updated), Toast.LENGTH_SHORT)
@@ -179,6 +203,38 @@ fun EditGroupInfoScreen(
enabled = !isSaving,
)
Spacer(modifier = Modifier.height(16.dp))
// The plain-https avatar carrier. It wins over the uploaded
// Blossom image while it is set, and clearing it falls the group
// back to that image — so the two fields are not alternatives to
// choose between, they stack.
//
// A legacy group has no carrier for `0x8007` at all, and cannot be
// upgraded to one, so the field is replaced by the reason rather
// than shown and then rejected on save. The uploaded image above
// still works there, which is what makes this a missing option
// rather than a missing feature.
if (isCurrentProfile) {
OutlinedTextField(
value = avatarUrl,
onValueChange = { avatarUrl = it },
label = { Text(stringRes(Res.string.marmot_avatar_url)) },
placeholder = { Text(stringRes(Res.string.marmot_avatar_url_placeholder)) },
supportingText = { Text(stringRes(Res.string.marmot_avatar_url_footer)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isSaving,
)
} else {
Text(
text = stringRes(Res.string.marmot_legacy_group_no_avatar_url),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -43,7 +44,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -72,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -98,6 +102,11 @@ fun MarmotGroupChatView(
WatchLifecycleAndUpdateModel(feedViewModel)
val chatroom =
remember(nostrGroupId) {
accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
}
val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel")
newMessageModel.init(accountViewModel)
newMessageModel.load(nostrGroupId)
@@ -120,6 +129,20 @@ fun MarmotGroupChatView(
}
}
// The live agent-preview watcher is NOT started here.
//
// Opening the chat used to build a [MarmotAgentStreamWatcher] and call
// `watchLatest` on every feed change, which dials the QUIC brokers a
// kind:1200 advertises. Nothing in the deployed network publishes those
// streams, so that was a UDP connection attempt to a third-party endpoint
// on behalf of a feature no one is using — a service we start, not a
// capability we hold.
//
// The watcher, the transport and the banner all still exist and are still
// tested; `amy marmot stream watch` drives the same code on demand. Wiring
// it back is re-adding the watcher, the LaunchedEffect and the banner
// below, once there is something to watch.
Column(Modifier.fillMaxHeight()) {
Column(
modifier =
@@ -134,20 +157,36 @@ fun MarmotGroupChatView(
routeForLastRead = marmotGroupLastReadRoute(nostrGroupId),
onWantsToReply = { note -> newMessageModel.reply(note) },
onWantsToEditDraft = { },
// kind:1210 rows sit in the conversation in order but are
// group-state captions rather than messages, so they get their
// own centered style instead of a bubble.
rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) },
)
}
Spacer(modifier = DoubleVertSpacer)
MarmotGroupMessageComposer(
nostrGroupId = nostrGroupId,
newMessageModel = newMessageModel,
accountViewModel = accountViewModel,
nav = nav,
onMessageSent = {
feedViewModel.feedState.sendToTop()
},
)
// A durable outbound gate means the group takes no new work: an
// unresolved disband request, a SelfRemove already sent, a realized
// removal. Sending would throw behind it, so the composer is replaced
// by the reason rather than left there to fail on tap — the history
// stays readable either way, which is the point of a gate that is not
// a terminal state.
val outboundGate by chatroom.outboundGate.collectAsStateWithLifecycle()
val gate = outboundGate
if (gate != null) {
MarmotGroupClosedComposer(gate)
} else {
MarmotGroupMessageComposer(
nostrGroupId = nostrGroupId,
newMessageModel = newMessageModel,
accountViewModel = accountViewModel,
nav = nav,
onMessageSent = {
feedViewModel.feedState.sendToTop()
},
)
}
}
}
@@ -331,3 +370,32 @@ private fun MarmotGroupFileUploadDialog(
isNip17 = false,
)
}
/**
* Stands in for the composer when an outbound gate is up.
*
* Deliberately a statement rather than a disabled text field: a greyed-out
* input still invites typing, and the three reasons are not the same — one is
* waiting on the group, one on a commit, and one is over. The group's history
* stays on screen above it.
*/
@Composable
private fun MarmotGroupClosedComposer(gate: LocalOutboundGate) {
val message =
when (gate) {
LocalOutboundGate.DISBANDING -> stringRes(R.string.marmot_group_composer_disbanding)
LocalOutboundGate.LEAVING -> stringRes(R.string.marmot_group_composer_leaving)
LocalOutboundGate.REMOVED -> stringRes(R.string.marmot_group_composer_removed)
}
Row(
modifier = EditFieldModifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = message,
color = MaterialTheme.colorScheme.placeholderText,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
)
}
}
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -92,6 +94,38 @@ fun rememberMarmotGroupIconUrl(
return url
}
/**
* The avatar URL for a group that may carry either avatar carrier, applying the
* components' precedence: `marmot.group.avatar-url.v1` wins over
* `marmot.group.blossom.image.v1`, and clearing the URL one falls back to the
* Blossom blob.
*
* The URL avatar is a plain link with no key material, so there is no cipher to
* register — it just goes to Coil. It does get a contact check first: a URL can
* be valid group state and still be somewhere we refuse to fetch from, and the
* spec puts that decision squarely on the client. An unsafe destination renders
* as no URL avatar rather than as an error, which lets the Blossom image (or the
* relay icon) take over.
*
* Returns null when the group has neither carrier.
*/
@Composable
fun rememberMarmotGroupAvatarUrl(
avatarUrl: GroupAvatarUrlV1?,
image: MarmotGroupImage?,
accountViewModel: AccountViewModel,
adminPubkeys: List<HexKey> = emptyList(),
): String? {
val link =
remember(avatarUrl) {
avatarUrl?.url?.takeIf { it.isNotEmpty() && MarmotWebUrl.isSafeToContact(it) }
}
// Branch rather than resolving both: the Blossom path registers a
// decryption cipher and probes servers as a side effect, and neither is
// worth doing for an avatar the renderer is not going to show.
return if (link != null) link else rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys)
}
/**
* The NIP-11 icon of the group's first resolvable relay, used as a fallback avatar
* when the group has no image of its own. Fetches the relay's NIP-11 document on a
@@ -41,6 +41,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
@@ -75,7 +76,12 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_add_member
import com.vitorpamplona.amethyst.commons.resources.marmot_add_member_placeholder
import com.vitorpamplona.amethyst.commons.resources.marmot_add_to_group
import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group
import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group_action
import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group_confirm
import com.vitorpamplona.amethyst.commons.resources.marmot_edit_group_info
import com.vitorpamplona.amethyst.commons.resources.marmot_enable_encrypted_media
import com.vitorpamplona.amethyst.commons.resources.marmot_enable_encrypted_media_explainer
import com.vitorpamplona.amethyst.commons.resources.marmot_grant
import com.vitorpamplona.amethyst.commons.resources.marmot_grant_admin_confirm
import com.vitorpamplona.amethyst.commons.resources.marmot_grant_admin_privileges
@@ -85,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_group_info_title
import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_required
import com.vitorpamplona.amethyst.commons.resources.marmot_leave_group
import com.vitorpamplona.amethyst.commons.resources.marmot_leave_group_confirm
import com.vitorpamplona.amethyst.commons.resources.marmot_legacy_group_no_disband
import com.vitorpamplona.amethyst.commons.resources.marmot_member_suffix_admin
import com.vitorpamplona.amethyst.commons.resources.marmot_member_suffix_you
import com.vitorpamplona.amethyst.commons.resources.marmot_relay_last_event
@@ -92,6 +99,7 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_relay_no_events
import com.vitorpamplona.amethyst.commons.resources.marmot_relays_header
import com.vitorpamplona.amethyst.commons.resources.marmot_remove_member
import com.vitorpamplona.amethyst.commons.resources.marmot_remove_member_confirm
import com.vitorpamplona.amethyst.commons.resources.marmot_retention_active
import com.vitorpamplona.amethyst.commons.resources.marmot_revoke
import com.vitorpamplona.amethyst.commons.resources.marmot_revoke_admin_confirm
import com.vitorpamplona.amethyst.commons.resources.marmot_revoke_admin_privileges
@@ -140,8 +148,13 @@ fun MarmotGroupInfoScreen(
val groupRelays by chatroom.relays.collectAsStateWithLifecycle()
val relayActivity by chatroom.relayActivity.collectAsStateWithLifecycle()
val members by chatroom.members.collectAsStateWithLifecycle()
val isCurrentProfile by chatroom.isCurrentProfile.collectAsStateWithLifecycle()
val hasEncryptedMedia by chatroom.hasEncryptedMediaPolicy.collectAsStateWithLifecycle()
var showLeaveDialog by remember { mutableStateOf(false) }
var showDisbandDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
var isDisbanding by remember { mutableStateOf(false) }
var isEnablingMedia by remember { mutableStateOf(false) }
var memberToRemove by remember { mutableStateOf<GroupMemberInfo?>(null) }
var memberToPromote by remember { mutableStateOf<GroupMemberInfo?>(null) }
var memberToDemote by remember { mutableStateOf<GroupMemberInfo?>(null) }
@@ -181,9 +194,30 @@ fun MarmotGroupInfoScreen(
contentDescription = stringRes(Res.string.marmot_edit_group_info),
)
}
// Disband ends the conversation for EVERYONE, so only an
// admin sees it and it sits behind its own confirmation.
// Peers reject a non-admin's lifecycle commit anyway; not
// offering it is what keeps a member from trying.
//
// A legacy group is hidden for a different reason: it has
// no carrier for the lifecycle component at all, so the
// commit is refused before it is built. The room says why
// further down rather than leaving the absence unexplained.
if (myPubkey in adminPubkeys && isCurrentProfile) {
IconButton(
onClick = { showDisbandDialog = true },
enabled = !isLeaving && !isDisbanding,
) {
Icon(
symbol = MaterialSymbols.DeleteForever,
contentDescription = stringRes(Res.string.marmot_disband_group),
tint = MaterialTheme.colorScheme.error,
)
}
}
IconButton(
onClick = { showLeaveDialog = true },
enabled = !isLeaving,
enabled = !isLeaving && !isDisbanding,
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ExitToApp,
@@ -233,6 +267,19 @@ fun MarmotGroupInfoScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
// Disappearing messages, when the group has them. Shown
// rather than editable: the setting is fixed at epoch 0,
// and a member who cannot change it still needs to know
// their messages are on a clock.
val retention = remember(nostrGroupId) { accountViewModel.marmotRetentionSeconds(nostrGroupId) }
if (retention > 0L) {
Text(
text = stringRes(Res.string.marmot_retention_active, formatRetention(retention)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
if (groupRelays.isNotEmpty()) {
GroupRelayStrip(
@@ -259,6 +306,84 @@ fun MarmotGroupInfoScreen(
}
}
// Groups are created WITHOUT the encrypted-media component so
// that epoch 0 matches the reference implementation's byte for
// byte; the spec's answer is that a group which wants one
// commits it. This is where an admin does that. Offered only
// while the group lacks it, because the component has no
// defined removal and this is a one-way change.
if (isCurrentProfile && !hasEncryptedMedia && myPubkey in adminPubkeys) {
item {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Button(
onClick = {
if (!accountViewModel.hasBlossomServers()) {
Toast
.makeText(
context,
stringRes(context, R.string.marmot_enable_encrypted_media_needs_server),
Toast.LENGTH_LONG,
).show()
return@Button
}
isEnablingMedia = true
scope.launch(Dispatchers.IO) {
try {
accountViewModel.enableMarmotEncryptedMediaV2(nostrGroupId)
launch(Dispatchers.Main) {
Toast
.makeText(
context,
stringRes(context, R.string.marmot_encrypted_media_enabled_toast),
Toast.LENGTH_SHORT,
).show()
}
} catch (e: Exception) {
launch(Dispatchers.Main) {
Toast
.makeText(
context,
stringRes(
context,
R.string.marmot_failed_to_enable_encrypted_media,
e.message,
),
Toast.LENGTH_LONG,
).show()
}
} finally {
isEnablingMedia = false
}
}
},
enabled = !isEnablingMedia && !isLeaving && !isDisbanding,
) {
Text(stringRes(Res.string.marmot_enable_encrypted_media))
}
Text(
text = stringRes(Res.string.marmot_enable_encrypted_media_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// An admin of a legacy group would otherwise just find the
// disband action missing. Say why, and say that it cannot be
// fixed by waiting for an update, so the only surprising part
// — that a NEW group would have it — is the part explained.
if (!isCurrentProfile && myPubkey in adminPubkeys) {
item {
Text(
text = stringRes(Res.string.marmot_legacy_group_no_disband),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
}
item {
Text(
text = stringRes(Res.string.members),
@@ -374,6 +499,57 @@ fun MarmotGroupInfoScreen(
)
}
if (showDisbandDialog) {
DisbandGroupDialog(
groupName = displayName ?: stringRes(Res.string.marmot_this_group),
onConfirm = {
showDisbandDialog = false
isDisbanding = true
scope.launch(Dispatchers.IO) {
try {
// Ended, or ending. A disband terminalizes only once
// convergence SELECTS the Commit, so the request can
// still be pending here — and telling someone their
// conversation is over when it may not be is the one
// wrong answer. Either way the group takes no further
// outbound work, so leaving the screen is right.
val ended = accountViewModel.disbandMarmotGroup(nostrGroupId)
launch(Dispatchers.Main) {
Toast
.makeText(
context,
stringRes(
context,
if (ended) {
R.string.marmot_group_disbanded_toast
} else {
R.string.marmot_group_disbanding_toast
},
),
Toast.LENGTH_SHORT,
).show()
}
nav.nav(Route.Message)
} catch (e: Exception) {
// A disband that reached no relay leaves the group
// live, so the screen must stay usable rather than
// navigate away on a change that did not happen.
isDisbanding = false
launch(Dispatchers.Main) {
Toast
.makeText(
context,
stringRes(context, R.string.marmot_failed_to_disband, e.message),
Toast.LENGTH_LONG,
).show()
}
}
}
},
onDismiss = { showDisbandDialog = false },
)
}
memberToRemove?.let { member ->
ConfirmRemoveMemberDialog(
memberPubkey = member.pubkey,
@@ -636,6 +812,38 @@ fun LeaveGroupDialog(
)
}
/**
* Confirmation for the one group action that cannot be undone.
*
* Disband is absorbing: every member's copy terminalizes and no commit walks it
* back, so the wording says "for everyone" and "cannot be reopened" rather than
* the usual "are you sure".
*/
@Composable
fun DisbandGroupDialog(
groupName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.marmot_disband_group)) },
text = {
Text(stringRes(Res.string.marmot_disband_group_confirm, groupName))
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringRes(Res.string.marmot_disband_group_action), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@Composable
private fun ConfirmRemoveMemberDialog(
memberPubkey: HexKey,
@@ -927,3 +1135,20 @@ private fun RelayHealthRow(
}
}
}
/**
* A retention duration as a reader sees it.
*
* Deliberately coarse — the exact second is committed group state, but what a
* member needs from this line is "how long roughly", and rounding down keeps a
* 90-minute setting from reading as "1 hour" only after it has already been
* displayed as "2 hours" somewhere else.
*/
private fun formatRetention(seconds: Long): String =
when {
seconds % 604_800L == 0L -> "${seconds / 604_800L}w"
seconds % 86_400L == 0L -> "${seconds / 86_400L}d"
seconds % 3_600L == 0L -> "${seconds / 3_600L}h"
seconds % 60L == 0L -> "${seconds / 60L}m"
else -> "${seconds}s"
}
@@ -0,0 +1,111 @@
/*
* 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.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_retention_1d
import com.vitorpamplona.amethyst.commons.resources.marmot_retention_1h
import com.vitorpamplona.amethyst.commons.resources.marmot_retention_1w
import com.vitorpamplona.amethyst.commons.resources.marmot_retention_footer
import com.vitorpamplona.amethyst.commons.resources.marmot_retention_off
import com.vitorpamplona.amethyst.commons.resources.marmot_retention_title
import com.vitorpamplona.amethyst.ui.stringRes
/**
* How long messages live in a new group — component `0x8005`,
* `marmot.group.message-retention.v1`.
*
* A fixed set rather than a free-form duration, because the value is committed
* into group state that every member's client reads: an arbitrary number buys
* nothing and gives a reader one more shape to render.
*/
enum class MarmotRetentionChoice(
val seconds: ULong?,
) {
OFF(null),
ONE_HOUR(3_600uL),
ONE_DAY(86_400uL),
ONE_WEEK(604_800uL),
}
/**
* The picker, shown only at group creation.
*
* Retention is chosen once and not changed later, and that is a limitation
* rather than a policy: making a component required after epoch 0 takes two
* commits — install the state, then promote it — and this screen makes one.
*/
@Composable
fun MarmotRetentionPicker(
selected: MarmotRetentionChoice,
onSelect: (MarmotRetentionChoice) -> Unit,
enabled: Boolean,
) {
Column {
Text(
stringRes(Res.string.marmot_retention_title),
style = MaterialTheme.typography.titleSmall,
)
MarmotRetentionChoice.entries.forEach { choice ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.selectable(
selected = choice == selected,
enabled = enabled,
onClick = { onSelect(choice) },
).padding(vertical = 2.dp),
) {
RadioButton(
selected = choice == selected,
onClick = { onSelect(choice) },
enabled = enabled,
)
Text(stringRes(choice.label()))
}
}
Text(
stringRes(Res.string.marmot_retention_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun MarmotRetentionChoice.label() =
when (this) {
MarmotRetentionChoice.OFF -> Res.string.marmot_retention_off
MarmotRetentionChoice.ONE_HOUR -> Res.string.marmot_retention_1h
MarmotRetentionChoice.ONE_DAY -> Res.string.marmot_retention_1d
MarmotRetentionChoice.ONE_WEEK -> Res.string.marmot_retention_1w
}
@@ -0,0 +1,173 @@
/*
* 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.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_system_admin_added
import com.vitorpamplona.amethyst.commons.resources.marmot_system_admin_added_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_admin_removed
import com.vitorpamplona.amethyst.commons.resources.marmot_system_admin_removed_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_avatar_changed
import com.vitorpamplona.amethyst.commons.resources.marmot_system_avatar_changed_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_group_disbanded
import com.vitorpamplona.amethyst.commons.resources.marmot_system_group_disbanded_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_group_renamed
import com.vitorpamplona.amethyst.commons.resources.marmot_system_group_renamed_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_added
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_added_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_left
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_removed
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_removed_passive
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatFeedRowRenderer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemType
import org.jetbrains.compose.resources.StringResource
/**
* Renders a kind:1210 group system row as a centered caption.
*
* These are not chat and must not read like it. A row is derived locally from
* canonical group state rather than received as a message, so it has no sender
* to attribute a bubble to — and its `content` is JSON, which a chat bubble
* would render verbatim.
*
* The caption is built from the row's STRUCTURED fields, with its `text` member
* used only as a fallback. That ordering is the spec's ("Clients SHOULD render
* from the structured fields instead") and it is what lets the same row read in
* the viewer's own terms rather than the writer's.
*/
class MarmotSystemRowRenderer(
private val accountViewModel: AccountViewModel,
) : ChatFeedRowRenderer {
override fun claims(note: Note): Boolean = note.event?.kind == MarmotAppEvent.KIND_SYSTEM
@Composable
override fun Render(note: Note) {
val event = note.event ?: return
val row = remember(event.id) { MarmotSystemEvent.fromAppEvent(MarmotAppEvent.fromEvent(event)) }
// An unknown `system_type` decodes to null rather than throwing, because
// the registry grows and an unfamiliar row must not break the feed. There
// is nothing honest to draw for one, so it is simply not drawn.
if (row == null) return
val caption = caption(row)
// A two-party row with no subject has nothing true to say; the spec's
// fallback text would be a generic label, not information.
if (caption.isEmpty()) return
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = caption,
textAlign = TextAlign.Center,
fontSize = 12.sp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* The row in words, naming people rather than pubkeys where we know them.
*
* Each type has an active and a passive phrasing: the actor is optional —
* a row derived from a commit whose committer we cannot attribute is still
* a true row — so "who did it" is never assumed. The row's own `text`
* member is the last fallback, which is what it exists for.
*/
@Composable
private fun caption(row: MarmotSystemEvent): String {
val actor = row.actor?.let { displayName(it) }
val subject = row.subject?.let { displayName(it) }
return when (row.systemType) {
MarmotSystemType.MEMBER_ADDED ->
twoParty(subject, actor, Res.string.marmot_system_member_added, Res.string.marmot_system_member_added_passive)
MarmotSystemType.MEMBER_REMOVED ->
twoParty(subject, actor, Res.string.marmot_system_member_removed, Res.string.marmot_system_member_removed_passive)
MarmotSystemType.MEMBER_LEFT ->
subject?.let { stringRes(Res.string.marmot_system_member_left, it) } ?: row.text
MarmotSystemType.ADMIN_ADDED ->
twoParty(subject, actor, Res.string.marmot_system_admin_added, Res.string.marmot_system_admin_added_passive)
MarmotSystemType.ADMIN_REMOVED ->
twoParty(subject, actor, Res.string.marmot_system_admin_removed, Res.string.marmot_system_admin_removed_passive)
MarmotSystemType.GROUP_RENAMED ->
row.name?.let { name ->
if (actor != null) {
stringRes(Res.string.marmot_system_group_renamed, actor, name)
} else {
stringRes(Res.string.marmot_system_group_renamed_passive, name)
}
} ?: row.text
MarmotSystemType.GROUP_AVATAR_CHANGED ->
actor?.let { stringRes(Res.string.marmot_system_avatar_changed, it) }
?: stringRes(Res.string.marmot_system_avatar_changed_passive)
MarmotSystemType.GROUP_DISBANDED ->
actor?.let { stringRes(Res.string.marmot_system_group_disbanded, it) }
?: stringRes(Res.string.marmot_system_group_disbanded_passive)
}
}
/**
* A row about one member, phrased actively when the committer is known and
* passively when it is not.
*/
@Composable
private fun twoParty(
subject: String?,
actor: String?,
active: StringResource,
passive: StringResource,
): String {
if (subject == null) return ""
return if (actor != null) stringRes(active, actor, subject) else stringRes(passive, subject)
}
/** A known display name, or a short key when the account is a stranger. */
@Composable
private fun displayName(pubkeyHex: String): String {
val user = accountViewModel.getUserIfExists(pubkeyHex)
return user?.toBestDisplayName() ?: pubkeyHex.take(8)
}
}
@@ -25,8 +25,14 @@ import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.buildMip04IMetaTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Sends uploaded MIP-04 encrypted media as Marmot group messages.
* Each upload result becomes a separate kind:9 message with an imeta tag.
* Sends uploaded encrypted media as Marmot group messages. Each upload result
* becomes a separate kind:9 with an `imeta` tag.
*
* Every upload now carries an `encrypted-media-v2` reference, whatever policy
* the group holds -- see [MarmotFileUploader]. The MIP-04 branch below is kept
* because the result type still allows a null reference, but nothing this app
* writes takes it; the MIP-era shape survives only on the READ side, for
* messages older builds already sent.
*/
class MarmotFileSender(
val nostrGroupId: HexKey,
@@ -34,6 +40,16 @@ class MarmotFileSender(
) {
suspend fun send(uploads: List<Mip04UploadResult>) {
for (upload in uploads) {
val v2 = upload.encryptedMediaV2
if (v2 != null) {
accountViewModel.sendMarmotGroupEncryptedMediaV2(
nostrGroupId = nostrGroupId,
reference = v2,
caption = upload.caption.orEmpty(),
)
continue
}
val imeta =
buildMip04IMetaTag(
url = upload.url,
@@ -29,7 +29,11 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04NostrCipher
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2Cipher
import com.vitorpamplona.quartz.marmot.appComponents.MarmotMediaType
import com.vitorpamplona.quartz.marmot.appComponents.MediaLocatorV2
/**
* MIP-04 upload result containing all info needed to build the imeta tag.
@@ -44,13 +48,26 @@ class Mip04UploadResult(
val blurhash: String?,
val caption: String?,
val thumbhash: String? = null,
/**
* The `encrypted-media-v2` reference, when the group's policy asked for
* one. Null means this upload is a MIP-04 attachment and the fields above
* are what builds its tag.
*
* The two are carried together rather than as two result types because the
* upload pipeline is identical — only the cipher and the tag differ — and
* the choice belongs to the group, not to the uploader.
*/
val encryptedMediaV2: EncryptedMediaReferenceV2? = null,
)
/** What an unparseable media type becomes, so a file is never described in a dialect nobody reads. */
private const val GENERIC_MEDIA_TYPE = "application/octet-stream"
/**
* Handles MIP-04 encrypted media upload for Marmot groups.
* Handles encrypted media upload for Marmot groups.
*
* Uses the existing [UploadOrchestrator.uploadEncrypted] pipeline but
* provides a per-file [Mip04NostrCipher] for MIP-04 key derivation.
* provides a per-file [EncryptedMediaV2Cipher] for key derivation.
*/
class MarmotFileUploader(
val account: Account,
@@ -60,6 +77,10 @@ class MarmotFileUploader(
exporterSecret: ByteArray,
onError: (title: String, message: String) -> Unit,
context: Context,
/**
* Produce `encrypted-media-v2` references instead of MIP-04 ones.
* Decided by the group's policy component, not by the uploader.
*/
onceUploaded: suspend (List<Mip04UploadResult>) -> Unit,
) {
val multiOrchestrator = viewState.multiOrchestrator ?: return
@@ -75,7 +96,30 @@ class MarmotFileUploader(
val mimeType = media.mimeType ?: "application/octet-stream"
val filename = resolveFilename(context, media.uri, mimeType)
val cipher = Mip04NostrCipher(exporterSecret, mimeType, filename)
// v2 puts `m` inside both the key derivation and the AEAD
// associated data, so it has to be the canonical form and not
// whatever the content resolver reported.
//
// Always v2, whatever the group carries. This used to be gated on
// the group holding the `encrypted-media-v2` policy, and groups are
// created without it on purpose (epoch 0 has to match the reference
// implementation byte for byte), so in practice every attachment
// went out in the MIP-era dialect -- `url`/`x`/`n`/`v mip04-v2` --
// which no shipping Marmot implementation reads: MDK 0.9.21 knows
// only `encrypted-media-v1|v2` and drops anything else at the
// typed parser, silently. Receivers do not gate on the policy
// either (MDK's own test pins that an out-of-policy locator is
// "kept, not dropped on ingest"), so writing v2 into a group that
// never committed the component is read correctly; it is only the
// SENDER's own policy validation that a component would constrain.
//
// A media type too malformed to canonicalize becomes the generic
// octet-stream rather than falling back to the old dialect: an
// attachment nobody can render is worse than one labelled
// imprecisely.
val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE
val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename)
val v2Cipher = cipher
item.orchestrator.uploadEncrypted(
uri = media.uri,
@@ -93,17 +137,42 @@ class MarmotFileUploader(
val state = item.orchestrator.progressState.value
if (state is UploadingState.Finished && state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val serverResult = state.result
// The reference is built from what the cipher recorded while
// encrypting the bytes the pipeline actually uploaded — after
// compression and metadata stripping — because that is what the
// key was derived from.
val reference =
v2Cipher?.let {
EncryptedMediaReferenceV2(
locators =
listOf(
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
),
ciphertextSha256 = it.ciphertextSha256,
plaintextSha256 = it.plaintextSha256,
nonce = it.nonce,
mediaType = it.mediaType,
filename = filename,
dim = serverResult.fileHeader.dim?.toString(),
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
)
}
results.add(
Mip04UploadResult(
url = serverResult.url,
mimeType = mimeType,
filename = filename,
originalFileHash = cipher.originalFileHash,
nonce = cipher.nonce,
// Empty now that nothing is encrypted with the MIP-era
// scheme. The fields stay on the result type because the
// reader still accepts that shape for messages older
// builds already sent.
originalFileHash = ByteArray(0),
nonce = ByteArray(0),
dimensions = serverResult.fileHeader.dim?.toString(),
blurhash = serverResult.fileHeader.blurHash?.blurhash,
caption = viewState.caption.ifEmpty { null },
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
encryptedMediaV2 = reference,
),
)
} else {
@@ -51,6 +51,15 @@ class MarmotGroupIconUpload(
val imageNonce: ByteArray,
/** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). */
val imageUploadKey: ByteArray,
/**
* Media type of the DECRYPTED image.
*
* MIP-01's blob never carried one, but the current profile's `0x8002`
* component requires it on a present image — and it is bound into the
* AEAD's AAD there, so a receiver cannot be steered into decoding the
* plaintext as a different type than the uploader meant.
*/
val mediaType: String,
)
/**
@@ -123,6 +132,7 @@ class MarmotGroupIconUploader(
imageKey = cipher.imageKey,
imageNonce = cipher.imageNonce,
imageUploadKey = uploadKeySeed,
mediaType = uploadMime,
)
}
@@ -103,7 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimeli
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill
@@ -466,6 +466,7 @@ private fun MarmotGroupRoomCompose(
) {
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val image by chatroom.image.collectAsStateWithLifecycle()
val avatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle()
val relays by chatroom.relays.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
@@ -473,11 +474,12 @@ private fun MarmotGroupRoomCompose(
val noteEvent = lastMessage.event
val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}"
// Prefer the group's own (encrypted) avatar; when it has none, fall back to the
// NIP-11 icon of one of the group's relays (fetched on a cache miss).
// Prefer the group's own avatar — the plain https link first, then the
// encrypted Blossom blob; when it has neither, fall back to the NIP-11 icon
// of one of the group's relays (fetched on a cache miss).
val channelPicture =
if (image != null) {
rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys)
if (avatarUrl != null || image != null) {
rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys)
} else {
loadMarmotRelayIcon(relays)
}
@@ -90,7 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
import com.vitorpamplona.amethyst.ui.stringRes
@@ -593,14 +593,15 @@ private fun rememberMarmotEntity(
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val image by chatroom.image.collectAsStateWithLifecycle()
val avatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle()
val relays by chatroom.relays.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
// Same name/icon precedence the chat-rooms list uses, so a group reads identically in both places.
val name = displayName?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.marmot_group_fallback_name, id.take(8))
val picture =
if (image != null) {
rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys)
if (avatarUrl != null || image != null) {
rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys)
} else {
loadMarmotRelayIcon(relays)
}
+9
View File
@@ -2713,6 +2713,15 @@
<string name="marmot_failed_to_update">Failed to update: %1$s</string>
<string name="marmot_failed_to_create_group">Failed to create group: %1$s</string>
<string name="marmot_failed_to_leave_group">Failed to leave group: %1$s</string>
<string name="marmot_enable_encrypted_media_needs_server">Add a Blossom media server in Settings first — the group needs somewhere to upload attachments to.</string>
<string name="marmot_encrypted_media_enabled_toast">This group now uses encrypted attachments</string>
<string name="marmot_failed_to_enable_encrypted_media">Could not switch this group to encrypted attachments: %1$s</string>
<string name="marmot_group_disbanded_toast">Group disbanded</string>
<string name="marmot_group_disbanding_toast">Ending the group. It finishes once the group agrees.</string>
<string name="marmot_group_composer_disbanding">This group is being ended. You can still read it.</string>
<string name="marmot_group_composer_leaving">You are leaving this group.</string>
<string name="marmot_group_composer_removed">You are no longer a member of this group.</string>
<string name="marmot_failed_to_disband">Could not disband the group: %1$s</string>
<string name="marmot_adding_user">Adding %1$s…</string>
<string name="marmot_failed_to_add_user">Failed to add %1$s: %2$s</string>
<string name="marmot_unknown_error">unknown error</string>
@@ -0,0 +1,168 @@
/*
* 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.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
import kotlin.random.Random
/**
* Container sniffing under adversarial bytes.
*
* Ported from the reference client's `ImageContainerBytesFuzzTest`. Their
* walkers strip metadata from a `ByteArray`; ours identifies a container from a
* file's first bytes, so the oracles carry over even though the code does not:
* arbitrary input never throws, the answer is deterministic and always a
* declared kind, a mismatched walker does not claim the container, and only the
* header can decide — trailing bytes must be irrelevant.
*
* That last one is the load-bearing invariant here. A sniffer that read past
* its header would let attacker-chosen bytes deep inside a file change how the
* file is labelled, which is exactly what content-type confusion needs.
*/
class ShareHelperContainerSniffingTest {
private val imageKinds = setOf("jpg", "png", "gif", "webp")
private val videoKinds = setOf("mp4", "mov", "webm", "avi")
private lateinit var dir: File
private fun file(bytes: ByteArray): File {
if (!::dir.isInitialized) dir = Files.createTempDirectory("sniffing").toFile()
val f = File.createTempFile("probe", ".bin", dir)
f.writeBytes(bytes)
return f
}
private fun headers(): List<Pair<String, ByteArray>> =
listOf(
"jpg" to byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0x00, 0x01),
"png" to byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47),
"gif" to "GIF89a".encodeToByteArray(),
"webp" to ("RIFF".encodeToByteArray() + ByteArray(4) + "WEBP".encodeToByteArray()),
"webm" to byteArrayOf(0x1A, 0x45, 0xDF.toByte(), 0xA3.toByte()),
"avi" to ("RIFF".encodeToByteArray() + ByteArray(4) + "AVI ".encodeToByteArray()),
"mp4" to (ByteArray(4) + "ftyp".encodeToByteArray() + "isom".encodeToByteArray()),
"mov" to (ByteArray(4) + "ftyp".encodeToByteArray() + "qt ".encodeToByteArray()),
)
/** Random bytes, truncations, and real headers with random tails. */
private fun corpus(seed: Int): List<ByteArray> {
val rnd = Random(seed)
return buildList {
repeat(200) { add(ByteArray(rnd.nextInt(0, 64)) { rnd.nextInt(256).toByte() }) }
headers().forEach { (_, header) ->
add(header)
repeat(8) { add(header + ByteArray(rnd.nextInt(0, 128)) { rnd.nextInt(256).toByte() }) }
// Truncated to every prefix length: the sniffer must survive a
// header that stops in the middle of a magic number.
for (cut in 0 until header.size) add(header.copyOfRange(0, cut))
}
add(ByteArray(0))
}
}
@Test
fun sniffingNeverThrowsAndAlwaysNamesADeclaredKind() {
val seed = 20260914
corpus(seed).forEach { bytes ->
val f = file(bytes)
val image =
try {
ShareHelper.getImageExtension(f)
} catch (e: Throwable) {
throw AssertionError("image sniffing threw on " + bytes.size + " bytes (seed " + seed + ")", e)
}
val video =
try {
ShareHelper.getVideoExtension(f)
} catch (e: Throwable) {
throw AssertionError("video sniffing threw on " + bytes.size + " bytes (seed " + seed + ")", e)
}
assertTrue("unexpected image kind " + image, image in imageKinds)
assertTrue("unexpected video kind " + video, video in videoKinds)
}
}
@Test
fun sniffingIsDeterministic() {
corpus(20260915).forEach { bytes ->
val f = file(bytes)
assertEquals(ShareHelper.getImageExtension(f), ShareHelper.getImageExtension(f))
assertEquals(ShareHelper.getVideoExtension(f), ShareHelper.getVideoExtension(f))
}
}
@Test
fun onlyTheHeaderDecides() {
// Appending arbitrary bytes must not change the verdict. A sniffer that
// read further would let bytes deep inside a file relabel it.
val rnd = Random(20260916)
headers().forEach { (_, header) ->
val bare = file(header)
val image = ShareHelper.getImageExtension(bare)
val video = ShareHelper.getVideoExtension(bare)
repeat(16) {
val padded = file(header + ByteArray(rnd.nextInt(1, 512)) { rnd.nextInt(256).toByte() })
assertEquals("a trailing byte changed the image verdict", image, ShareHelper.getImageExtension(padded))
assertEquals("a trailing byte changed the video verdict", video, ShareHelper.getVideoExtension(padded))
}
}
}
@Test
fun everyRealHeaderIsIdentifiedByItsOwnWalker() {
headers().forEach { (kind, header) ->
val f = file(header + ByteArray(32))
val sniffed = if (kind in imageKinds) ShareHelper.getImageExtension(f) else ShareHelper.getVideoExtension(f)
assertEquals("header for " + kind + " was not identified", kind, sniffed)
}
}
@Test
fun aMismatchedWalkerDoesNotClaimTheContainer() {
// Their "a mismatched walker must reject the container", in the shape
// our API allows: asking the video sniffer about a JPEG must fall back
// to the video default rather than reporting an image kind.
headers().forEach { (kind, header) ->
val f = file(header + ByteArray(32))
if (kind in imageKinds) {
assertTrue("an image was reported as a video kind", ShareHelper.getVideoExtension(f) in videoKinds)
} else {
assertTrue("a video was reported as an image kind", ShareHelper.getImageExtension(f) in imageKinds)
}
}
}
@Test
fun aTruncatedHeaderFallsBackRatherThanGuessing() {
// Under four readable bytes there is nothing to decide on, and reading
// past the end is how a sniffer turns a short file into a crash.
listOf(ByteArray(0), byteArrayOf(0xFF.toByte()), byteArrayOf(0xFF.toByte(), 0xD8.toByte()), byteArrayOf(0x89.toByte(), 0x50, 0x4E))
.forEach { bytes ->
val f = file(bytes)
assertEquals("jpg", ShareHelper.getImageExtension(f))
assertEquals("mp4", ShareHelper.getVideoExtension(f))
}
}
}
+3 -1
View File
@@ -565,9 +565,11 @@ kind:10040 out-of-band.
| `amy marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages and invite. |
| `amy marmot group rename GID NAME` | Commit a metadata change. |
| `amy marmot group promote / demote / remove GID NPUB` | Admin verbs. |
| `amy marmot group set-retention GID SECS` | Disappearing messages, in seconds (`0` disables). Not retroactive: each message keeps the expiry pinned from the epoch that delivered it. |
| `amy marmot group leave GID` | Self-remove. |
| `amy marmot group disband GID --yes` | End the group for every member. Terminal and irreversible — a replacement conversation is a new group with a new id — so `--yes` is required. |
| `amy marmot message send GID TEXT` | Publish a kind:9 inner event into the group. |
| `amy marmot message list GID [--limit N]` | Decrypted inner events, oldest first. Default `--limit 50`. |
| `amy marmot message list GID [--limit N]` | Decrypted inner events, oldest first. Default `--limit 50`. Each row carries `edited`, `deleted` and the pinned `expires_at`. |
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
+4
View File
@@ -43,6 +43,10 @@ tasks.named<Test>("test") {
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
// Agent text stream previews: the raw-QUIC binding, and the QUIC
// stack under it for the certificate validator the transport requires.
implementation(project(":marmotQuic"))
implementation(project(":quic"))
// `amy serve` embeds geode (the standalone Ktor relay built on quartz's
// relay-server code). geode depends only on :quartz, never on :amethyst.
implementation(project(":geode"))
@@ -228,6 +228,17 @@ class DataDir(
val groupsDir = File(marmotDir, "groups")
val keyPackageBundleFile = File(marmotDir, "keypackages.bundle")
/**
* Unresolved publish obligations. Durable because publish-before-apply is
* only meaningful across a crash: without this, a commit recorded and then
* lost to a restart is replaced by a fresh one for the same epoch, forking
* us against the peers that accepted the first.
*/
val publishObligationsDir = File(marmotDir, "obligations")
/** Inbound events this account has terminally decided about. */
val ingestDedupFile = File(marmotDir, "ingested.ids")
/**
* SQLite event-store DB file, a sibling of [eventsDir] under
* `<root>/shared/`. Used when the store backend is SQLite (the
@@ -21,15 +21,18 @@
package com.vitorpamplona.amethyst.cli
import com.sun.management.UnixOperatingSystemMXBean
import com.vitorpamplona.amethyst.cli.stores.FileIngestDedupStore
import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore
import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore
import com.vitorpamplona.amethyst.cli.stores.FilePublishObligationStore
import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader
import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps
import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.marmot.MarmotPublisher
import com.vitorpamplona.amethyst.commons.marmot.MarmotSyncPolicy
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -43,6 +46,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPoolWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
@@ -317,6 +321,8 @@ class Context(
private val mlsStore by lazy { FileMlsGroupStateStore(dataDir.groupsDir) }
private val keyPackageStore by lazy { FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) }
private val messageStore by lazy { FileMarmotMessageStore(dataDir.groupsDir) }
private val publishObligationStore by lazy { FilePublishObligationStore(dataDir.publishObligationsDir) }
private val ingestDedupStore by lazy { FileIngestDedupStore(dataDir.ingestDedupFile) }
/**
* Shared Nostr event store for this run, opened via [StoreFactory]
@@ -344,7 +350,20 @@ class Context(
}
/** Fully-wired manager. Call [prepare] once before use to load persisted state. */
val marmot: MarmotManager by lazy { MarmotManager(signer, mlsStore, messageStore, keyPackageStore) }
val marmot: MarmotManager by lazy {
MarmotManager(
signer,
mlsStore,
messageStore,
keyPackageStore,
// Publish-before-apply: a group-state change becomes canonical only
// once a relay in the group's own scope returns OK true. Anything
// weaker (queued, sent, no error yet) is explicitly not success.
MarmotPublisher { event, relays -> client.publishAndConfirm(event, relays) },
publishObligationStore,
ingestDedupStore,
)
}
// ------------------------------------------------------------------
// Cashu (NIP-60 / NIP-61) — shared wallet code from commons
@@ -444,7 +463,7 @@ class Context(
* Android app.
*/
suspend fun outboxRelays(): Set<NormalizedRelayUrl> =
relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
relaysOf(identity.pubKeyHex)?.allWriteRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
?: DefaultNIP65RelaySet
/**
@@ -455,7 +474,7 @@ class Context(
* marked.
*/
suspend fun nip65ReadRelays(): Set<NormalizedRelayUrl> =
relaysOf(identity.pubKeyHex)?.readRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
relaysOf(identity.pubKeyHex)?.allReadRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
?: outboxRelays()
/**
@@ -463,16 +482,23 @@ class Context(
* to [DefaultDMRelayList] when no kind:10050 has been seen.
*/
suspend fun inboxRelays(): Set<NormalizedRelayUrl> =
dmInboxOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
dmInboxOf(identity.pubKeyHex)?.allRelays()?.takeIf { it.isNotEmpty() }?.toSet()
?: DefaultDMRelayList.toSet()
/**
* KeyPackage relays (MIP-00 kind:10051) for this account. Falls
* back to [outboxRelays] when no kind:10051 has been seen — same
* fallback the Android app uses for KeyPackage discovery.
* Our own KeyPackage relay list (MIP-00 kind:10051). Falls back to
* [outboxRelays] when no kind:10051 has been seen — the same fallback the
* Android app uses for KeyPackage discovery.
*
* `allRelays()`, not `relays()`: the filtered accessor drops local-network
* entries because someone else's list is attacker-supplied input, but this
* is a list we published ourselves. Reading it filtered made a deliberately
* configured local relay look like no configuration at all, and the
* publisher then fell back to a default relay set the operator never chose
* — sending a KeyPackage somewhere they did not pick.
*/
suspend fun keyPackageRelays(): Set<NormalizedRelayUrl> =
keyPackageRelaysOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
keyPackageRelaysOf(identity.pubKeyHex)?.allRelays()?.takeIf { it.isNotEmpty() }?.toSet()
?: outboxRelays()
/** Union of all three buckets. */
@@ -778,10 +804,12 @@ class Context(
val kp = keyPackageRelaysOf(pubKey)
val nip65 = relaysOf(pubKey)
if (dm == null && kp == null && nip65 == null) return null
val dmInbox = dm?.relays().orEmpty()
return RecipientRelayFetcher.Lists(
dmInbox = dm?.relays().orEmpty(),
dmInbox = dmInbox,
keyPackage = kp?.relays().orEmpty(),
nip65 = nip65,
dmInboxWithheld = dmInbox.isEmpty() && dm?.allRelays().orEmpty().isNotEmpty(),
)
}
@@ -865,14 +893,15 @@ class Context(
} ?: input
}
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val m = marmot.groupMetadata(nostrGroupId) ?: return emptySet()
return m.relays
.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}.toSet()
}
/**
* The group's own relay set, from whichever routing component it carries.
*
* Delegates rather than reading `MarmotGroupData` directly: a
* current-profile group has no `0xF2EE` extension at all, and reading only
* that one silently returned an empty set for every group the current
* profile creates.
*/
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> = marmot.groupRelays(nostrGroupId).toSet()
override fun close() {
// Nothing to persist for an anonymous run (no account dir to write into).
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands
import com.vitorpamplona.amethyst.cli.commands.KindCommand
import com.vitorpamplona.amethyst.cli.commands.LoginCommand
import com.vitorpamplona.amethyst.cli.commands.LogoffCommand
import com.vitorpamplona.amethyst.cli.commands.MarmotMediaCommands
import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand
import com.vitorpamplona.amethyst.cli.commands.MessageCommands
import com.vitorpamplona.amethyst.cli.commands.NamecoinCommand
@@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.cli.commands.SearchCommand
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
import com.vitorpamplona.amethyst.cli.commands.StatusCommand
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
import com.vitorpamplona.amethyst.cli.commands.StreamCommands
import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
import com.vitorpamplona.amethyst.cli.commands.UseCommand
@@ -370,12 +372,14 @@ private suspend fun marmotDispatch(
route(
name = "marmot",
tail = tail,
usage = "marmot <key-package|group|message|await|reset>",
usage = "marmot <key-package|group|message|media|stream|await|reset>",
routes =
mapOf(
"key-package" to { rest -> KeyPackageCommands.dispatch(dataDir, rest) },
"group" to { rest -> GroupCommands.dispatch(dataDir, rest) },
"message" to { rest -> MessageCommands.dispatch(dataDir, rest) },
"media" to { rest -> MarmotMediaCommands.dispatch(dataDir, rest) },
"stream" to { rest -> StreamCommands.dispatch(dataDir, rest) },
"await" to { rest -> AwaitCommands.dispatch(dataDir, rest) },
"reset" to { rest -> MarmotResetCommand.run(dataDir, rest) },
),
@@ -844,8 +848,10 @@ private fun printUsage() {
| marmot group rename GID NAME commit a rename
| marmot group promote GID NPUB add admin
| marmot group demote GID NPUB remove admin
| marmot group set-retention GID SECS set disappearing messages (0 disables)
| marmot group remove GID NPUB remove member
| marmot group leave GID self-remove
| marmot group disband GID --yes end the group for everyone (irreversible)
|
| marmot message send GID TEXT publish kind:9 inner event into the group
| marmot message list GID [--limit N] dump decrypted inner events
@@ -145,14 +145,14 @@ object AwaitCommands {
ctx.syncIncoming(timeoutMs = 3_000)
val match =
ctx.marmot.activeGroupIds().firstOrNull { gid ->
wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName
wantedName == null || ctx.marmot.groupView(gid)?.name == wantedName
}
if (match != null) {
Output.emit(
mapOf(
"group_id" to match,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(match),
"name" to (ctx.marmot.groupMetadata(match)?.name ?: ""),
"name" to (ctx.marmot.groupView(match)?.name ?: ""),
"epoch" to ctx.marmot.groupEpoch(match),
),
)
@@ -190,7 +190,7 @@ object AwaitCommands {
if (!ctx.marmot.isMember(gid)) {
null
} else if (ctx.marmot
.groupMetadata(gid)
.groupView(gid)
?.adminPubkeys
?.contains(target) == true
) {
@@ -215,7 +215,7 @@ object AwaitCommands {
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val name = ctx.marmot.groupMetadata(gid)?.name
val name = ctx.marmot.groupView(gid)?.name
if (name == wantedName) {
Output.emit(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid)))
return 0
@@ -83,9 +83,9 @@ object GroupAddMemberCommand {
seedRelays = seed,
)
// KeyPackage discovery (MIP-00): prefer the invitee's own
// kind:10051, then their kind:10002 write marker, then our
// bootstrap pool as a last-resort fallback.
// KeyPackage discovery: the invitee's kind:10002 write set is
// the rule now; their kind:10051 is a legacy hint and our
// bootstrap pool a last-resort fallback.
val kpRelays =
KeyPackageFetcher.fetchRelaysFor(
targetKeyPackageRelays = recipient.keyPackage,
@@ -111,9 +111,20 @@ object GroupAddMemberCommand {
relays = groupRelays.toList(),
)
// Order matters: commit first (so invitee doesn't join at a future epoch),
// then welcome.
val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays)
// Order matters: commit first (so invitee doesn't join at a future
// epoch), then welcome.
//
// A FOUNDING add returns no commit at all: the creator was the
// group's only member, so the Add is merged locally under the
// empty publication obligation and the invitee learns epoch 1
// from the Welcome's own GroupInfo. There is nothing to send
// first, and nothing whose acknowledgement to wait for.
val commitAck =
if (commitEvent != null) {
ctx.publish(commitEvent.signedEvent, groupRelays)
} else {
emptyMap()
}
val welcomeTargets: Set<NormalizedRelayUrl> =
if (welcomeDelivery != null) {
// Welcome gift wrap (kind:1059 wrapping kind:444) must
@@ -127,9 +138,17 @@ object GroupAddMemberCommand {
// bootstrapped Amethyst accounts listen on these)
// Our own outbox is added as belt-and-braces so we
// can re-ingest the welcome ourselves too.
//
// The default set is a bootstrap for someone who has
// advertised NOTHING, not a fallback for someone whose
// advertised inbox we declined to use. When they named
// a local-network relay we refuse to reach, sending
// their invite to a public default set instead is a
// different action than they asked for — so we keep it
// on the group's own relays and say so.
buildSet {
addAll(recipient.dmInboxOrFallback())
if (isEmpty()) {
if (isEmpty() && !recipient.dmInboxWithheld) {
addAll(DefaultDMRelayList)
}
addAll(ctx.outboxRelays())
@@ -149,11 +168,15 @@ object GroupAddMemberCommand {
"pubkey" to pub,
"status" to "invited",
"key_package_event_id" to kpEvent.id,
"commit_event_id" to commitEvent.signedEvent.id,
"commit_event_id" to commitEvent?.signedEvent?.id,
// Null commit_event_id is not a failure — it is the
// founding add, which publishes no group message.
"founding_local_merge" to (commitEvent == null),
"welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id,
"commit_accepted_by" to commitAck.filterValues { it.accepted }.keys.map { it.url },
"welcome_accepted_by" to welcomeAck.filterValues { it.accepted }.keys.map { it.url },
"welcome_targets" to welcomeTargets.map { it.url },
"welcome_inbox_withheld" to recipient.dmInboxWithheld,
"key_package_relays" to kpRelays.map { it.url },
),
)
@@ -27,7 +27,9 @@ object GroupCommands {
"""
|amy marmot group — MLS group management
|
| marmot group create [--name NAME] create an empty group (self-only)
| marmot group create [--name NAME] create an empty group (self-only);
| [--legacy] --legacy builds a MIP-era group that
| only 0xF2EE-capable leaves can join
| marmot group list list joined groups
| marmot group show GID print full group details
| marmot group members GID print members
@@ -39,8 +41,13 @@ object GroupCommands {
| marmot group set-image GID FILE encrypt + commit a group avatar
| [--server URL] (--server uploads the ciphertext to Blossom)
| marmot group clear-image GID remove the group avatar
| marmot group set-avatar-url GID URL commit a plain https avatar link
| [--dim WxH] [--thumbhash TEXT] (optional opaque render hints)
| marmot group clear-avatar-url GID remove the https avatar link
| marmot group set-retention GID SECS set disappearing messages (0 disables)
| marmot group remove GID NPUB remove member
| marmot group leave GID self-remove
| marmot group disband GID --yes end the group for everyone (irreversible)
""".trimMargin()
suspend fun dispatch(
@@ -63,8 +70,12 @@ object GroupCommands {
"demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) },
"set-image" to { rest -> GroupMetadataCommands.setImage(dataDir, rest) },
"clear-image" to { rest -> GroupMetadataCommands.clearImage(dataDir, rest) },
"set-avatar-url" to { rest -> GroupMetadataCommands.setAvatarUrl(dataDir, rest) },
"clear-avatar-url" to { rest -> GroupMetadataCommands.clearAvatarUrl(dataDir, rest) },
"set-retention" to { rest -> GroupMetadataCommands.setRetention(dataDir, rest) },
"remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) },
"leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) },
"disband" to { rest -> GroupMembershipCommands.disband(dataDir, rest) },
),
help = USAGE,
)
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -35,32 +37,77 @@ object GroupCreateCommand {
): Int {
val args = Args(rest)
val name = args.flag("name", "")!!
val legacy = args.bool("legacy")
// Disappearing messages (`marmot.group.message-retention.v1`, 0x8005).
// Fixed at creation: promoting a component to required later needs its
// state installed first, which is a second commit this command does not
// make.
val disappearing = args.flag("disappearing-secs", "")!!
val disappearingSecs =
if (disappearing.isEmpty()) {
null
} else {
disappearing.toULongOrNull()?.takeIf { it > 0uL }
?: return Output.error(
"bad_args",
"--disappearing-secs must be a positive whole number of seconds",
)
}
args.rejectUnknown()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = RandomInstance.bytes(32).toHexKey()
// Stamp initial metadata via the shared factory so UI + CLI stay
// byte-identical. Bake the MarmotGroupData extension into the
// epoch-0 GroupContext directly (see `MarmotManager.createGroup`)
// so later invitees receive a pre-populated group from the
// welcome and never have to chase an undecryptable bootstrap
// commit that predates their membership.
val outboxUrls = ctx.outboxRelays().map { it.url }
val metadata =
MarmotGroupData.bootstrap(
if (legacy) {
// MIP-era group: its GroupContext requires the `0xF2EE`
// group-data extension, so only members whose leaves advertise
// that capability can be added. Kept for reproducing the
// behaviour of groups already on disk.
//
// Bake MarmotGroupData into the epoch-0 GroupContext directly
// so later invitees receive a pre-populated group from the
// welcome and never have to chase an undecryptable bootstrap
// commit that predates their membership.
// The legacy blob only carries `disappearing_message_secs`
// from v3 on, so asking for it bumps the version — v1/v2 stays
// byte-for-byte what MDK's older parser accepts.
val metadata =
MarmotGroupData
.bootstrap(
nostrGroupId = gid,
creatorPubKey = ctx.identity.pubKeyHex,
outboxRelays = outboxUrls,
name = name,
).let {
if (disappearingSecs == null) {
it
} else {
it.copy(disappearingMessageSecs = disappearingSecs, version = 3)
}
}
ctx.marmot.createGroup(gid, initialMetadata = metadata)
} else {
// Current profile by default. The difference is what the group
// REQUIRES of a joining leaf: a current-profile group asks for
// the account identity proof, which every conformant peer's
// KeyPackage carries, while a legacy group asks for `0xF2EE`,
// which none of them do. Defaulting to legacy made every
// outside member un-addable.
ctx.marmot.createCurrentProfileGroup(
nostrGroupId = gid,
creatorPubKey = ctx.identity.pubKeyHex,
outboxRelays = outboxUrls,
name = name,
relays = outboxUrls,
profile = if (name.isEmpty()) null else GroupProfileV1(name, ""),
retention = disappearingSecs?.let { MessageRetentionV1(it) },
)
ctx.marmot.createGroup(gid, initialMetadata = metadata)
}
Output.emit(
mapOf(
"group_id" to gid,
"mls_group_id" to ctx.marmot.mlsGroupIdHex(gid),
"name" to name,
"profile" to if (legacy) "legacy" else "current",
"epoch" to ctx.marmot.groupEpoch(gid),
),
)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
@@ -77,10 +78,10 @@ object GroupMembershipCommands {
// leave the group with zero admins (admin depletion). If we're
// the only admin, hand admin to another member first.
val demoteEventId: String? =
ctx.marmot.groupMetadata(gid)?.let { metadata ->
if (!metadata.adminPubkeys.contains(ctx.identity.pubKeyHex)) return@let null
ctx.marmot.groupView(gid)?.let { view ->
if (!view.adminPubkeys.contains(ctx.identity.pubKeyHex)) return@let null
val newAdmins = metadata.adminPubkeys.filter { it != ctx.identity.pubKeyHex }.toMutableList()
val newAdmins = view.adminPubkeys.filter { it != ctx.identity.pubKeyHex }.toMutableList()
if (newAdmins.isEmpty()) {
val heir =
ctx.marmot
@@ -90,8 +91,7 @@ object GroupMembershipCommands {
?: return@let null // solo group — skip demote, let MLS state cleanup handle it
newAdmins.add(heir)
}
val demoted = metadata.copy(adminPubkeys = newAdmins)
val demoteCommit = ctx.marmot.updateGroupMetadata(gid, demoted)
val demoteCommit = ctx.marmot.setGroupAdmins(gid, newAdmins, targets.toList())
ctx.publish(demoteCommit.signedEvent, targets)
demoteCommit.signedEvent.id
}
@@ -108,4 +108,64 @@ object GroupMembershipCommands {
return 0
}
}
/**
* End the group for everyone. `group disband <gid> [--yes]`
*
* Terminal and irreversible: there is no un-disband commit, no later branch
* supersedes it, and a replacement conversation is a NEW group with a new
* id. `--yes` is required for exactly that reason — every other verb here
* is recoverable by issuing its opposite, and this one is not.
*
* The commit shape, the admin check and the enablement step all live in
* [com.vitorpamplona.amethyst.commons.marmot.MarmotManager.disbandGroup];
* this only confirms the intent and reports what happened.
*/
suspend fun disband(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group disband <gid> --yes")
val args = Args(rest.drop(1).toTypedArray())
val confirmed = args.bool("yes")
args.rejectUnknown()
if (!confirmed) {
return Output.error(
"needs_confirmation",
"disbanding ends the group for every member and cannot be undone; pass --yes",
)
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
// Publish-before-apply happens INSIDE disbandGroup, which refuses
// to terminalize on anything less than a relay OK — so unlike every
// other group verb here there is no second publish afterwards. A
// re-publish of a commit that already landed can still fail on a
// dropped connection, and reporting a completed, irreversible
// disband as a failure is the one wrong answer this command can
// give.
val outbound =
try {
ctx.marmot.disbandGroup(gid, targets.toList())
} catch (e: IllegalStateException) {
return Output.error("refused", e.message ?: "cannot disband group $gid")
}
Output.emit(
mapOf(
"group_id" to gid,
"disbanded" to (ctx.marmot.groupState(gid)?.isDisbanded == true),
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to outbound.signedEvent.id,
),
)
return 0
}
}
}
@@ -24,12 +24,17 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.OutboundGroupEvent
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1
import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import java.io.File
@@ -44,7 +49,9 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "group rename <gid> <name>")
return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) }
return commit(dataDir, rest[0]) { ctx, gid, view ->
ctx.marmot.setGroupProfile(gid, rest[1], view.description)
}
}
suspend fun promote(
@@ -52,11 +59,9 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "group promote <gid> <npub>")
return edit(dataDir, rest[0]) { ctx, cur ->
return commit(dataDir, rest[0]) { ctx, gid, view ->
val newAdmin = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.toMutableList()
if (newAdmin !in admins) admins.add(newAdmin)
cur.copy(adminPubkeys = admins)
ctx.marmot.setGroupAdmins(gid, (view.adminPubkeys + newAdmin).distinct())
}
}
@@ -65,10 +70,9 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "group demote <gid> <npub>")
return edit(dataDir, rest[0]) { ctx, cur ->
return commit(dataDir, rest[0]) { ctx, gid, view ->
val target = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.filter { it != target }
cur.copy(adminPubkeys = admins)
ctx.marmot.setGroupAdmins(gid, view.adminPubkeys.filter { it != target })
}
}
@@ -114,8 +118,17 @@ object GroupMetadataCommands {
}
}
return edit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { _, cur ->
cur.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, uploadKeySeed)
return commit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { ctx, resolved, _ ->
ctx.marmot.setGroupImage(
resolved,
GroupBlossomImageV1(
imageHash = enc.imageHash.hexToByteArray(),
imageKey = enc.imageKey,
imageNonce = enc.imageNonce,
imageUploadKey = uploadKeySeed,
mediaType = args.flag("mime") ?: "image/jpeg",
),
)
}
}
@@ -125,40 +138,113 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group clear-image <gid>")
return edit(dataDir, rest[0]) { _, cur -> cur.withoutImage() }
return commit(dataDir, rest[0]) { ctx, gid, _ -> ctx.marmot.setGroupImage(gid, null) }
}
private suspend fun edit(
/**
* Point the group avatar at a plain `https` URL (`0x8007`).
*
* The URL is normalized by the component's encoder, so what gets committed
* may differ from what was typed — the emitted `avatar_url` is the stored
* form, not the argument.
*
* `group set-avatar-url <gid> <https-url> [--dim WIDTHxHEIGHT] [--thumbhash TEXT]`
*/
suspend fun setAvatarUrl(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val gid = args.positional(0, "gid")
val url = args.positional(1, "url")
val dim = args.flag("dim")
val thumbhash = args.flag("thumbhash")
args.rejectUnknown()
val avatar =
try {
GroupAvatarUrlV1(
url = MarmotWebUrl.normalize(url, label = "avatar URL"),
dim = dim?.encodeToByteArray() ?: ByteArray(0),
thumbhash = thumbhash?.encodeToByteArray() ?: ByteArray(0),
)
} catch (e: IllegalArgumentException) {
return Output.error("bad_args", e.message ?: "invalid avatar URL")
}
return commit(dataDir, gid, mapOf("avatar_url" to avatar.url)) { ctx, resolved, _ ->
ctx.marmot.setGroupAvatarUrl(resolved, avatar)
}
}
/**
* Set the disappearing-message duration. `group set-retention <gid> <secs>`
*
* `0` turns disappearing messages off. The change is not retroactive:
* every message already carries the expiry pinned from the epoch that
* delivered it, so this only governs what arrives after the commit.
*/
suspend fun setRetention(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val gid = args.positional(0, "gid")
val raw = args.positional(1, "secs")
args.rejectUnknown()
val secs =
raw.toULongOrNull()
?: return Output.error("bad_args", "secs must be a whole number of seconds (0 disables)")
return commit(dataDir, gid, mapOf("disappearing_secs" to secs.toString())) { ctx, resolved, _ ->
ctx.marmot.setMessageRetention(resolved, secs)
}
}
/** Remove the https avatar link. `group clear-avatar-url <gid>` */
suspend fun clearAvatarUrl(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group clear-avatar-url <gid>")
return commit(dataDir, rest[0]) { ctx, gid, _ -> ctx.marmot.setGroupAvatarUrl(gid, null) }
}
/**
* Run one metadata commit and report it.
*
* The mutation goes through [MarmotManager]'s profile-agnostic setters
* rather than being applied to a legacy `MarmotGroupData` here. Building
* that blob locally was the bug: `groupMetadata` is null for every
* current-profile group, so this bootstrapped a legacy `0xF2EE` extension
* and committed it INTO a current-profile group — the rename appeared to
* succeed locally and every peer kept showing the old name.
*/
private suspend fun commit(
dataDir: DataDir,
rawGid: HexKey,
extra: Map<String, Any?> = emptyMap(),
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
mutate: suspend (Context, HexKey, MarmotManager.GroupView) -> OutboundGroupEvent,
): Int {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rawGid)
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val outboxUrls = ctx.outboxRelays().map { it.url }
val cur =
ctx.marmot.groupMetadata(gid)
?: MarmotGroupData.bootstrap(
nostrGroupId = gid,
creatorPubKey = ctx.identity.pubKeyHex,
outboxRelays = outboxUrls,
)
val updated = mutate(ctx, cur).withMergedRelays(outboxUrls)
val view = ctx.marmot.groupView(gid) ?: return Output.error("not_member", "not a member of group $gid")
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
val commit = mutate(ctx, gid, view)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(commit.signedEvent, targets)
RawEventSupport.publishGuard(ack, commit.signedEvent.id)?.let { return it }
val after = ctx.marmot.groupView(gid)
Output.emit(
mapOf(
"group_id" to gid,
"name" to updated.name,
"admins" to updated.adminPubkeys,
"name" to (after?.name ?: view.name),
"admins" to (after?.adminPubkeys ?: view.adminPubkeys),
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
) + RawEventSupport.ackFields(ack) + extra,
@@ -35,7 +35,7 @@ object GroupReadCommands {
val ids = ctx.marmot.activeGroupIds()
val items =
ids.map { id ->
val m = ctx.marmot.groupMetadata(id)
val m = ctx.marmot.groupView(id)
mapOf(
"group_id" to id,
"name" to (m?.name ?: ""),
@@ -58,7 +58,7 @@ object GroupReadCommands {
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
val meta = ctx.marmot.groupMetadata(gid)
val meta = ctx.marmot.groupView(gid)
val members =
ctx.marmot.memberPubkeys(gid).map {
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
@@ -72,8 +72,30 @@ object GroupReadCommands {
"epoch" to ctx.marmot.groupEpoch(gid),
"admins" to (meta?.adminPubkeys ?: emptyList()),
"relays" to (meta?.relays ?: emptyList()),
"avatar_url" to meta?.avatarUrl?.url,
// Hints are opaque bytes by contract; render them as text
// only for the conventional UTF-8 case an operator can read.
"avatar_dim" to
meta
?.avatarUrl
?.dim
?.takeIf { it.isNotEmpty() }
?.decodeToString(),
"avatar_thumbhash" to
meta
?.avatarUrl
?.thumbhash
?.takeIf { it.isNotEmpty() }
?.decodeToString(),
"members" to members,
"is_admin" to (meta?.adminPubkeys?.contains(ctx.identity.pubKeyHex) == true),
// Disappearing messages, in seconds; 0 means off.
"disappearing_secs" to ctx.marmot.retentionSeconds(gid),
// The terminal state has no way back, so it is worth
// saying out loud rather than leaving a caller to infer it
// from a group that quietly refuses every verb.
"disbanded" to (ctx.marmot.groupState(gid)?.isDisbanded == true),
"lifecycle" to ctx.marmot.lifecycle(gid).name,
),
)
return 0
@@ -109,7 +131,7 @@ object GroupReadCommands {
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
val m = ctx.marmot.groupMetadata(gid)
val m = ctx.marmot.groupView(gid)
Output.emit(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList())))
return 0
}
@@ -0,0 +1,349 @@
/*
* 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.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2
import com.vitorpamplona.quartz.marmot.appComponents.MarmotMediaType
import com.vitorpamplona.quartz.marmot.appComponents.MediaLocatorV2
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
/**
* `amy marmot media` — `encrypted-media-v2` attachments (`0x800b`).
*
* The server only ever sees ciphertext and its hash. The key is derived from
* the group's own MLS exporter and never leaves the group, so the blob store
* is storage and not a party to the conversation.
*/
object MarmotMediaCommands {
val USAGE: String =
"""
|amy marmot media — encrypted-media-v2 attachments
|
| marmot media policy GID print the group's media policy
| marmot media set-policy GID URL[,URL…] commit a policy naming these blob stores,
| in upload/fetch fallback order
| marmot media send GID FILE [--caption TXT] encrypt, upload, and post the kind:9
| [--server URL] [--mime TYPE] (--server overrides the policy's first endpoint)
| marmot media get GID EVENT_ID --out PATH fetch, decrypt and verify an attachment
""".trimMargin()
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"media",
tail,
"media <policy|set-policy|send|get> …",
mapOf(
"policy" to { rest -> policy(dataDir, rest) },
"set-policy" to { rest -> setPolicy(dataDir, rest) },
"send" to { rest -> send(dataDir, rest) },
"get" to { rest -> get(dataDir, rest) },
),
help = USAGE,
)
private suspend fun policy(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "media policy <gid>")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val policy = ctx.marmot.encryptedMediaPolicy(gid)
Output.emit(
mapOf(
"group_id" to gid,
"media_format" to policy?.mediaFormat,
"allowed_locator_kinds" to policy?.allowedLocatorKinds,
"default_blob_endpoints" to
policy?.defaultBlobEndpoints?.map {
mapOf("locator_kind" to it.locatorKind, "base_url" to it.baseUrl)
},
),
)
return 0
}
}
private suspend fun setPolicy(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "media set-policy <gid> <base-url>[,<base-url>…]")
val urls = rest[1].split(',').map { it.trim() }.filter { it.isNotEmpty() }
if (urls.isEmpty()) return Output.error("bad_args", "media set-policy needs at least one base URL")
val policy =
try {
EncryptedMediaPolicyV2(
allowedLocatorKinds = listOf(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND),
// Order is preserved deliberately: it IS the upload/fetch
// fallback priority, so sorting it would change where the
// group uploads.
defaultBlobEndpoints =
urls.map { BlobStoreEndpointV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, it) },
)
} catch (e: IllegalArgumentException) {
return Output.error("bad_args", e.message ?: "invalid media policy")
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val commit = ctx.marmot.setEncryptedMediaPolicy(gid, policy)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(commit.signedEvent, targets)
RawEventSupport.publishGuard(ack, commit.signedEvent.id)?.let { return it }
Output.emit(
mapOf(
"group_id" to gid,
"default_blob_endpoints" to policy.defaultBlobEndpoints.map { it.baseUrl },
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
private suspend fun send(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val gid = args.positional(0, "gid")
val path = args.positional(1, "file")
val serverFlag = args.flag("server")
val caption = args.flag("caption") ?: ""
val mime = args.flag("mime")
args.rejectUnknown()
val file = File(path)
if (!file.isFile) return Output.error("bad_args", "no such file: $path")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val resolved = ctx.resolveGroupId(gid)
ctx.syncIncoming()
if (!ctx.marmot.isMember(resolved)) return Output.error("not_member", "not a member of group $resolved")
val endpoint =
serverFlag
?: ctx.marmot
.encryptedMediaPolicy(resolved)
?.defaultBlobEndpoints
?.firstOrNull { it.locatorKind == EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND }
?.baseUrl
?: return Output.error(
"no_endpoint",
"group $resolved has no encrypted-media policy; pass --server or commit one with media set-policy",
)
val store = BlobStoreEndpointV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, endpoint)
// `m` has to be byte-for-byte canonical: it feeds both the key
// derivation and the AEAD associated data, so "image/JPEG" and
// "image/jpeg" would be different keys for the same file.
val rawMediaType = mime ?: guessMediaType(file.name)
// A media type that will not canonicalize is refused rather than
// guessed at: `m` is inside both the key derivation and the AEAD
// associated data, so a sender and a receiver that canonicalized it
// differently would not agree on the key at all.
val mediaType =
MarmotMediaType.canonicalize(rawMediaType)
?: return Output.error("bad_args", "'$rawMediaType' is not a usable media type")
val encrypted =
try {
ctx.marmot.encryptMedia(resolved, file.readBytes(), mediaType, file.name)
} catch (e: IllegalArgumentException) {
return Output.error("bad_args", e.message ?: "cannot encrypt this attachment")
}
val ciphertextHash = encrypted.ciphertextSha256.toHexKey()
val uploadedUrl: String
try {
val auth =
BlossomAuth.createUploadAuth(
ciphertextHash,
encrypted.ciphertext.size.toLong(),
"Encrypted attachment",
ctx.signer,
)
val result =
BlossomClient().upload(encrypted.ciphertext, "application/octet-stream", store.serverRoot, auth)
if (result.sha256 != null && result.sha256 != ciphertextHash) {
return Output.error("hash_mismatch", "blossom returned ${result.sha256}, expected $ciphertextHash")
}
// The locator is the canonical BUD-01 URL for the ciphertext
// hash, not whatever the server echoed: the hash is what a
// receiver verifies, and a server-chosen URL could name
// something else entirely.
uploadedUrl = store.blossomFetchUrl(ciphertextHash)
} catch (e: Exception) {
return Output.error("upload_failed", "${e.message}")
}
val reference =
EncryptedMediaReferenceV2(
locators = listOf(MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, uploadedUrl)),
ciphertextSha256 = encrypted.ciphertextSha256,
plaintextSha256 = encrypted.plaintextSha256,
nonce = encrypted.nonce,
mediaType = mediaType,
filename = file.name,
)
val bundle = ctx.marmot.buildMediaMessage(resolved, reference, caption)
val targets = ctx.marmotGroupRelays(resolved).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
RawEventSupport.publishGuard(ack, bundle.outbound.signedEvent.id)?.let { return it }
Output.emit(
mapOf(
"group_id" to resolved,
"inner_event_id" to bundle.innerEvent.id,
"outer_event_id" to bundle.outbound.signedEvent.id,
"locator" to uploadedUrl,
"ciphertext_sha256" to ciphertextHash,
"plaintext_sha256" to encrypted.plaintextSha256.toHexKey(),
"m" to mediaType,
"filename" to file.name,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
private suspend fun get(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val gid = args.positional(0, "gid")
val eventId = args.positional(1, "event-id")
val out = args.flag("out") ?: return Output.error("bad_args", "media get <gid> <event_id> --out PATH")
args.rejectUnknown()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val resolved = ctx.resolveGroupId(gid)
ctx.syncIncoming()
if (!ctx.marmot.isMember(resolved)) return Output.error("not_member", "not a member of group $resolved")
val message =
ctx.marmot
.loadStoredMessages(resolved)
.mapNotNull { Event.fromJsonOrNull(it) }
.firstOrNull { it.id == eventId }
?: return Output.error("not_found", "no stored message $eventId in group $resolved")
val reference =
message.tags
.firstOrNull { it.isNotEmpty() && it[0] == "imeta" }
?.let {
try {
EncryptedMediaV2.parseImetaTag(it)
} catch (e: IllegalArgumentException) {
return Output.error("bad_reference", e.message ?: "invalid imeta tag")
}
}
?: return Output.error("no_media", "message $eventId carries no encrypted-media reference")
val locator =
reference.locators.firstOrNull { it.kind == EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND }
?: return Output.error("no_locator", "no blossom-v1 locator in message $eventId")
val ciphertext =
try {
BlossomClient().download(locator.value)
} catch (e: Exception) {
return Output.error("download_failed", "${e.message}")
} ?: return Output.error("download_failed", "blob ${locator.value} not available")
// The ciphertext hash is checked BEFORE decryption: it is what the
// locator names, so a server that served something else is caught
// here rather than as a confusing AEAD failure.
if (!sha256(ciphertext).contentEquals(reference.ciphertextSha256)) {
return Output.error("hash_mismatch", "the blob at ${locator.value} is not the one the message names")
}
val plaintext =
try {
ctx.marmot.decryptMedia(resolved, reference, ciphertext)
} catch (e: Exception) {
// A failure here is not "the file is corrupt": the media
// secret is per-epoch, so an attachment from an older epoch
// simply does not open under the current one.
return Output.error("decrypt_failed", "${e.message}")
}
File(out).writeBytes(plaintext)
Output.emit(
mapOf(
"group_id" to resolved,
"event_id" to eventId,
"locator" to locator.value,
"out" to out,
"bytes" to plaintext.size,
"m" to reference.mediaType,
"filename" to reference.filename,
),
)
return 0
}
}
/** Extension-based guess, only as a default for `--mime`. */
private fun guessMediaType(name: String): String =
when (name.substringAfterLast('.', "").lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"mp3" -> "audio/mpeg"
"pdf" -> "application/pdf"
"txt" -> "text/plain"
else -> "application/octet-stream"
}
}
@@ -35,6 +35,7 @@ object MessageCommands {
| marmot message send GID TEXT publish kind:9 inner event into the group
| marmot message list GID [--limit N] dump decrypted inner events (default --limit 50;
| --limit 0 = unlimited)
| marmot message edit GID EVENT_ID TEXT publish kind:1009 replacing a message's text
| 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
""".trimMargin()
@@ -46,10 +47,11 @@ object MessageCommands {
route(
"message",
tail,
"message <send|list|react|delete> …",
"message <send|list|edit|react|delete> …",
mapOf(
"send" to { rest -> send(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"edit" to { rest -> edit(dataDir, rest) },
"react" to { rest -> react(dataDir, rest) },
"delete" to { rest -> delete(dataDir, rest) },
),
@@ -102,17 +104,37 @@ object MessageCommands {
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val raw = ctx.marmot.loadStoredMessages(gid)
val parsed = raw.mapNotNull { Event.fromJsonOrNull(it) }
// An edit is not its own row: it replaces the target's text in
// place. Resolving the overlay here rather than in the renderer is
// what keeps every front end from re-deriving the authorship and
// tie-break rules, and getting one of them subtly different.
val overlays = ctx.marmot.editOverlays(parsed)
// A deletion is not its own row either. The retracted body is
// blanked rather than the row dropped, so a harness (or a reader
// paging back) can tell "retracted" from "never arrived".
val deleted = ctx.marmot.deletedIds(parsed)
// Pinned at persist time from the retention of the epoch that
// DELIVERED each message, so it is the message's own expiry and not
// a recomputation against whatever the group's setting is now.
val expiries = ctx.marmot.messageExpiries(gid)
val items =
raw
.map { line ->
try {
@Suppress("UNCHECKED_CAST")
val obj = Output.mapper.readValue<Map<String, Any?>>(line)
val id = obj["id"] as? String
val edited = overlays[id]
val retracted = id != null && id in deleted
mapOf(
"event_id" to obj["id"],
"author" to obj["pubkey"],
"kind" to obj["kind"],
"content" to obj["content"],
"content" to if (retracted) "" else (edited ?: obj["content"]),
"edited" to (edited != null && !retracted),
"deleted" to retracted,
"expires_at" to expiries[id],
"created_at" to obj["created_at"],
)
} catch (_: Exception) {
@@ -125,6 +147,52 @@ object MessageCommands {
}
}
/**
* Replace a prior message's text. `message edit <gid> <event_id> <text>`
*
* The edit only lands for readers if this account wrote the target — every
* receiver re-checks that against the message it holds — so the same check
* runs here rather than publishing something that will be ignored.
*/
private suspend fun edit(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 3) return Output.error("bad_args", "message edit <gid> <target_event_id> <text>")
val targetId = rest[1]
val replacement = rest[2]
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val target =
findStoredInnerEvent(ctx, gid, targetId)
?: return Output.error("not_found", "no stored message $targetId in group $gid")
if (target.pubKey != ctx.identity.pubKeyHex) {
return Output.error("not_author", "only the author of $targetId may replace its text")
}
val bundle = ctx.marmot.buildMessageEdit(gid, target.id, replacement)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
RawEventSupport.publishGuard(ack, bundle.outbound.signedEvent.id)?.let { return it }
Output.emit(
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,
"content" to replacement,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
private suspend fun react(
dataDir: DataDir,
rest: Array<String>,
@@ -146,7 +146,11 @@ object RelayCommands {
"dm",
ChatMessageRelayListEvent.KIND,
setOf("chat", "inbox-dm"),
read = { c, pk -> c.dmInboxOf(pk)?.relays().orEmpty() },
// allRelays(), not relays(): every Flat here is read for SELF,
// and the filtered accessor drops local entries meant for
// attacker-supplied lists — making a deliberately configured
// local relay report as no configuration at all.
read = { c, pk -> c.dmInboxOf(pk)?.allRelays().orEmpty() },
build = { c, r -> ChatMessageRelayListEvent.create(r, c.signer) },
),
Flat(
@@ -154,7 +158,7 @@ object RelayCommands {
"key_package",
KeyPackageRelayListEvent.KIND,
setOf("keypackage", "key_package"),
read = { c, pk -> c.keyPackageRelaysOf(pk)?.relays().orEmpty() },
read = { c, pk -> c.keyPackageRelaysOf(pk)?.allRelays().orEmpty() },
build = { c, r -> KeyPackageRelayListEvent.create(r, c.signer) },
),
Flat(
@@ -452,15 +456,21 @@ object RelayCommands {
"add" -> {
val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url"))
val existing = flat.read(ctx, self)
val added = existing.none { it.url == url.url }
if (added) ctx.verifyAndStore(flat.build(ctx, existing + url))
// Report what the STORE did, not what we decided to try.
// These used to report the decision, so a rejected write
// printed `added: yes` and the caller only found out much
// later, when a publish silently fell back to defaults.
val added =
existing.none { it.url == url.url } &&
ctx.verifyAndStore(flat.build(ctx, existing + url))
Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "url" to url.url, "added" to added))
}
"remove", "rm" -> {
val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url"))
val existing = flat.read(ctx, self)
val removed = existing.any { it.url == url.url }
if (removed) ctx.verifyAndStore(flat.build(ctx, existing.filterNot { it.url == url.url }))
val removed =
existing.any { it.url == url.url } &&
ctx.verifyAndStore(flat.build(ctx, existing.filterNot { it.url == url.url }))
Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "url" to url.url, "removed" to removed))
}
"set" -> {
@@ -547,8 +557,11 @@ object RelayCommands {
mapOf(
"noun" to "nip65",
"kind" to AdvertisedRelayListEvent.KIND,
"read" to (nip65?.readRelaysNorm()?.map { it.url } ?: emptyList<String>()),
"write" to (nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList<String>()),
// Unfiltered: this is OUR list, and reporting it
// through the attacker-input filter would hide a
// local relay the operator deliberately configured.
"read" to (nip65?.allReadRelaysNorm()?.map { it.url } ?: emptyList<String>()),
"write" to (nip65?.allWriteRelaysNorm()?.map { it.url } ?: emptyList<String>()),
"relays" to (nip65?.relaysNorm()?.map { it.url } ?: emptyList<String>()),
),
)
@@ -601,13 +614,11 @@ object RelayCommands {
val existing = flat.read(ctx, self)
changed[flat.jsonKey] =
if (add) {
val doAdd = existing.none { it.url == url.url }
if (doAdd) ctx.verifyAndStore(flat.build(ctx, existing + url))
doAdd
existing.none { it.url == url.url } &&
ctx.verifyAndStore(flat.build(ctx, existing + url))
} else {
val doRemove = existing.any { it.url == url.url }
if (doRemove) ctx.verifyAndStore(flat.build(ctx, existing.filterNot { it.url == url.url }))
doRemove
existing.any { it.url == url.url } &&
ctx.verifyAndStore(flat.build(ctx, existing.filterNot { it.url == url.url }))
}
}
@@ -633,8 +644,8 @@ object RelayCommands {
val self = ctx.identity.pubKeyHex
val nip65 = ctx.relaysOf(self)
val out = linkedMapOf<String, Any?>()
out["outbox"] = nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList<String>()
out["inbox"] = nip65?.readRelaysNorm()?.map { it.url } ?: emptyList<String>()
out["outbox"] = nip65?.allWriteRelaysNorm()?.map { it.url } ?: emptyList<String>()
out["inbox"] = nip65?.allReadRelaysNorm()?.map { it.url } ?: emptyList<String>()
out["nip65"] = nip65?.relaysNorm()?.map { it.url } ?: emptyList<String>()
for (flat in FLATS) {
out[flat.jsonKey] = flat.read(ctx, self).map { it.url }
@@ -724,7 +735,7 @@ object RelayCommands {
facet: Facet,
): List<String> {
val nip65 = ctx.relaysOf(self)
val urls = if (facet == Facet.OUTBOX) nip65?.writeRelaysNorm() else nip65?.readRelaysNorm()
val urls = if (facet == Facet.OUTBOX) nip65?.allWriteRelaysNorm() else nip65?.allReadRelaysNorm()
return urls?.map { it.url } ?: emptyList()
}
@@ -0,0 +1,424 @@
/*
* 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.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.marmotquic.QuicAgentTextStreamTransport
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamPublisher
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRecordV1
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamStart
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamSubscriber
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.InMemoryAgentTextStreamSequenceStore
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStatus
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.RecordOutcome
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quic.tls.CertificateValidator
import com.vitorpamplona.quic.tls.JdkCertificateValidator
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
import com.vitorpamplona.quic.tls.PinnedCertificateValidator
import kotlinx.coroutines.withTimeoutOrNull
/**
* `amy marmot stream` — agent text stream previews (`0x8006`).
*
* The durable half is ordinary Marmot messaging: a hidden kind:1200 anchors
* the stream and a kind:9 closes it, both over MLS. The live half is raw QUIC
* to a broker, and it is strictly a progressive enhancement — a member that
* never opens a QUIC connection still reads the whole answer from the final
* kind:9.
*/
object StreamCommands {
val USAGE: String =
"""
|amy marmot stream — agent text stream previews over QUIC
|
| marmot stream start GID [--stream-id HEX] [--broker quic://HOST:PORT[,…]]
| publish the kind:1200 that anchors a stream; prints stream_id + start_event_id
|
| marmot stream send GID --stream-id HEX --start-event-id HEX
| (--broker URI | --direct quic://HOST:PORT) TEXT…
| push TEXT as TextDelta records; --direct dials the receiver point to point
| (ALPN marmot.quic_stream.v1, no control envelope) instead of a broker
|
| marmot stream watch GID [--stream-id HEX] [--timeout SECS]
| find the kind:1200 in the group, subscribe over QUIC, fold the preview
|
| marmot stream finish GID --stream-id HEX --transcript-hash HEX --chunk-count N TEXT…
| publish the authoritative kind:9 carrying the transcript a receiver checks against
|
|Every record is encrypted under the group's own MLS exporter secret, so a
|broker relays ciphertext and learns only which room it belongs to.
|
|TLS trust for the QUIC hop (send and watch):
| --pin-sha256 HEX[,HEX…] trust exactly these leaf certificates (self-signed
| endpoints; colons and whitespace are ignored)
| --insecure accept any certificate — local testing only
|Without either, the platform trust store decides.
""".trimMargin()
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"stream",
tail,
"stream <start|send|watch|finish> …",
mapOf(
"start" to { rest -> start(dataDir, rest) },
"send" to { rest -> send(dataDir, rest) },
"watch" to { rest -> watch(dataDir, rest) },
"finish" to { rest -> finish(dataDir, rest) },
),
help = USAGE,
)
private suspend fun start(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val positional = args.positional
if (positional.isEmpty()) return Output.error("bad_args", "stream start GID [--stream-id HEX] [--broker URI]…")
val streamId = args.flag("stream-id") ?: MlsCryptoProvider.randomBytes(32).toHexKey()
if (streamId.length != 64) return Output.error("bad_args", "--stream-id must be 32 bytes of hex")
// Repeatable in the spec, comma-separated here: `Args` keeps one
// value per flag and a receiver tries them in the order given.
val brokers =
args
.flag("broker")
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() } ?: emptyList()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(positional[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val bundle = ctx.marmot.buildAgentStreamStart(gid, streamId, brokers, parentEventId = args.flag("parent"))
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
RawEventSupport.publishGuard(ack, bundle.outbound.signedEvent.id)?.let { return it }
Output.emit(
mapOf(
"group_id" to gid,
"stream_id" to streamId,
// The start payload's OWN id is the stream anchor that
// goes into the key context — not the kind:445 that
// carried it, and not the MLS message id.
"start_event_id" to bundle.innerEvent.id,
"epoch" to ctx.marmot.currentEpoch(gid),
"brokers" to brokers,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
private suspend fun send(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val positional = args.positional
val streamId = args.flag("stream-id")
val startEventId = args.flag("start-event-id")
val broker = args.flag("broker")
// The two delivery modes are alternatives, not a fallback chain: one
// dials a broker room, the other dials the receiver itself, and they
// negotiate different ALPNs. Picking silently when both are given
// would hide which one actually carried the records.
val direct = args.flag("direct")
if (positional.size < 2 || streamId == null || startEventId == null || (broker == null) == (direct == null)) {
return Output.error(
"bad_args",
"stream send GID --stream-id HEX --start-event-id HEX (--broker URI | --direct quic://HOST:PORT) TEXT…",
)
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(positional[0])
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
// The stream's epoch is the one that carried its kind:1200, not
// whatever the group has reached by now — a commit between the
// start and the first record would otherwise put the publisher on
// a key no receiver derives.
val anchorEpoch =
args.flag("epoch")?.toLongOrNull()
?: ctx.marmot.storedEpochs(gid)[startEventId]
val crypto =
ctx.marmot.agentTextStreamCrypto(
nostrGroupId = gid,
streamId = streamId.hexToByteArray(),
startEventId = startEventId.hexToByteArray(),
epoch = anchorEpoch,
)
val publisher = AgentTextStreamPublisher.open(crypto, InMemoryAgentTextStreamSequenceStore())
val transport = QuicAgentTextStreamTransport(certificateValidator = certificateValidator(args))
val stream =
try {
if (direct != null) {
transport.sendDirect(direct, streamId.hexToByteArray(), startEventId.hexToByteArray())
} else {
transport.publish(broker!!, streamId.hexToByteArray(), startEventId.hexToByteArray())
}
} catch (e: Exception) {
return Output.error(
if (direct != null) "receiver_unreachable" else "broker_unreachable",
"${e.message}",
)
}
try {
for (text in positional.drop(1)) {
stream.send(publisher.publish(AgentTextStreamRecordV1.TYPE_TEXT_DELTA, text.encodeToByteArray()))
}
stream.finish()
} finally {
stream.close()
}
Output.emit(
mapOf(
"group_id" to gid,
"stream_id" to streamId,
"start_event_id" to startEventId,
"mode" to if (direct != null) "direct" else "broker",
"endpoint" to (direct ?: broker),
"records" to positional.size - 1,
"epoch" to crypto.context.mlsEpoch,
// What `stream finish` has to publish so a receiver can
// prove it saw this exact stream.
"transcript_hash" to publisher.transcript.hash.toHexKey(),
"chunk_count" to publisher.transcript.chunkCount,
),
)
return 0
}
}
/**
* The TLS trust policy for the QUIC hop, from the flags.
*
* Pinning is the interesting one and the binding calls it out: preview
* endpoints and brokers are commonly self-signed, so a client MAY pin the
* endpoint certificate by SHA-256 fingerprint instead of chaining to a CA.
* `--insecure` stays available because a local test broker mints a fresh
* certificate on every boot, but it is not a weaker trust model — it is
* none, so it has to be asked for by name.
*/
private fun certificateValidator(args: Args): CertificateValidator {
val pins =
args
.flag("pin-sha256")
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
if (pins.isNotEmpty()) return PinnedCertificateValidator.ofSha256Hex(*pins.toTypedArray())
if (args.bool("insecure")) return PermissiveCertificateValidator()
return JdkCertificateValidator()
}
private suspend fun watch(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val positional = args.positional
if (positional.isEmpty()) return Output.error("bad_args", "stream watch GID [--stream-id HEX] [--timeout SECS]")
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 30L) * 1000
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(positional[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val wanted = args.flag("stream-id")
val anchor =
findStart(ctx, gid, wanted)
?: return Output.error("no_stream", "no kind:1200 stream start in group $gid")
val (startEvent, start) = anchor
if (!start.isTextProfile) {
return Output.error("unsupported_stream", "stream-type=${start.streamType} final-kind=${start.finalKind}")
}
if (!start.isQuicRoute) {
return Output.error("unsupported_route", "route=${start.route} — only the raw QUIC binding is implemented")
}
if (start.brokerCandidates.isEmpty()) {
return Output.error("no_candidate", "the start payload advertises no broker; the final kind:9 is the answer")
}
// The key context is the PUBLISHER's, not ours: the sender id and
// the epoch are theirs, and every member of that epoch derives the
// same record key from the group exporter.
//
// The epoch is the one that DELIVERED the anchor, not the group's
// current one. A commit landing between the start and the watch
// moves the group on, and deriving under the newer epoch produces
// a different key and an empty preview.
val anchorEpoch =
args.flag("epoch")?.toLongOrNull()
?: ctx.marmot.storedEpochs(gid)[startEvent.id]
val crypto =
ctx.marmot.agentTextStreamCrypto(
nostrGroupId = gid,
streamId = start.streamId.hexToByteArray(),
startEventId = startEvent.id.hexToByteArray(),
senderPubKey = startEvent.pubKey,
epoch = anchorEpoch,
)
val subscriber = AgentTextStreamSubscriber(crypto)
val transport = QuicAgentTextStreamTransport(certificateValidator = certificateValidator(args))
// "A receiver tries advertised candidates in listed order"; the
// first that yields the matching stream wins.
var lastError: String? = null
for (candidate in start.brokerCandidates) {
val stream =
try {
transport.subscribe(candidate, start.streamId.hexToByteArray(), startEvent.id.hexToByteArray())
} catch (e: Exception) {
lastError = "${e.message}"
continue
}
val outcomes = mutableMapOf<String, Int>()
try {
withTimeoutOrNull(timeoutMs) {
stream.incoming().collect { record ->
val outcome = subscriber.accept(record)
outcomes[outcome.name] = (outcomes[outcome.name] ?: 0) + 1
if (outcome == RecordOutcome.Accepted &&
(subscriber.status == PreviewStatus.FINISHED || subscriber.status == PreviewStatus.ABORTED)
) {
throw StreamComplete()
}
}
}
} catch (_: StreamComplete) {
// The publisher said the final message is coming.
} finally {
stream.close()
}
Output.emit(
mapOf(
"group_id" to gid,
"stream_id" to start.streamId,
"start_event_id" to startEvent.id,
"author" to startEvent.pubKey,
"broker" to candidate,
"preview" to subscriber.previewText,
"status" to subscriber.status.name,
"records" to subscriber.highWaterMark,
"transcript_hash" to subscriber.transcript.hash.toHexKey(),
"chunk_count" to subscriber.transcript.chunkCount,
"epoch" to crypto.context.mlsEpoch,
"outcomes" to outcomes,
"latest_status" to subscriber.latestStatus,
"latest_progress" to subscriber.latestProgress,
),
)
return 0
}
return Output.error("no_candidate_worked", lastError ?: "every advertised broker candidate was unusable")
}
}
private suspend fun finish(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val positional = args.positional
val streamId = args.flag("stream-id")
val transcriptHash = args.flag("transcript-hash")
val chunkCount = args.flag("chunk-count")?.toLongOrNull()
if (positional.size < 2 || streamId == null || transcriptHash == null || chunkCount == null) {
return Output.error(
"bad_args",
"stream finish GID --stream-id HEX --transcript-hash HEX --chunk-count N TEXT…",
)
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(positional[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid")
val text = positional.drop(1).joinToString(" ")
val bundle = ctx.marmot.buildAgentStreamFinal(gid, streamId, transcriptHash, chunkCount, text)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
RawEventSupport.publishGuard(ack, bundle.outbound.signedEvent.id)?.let { return it }
Output.emit(
mapOf(
"group_id" to gid,
"stream_id" to streamId,
"final_event_id" to bundle.innerEvent.id,
"transcript_hash" to transcriptHash,
"chunk_count" to chunkCount,
"content" to text,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
/**
* The newest kind:1200 in the group's decrypted log, optionally pinned to
* one stream id. Newest wins because a group can carry many streams over
* its life and a watcher almost always means the current one.
*/
private suspend fun findStart(
ctx: Context,
nostrGroupId: String,
streamId: String?,
): Pair<Event, AgentTextStreamStart>? {
var best: Pair<Event, AgentTextStreamStart>? = null
for (line in ctx.marmot.loadStoredMessages(nostrGroupId)) {
val parsed = Event.fromJsonOrNull(line) ?: continue
val start = AgentTextStreamStart.fromTags(parsed.kind, parsed.tags) ?: continue
if (streamId != null && !start.streamId.equals(streamId, ignoreCase = true)) continue
if (best == null || parsed.createdAt >= best.first.createdAt) best = parsed to start
}
return best
}
/** Unwinds the collect loop once the publisher signalled the end. */
private class StreamComplete : RuntimeException(null, null, false, false)
}
@@ -22,9 +22,15 @@ package com.vitorpamplona.amethyst.cli.stores
import com.vitorpamplona.amethyst.cli.SecureFileIO
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
/**
@@ -146,5 +152,227 @@ class FileMarmotMessageStore(
override suspend fun delete(nostrGroupId: String) {
file(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group messages")
epochFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group message epochs")
snapshotFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group system-row baseline")
expiryFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group message expiries")
epochRetentionFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group epoch retentions")
}
private fun snapshotFile(id: String) = File(dir, "$id.snapshot")
override suspend fun recordGroupSnapshot(
nostrGroupId: String,
snapshotJson: String,
) {
// Overwritten, not appended: this is one baseline, not a history.
SecureFileIO.writeBytesAtomic(snapshotFile(nostrGroupId), snapshotJson.encodeToByteArray())
}
override suspend fun loadGroupSnapshot(nostrGroupId: String): String? = snapshotFile(nostrGroupId).takeIf { it.exists() }?.readText()
private fun expiryFile(id: String) = File(dir, "$id.expiries")
/**
* First write wins: an expiry is pinned to the retention of the message's
* own source epoch, so re-persisting the same message after a replay must
* not re-time it under a setting that has since changed.
*/
override suspend fun recordExpiry(
nostrGroupId: String,
innerEventId: String,
expiresAtSecs: Long,
) {
val target = expiryFile(nostrGroupId)
if (target.exists() && target.readLines().any { it.substringBefore(' ') == innerEventId }) return
SecureFileIO.appendText(target, "$innerEventId $expiresAtSecs\n")
}
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> =
expiryFile(nostrGroupId)
.takeIf { it.exists() }
?.readLines()
?.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
val at = parts[1].toLongOrNull() ?: return@mapNotNull null
parts[0] to at
}?.toMap()
?: emptyMap()
/** Rewrites both logs: a disappearing message has to actually leave the disk. */
override suspend fun removeMessages(
nostrGroupId: String,
innerEventIds: Set<String>,
) {
if (innerEventIds.isEmpty()) return
val target = file(nostrGroupId)
if (target.exists()) {
val kept =
target.readLines().filter { line ->
line.isNotBlank() && Event.fromJsonOrNull(line)?.id !in innerEventIds
}
SecureFileIO.writeBytesAtomic(target, (kept.joinToString("\n") + if (kept.isEmpty()) "" else "\n").encodeToByteArray())
}
val expiries = expiryFile(nostrGroupId)
if (expiries.exists()) {
val kept = expiries.readLines().filter { it.isNotBlank() && it.substringBefore(' ') !in innerEventIds }
SecureFileIO.writeBytesAtomic(expiries, (kept.joinToString("\n") + if (kept.isEmpty()) "" else "\n").encodeToByteArray())
}
}
private fun epochRetentionFile(id: String) = File(dir, "$id.epoch-retentions")
/** First write wins: an epoch's required components are fixed once it exists. */
override suspend fun recordEpochRetention(
nostrGroupId: String,
epoch: Long,
retentionSecs: Long,
) {
val target = epochRetentionFile(nostrGroupId)
if (target.exists() && target.readLines().any { it.substringBefore(' ') == epoch.toString() }) return
SecureFileIO.appendText(target, "$epoch $retentionSecs\n")
}
override suspend fun loadEpochRetentions(nostrGroupId: String): Map<Long, Long> =
epochRetentionFile(nostrGroupId)
.takeIf { it.exists() }
?.readLines()
?.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
val epoch = parts[0].toLongOrNull() ?: return@mapNotNull null
val secs = parts[1].toLongOrNull() ?: return@mapNotNull null
epoch to secs
}?.toMap()
?: emptyMap()
private fun epochFile(id: String) = File(dir, "$id.epochs")
override suspend fun recordEpoch(
nostrGroupId: String,
innerEventId: String,
epoch: Long,
) {
val line = "$innerEventId $epoch"
val target = epochFile(nostrGroupId)
if (target.exists() && target.readLines().any { it == line }) return
SecureFileIO.appendText(target, line + "\n")
}
override suspend fun loadEpochs(nostrGroupId: String): Map<String, Long> =
epochFile(nostrGroupId)
.takeIf { it.exists() }
?.readLines()
?.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
val epoch = parts[1].toLongOrNull() ?: return@mapNotNull null
parts[0] to epoch
}?.toMap() ?: emptyMap()
}
/**
* Durable publish obligations, one file per obligation under [dir].
*
* Publish-before-apply only means anything if the obligation outlives the
* process: the whole point is that a commit is prepared, recorded, published,
* and only then applied, so a crash between record and publish must leave a
* trace. With a non-durable store that window silently becomes "the commit
* never happened", and on relaunch the client mints a REPLACEMENT commit for
* the same epoch — forking itself against the peers that accepted the first
* one.
*
* A file per obligation rather than one appended log: obligations resolve out
* of order (two groups publish concurrently), and deleting one must not
* rewrite the others.
*/
class FilePublishObligationStore(
private val dir: File,
) : MarmotPublishObligationStore {
init {
SecureFileIO.secureMkdirs(dir)
}
private fun file(obligationId: String) = File(dir, "$obligationId.obligation")
override suspend fun save(
obligationId: HexKey,
bytes: ByteArray,
) {
SecureFileIO.writeBytesAtomic(file(obligationId), bytes)
}
override suspend fun delete(obligationId: HexKey) {
file(obligationId).deleteOrWarn("FilePublishObligationStore", "publish obligation")
}
override suspend fun loadAll(): List<ByteArray> =
dir
.listFiles { f -> f.isFile && f.name.endsWith(".obligation") }
?.sortedBy { it.name }
?.mapNotNull { runCatching { it.readBytes() }.getOrNull() }
.orEmpty()
private fun gateFile(groupId: String) = File(dir, "$groupId.gate")
/**
* Outbound gates live beside the obligations and are durable for the same
* reason: `Disbanding` must survive "publication failure, restart, and a
* losing branch", and every `amy` verb is its own process — so an
* in-memory gate would not survive even the next command, let alone a
* crash.
*/
override suspend fun saveGate(
groupId: HexKey,
gate: String,
) {
SecureFileIO.writeBytesAtomic(gateFile(groupId), gate.encodeToByteArray())
}
override suspend fun deleteGate(groupId: HexKey) {
gateFile(groupId).deleteOrWarn("FilePublishObligationStore", "outbound gate")
}
override suspend fun loadGates(): Map<HexKey, String> =
dir
.listFiles { f -> f.isFile && f.name.endsWith(".gate") }
?.mapNotNull { file ->
runCatching { file.name.removeSuffix(".gate") to file.readText().trim() }.getOrNull()
}?.toMap()
.orEmpty()
}
/**
* Durable "already decided" markers, one hex id per line.
*
* Append-only and capped: the point is to stop re-deciding backdated gift
* wraps forever, not to remember every event this account has ever seen. When
* the cap is hit the oldest half is dropped — the worst case for a forgotten
* marker is one wasted re-decision, so trading memory for exactness is the
* right way round.
*/
class FileIngestDedupStore(
private val file: File,
private val maxEntries: Int = 20_000,
) : MarmotIngestDedupStore {
private val mutex = Mutex()
override suspend fun mark(eventId: HexKey) =
mutex.withLock {
SecureFileIO.appendText(file, eventId + "\n")
if (file.length() > maxEntries.toLong() * 65L) {
val kept = file.readLines().filter { it.isNotBlank() }.takeLast(maxEntries / 2)
SecureFileIO.writeBytesAtomic(file, (kept.joinToString("\n") + "\n").encodeToByteArray())
}
}
override suspend fun loadAll(): Set<HexKey> =
mutex.withLock {
file
.takeIf { it.exists() }
?.readLines()
?.filter { it.isNotBlank() }
?.toSet()
.orEmpty()
}
}
@@ -0,0 +1,91 @@
/*
* 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
import com.vitorpamplona.amethyst.cli.stores.FilePublishObligationStore
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Outbound gates have to reach the disk, not just the map.
*
* `LocalOutboundGate.DISBANDING` is required to survive "publication failure,
* restart, and a losing branch". The gate API is defaulted on the store
* interface so existing implementations keep compiling — which means a store
* that does not override it drops every gate silently, and the group comes back
* after a restart offering itself as ordinarily live with an admin's
* irreversible request forgotten. Every `amy` verb is its own process, so
* "survives a restart" is the ordinary case here rather than a crash scenario.
*/
class FileStoresGateTest {
@get:Rule val tmp = TemporaryFolder()
private val groupA = "a".repeat(64)
private val groupB = "b".repeat(64)
@Test
fun `a raised gate is readable by the next process`() =
runBlocking {
val dir = tmp.newFolder("obligations")
FilePublishObligationStore(dir).saveGate(groupA, LocalOutboundGate.DISBANDING.name)
// A different instance over the same directory is what the next
// `amy` invocation actually does.
val reopened = FilePublishObligationStore(dir).loadGates()
assertEquals(mapOf(groupA to LocalOutboundGate.DISBANDING.name), reopened)
}
@Test
fun `clearing a gate removes it for good`() =
runBlocking {
val dir = tmp.newFolder("obligations")
val store = FilePublishObligationStore(dir)
store.saveGate(groupA, LocalOutboundGate.DISBANDING.name)
store.deleteGate(groupA)
assertTrue(FilePublishObligationStore(dir).loadGates().isEmpty())
}
@Test
fun `gates are per group and do not disturb obligations`() =
runBlocking {
// One file per group and per obligation, in one directory: a gate
// write must not be mistaken for an obligation on reload, or the
// publish gate would try to decode an enum name as a TLS record.
val dir = tmp.newFolder("obligations")
val store = FilePublishObligationStore(dir)
store.save("f".repeat(64), byteArrayOf(1, 2, 3))
store.saveGate(groupA, LocalOutboundGate.DISBANDING.name)
store.saveGate(groupB, LocalOutboundGate.LEAVING.name)
val reopened = FilePublishObligationStore(dir)
assertEquals(
mapOf(groupA to LocalOutboundGate.DISBANDING.name, groupB to LocalOutboundGate.LEAVING.name),
reopened.loadGates(),
)
assertEquals(1, reopened.loadAll().size)
}
}
+1
View File
@@ -10,3 +10,4 @@ git/state-git-nip34/
buzz/state-job-loop/
buzz/state-workflow-loop/
buzz/state-agent-exec/
marmot/state-repro/
+50 -14
View File
@@ -28,7 +28,8 @@ cli/tests/
│ ├── tests-create.sh # tests 01–05
│ ├── tests-manage.sh # tests 06–08, 11
│ ├── tests-extras.sh # tests 09, 10, 12, 13
│ └── patches/ # whitenoise-rs harness patches
│ ├── tests-media.sh # tests 20-29 (avatar, edits, deletions,
│ │ # media v2, retention, disband)
├── nests/ # Audio-rooms interop (Amethyst ↔ nostrnests.com)
│ ├── nests-interop.sh # 47-test manual harness
│ └── README.md # operator brief + per-test matrix
@@ -92,11 +93,39 @@ The Marmot harnesses come in two flavours, same scenarios:
and for iterating on the Nostr/Marmot plumbing without needing to touch a
phone.
Most features are covered in BOTH directions — founding (02/03), adding
(04/05), removal (06/14), leaving (11/15), keypackage rotation (13/16),
agent streams (18/19), avatar URL (20/21), encrypted media v2 (24/25),
deletion (23/27), retention (26/28). Three gaps are the reference CLI's,
not ours, and cannot be closed from here:
- **edits wn→amy.** `wn messages` has no `edit` verb, and MDK reserves
kind 1009 so `messages send-event` refuses to forge one. MDK's runtime
has `edit_message` and its uniffi surface exposes it; only the CLI
does not.
- **setting retention from wn.** `wn groups` has no retention verb and
`groups create` has no flag for it, so test 28 has amy own the setting
and wn own the sending — which is the half that was untested anyway,
since inbound messages are where the epoch-pinning rule lives.
- **disband wn→amy.** Same shape: `disband_group` exists on MDK's runtime
and uniffi surface (the apps call it) but has no `wn groups` verb, so
test 29 runs one way only.
**The daemon is not a way around this**, which is worth stating because it
is the obvious next idea. `wnd`'s socket protocol
(`crates/cli/src/daemon/protocol.rs`) carries `Ping`, `Status`, `Shutdown`,
four `*Subscribe` variants, and `Execute { cli: Box<Cli> }` — and that last
one takes the same clap command tree `wn` parses. The daemon is a persistent
host for the CLI's verbs, not a richer RPC, so a verb missing from `Cli` is
unreachable through the socket too. Closing these three needs either a verb
upstream in MDK or a driver linked against `marmot-uniffi`/`marmot-c`; both
are out of scope for a harness that deliberately builds MDK unpatched.
A third, slimmer harness covers the NIP-17 DM surface:
- **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and
Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay.
No whitenoise-rs required — only `amy` and the relay binary (which
No MDK required — only `amy` and the relay binary (which
is shared with the Marmot harness's checkout at
`marmot/state-headless/nostr-rs-relay/`).
@@ -123,11 +152,17 @@ A fourth harness covers audio rooms (NIP-53 + moq-lite):
background audio. See `nests/README.md` for the full matrix and
prereqs.
Both Marmot harnesses validate Amethyst against **whitenoise-rs**
(https://github.com/marmot-protocol/whitenoise-rs), the reference Rust
implementation that powers the White Noise Flutter app. Every test records a
pass/fail/skip result into a tab-separated log, and the summary is printed at
the end of the run.
Both Marmot harnesses validate Amethyst against **MDK**
(https://github.com/marmot-protocol/mdk), the reference Rust implementation of
the Marmot protocol, via its `wn` / `wnd` binaries (the `wn-cli` package).
Every test records a pass/fail/skip result into a tab-separated log, and the
summary is printed at the end of the run.
> These harnesses previously targeted `marmot-protocol/whitenoise-rs`, which was
> archived on 2026-08-05 pinned to `mdk-core 0.8.0`. Testing against it meant
> testing against a frozen MIP-era client. The reference moved into `mdk`, and
> so did we — see `quartz/plans/2026-09-08-marmot-spec-resync.md` for what that
> change exposed.
## What gets tested
@@ -197,9 +232,10 @@ cd tools/marmot-interop
The script will, in order:
1. Verify `jq`, `git`, `cargo` etc. are present.
2. Clone `whitenoise-rs` into `state/whitenoise-rs/` and build `wn`/`wnd`
(release, `--features cli`). First build takes ~5 minutes; subsequent runs
reuse the binaries.
2. Clone `mdk` into `state/mdk/` and build `wn`/`wnd`
(`cargo build --release -p wn-cli`). First build takes ~5 minutes;
subsequent runs reuse the binaries. MDK pins its own Rust toolchain in
`rust-toolchain.toml`, so rustup may fetch a toolchain on the first run.
3. Launch two `wnd` daemons (one for Identity B, one for Identity C).
4. Create Nostr identities for B and C, persist their npubs in `state/run.env`.
5. Ask you to paste **your Amethyst account npub** (Identity A). This is
@@ -219,7 +255,7 @@ The script will, in order:
```
--local-relays Use ws://localhost:8080 instead of the default public relays.
Required if the public relays reject kinds 444/445/30443.
Run 'just docker-up' inside whitenoise-rs first.
Run 'just docker-up' inside the mdk checkout first.
--transponder Run Test 14 (push notifications via the transponder service).
--no-build Fail instead of rebuilding wn/wnd. Useful when iterating.
-h, --help Show help.
@@ -228,7 +264,7 @@ The script will, in order:
Environment overrides:
```
WN_REPO=/some/path/whitenoise-rs # use an existing checkout
WN_REPO=/some/path/mdk # use an existing checkout
```
## Default relays
@@ -245,7 +281,7 @@ that B just published — the harness warns you and continues. In that case
re-run with `--local-relays` after starting the Docker stack:
```bash
cd state/whitenoise-rs
cd state/mdk
just docker-up
cd ../..
./marmot-interop.sh --local-relays
@@ -324,6 +360,6 @@ for B/C if this matters to you.
- `marmot-interop.sh` — main entry point; orchestrates preflight, daemons,
identities, relays, and runs the 13 tests in sequence.
- `lib.sh` — helpers (logging, prompts, polling, jq wrappers, result table).
- `state/` — runtime directory, gitignored. Contains `whitenoise-rs/` source
- `state/` — runtime directory, gitignored. Contains the `mdk/` source
checkout, per-daemon data/log dirs, the session `run.env`, logs, and
results TSVs.
+7 -3
View File
@@ -18,9 +18,13 @@ amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext --
# Run amy, log stderr, surface JSON on stdout, remember last result.
amy_json() {
local out
if ! out=$(amy_a "$@" 2>>"$LOG_FILE"); then
fail_msg "amy $*: exit $? (see $LOG_FILE)"
local out rc
# Capture the status separately: inside `if ! cmd; then`, `$?` is the status
# of the negation (always 0), so the message reported "exit 0" for every
# failure and told a reader nothing about what went wrong.
out=$(amy_a "$@" 2>>"$LOG_FILE"); rc=$?
if [[ $rc -ne 0 ]]; then
fail_msg "amy $*: exit $rc (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE"
return 1
fi
+65 -28
View File
@@ -145,36 +145,66 @@ expect_contains() {
# ------- JSON helpers --------------------------------------------------------
# Extract MLS group ID as lowercase hex from wn JSON output.
# Handles both formats:
# - plain hex string (from `groups list`)
# - {"value":{"vec":[...]}} serde struct (from `groups create`)
# - flat byte array [n, ...] (from some responses)
# Plus the three wrapper shapes wn actually uses:
# - {"result": {"mls_group_id": ...}} (groups create)
# - {"group": {"mls_group_id": ...}, "membership": ...} (groups invites[0])
# - {"mls_group_id": ...} (bare)
# Input: JSON string via stdin; optional 2nd arg = field name (default: mls_group_id)
# Extract an MLS group id as lowercase hex from `wn --json` output.
#
# MDK 0.9.x settled on one envelope, `{"ok":true,"result":{...}}`, but the
# group id sits at a different place per verb:
# groups create -> .result.group_id
# groups accept / rename -> .result.group.group_id
# groups invites[] -> .group_id (an element, already peeled)
# groups members/admins -> .result.group_id
# Older builds wrapped the id as a serde `{"value":{"vec":[...]}}` struct or a
# bare byte array; both are still decoded so a run against an older `wn` binary
# reports a real mismatch instead of an empty string.
#
# Input: JSON on stdin. Optional 1st arg overrides the field name.
jq_group_id() {
local field="${1:-mls_group_id}"
local field="${1:-group_id}"
jq -r --arg f "$field" '
def byte2hex:
. as $n |
[($n / 16 | floor), ($n % 16)] |
map(if . < 10 then (48 + .) else (87 + .) end) |
implode;
(.group // .result // .) |
(.group // .) |
.[$f] |
if type == "string" then .
elif (type == "object" and (.value.vec != null)) then
[.value.vec[] | byte2hex] | join("")
elif type == "array" then
[.[] | byte2hex] | join("")
else empty end
def as_hex:
if type == "string" then .
elif (type == "object" and (.value.vec != null)) then
[.value.vec[] | byte2hex] | join("")
elif type == "array" then
[.[] | byte2hex] | join("")
else empty end;
[ (.result? // empty), (.result?.group? // empty), (.group? // empty), . ]
| map(select(type == "object") | .[$f]? // empty | as_hex)
| map(select(. != null and . != ""))
| first // empty
' 2>/dev/null || true
}
# Peel MDK 0.9.x's `{"ok":true,"result":{...}}` envelope and hand back the
# named collection as a JSON array. MDK moved every list one level in and gave
# it a name (`invites`, `members`, `admins`, `messages`), so a bare
# `(.result // .) | .[]?` now iterates the RESULT OBJECT'S VALUES — three
# scalars where the harness expected invite objects. That failure is silent:
# every poll simply never matches, and the test reports "never received
# invite" for a welcome that arrived and was accepted.
#
# Input: JSON on stdin, collection name as $1.
jq_list() {
local name="$1"
jq -c --arg n "$name" '
[ (.result?[$n]? // empty), (.[$n]? // empty), (.result? // empty), . ]
| map(select(type == "array"))
| (first // [])
| .[]
' 2>/dev/null || true
}
# npub or hex pubkey of one member/admin entry. MDK names the field per
# collection: members carry `member_id`, admins carry `admin_id`.
jq_member_ids() {
jq -r '.member_id? // .admin_id? // .pubkey? // .public_key? // empty' 2>/dev/null || true
}
# ------- polling helpers -----------------------------------------------------
# Snapshot currently-pending invites on <B|C> as a comma-separated list of
@@ -197,7 +227,7 @@ snapshot_invites() {
local g
g=$(printf '%s' "$one" | jq_group_id)
[[ -n "$g" ]] && gids+=("$g")
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
done < <(printf '%s' "$raw" | jq_list invites)
# bash 3.2 (stock macOS) treats "${gids[*]}" on an empty array as an
# unbound reference under `set -u`, so guard the expansion.
if (( ${#gids[@]} > 0 )); then
@@ -228,9 +258,10 @@ wait_for_invite() {
deadline=$(( start + timeout ))
last_hb=$start
while [[ $(date +%s) -lt $deadline ]]; do
# Post-v0.2 `wn --json groups invites` returns `{"result": [...]}`
# (older builds returned the bare array). Peel the wrapper when
# present so a pending invite is actually detected.
# MDK 0.9.x returns `{"ok":true,"result":{"invites":[...], …}}`.
# `jq_list` peels the envelope AND names the collection; iterating
# `.result` directly walks the sibling scalars instead and never
# matches.
local raw
raw=$("$wnfn" --json groups invites 2>/dev/null || true)
# Walk every pending invite (not just .[0]) so we skip past stales.
@@ -242,14 +273,14 @@ wait_for_invite() {
printf '%s\n' "$gid"
return 0
fi
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
done < <(printf '%s' "$raw" | jq_list invites)
# Heartbeat every ~10s.
local now=$(date +%s)
if (( now - last_hb >= 10 )); then
local elapsed=$(( now - start )) remaining=$(( deadline - now ))
local pending
pending=$(printf '%s' "$raw" | jq '(.result // .) | length' 2>/dev/null || echo "?")
pending=$(printf '%s' "$raw" | jq_list invites | wc -l | tr -d ' ')
local recent=""
if [[ -f "$data_dir/logs/stderr.log" ]]; then
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
@@ -277,9 +308,10 @@ wait_for_message() {
else
payload=$(wn_c_json messages list "$gid" --limit 20 2>/dev/null || true)
fi
# MDK 0.9.x: `.result.messages[]`, decrypted body in `plaintext`.
if [[ -n "${payload:-}" ]] && \
printf '%s' "$payload" | jq -e --arg n "$needle" \
'(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \
printf '%s' "$payload" | jq_list messages | jq -e --arg n "$needle" \
'select((.plaintext // .content // .text // "") | contains($n))' \
>/dev/null 2>&1; then
return 0
fi
@@ -320,6 +352,11 @@ extract_pubkey() {
# JSON: {"result": [ {"pubkey": …}, … ]} — post-v0.2 `wn --json whoami` shape
v=$(printf '%s' "$raw" | jq -r '.result[0].pubkey // .result[0].npub // .result[0].public_key // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
# JSON: {"ok":true,"result":{"accounts":[{"npub": ...}, ...]}} — MDK 0.9.x.
# `result` became an object with a named list, so the array-indexed probes
# above miss it entirely and the caller sees an empty npub.
v=$(printf '%s' "$raw" | jq -r '.result.accounts[0].npub // .result.accounts[0].pubkey // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
# JSON: array of accounts (whoami may return a list)
v=$(printf '%s' "$raw" | jq -r '.[0].pubkey // .[0].npub // .[0].public_key // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""A throwaway Blossom server for the Marmot interop harness.
Enough of BUD-01/BUD-02 for both implementations to store and fetch an
encrypted attachment: `PUT /upload` stores the body under its SHA-256 and
returns the blob descriptor, `GET /<sha256>` serves it back, `HEAD` answers
existence checks.
Deliberately unauthenticated. Real Blossom servers verify a kind-24242
authorization event; this one runs on loopback for the duration of a test run
and holds nothing but ciphertext the group already encrypted. Checking the
signature would test the harness, not the protocol.
"""
import argparse
import hashlib
import json
import os
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
blob_dir = "."
base_url = ""
def _blob_path(self, sha):
return os.path.join(self.blob_dir, sha)
def _sha_from_path(self):
# BUD-01 allows an optional extension: `/<sha256>` or `/<sha256>.bin`.
name = self.path.lstrip("/").split("?")[0]
sha = name.split(".")[0]
if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha.lower()):
return None
return sha.lower()
def _send_json(self, code, payload):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_PUT(self):
if not self.path.startswith("/upload"):
self._send_json(404, {"message": "not found"})
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
sha = hashlib.sha256(body).hexdigest()
with open(self._blob_path(sha), "wb") as handle:
handle.write(body)
self._send_json(
200,
{
"sha256": sha,
"size": len(body),
"type": self.headers.get("Content-Type", "application/octet-stream"),
"uploaded": int(time.time()),
"url": f"{self.base_url}/{sha}",
},
)
def do_GET(self):
sha = self._sha_from_path()
if sha is None or not os.path.exists(self._blob_path(sha)):
self._send_json(404, {"message": "blob not found"})
return
with open(self._blob_path(sha), "rb") as handle:
body = handle.read()
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_HEAD(self):
sha = self._sha_from_path()
exists = sha is not None and os.path.exists(self._blob_path(sha))
self.send_response(200 if exists else 404)
self.send_header("Content-Type", "application/octet-stream")
self.end_headers()
def log_message(self, fmt, *args):
# The harness captures stdout; one line per request is useful when a
# media test fails and useless otherwise.
print("blossom %s - %s" % (self.address_string(), fmt % args), flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8081)
parser.add_argument("--dir", required=True)
args = parser.parse_args()
os.makedirs(args.dir, exist_ok=True)
Handler.blob_dir = args.dir
Handler.base_url = f"http://{args.host}:{args.port}"
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(json.dumps({"ready": True, "base_url": Handler.base_url}), flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+112 -22
View File
@@ -3,13 +3,14 @@
# marmot-interop-headless.sh — zero-prompt, zero-internet interop harness.
#
# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and
# Identities B/C via whitenoise-rs `wn`/`wnd`. Spins up a local
# Identities B/C via MDK's `wn`/`wnd`. Spins up a local
# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the
# machine. Matches the 13 test scenarios in marmot-interop.sh but without
# any human prompts — all checks run to completion and the exit code
# reflects pass/fail totals.
#
# Usage: ./marmot-interop-headless.sh [--port N] [--no-build]
# Usage: ./marmot-interop-headless.sh [--port N] [--no-build] [--reuse-state]
# [--tests "name ..."]
#
set -uo pipefail
@@ -24,14 +25,14 @@ LOG_DIR="$STATE_DIR/logs"
A_DIR="$STATE_DIR/.amy/A"
B_DIR="$STATE_DIR/B"
C_DIR="$STATE_DIR/C"
B_SOCKET="$B_DIR/release/wnd.sock"
C_SOCKET="$C_DIR/release/wnd.sock"
B_SOCKET="${B_SOCKET:-$B_DIR/wnd.sock}"
C_SOCKET="${C_SOCKET:-$C_DIR/wnd.sock}"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
WN_REPO="${WN_REPO:-$SCRIPT_DIR/state/whitenoise-rs}"
WN_REPO="${WN_REPO:-$SCRIPT_DIR/state/mdk}"
WN_BIN="$WN_REPO/target/release/wn"
WND_BIN="$WN_REPO/target/release/wnd"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
@@ -52,7 +53,48 @@ RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8080}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
# MDK's reference QUIC broker, for the agent-text-stream tests. Loopback like
# everything else; the tests skip when the binary was never built.
BROKER_BIN="$WN_REPO/target/release/marmot-quic-broker"
BROKER_HOST="${BROKER_HOST:-127.0.0.1}"
BROKER_PORT="${BROKER_PORT:-4455}"
BROKER_URI="quic://$BROKER_HOST:$BROKER_PORT"
BROKER_PID=""
# SHA-256 of the broker's self-signed leaf, read from its startup JSON.
BROKER_PIN=""
# A loopback Blossom blob store for the encrypted-media tests. Ciphertext only:
# the file key comes from each group's MLS exporter and never reaches it.
BLOSSOM_HOST="${BLOSSOM_HOST:-127.0.0.1}"
BLOSSOM_PORT="${BLOSSOM_PORT:-8456}"
BLOSSOM_URL="http://$BLOSSOM_HOST:$BLOSSOM_PORT"
BLOSSOM_PID=""
NO_BUILD=0
# Every run starts from empty stores. wnd already wipes B's and C's data dirs
# on each start, but A's amy home and the relay's SQLite file used to survive,
# and the leftovers are not inert: a KeyPackage A published in an earlier run
# is still on the relay for B to invite with, an old group's kind:445 events
# still arrive and fail to decrypt, and A's cursors still say it has seen them.
# That drift is what made tests 03 and 08 fail on a dirty tree and pass on a
# clean one. Pass --reuse-state when you are deliberately debugging carry-over.
RESET_STATE=1
# Space-separated test function names; empty means the full suite below.
ONLY_TESTS=""
# Required as of MDK 0.9.x. `validate_relay_url` accepts `wss://`
# unconditionally but `ws://` only for a loopback host AND only behind this
# explicit opt-in; without it wnd refuses the harness relay with "invalid relay
# URL" and exits before creating its socket. 127.0.0.2 is inside 127.0.0.0/8 and
# already passes MDK's own loopback test, so the env var is the gate, not the
# address. Exported once here so `wn` and `wnd` both inherit it — `wn` runs the
# same validation on any relay argument.
export WN_ALLOW_LOOPBACK_RELAYS=1
# Same shape for blob stores: wn refuses a loopback Blossom endpoint unless it
# is told this is a dev/test run. The harness's blob store is loopback by
# design — nothing in a test run may leave the machine.
export WN_ALLOW_LOOPBACK_BLOB_ENDPOINTS=1
A_NPUB=""
A_HEX=""
@@ -66,6 +108,8 @@ while [[ $# -gt 0 ]]; do
--port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
--host) RELAY_HOST="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
--no-build) NO_BUILD=1 ;;
--reuse-state) RESET_STATE=0 ;;
--tests) ONLY_TESTS="$2"; shift ;;
-h|--help)
sed -n '3,14p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0 ;;
@@ -74,6 +118,17 @@ while [[ $# -gt 0 ]]; do
shift
done
if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then
# Keep the relay checkout + its build (minutes to rebuild) and the log and
# results history; drop everything that holds protocol state.
#
# run.env counts as protocol state: it is where tests hand each other group
# ids. Leaving it behind a wipe leaves ids naming groups nobody is in any
# more, and a later `--tests` subset that consumes without re-creating then
# fails on "not a member" for a group id from a previous run.
rm -rf "$STATE_DIR/.amy" "$B_DIR" "$C_DIR" "$RELAY_DATA" "$STATE_DIR/run.env"
fi
mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
@@ -92,6 +147,8 @@ source "$SCRIPT_DIR/tests-create.sh"
source "$SCRIPT_DIR/tests-manage.sh"
# shellcheck source=tests-extras.sh
source "$SCRIPT_DIR/tests-extras.sh"
# shellcheck source=tests-media.sh
source "$SCRIPT_DIR/tests-media.sh"
# Make sure Ctrl+C / SIGTERM / SIGHUP all run the full cleanup path —
# otherwise wnd is nohup'd and keeps running after the script dies,
@@ -101,6 +158,8 @@ cleanup() {
local rc=$?
trap - EXIT INT TERM HUP
stop_daemons
stop_quic_broker
stop_blossom
stop_local_relay
print_summary
exit "$rc"
@@ -113,6 +172,8 @@ trap 'exit 129' HUP
banner "Marmot headless interop harness ($RUN_TS)"
preflight
start_local_relay
start_quic_broker || true
start_blossom || true
start_daemon B "$B_DIR" "$B_SOCKET"
start_daemon C "$C_DIR" "$C_SOCKET"
ensure_identity_a
@@ -120,20 +181,49 @@ ensure_identity B
ensure_identity C
configure_relays
test_01_keypackage_discovery
test_02_a_creates_group
test_03_b_creates_group
test_04_three_member_group
test_05_b_adds_a_existing
test_06_member_removal
test_07_metadata_rename
test_08_admin_promote_demote
test_17_group_image_commit
test_09_reply_react_unreact
test_10_concurrent_commits
test_11_leave_group
test_12_offline_catchup
test_13_keypackage_rotation
test_14_wn_removes_a
test_15_wn_member_leaves
test_16_wn_keypackage_rotation
ALL_TESTS=(
test_01_keypackage_discovery
test_02_a_creates_group
test_03_b_creates_group
test_04_three_member_group
test_05_b_adds_a_existing
test_06_member_removal
test_07_metadata_rename
test_08_admin_promote_demote
test_17_group_image_commit
test_09_reply_react_unreact
test_10_concurrent_commits
test_11_leave_group
test_12_offline_catchup
test_13_keypackage_rotation
test_14_wn_removes_a
test_15_wn_member_leaves
test_16_wn_keypackage_rotation
test_18_agent_stream_amy_publishes
test_19_agent_stream_wn_publishes
test_20_avatar_url_amy_to_wn
test_21_avatar_url_wn_to_amy
test_22_message_edit_amy_to_wn
test_23_deletion_amy_to_wn
test_24_media_v2_amy_to_wn
test_25_media_v2_wn_to_amy
test_26_retention_amy_to_wn
test_27_deletion_wn_to_amy
test_28_retention_wn_to_amy
test_29_disband_amy_to_wn
)
# --tests runs a subset in the order given. Most tests read state a previous
# one saved (GROUP_02, GROUP_05, …), so a subset that skips a producer will
# report `skip`, not a false failure.
if [[ -n "$ONLY_TESTS" ]]; then
read -r -a ALL_TESTS <<<"$ONLY_TESTS"
fi
for t in "${ALL_TESTS[@]}"; do
if ! declare -F "$t" >/dev/null; then
fail_msg "unknown test: $t"
continue
fi
"$t"
done
+28 -24
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# marmot-interop.sh — interop test harness: Amethyst <-> whitenoise-rs (wn/wnd)
# marmot-interop.sh — interop test harness: Amethyst <-> MDK (wn/wnd)
#
# Sequential, all-or-nothing. Script drives the `wn` side automatically and
# prompts the human operator at each step that requires Amethyst UI action.
@@ -15,17 +15,16 @@ STATE_DIR="$SCRIPT_DIR/state"
LOG_DIR="$STATE_DIR/logs"
B_DIR="$STATE_DIR/B"
C_DIR="$STATE_DIR/C"
# wnd derives its socket path as "{data_dir}/release/wnd.sock" for release
# builds (and ".../dev/wnd.sock" for debug); our preflight always uses
# --release, so we hardcode the "release" suffix here.
B_SOCKET="$B_DIR/release/wnd.sock"
C_SOCKET="$C_DIR/release/wnd.sock"
# The harness pins the daemon socket explicitly via wnd's --socket flag, so
# these paths are our choice rather than a guess at wnd's derived default.
B_SOCKET="$B_DIR/wnd.sock"
C_SOCKET="$C_DIR/wnd.sock"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
WN_REPO="${WN_REPO:-$STATE_DIR/whitenoise-rs}"
WN_REPO="${WN_REPO:-$STATE_DIR/mdk}"
WN_BIN=""
WND_BIN=""
B_NPUB=""
@@ -48,7 +47,7 @@ NO_BUILD=0
usage() {
cat <<EOF
marmot-interop.sh — Amethyst <-> whitenoise-rs interop harness
marmot-interop.sh — Amethyst <-> MDK interop harness
Options:
--local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up')
@@ -57,7 +56,7 @@ Options:
-h, --help Show this help
Environment:
WN_REPO Path to whitenoise-rs checkout (default: state/whitenoise-rs)
WN_REPO Path to the mdk checkout (default: state/mdk)
EOF
}
@@ -97,12 +96,14 @@ preflight() {
fail_msg "wn/wnd not found and --no-build set: $WN_BIN"; exit 1
fi
if [[ ! -d "$WN_REPO/.git" ]]; then
step "cloning whitenoise-rs into $WN_REPO"
git clone --depth 1 https://github.com/marmot-protocol/whitenoise-rs.git "$WN_REPO" \
# marmot-protocol/whitenoise-rs was archived on 2026-08-05; wn/wnd now
# ship from marmot-protocol/mdk as the `wn-cli` package.
step "cloning mdk into $WN_REPO"
git clone --depth 1 https://github.com/marmot-protocol/mdk.git "$WN_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
step "building wn + wnd (cargo build --release --features cli) — ~5 min first run"
( cd "$WN_REPO" && cargo build --release --features cli --bin wn --bin wnd ) \
step "building wn + wnd (cargo build --release -p wn-cli) — ~5 min first run"
( cd "$WN_REPO" && cargo build --release -p wn-cli --bin wn --bin wnd ) \
2>&1 | tee -a "$LOG_FILE"
fi
printf ' wn: %s\n wnd: %s\n' "$WN_BIN" "$WND_BIN" >>"$LOG_FILE"
@@ -114,10 +115,13 @@ preflight() {
_start_daemon_attempt() {
local name="$1" data_dir="$2" socket="$3"
rm -f "$socket"
mkdir -p "$data_dir/logs" "$data_dir/release"
# wnd puts its socket at {data_dir}/release/wnd.sock (release build) — we
# don't pass --socket because the daemon doesn't accept that flag.
mkdir -p "$data_dir/logs"
# MDK's wnd accepts an explicit --socket, so the harness pins the listen
# path instead of guessing at the derived one ({home}/dev/wnd.sock today).
# --secret-store file keeps account secrets out of the OS keychain, which
# is what lets this run in a container.
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
--socket "$socket" --secret-store file \
>"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" &
local pid=$!
echo "$pid" > "$data_dir/pid"
@@ -153,11 +157,11 @@ start_daemon() {
if _start_daemon_attempt "$name" "$data_dir" "$socket"; then
return 0
fi
# Recover from a stale MLS SQLite DB whose keyring entry has gone
# missing (e.g. the keychain entry was pruned, the data dir was
# restored without the keyring, or a previous run used the mock
# keyring). wnd can't open the DB in that state, but the identity is
# disposable — wipe the data dir and let ensure_identity recreate it.
# Recover from a stale MLS SQLite DB whose secret has gone missing (the
# data dir was restored without its secret store, or an earlier run used a
# different --secret-store). wnd can't open the DB in that state, but the
# identity is disposable — wipe the data dir and let ensure_identity
# recreate it.
if [[ -s "$data_dir/logs/stderr.log" ]] && \
grep -q 'KeyringEntryMissingForExistingDatabase' "$data_dir/logs/stderr.log"; then
warn "$name: stale MLS DB detected (keyring entry missing) — wiping $data_dir and retrying"
@@ -428,7 +432,7 @@ configure_relays() {
local who="$1" wnfn
if [[ "$who" == "B" ]]; then wnfn=wn_b; else wnfn=wn_c; fi
local name="marmot-interop $who"
local about="Scripted wn identity for Amethyst<->whitenoise-rs interop harness"
local about="Scripted wn identity for Amethyst<->MDK interop harness"
local out
if out=$("$wnfn" profile update --name "$name" --about "$about" 2>&1); then
printf '%s profile update ok: %s\n' "$who" "$out" >>"$LOG_FILE"
@@ -530,7 +534,7 @@ configure_relays() {
wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true
else
warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists"
warn "Consider rerunning with --local-relays (requires 'just docker-up' in whitenoise-rs)."
warn "Consider rerunning with --local-relays (requires 'just docker-up' in the mdk checkout)."
fi
fi
}
@@ -1304,7 +1308,7 @@ main() {
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
banner "Amethyst <-> whitenoise-rs interop harness ($RUN_TS)"
banner "Amethyst <-> MDK interop harness ($RUN_TS)"
preflight
start_daemon B "$B_DIR" "$B_SOCKET"
@@ -1,17 +0,0 @@
--- a/crates/whitenoise-cli/src/bin/wnd.rs
+++ b/crates/whitenoise-cli/src/bin/wnd.rs
@@ -44,6 +44,14 @@ async fn main() -> whitenoise_cli::Result<()> {
let args = Args::parse();
let config = Config::resolve(args.data_dir.as_ref(), args.logs_dir.as_ref());
+ // marmot-interop-headless patch: allow sandboxes / CI without a real kernel
+ // keyring to fall back to the integration-tests mock keyring store by setting
+ // $WHITENOISE_MOCK_KEYRING=1. Requires building the binaries with
+ // `--features whitenoise/integration-tests` (the harness does).
+ if std::env::var("WHITENOISE_MOCK_KEYRING").is_ok() {
+ Whitenoise::initialize_mock_keyring_store();
+ }
+
let mut wn_config =
WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID);
if !args.discovery_relays.is_empty() {
@@ -1,26 +0,0 @@
--- a/src/whitenoise/event_processor/account_event_processor.rs
+++ b/src/whitenoise/event_processor/account_event_processor.rs
@@ -178,7 +178,22 @@
}
Err(e) => {
// Handle retry logic for actual processing errors
- if retry_info.should_retry() {
+ // marmot-interop-headless patch: MLS errors that come from
+ // mdk are ALREADY terminal — mdk doesn't retry internally, so
+ // any Err it returns (Unprocessable, PreviouslyFailed, decrypt
+ // failure, group-not-found, etc.) is provably permanent.
+ // Retrying those 10 times with exponential backoff (total
+ // ~17 min) just blocks later decryptable commits behind a
+ // queue of doomed retries, so every later join / rename /
+ // leave propagation races the test timeout. Treat them all
+ // as one-shot: log once, move on.
+ let is_terminal = matches!(
+ e,
+ WhitenoiseError::MlsMessageUnprocessable(_)
+ | WhitenoiseError::MlsMessagePreviouslyFailed
+ | WhitenoiseError::MdkCoreError(_),
+ );
+ if !is_terminal && retry_info.should_retry() {
self.schedule_retry(event, source, retry_info, e);
} else {
tracing::error!(
+186 -64
View File
@@ -6,12 +6,11 @@
# --- preflight ---------------------------------------------------------------
preflight() {
banner "Preflight"
for cmd in jq git curl cargo protoc patch; do
for cmd in jq git curl cargo protoc; do
if ! command -v "$cmd" >/dev/null 2>&1; then
fail_msg "missing required tool: $cmd"
case "$cmd" in
protoc) info "hint: apt-get install protobuf-compiler (or brew install protobuf on macOS)" ;;
patch) info "hint: apt-get install patch" ;;
esac
exit 1
fi
@@ -42,65 +41,91 @@ preflight() {
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
info "amy: $AMY_BIN"
# Clone/build whitenoise-rs if needed (shared between both harnesses).
# Clone/build the MDK reference client if needed (shared between both
# harnesses).
#
# This used to point at marmot-protocol/whitenoise-rs. That repository was
# archived on 2026-08-05 ("This repository is obsolete and is no longer
# updated") pinned to mdk-core 0.8.0, and wn/wnd moved into
# marmot-protocol/mdk as the `wn-cli` package. Pointing the harness at the
# dead repo tested us against a frozen MIP-era client, which is exactly the
# blind spot that let our implementation drift off the adopted spec.
if [[ ! -d "$WN_REPO/.git" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "whitenoise-rs checkout missing at $WN_REPO and --no-build set"; exit 1
fail_msg "mdk checkout missing at $WN_REPO and --no-build set"; exit 1
fi
step "cloning whitenoise-rs into $WN_REPO"
git clone --depth 1 https://github.com/marmot-protocol/whitenoise-rs.git "$WN_REPO" \
step "cloning mdk into $WN_REPO"
git clone --filter=blob:none https://github.com/marmot-protocol/mdk.git "$WN_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
# Two harness-only patches to whitenoise-rs so it runs in sandboxes that
# block the kernel keyring:
# 1. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the
# integration-tests mock keyring store when the kernel keyutils
# syscalls are blocked (common in containers / CI). Compiled in via
# `--features whitenoise/integration-tests` on the build below.
# 2. skip-unprocessable-retry: when mdk-core returns a terminal MLS
# error (MlsMessageUnprocessable / PreviouslyFailed / MdkCoreError)
# the message is provably undecryptable — retrying it ten times with
# exponential backoff (~17 min) just blocks later decryptable commits
# behind a queue of doomed retries, which in the harness manifests as
# "A already left" / "name unchanged" timeouts. The patch treats those
# errors as terminal.
# Pin to the commit the SHIPPING apps embed, not whatever master is today.
# Both White Noise clients vendor an immutable MarmotKit artifact and name
# its `mdk-sha` in a lockfile — whitenoise-android's
# `app/src/main/marmotkit/MARMOT_VERSION` and whitenoise-ios's
# `Packages/MarmotKit/MARMOT_VERSION`. Testing against master answers "are we
# compatible with tip"; testing against this answers "are we compatible with
# what users are running", which is the question the harness exists to answer.
#
# The relay-override patches this harness used to carry (discovery-env /
# defaults-env) are gone: upstream wnd now takes native --discovery-relays
# and --default-account-relays flags (passed in start_daemon), which do the
# same job without patching. wn/wnd also moved into the crates/whitenoise-cli
# workspace member — the mock-keyring patch targets that path.
local -a patches=(
"whitenoise-mock-keyring.patch"
"whitenoise-skip-unprocessable-retry.patch"
)
# Apply each patch with a real exit-code check. The previous version
# swallowed patch's exit status via `| tee`, which meant a miscounted
# hunk header silently left the marker touched and the binary unpatched
# — the resulting wn retried provably-doomed MLS messages for ~17min
# and every later test flapped or timed out. Fail fast instead.
for name in "${patches[@]}"; do
local marker="$WN_REPO/.headless-patched-${name%.patch}"
if [[ ! -f "$marker" ]]; then
step "patching whitenoise-rs: $name"
if ( cd "$WN_REPO" && patch -p1 --forward --reject-file=- \
<"$SCRIPT_DIR/patches/$name" >>"$LOG_FILE" 2>&1 ); then
touch "$marker"
# Invalidate the previous build so the patched source is picked up.
rm -f "$WN_BIN" "$WND_BIN"
else
fail_msg "patch $name failed — see $LOG_FILE"
tail -n 30 "$LOG_FILE" | sed 's/^/ /' >&2
exit 1
# THE TWO APPS NO LONGER AGREE, and the rule for that is: take the newer.
# As of 2026-09-10 android is on 0.9.21 (`fdd398a8`) and ios is still on
# 0.9.20 (`2f44f6b6`) — android syncs its bindings on its own cadence and got
# there first. The newer one is where new validation lands, so it is where
# drift shows up first; a client that satisfies 0.9.21 satisfies 0.9.20,
# since every 0.9.20 rule is still in 0.9.21. Pinning to the laggard would
# test the subset and call it coverage.
#
# Bump it deliberately, by reading those lockfiles again — not by drifting.
# If they agree again, that is the value; if they disagree, take the newer
# and say so here.
MDK_PIN="${MDK_PIN:-fdd398a80f1626f1713787cebe416f7890b5b204}"
if [[ "$(git -C "$WN_REPO" rev-parse HEAD 2>/dev/null)" != "$MDK_PIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
info "mdk is not at the pinned $MDK_PIN and --no-build set — testing whatever is checked out"
else
step "checking out the pinned mdk $MDK_PIN"
if ! git -C "$WN_REPO" cat-file -e "$MDK_PIN^{commit}" 2>/dev/null; then
git -C "$WN_REPO" fetch --filter=blob:none origin "$MDK_PIN" 2>&1 | tee -a "$LOG_FILE"
fi
git -C "$WN_REPO" checkout --detach "$MDK_PIN" 2>&1 | tee -a "$LOG_FILE" || {
fail_msg "could not check out the pinned mdk $MDK_PIN"; exit 1
}
fi
done
fi
# No source patches. The harness used to carry two against whitenoise-rs:
#
# 1. mock-keyring, so wnd could run where the kernel keyring is blocked.
# MDK replaces this with a native flag: `--secret-store file` keeps
# account secrets in files under the data dir instead of the OS
# keychain. start_daemon passes it.
# 2. skip-unprocessable-retry, which made terminal MLS errors stop
# retrying. That patched `src/whitenoise/event_processor/`, a path MDK
# does not have. If MDK's retry behaviour turns out to stall this
# harness the same way, that is a fresh diagnosis against MDK's own
# code, not a patch to port.
#
# cargo's transitive deps (rustup, crates.io) both return 503 on cold
# caches often enough that a single attempt fails ~30% of the time.
# Retry each cargo build until the binary actually exists or we've
# exhausted the budget — the build is incremental so retries are cheap.
# Rebuild when the checkout moved, not only when the binary is missing.
# A pinned checkout beside a binary built from a different commit is worse
# than no pin at all: the run would report a version it did not test.
local built_marker="$WN_REPO/target/release/.harness-built-sha"
local want_sha
want_sha=$(git -C "$WN_REPO" rev-parse HEAD 2>/dev/null || echo "")
local built_sha=""
[[ -f "$built_marker" ]] && built_sha=$(cat "$built_marker" 2>/dev/null || echo "")
if [[ -x "$WN_BIN" && -x "$WND_BIN" && -n "$want_sha" && "$built_sha" != "$want_sha" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
info "wn was built from ${built_sha:-an unrecorded commit}, not $want_sha — --no-build keeps it"
else
step "mdk moved to $want_sha — rebuilding wn + wnd"
rm -f "$WN_BIN" "$WND_BIN"
fi
fi
if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "wn/wnd not found and --no-build set"; exit 1
@@ -109,8 +134,7 @@ preflight() {
for attempt in $(seq 1 $max); do
step "building wn + wnd (attempt $attempt/$max, ~5 min first run)"
( cd "$WN_REPO" && \
cargo build --release -p whitenoise-cli \
--features whitenoise/integration-tests --bin wn --bin wnd ) \
cargo build --release -p wn-cli --bin wn --bin wnd ) \
2>&1 | tee -a "$LOG_FILE"
[[ -x "$WN_BIN" && -x "$WND_BIN" ]] && break
[[ "$attempt" -lt "$max" ]] && warn "wn/wnd build failed (likely transient 503 from rustup or crates.io) — retrying"
@@ -118,8 +142,9 @@ preflight() {
[[ -x "$WN_BIN" && -x "$WND_BIN" ]] || {
fail_msg "wn/wnd still missing after $max build attempts"; exit 1
}
[[ -n "$want_sha" ]] && printf '%s\n' "$want_sha" >"$built_marker"
fi
info "wn: $WN_BIN"
info "wn: $WN_BIN ($(git -C "$WN_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown))"
info "wnd: $WND_BIN"
# Clone/build nostr-rs-relay — the harness's single loopback relay.
@@ -147,6 +172,95 @@ preflight() {
info "relay bin: $RELAY_BIN"
}
# --- local QUIC broker -------------------------------------------------------
# MDK's own `marmot-quic-broker`, the reference implementation of the other
# side of `transports/quic.md`. Agent text stream previews are the only tests
# that need it, and they are the only way to know our binding is right — the
# ALPN, the control envelope, the frame prefix and the record key schedule all
# have to agree with an implementation that is not ours.
#
# `--replay-ttl-secs` is what lets a subscriber that connects after the
# records were pushed still see them; with the default 0 a test would have to
# race the publisher.
start_quic_broker() {
if [[ ! -x "$BROKER_BIN" ]]; then
info "marmot-quic-broker not built — agent text stream tests will skip"
return 1
fi
step "starting QUIC broker on $BROKER_HOST:$BROKER_PORT"
mkdir -p "$STATE_DIR/broker"
nohup "$BROKER_BIN" --bind "$BROKER_HOST:$BROKER_PORT" --replay-ttl-secs 60 --json \
>"$STATE_DIR/broker/stdout.log" 2>"$STATE_DIR/broker/stderr.log" &
BROKER_PID=$!
local deadline=$(( $(date +%s) + 15 ))
while [[ $(date +%s) -lt $deadline ]]; do
if grep -q '"local_addr"' "$STATE_DIR/broker/stdout.log" 2>/dev/null; then
# The broker generates a self-signed certificate and prints its
# fingerprint. `amy` pins that exact leaf rather than trusting a chain —
# there is no CA in this picture, and without the pin every stream test
# fails inside TLS before a single frame is written.
BROKER_PIN=$(sed -n 's/.*"server_cert_sha256_fingerprint":"\([0-9a-f]*\)".*/\1/p' \
"$STATE_DIR/broker/stdout.log" | head -1)
if [[ -z "$BROKER_PIN" ]]; then
fail_msg "broker printed no server_cert_sha256_fingerprint — cannot pin it"
BROKER_PID=""
return 1
fi
info "broker pid $BROKER_PID ready (cert ${BROKER_PIN:0:16}…)"
return 0
fi
if ! kill -0 "$BROKER_PID" 2>/dev/null; then break; fi
sleep 1
done
fail_msg "broker never came up (see $STATE_DIR/broker/stderr.log)"
tail -n 20 "$STATE_DIR/broker/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
BROKER_PID=""
return 1
}
# --- blossom blob store ------------------------------------------------------
# A loopback Blossom server for the encrypted-media tests. Both implementations
# upload ciphertext to it and fetch each other's back; it never sees a key.
start_blossom() {
if ! command -v python3 >/dev/null 2>&1; then
info "python3 not found — encrypted-media tests will skip"
return 1
fi
step "starting blossom blob store on $BLOSSOM_URL"
mkdir -p "$STATE_DIR/blossom/blobs"
nohup python3 "$SCRIPT_DIR/blossom-server.py" \
--host "$BLOSSOM_HOST" --port "$BLOSSOM_PORT" --dir "$STATE_DIR/blossom/blobs" \
>"$STATE_DIR/blossom/stdout.log" 2>"$STATE_DIR/blossom/stderr.log" &
BLOSSOM_PID=$!
local deadline=$(( $(date +%s) + 15 ))
while [[ $(date +%s) -lt $deadline ]]; do
if grep -q '"ready"' "$STATE_DIR/blossom/stdout.log" 2>/dev/null; then
info "blossom pid $BLOSSOM_PID ready"
return 0
fi
if ! kill -0 "$BLOSSOM_PID" 2>/dev/null; then break; fi
sleep 1
done
fail_msg "blossom never came up (see $STATE_DIR/blossom/stderr.log)"
tail -n 20 "$STATE_DIR/blossom/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
BLOSSOM_PID=""
return 1
}
stop_blossom() {
[[ -n "${BLOSSOM_PID:-}" ]] || return 0
step "stopping blossom pid $BLOSSOM_PID"
kill "$BLOSSOM_PID" 2>/dev/null || true
BLOSSOM_PID=""
}
stop_quic_broker() {
[[ -n "${BROKER_PID:-}" ]] || return 0
step "stopping broker pid $BROKER_PID"
kill "$BROKER_PID" 2>/dev/null || true
BROKER_PID=""
}
# --- local relay -------------------------------------------------------------
# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test
# runs against this one loopback endpoint — no external network traffic.
@@ -166,7 +280,7 @@ description = "Loopback relay for marmot-interop-headless.sh — do not use for
data_directory = "$RELAY_DATA"
[network]
address = "${RELAY_HOST:-127.0.0.1}"
address = "${RELAY_BIND:-${RELAY_HOST:-127.0.0.1}}"
port = $RELAY_PORT
[options]
@@ -226,29 +340,37 @@ start_daemon() {
info "$name daemon already running"; return 0
fi
rm -f "$socket"
# The mock keyring (WHITENOISE_MOCK_KEYRING=1) is in-memory only and
# resets to empty on every wnd restart, but the SQLite databases that
# wnd writes under $data_dir persist across runs and reference keys that
# no longer exist — wnd then bails with KeyringEntryMissingForExistingDatabase
# before it can even open a socket. Wipe the keyring-dependent state on
# each start so the daemon always comes up cold and consistent. Logs
# and the pid file are preserved for post-mortem.
# Start every daemon from a cold data dir. A stale SQLite database whose
# matching secret is gone leaves wnd unable to open its store, and it then
# bails before it can even create the socket. The identities here are
# disposable, so wiping is always the right move. Logs and the pid file are
# preserved for post-mortem.
if [[ -d "$data_dir" ]]; then
find "$data_dir" -mindepth 1 -maxdepth 1 \
! -name 'logs' ! -name 'pid' \
-exec rm -rf {} + 2>/dev/null || true
fi
mkdir -p "$data_dir/logs" "$data_dir/release"
mkdir -p "$data_dir/logs"
# MDK refuses to create its socket if the socket's parent directory is
# group-writable or world-accessible ("unsafe on-disk permissions"). A default
# umask gives 0755, so tighten it explicitly rather than depending on whatever
# umask the caller's shell happens to have.
chmod 700 "$data_dir"
# --discovery-relays / --default-account-relays are native wnd flags that
# force both the discovery plane and freshly-created accounts' NIP-65 / inbox
# / key-package lists onto our loopback relay (kills the "can't reach nos.lol"
# exit path and stops accounts from carrying unreachable public relays).
#
# WHITENOISE_MOCK_KEYRING=1 is consumed by the mock-keyring patch: it swaps in
# the integration-tests mock secret store so wnd doesn't fall over when the
# kernel blocks keyutils syscalls. Harmless on a real host with a real keyring.
WHITENOISE_MOCK_KEYRING=1 \
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
# --socket pins the listen path instead of letting wnd derive it. MDK derives
# it as {home}/dev/wnd.sock, whitenoise-rs used {data_dir}/{profile}/wnd.sock;
# passing it explicitly makes the harness independent of that choice.
#
# --secret-store file replaces the old mock-keyring source patch: account
# secrets live in files under the data dir, so the daemon comes up in
# containers and CI where the kernel keyring is unavailable.
#
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
--socket "$socket" --secret-store file \
--discovery-relays "$RELAY_URL" --default-account-relays "$RELAY_URL" \
>"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" &
local pid=$!
+13 -4
View File
@@ -10,7 +10,9 @@ test_01_keypackage_discovery() {
# B finds A's KP
local raw ev
raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true)
ev=$(printf '%s' "$raw" | jq -r '.result.event_id // .event_id // empty')
# MDK 0.9.x reports the found KeyPackage under result.key_package; the two
# older shapes are kept so this still reads a pre-0.9 daemon.
ev=$(printf '%s' "$raw" | jq -r '.result.key_package.key_package_event_id // .result.event_id // .event_id // empty')
if [[ -z "$ev" || "$ev" == "null" ]]; then
record_result "$id (B->A)" fail "wn couldn't find A's KP"; return
fi
@@ -182,12 +184,19 @@ test_05_b_adds_a_existing() {
record_result "$id" fail "wn add-members A failed"; return
}
# A joins
if ! amy_json marmot await group --name "Interop-05" --timeout 30 >/dev/null; then
# A joins. `gid` is wn's MLS group id; amy indexes by the MIP-01
# nostr_group_id, which only exists locally once A has processed the
# welcome — so take amy's id from its own await, never wn's.
local a_out a_gid
a_out=$(amy_json marmot await group --name "Interop-05" --timeout 30) || {
record_result "$id" fail "A never received invite to Interop-05"; return
}
a_gid=$(printf '%s' "$a_out" | jq -r '.group_id // empty')
if [[ -z "$a_gid" ]]; then
record_result "$id" fail "A joined Interop-05 but reported no group_id"; return
fi
amy_json marmot message send "$gid" "joined from amethyst" >/dev/null || {
amy_json marmot message send "$a_gid" "joined from amethyst" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
if wait_for_message B "$gid" "joined from amethyst" 90 \
+199 -23
View File
@@ -17,7 +17,8 @@ test_09_reply_react_unreact() {
# B anchors. Needs a member to be present — if Test 11 already ran and A left,
# skip cleanly so we don't double-fail.
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
| jq_list members | jq -e --arg p "$A_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return
fi
@@ -26,7 +27,9 @@ test_09_reply_react_unreact() {
sleep 3
local msg_id
msg_id=$(wn_b --json messages list "$mls_gid" --limit 10 2>/dev/null \
| jq -r '[(.result // .) | .[]? | select((.content // .text // "") == "anchor for reactions")][0].id // empty')
| jq_list messages \
| jq -r 'select((.plaintext // .content // .text // "") == "anchor for reactions")
| (.message_id // .id)' | head -n 1)
if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then
record_result "$id" fail "couldn't find anchor message id"; return
fi
@@ -47,7 +50,9 @@ test_09_reply_react_unreact() {
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].event_id // empty')
| jq_list messages \
| jq -r 'select((.plaintext // .content // "") == "anchor for reactions")
| (.message_id // .event_id)' | head -n 1)
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
@@ -55,19 +60,29 @@ test_09_reply_react_unreact() {
record_result "$id" fail "amy marmot message react failed"; return
fi
# Round-trip: B should surface amy's kind:7 reaction. wn aggregates
# reactions onto the anchor message (`.reactions.by_emoji[<emoji>]`),
# not as a standalone entry whose `.content` is the emoji — so polling
# `messages list` for an entry whose content equals "🍕" would never
# match, even when the reaction was successfully decrypted. Look for
# the emoji under any message's `reactions.by_emoji` keys instead.
# Round-trip: B should surface amy's kind:7 reaction. `wn messages list`
# reads the raw app-event log, where a reaction is its own kind:7 entry
# carrying the emoji and an "e" tag naming the anchor — the aggregated
# `reactions.by_emoji` summary belongs to the materialized timeline, which
# this command does not project. Match the raw shape, and accept an
# aggregated one too so a wn that starts summarising here still passes.
local deadline=$(( $(date +%s) + 90 )) saw=0
while [[ $(date +%s) -lt $deadline ]]; do
local payload
payload=$(wn_b_json messages list "$mls_gid" --limit 50 2>/dev/null || true)
if [[ -n "$payload" ]] && \
printf '%s' "$payload" \
| jq -e '(.result // .) | .[]? | (.reactions.by_emoji // {}) | keys[]?' \
| jq_list messages \
| jq -e --arg anchor "$msg_id" --arg emoji "🍕" \
'select(.kind == 7)
| select((.plaintext // .content // "") == $emoji)
| select([(.tags // [])[] | select(.[0] == "e") | .[1]] | index($anchor))' \
>/dev/null 2>&1; then
saw=1; break
fi
if [[ -n "$payload" ]] && \
printf '%s' "$payload" \
| jq_list messages | jq -e '(.reactions.by_emoji // {}) | keys[]?' \
2>/dev/null \
| grep -qF '"🍕"'; then
saw=1; break
@@ -92,7 +107,8 @@ test_10_concurrent_commits() {
record_result "$id" skip "no GROUP_02"; return
fi
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
| jq_list members | jq -e --arg p "$A_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return
fi
@@ -110,7 +126,8 @@ test_10_concurrent_commits() {
# whitenoise-rs ≥ v0.2.x wraps the group payload one level deeper as
# `{"result": {"group": {…name…}}}`; the older shape was a bare group
# object under `.result`. Accept both.
b_name=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | (.group // .) | .name // empty')
b_name=$(wn_b --json groups show "$mls_gid" 2>/dev/null \
| jq -r '(.result // .) | (.group // .) | (.profile.name // .name) // empty')
local a_name
a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "")
@@ -193,10 +210,15 @@ test_13_keypackage_rotation() {
banner "Test 13 — KeyPackage rotation"
local id="13 keypackage rotation"
local before
before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty')
# `keys check` resolves A's KeyPackage the way an invite would, so an empty
# answer here is a real finding, not a missing fixture: it means MDK looked
# at what A published and refused it. Keep the raw JSON in the log.
local before raw
raw=$(wn_b --json keys check "$A_NPUB" 2>&1)
printf 'wn keys check %s -> %s\n' "$A_NPUB" "$raw" >>"$LOG_FILE"
before=$(printf '%s' "$raw" | jq -r '.result.key_package.key_package_event_id // .result.event_id // empty')
if [[ -z "$before" ]]; then
record_result "$id" fail "no prior KP for A"; return
record_result "$id" fail "wn cannot resolve a KeyPackage for A"; return
fi
amy_json marmot key-package publish >/dev/null || {
@@ -205,7 +227,8 @@ test_13_keypackage_rotation() {
local deadline=$(( $(date +%s) + 60 )) after=""
while [[ $(date +%s) -lt $deadline ]]; do
after=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty')
after=$(wn_b --json keys check "$A_NPUB" 2>/dev/null \
| jq -r '.result.key_package.key_package_event_id // .result.event_id // empty')
[[ -n "$after" && "$after" != "$before" ]] && break
sleep 3
done
@@ -323,9 +346,9 @@ test_15_wn_member_leaves() {
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')
c_still=$(printf '%s' "$show" | jq --arg p "$C_HEX" '[.members[]? | select((.pubkey // .member_id) == $p)] | length')
local a_still
a_still=$(printf '%s' "$show" | jq --arg p "$A_HEX" '[.members[]? | select(.pubkey == $p)] | length')
a_still=$(printf '%s' "$show" | jq --arg p "$A_HEX" '[.members[]? | select((.pubkey // .member_id) == $p)] | length')
if [[ "$c_still" == "0" && "$a_still" == "1" ]]; then
ok=1; break
fi
@@ -364,11 +387,12 @@ test_16_wn_keypackage_rotation() {
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
# Ask B to rotate. It has to be `keys rotate` ("force mint and publish a
# fresh replacement"), not `keys publish` — the latter is the idempotent
# retry of the durable stable-slot replacement, so with nothing pending it
# republishes the same event id and there is no rotation to observe.
wn_b keys rotate >/dev/null 2>&1 || {
record_result "$id" fail "wn_b keys rotate failed"; return
}
local deadline=$(( $(date +%s) + 60 )) after=""
@@ -385,3 +409,155 @@ test_16_wn_keypackage_rotation() {
record_result "$id" fail "amy kept seeing the pre-rotation KP"
fi
}
# --- Agent text streams (0x8006) --------------------------------------------
# The live-preview half of an agent turn: a hidden kind:1200 anchors the
# stream over MLS, encrypted records ride raw QUIC through a broker, and a
# kind:9 closes it with the transcript a receiver checks its own fold against.
#
# Both tests need MDK's `marmot-quic-broker` — the reference implementation of
# the other side. Without it there is no honest way to claim the binding is
# right, so they skip rather than pretending.
test_18_agent_stream_amy_publishes() {
banner "Test 18 — amy publishes an agent text stream; wn verifies it"
local id="18 agent stream amy->wn"
if [[ -z "${BROKER_PID:-}" ]]; then record_result "$id" skip "no QUIC broker"; return; fi
# Its own group: by this point in the run A has left GROUP_02 and been
# removed from others, and a stream needs both parties actually present.
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-18") || {
record_result "$id" fail "amy group create failed"; return
}
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy group add B failed"; return
}
local b_gid
if ! b_gid=$(wait_for_invite B 60); then
record_result "$id" fail "B never received the invite"; return
fi
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
save_state GROUP_STREAM "$gid"
save_state GROUP_STREAM_MLS "$mls_gid"
local start_json sid seid
start_json=$(amy_json marmot stream start "$gid" --broker "$BROKER_URI") || {
record_result "$id" fail "amy stream start failed"; return
}
sid=$(printf '%s' "$start_json" | jq -r '.stream_id // empty')
seid=$(printf '%s' "$start_json" | jq -r '.start_event_id // empty')
if [[ -z "$sid" || -z "$seid" ]]; then
record_result "$id" fail "stream start reported no ids"; return
fi
local send_json thash chunks
send_json=$(amy_json marmot stream send "$gid" --stream-id "$sid" --start-event-id "$seid" \
--broker "$BROKER_URI" --pin-sha256 "$BROKER_PIN" "Hello " "from " "amethyst") || {
record_result "$id" fail "amy stream send failed"; return
}
thash=$(printf '%s' "$send_json" | jq -r '.transcript_hash // empty')
chunks=$(printf '%s' "$send_json" | jq -r '.chunk_count // empty')
printf 'stream18 start=%s\nstream18 send=%s\n' "$start_json" "$send_json" >>"$LOG_FILE"
# Our own subscriber must recover the stream from the broker's replay window
# and fold it to the same transcript the publisher computed.
local watch_json
watch_json=$(amy_json marmot stream watch "$gid" --stream-id "$sid" --timeout 15 \
--pin-sha256 "$BROKER_PIN") || {
record_result "$id" fail "amy stream watch failed"; return
}
printf 'stream18 watch=%s\n' "$watch_json" >>"$LOG_FILE"
if [[ "$(printf '%s' "$watch_json" | jq -r '.transcript_hash')" != "$thash" ]]; then
record_result "$id" fail "amy's own fold disagrees with what it published"; return
fi
if [[ "$(printf '%s' "$watch_json" | jq -r '.preview')" != "Hello from amethyst" ]]; then
record_result "$id" fail "preview text did not survive the round trip"; return
fi
amy_json marmot stream finish "$gid" --stream-id "$sid" \
--transcript-hash "$thash" --chunk-count "$chunks" "Hello from amethyst" >/dev/null || {
record_result "$id" fail "amy stream finish failed"; return
}
# The real check: MDK reads our kind:1200 + kind:9 and confirms the
# transcript itself.
local deadline=$(( $(date +%s) + 60 )) verified="false"
while [[ $(date +%s) -lt $deadline ]]; do
verified=$(wn_b --json stream verify "$mls_gid" --stream-id "$sid" --transcript-hash "$thash" 2>/dev/null \
| jq -r '.result.verified // false')
[[ "$verified" == "true" ]] && break
sleep 3
done
if [[ "$verified" == "true" ]]; then
record_result "$id" pass
else
record_result "$id" fail "wn could not verify amy's transcript"
fi
}
test_19_agent_stream_wn_publishes() {
banner "Test 19 — wn publishes an agent text stream; amy watches it"
local id="19 agent stream wn->amy"
local gid mls_gid
gid=$(load_state GROUP_STREAM || true)
mls_gid=$(load_state GROUP_STREAM_MLS || true)
if [[ -z "${gid:-}" ]]; then record_result "$id" skip "no stream group (test 18 did not run)"; return; fi
if [[ -z "${BROKER_PID:-}" ]]; then record_result "$id" skip "no QUIC broker"; return; fi
local wn_start wsid wseid
wn_start=$(wn_b --json stream start "$mls_gid" --quic-candidate "$BROKER_URI" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn stream start failed"; return
}
wsid=$(printf '%s' "$wn_start" | jq -r '.result.stream_id // empty')
# wn reports the kind:1200's own Marmot app event id as message_ids[0] —
# the same value amy resolves as start_event_id from the payload itself.
wseid=$(printf '%s' "$wn_start" | jq -r '.result.message_ids[0] // empty')
if [[ -z "$wsid" || -z "$wseid" ]]; then
record_result "$id" fail "wn stream start reported no ids"; return
fi
# amy has to have the kind:1200 before it can derive the stream's keys: the
# anchor's own event id is part of the key context. Sync until it lands.
local anchor_deadline=$(( $(date +%s) + 45 )) saw_anchor=0
while [[ $(date +%s) -lt $anchor_deadline ]]; do
if amy_a marmot message list "$gid" --limit 50 2>/dev/null \
| jq -e --arg id "$wseid" '.messages[]? | select(.event_id == $id)' >/dev/null 2>&1; then
saw_anchor=1; break
fi
sleep 3
done
if [[ "$saw_anchor" -ne 1 ]]; then
record_result "$id" fail "amy never received wn's kind:1200 anchor"; return
fi
local watch_out="$STATE_DIR/stream-19-watch.json"
( amy_a marmot stream watch "$gid" --stream-id "$wsid" --timeout 25 \
--pin-sha256 "$BROKER_PIN" >"$watch_out" 2>>"$LOG_FILE" ) &
local watch_pid=$!
sleep 4
local send_json wthash
send_json=$(wn_b --json stream send --broker --connect "$BROKER_HOST:$BROKER_PORT" --insecure-local \
--stream-id "$wsid" --start-event-id "$wseid" "Hello from whitenoise" 2>>"$LOG_FILE")
wthash=$(printf '%s' "$send_json" | jq -r '.result.transcript_hash // empty')
wait "$watch_pid" || true
local preview athash
preview=$(tail -n 1 "$watch_out" 2>/dev/null | jq -r '.preview // empty')
athash=$(tail -n 1 "$watch_out" 2>/dev/null | jq -r '.transcript_hash // empty')
if [[ "$preview" != "Hello from whitenoise" ]]; then
record_result "$id" fail "amy rendered '$preview' instead of wn's text"; return
fi
# The decisive one: our record key schedule, key context, AEAD and transcript
# construction all have to match MDK's exactly for these to agree.
if [[ -n "$wthash" && "$athash" != "$wthash" ]]; then
record_result "$id" fail "transcript hash disagrees with wn's (${athash:0:12}… vs ${wthash:0:12}…)"; return
fi
record_result "$id" pass
}
+11 -5
View File
@@ -28,7 +28,8 @@ test_06_member_removal() {
local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_c --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
| jq_list members | jq -e --arg p "$C_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
removed=1; break
fi
@@ -78,7 +79,10 @@ test_07_metadata_rename() {
# `{"result": {"group": {…name…}}}`; older builds returned the bare
# group object under `.result`. Probe both shapes so the test survives
# either schema.
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | (.group // .) | .name // empty')
# MDK 0.9.x keeps the display name in the profile component, not a
# top-level `name`: `.result.group.profile.name`.
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null \
| jq -r '(.result // .) | (.group // .) | (.profile.name // .name) // empty')
[[ "$seen" == "Interop-02-renamed" ]] && break
sleep 3
done
@@ -151,7 +155,7 @@ test_08_admin_promote_demote() {
local admins
admins=$(wn_b --json groups admins "$mls_gid" 2>/dev/null \
| jq -r '(.result // .) | .[]?.pubkey // .[]?.public_key // .[]?' | tr '\n' ' ')
| jq_list admins | jq_member_ids | tr '\n' ' ')
if [[ "$admins" == *"$A_HEX"* ]]; then
record_result "$id" fail "A still admin after demote"
else
@@ -177,7 +181,8 @@ test_11_leave_group() {
local deadline=$(( $(date +%s) + 120 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
| jq_list admins | jq -e --arg p "$A_HEX" \
'select((.admin_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
gone=1; break
fi
@@ -218,7 +223,8 @@ test_17_group_image_commit() {
# Skip cleanly if A is no longer a member of GROUP_02 (a later test may have removed
# A) — this test only makes sense while A can still commit to the group.
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
| jq_list members | jq -e --arg p "$A_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A not in GROUP_02"; return
fi
+714
View File
@@ -0,0 +1,714 @@
# shellcheck shell=bash
#
# tests-media.sh — tests 20-25.
# Focus: the avatar URL component, message edits, deletions, and
# encrypted-media v2 — each in both directions where the reference CLI can
# drive it.
#
# These all need A and B in one group with A as admin, so they share a group
# built once by the first test that needs it.
# Create (or reuse) the group these tests run in: A creates it, so A is the
# admin who may commit component updates, and B joins.
#
# It is its own group rather than GROUP_02 because by this point in the run A
# has left GROUP_02 and been removed from others, and every check here needs
# both parties actually present.
media_group() {
local gid mls_gid
gid=$(load_state GROUP_MEDIA || true)
mls_gid=$(load_state GROUP_MEDIA_MLS || true)
if [[ -n "${gid:-}" && -n "${mls_gid:-}" ]]; then
printf '%s %s\n' "$gid" "$mls_gid"
return 0
fi
local out
out=$(amy_json marmot group create --name "Interop-Media") || return 1
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || return 1
local b_gid
b_gid=$(wait_for_invite B 60) || return 1
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
save_state GROUP_MEDIA "$gid"
save_state GROUP_MEDIA_MLS "$mls_gid"
printf '%s %s\n' "$gid" "$mls_gid"
}
# Poll wn's view of the group until [jq filter] matches, or time out.
wn_group_field_becomes() {
local mls_gid="$1" filter="$2" want="$3" timeout="${4:-90}"
local deadline=$(( $(date +%s) + timeout )) got
while [[ $(date +%s) -lt $deadline ]]; do
# wn wraps every --json payload in {"ok":…,"result":…}; try inside the
# envelope first and fall back to a bare payload so a future shape change
# does not silently make this poll always fail.
got=$(wn_b_json groups show "$mls_gid" 2>/dev/null \
| jq -r "(.result | $filter) // ($filter) // empty" 2>/dev/null || true)
[[ "$got" == "$want" ]] && return 0
wn_b sync >/dev/null 2>&1 || true
sleep 3
done
printf 'wn_group_field_becomes: %s was %s, wanted %s\n' "$filter" "${got:-<none>}" "$want" >>"$LOG_FILE"
return 1
}
test_20_avatar_url_amy_to_wn() {
banner "Test 20 — amy commits a URL avatar; wn reads it back"
local id="20 avatar-url amy->wn"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
# The stored bytes are the NORMALIZED URL, so this deliberately passes a URL
# that is not: the default port and the dot-segment both have to disappear,
# and both sides have to agree on exactly what is left. A decoder rejects
# state whose bytes differ from its own serialization, so a mismatch here is
# a group wn cannot read at all rather than a cosmetic difference.
local raw="https://Example.COM:443/a/./avatars/../pic.png"
local want="https://example.com/a/pic.png"
local out stored
out=$(amy_json marmot group set-avatar-url "$gid" "$raw" --dim "512x512") || {
record_result "$id" fail "amy set-avatar-url failed"; return
}
stored=$(printf '%s' "$out" | jq -r '.avatar_url // empty')
if [[ "$stored" != "$want" ]]; then
record_result "$id" fail "amy stored '$stored', expected the normalized '$want'"; return
fi
if wn_group_field_becomes "$mls_gid" '.group.avatar_url.url // empty' "$want" 120; then
record_result "$id" pass
else
record_result "$id" fail "wn never saw the URL avatar"
fi
}
test_21_avatar_url_wn_to_amy() {
banner "Test 21 — wn commits a URL avatar; amy reads it back"
local id="21 avatar-url wn->amy"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
# B has to be an admin to commit a component update.
amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy could not promote B"; return
}
sleep 3
wn_b sync >/dev/null 2>&1 || true
local want="https://cdn.example.org/group.png"
if ! wn_b groups set-avatar-url "$mls_gid" --url "$want" >/dev/null 2>&1; then
record_result "$id" fail "wn set-avatar-url failed"; return
fi
local deadline=$(( $(date +%s) + 120 )) got
while [[ $(date +%s) -lt $deadline ]]; do
got=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.avatar_url // empty')
[[ "$got" == "$want" ]] && break
sleep 3
done
if [[ "${got:-}" == "$want" ]]; then
record_result "$id" pass
else
record_result "$id" fail "amy saw '${got:-<none>}', expected '$want'"
fi
}
test_22_message_edit_amy_to_wn() {
banner "Test 22 — amy edits a message; wn receives the 1009 and amy overlays it"
local id="22 edit amy->wn"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
local original="edit-target-frist-post"
local replacement="edit-target-first-post"
local send_json target
send_json=$(amy_json marmot message send "$gid" "$original") || {
record_result "$id" fail "amy send failed"; return
}
target=$(printf '%s' "$send_json" | jq -r '.inner_event_id')
if ! wait_for_message B "$mls_gid" "$original" 90; then
record_result "$id" fail "wn never received the original"; return
fi
if ! amy_json marmot message edit "$gid" "$target" "$replacement" >/dev/null; then
record_result "$id" fail "amy message edit failed"; return
fi
# What is checked on wn's side is that the EDIT EVENT interoperates: a
# kind:1009 carrying exactly one `e` tag naming the target, the replacement
# as its body, authored by A. Whether the reference CLI paints the overlay is
# its rendering choice — MDK's storage deliberately leaves the original row's
# body alone and lets the client compute the chain — so asserting on painted
# text would be testing its TUI, not the protocol.
local deadline=$(( $(date +%s) + 120 )) saw=0
while [[ $(date +%s) -lt $deadline ]]; do
local payload
payload=$(wn_b_json messages list "$mls_gid" --limit 50 2>/dev/null || true)
if [[ -n "$payload" ]] && \
printf '%s' "$payload" | jq_list messages \
| jq -e --arg t "$target" --arg r "$replacement" --arg a "$A_HEX" \
'select(.kind == 1009)
| select((.plaintext // .content // "") == $r)
| select((.pubkey // .author // $a) == $a)
| select([(.tags // [])[] | select(.[0] == "e") | .[1]] == [$t])' \
>/dev/null 2>&1; then
saw=1; break
fi
wn_b sync >/dev/null 2>&1 || true
sleep 3
done
if [[ "$saw" -ne 1 ]]; then
record_result "$id" fail "wn never received a well-formed kind:1009 for the target"; return
fi
# And our own reader must apply it: the target's body reads as the
# replacement and is flagged as edited, with no separate row for the edit.
local body edited
body=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages | jq -r --arg t "$target" 'select(.event_id == $t) | .content' | head -n 1)
edited=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages | jq -r --arg t "$target" 'select(.event_id == $t) | .edited' | head -n 1)
if [[ "$body" == "$replacement" && "$edited" == "true" ]]; then
record_result "$id" pass
else
record_result "$id" fail "amy shows '$body' (edited=$edited) for the edited message"
fi
}
test_23_deletion_amy_to_wn() {
banner "Test 23 — amy deletes a message; wn marks it deleted"
local id="23 deletion amy->wn"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
local doomed="delete-me-from-amethyst"
local send_json target
send_json=$(amy_json marmot message send "$gid" "$doomed") || {
record_result "$id" fail "amy send failed"; return
}
target=$(printf '%s' "$send_json" | jq -r '.inner_event_id')
if ! wait_for_message B "$mls_gid" "$doomed" 90; then
record_result "$id" fail "wn never received the message to delete"; return
fi
if ! amy_json marmot message delete "$gid" "$target" >/dev/null; then
record_result "$id" fail "amy message delete failed"; return
fi
# wn's materialized timeline carries a `deleted` flag per row — that is the
# user-visible truth, and it is what a kind:5 from another implementation has
# to be able to set. The raw event log keeps both events either way.
local deadline=$(( $(date +%s) + 120 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do
local payload
payload=$(wn_b_json messages timeline list "$mls_gid" --limit 50 2>/dev/null || true)
if [[ -n "$payload" ]] && \
printf '%s' "$payload" | jq_list messages \
| jq -e --arg t "$target" \
'select((.message_id // .id // .event_id) == $t) | select(.deleted == true)' \
>/dev/null 2>&1; then
gone=1; break
fi
wn_b sync >/dev/null 2>&1 || true
sleep 3
done
if [[ "$gone" -eq 1 ]]; then
record_result "$id" pass
else
record_result "$id" fail "wn never marked the message deleted"
fi
}
# --- encrypted media v2 (0x800b) --------------------------------------------
# Both directions upload ciphertext to the harness's loopback Blossom store and
# fetch the other side's back. The store never holds a key: the file key comes
# from each group's own MLS exporter, so a successful download that hashes back
# to the original bytes is proof both implementations derived the same one.
media_policy_committed() {
local gid="$1"
if [[ -n "$(load_state MEDIA_POLICY_SET || true)" ]]; then return 0; fi
amy_json marmot media set-policy "$gid" "$BLOSSOM_URL/" >/dev/null || return 1
save_state MEDIA_POLICY_SET 1
sleep 3
wn_b sync >/dev/null 2>&1 || true
return 0
}
test_24_media_v2_amy_to_wn() {
banner "Test 24 — amy sends an encrypted attachment; wn decrypts it"
local id="24 media-v2 amy->wn"
if [[ -z "${BLOSSOM_PID:-}" ]]; then record_result "$id" skip "no blossom blob store"; return; fi
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
if ! media_policy_committed "$gid"; then
record_result "$id" fail "amy could not commit the media policy"; return
fi
local src="$STATE_DIR/media-from-amy.bin"
head -c 4096 /dev/urandom >"$src" 2>/dev/null || printf 'attachment-bytes-from-amethyst' >"$src"
local want_hash
want_hash=$(sha256sum "$src" | cut -d' ' -f1)
local send_json
send_json=$(amy_json marmot media send "$gid" "$src" --caption "from amethyst" --mime "application/octet-stream") || {
record_result "$id" fail "amy media send failed"; return
}
printf 'media24 send=%s\n' "$send_json" >>"$LOG_FILE"
# wn recovers the plaintext by hash. Its `media download` takes the PLAINTEXT
# hash, which is what it also uses to key its own reference index — so
# finding it there at all already proves the imeta tag parsed.
local out="$STATE_DIR/media-to-wn.bin"
local deadline=$(( $(date +%s) + 150 )) ok=1
while [[ $(date +%s) -lt $deadline ]]; do
if wn_b media download "$mls_gid" "$want_hash" --output "$out" >/dev/null 2>&1; then ok=0; break; fi
wn_b sync >/dev/null 2>&1 || true
sleep 5
done
if [[ "$ok" -ne 0 ]]; then
record_result "$id" fail "wn could not download the attachment"; return
fi
if [[ "$(sha256sum "$out" | cut -d' ' -f1)" == "$want_hash" ]]; then
record_result "$id" pass
else
record_result "$id" fail "wn decrypted different bytes than amy sent"
fi
}
test_25_media_v2_wn_to_amy() {
banner "Test 25 — wn sends an encrypted attachment; amy decrypts it"
local id="25 media-v2 wn->amy"
if [[ -z "${BLOSSOM_PID:-}" ]]; then record_result "$id" skip "no blossom blob store"; return; fi
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
if ! media_policy_committed "$gid"; then
record_result "$id" fail "amy could not commit the media policy"; return
fi
local src="$STATE_DIR/media-from-wn.bin"
head -c 4096 /dev/urandom >"$src" 2>/dev/null || printf 'attachment-bytes-from-whitenoise' >"$src"
local want_hash
want_hash=$(sha256sum "$src" | cut -d' ' -f1)
if ! wn_b media upload "$mls_gid" "$src" --send --message "from whitenoise" \
--server "$BLOSSOM_URL/" >/dev/null 2>&1; then
record_result "$id" fail "wn media upload failed"; return
fi
# Find the kind:9 wn just sent, by its caption, and pull the attachment out
# of its imeta tag.
local deadline=$(( $(date +%s) + 150 )) event_id=""
while [[ $(date +%s) -lt $deadline ]]; do
event_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages \
| jq -r 'select((.content // "") == "from whitenoise") | .event_id' | head -n 1)
[[ -n "$event_id" && "$event_id" != "null" ]] && break
sleep 5
done
if [[ -z "$event_id" || "$event_id" == "null" ]]; then
record_result "$id" fail "amy never received wn's media message"; return
fi
local out="$STATE_DIR/media-to-amy.bin"
if ! amy_json marmot media get "$gid" "$event_id" --out "$out" >/dev/null; then
record_result "$id" fail "amy media get failed"; return
fi
if [[ "$(sha256sum "$out" | cut -d' ' -f1)" == "$want_hash" ]]; then
record_result "$id" pass
else
record_result "$id" fail "amy decrypted different bytes than wn sent"
fi
}
# --- 26: disappearing messages ------------------------------------------------
# `marmot.group.message-retention.v1` (0x8005) has the nastiest encoding in the
# component set: eight big-endian bytes with NO length prefix, unlike almost
# every other Marmot field, and MIP-01 spelled it differently. Nothing else
# proves MDK accepts a GroupContext that REQUIRES it with our bytes — and if it
# does not, the failure is not cosmetic: wn cannot read the group at all.
#
# One-directional on purpose. `wn` has no command that sets retention
# (`wn groups` is list/create/show/add-members/remove-members/members/admins/
# relays/leave/rename/set-avatar-url), so the reverse direction is untestable
# here. The encode side is the one that can be wrong anyway. What amy DOES with
# the expiry once it holds one is unit-tested in `MarmotRetentionTest`; this is
# purely "does the other implementation accept and read what we wrote".
test_26_retention_amy_to_wn() {
banner "Test 26 — amy creates a group with disappearing messages; wn reads the policy"
local id="26 retention amy->wn"
local want=3600
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-Retention" --disappearing-secs "$want") || {
record_result "$id" fail "amy group create --disappearing-secs failed"; return
}
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
if [[ -z "$gid" || "$gid" == "null" ]]; then
record_result "$id" fail "amy reported no group id"; return
fi
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy could not invite wn"; return
}
# The Welcome is the real assertion: a group requiring a component wn cannot
# decode is a group wn refuses to join.
local b_gid
b_gid=$(wait_for_invite B 60) || {
record_result "$id" fail "wn never received a Welcome for a group requiring 0x8005"; return
}
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
# wn's CLI `group_json` does not surface the retention value — it is on the
# uniffi group struct the apps consume, not this surface — so the assertion
# is acceptance rather than read-back. That is still the encoding test: the
# group REQUIRES 0x8005, and a required component whose bytes wn cannot
# decode makes the group unreadable, so `groups show` returning it at all
# means our eight big-endian bytes parsed.
if ! wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120; then
record_result "$id" fail "wn never surfaced a group that requires 0x8005"; return
fi
# And the group still works: a required component that decodes but breaks
# messaging would pass the check above and still be useless.
wn_b messages send "$mls_gid" "retention round trip" >/dev/null 2>&1 || {
record_result "$id" fail "wn could not send into the retention group"; return
}
if amy_json marmot await message "$gid" --match "retention round trip" --timeout 90 >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "amy never received wn's message in the retention group"
fi
}
# --- 27: a deletion the other way ---------------------------------------------
# Test 23 proves MDK applies OUR kind:5. This is the direction that was never
# covered, and it is the worse failure of the two: a message its sender believes
# is gone that stays on screen here.
#
# The authorization rule is the whole test. A kind:5 is authorized by ACCOUNT,
# so wn retracting its OWN message must land, and the forged cross-author case
# — which no CLI can send — is pinned in `MarmotEditsAndSystemRowsTest`.
test_27_deletion_wn_to_amy() {
banner "Test 27 — wn deletes its own message; amy marks it deleted"
local id="27 deletion wn->amy"
local gid mls_gid
read -r gid mls_gid < <(media_group) || { record_result "$id" fail "could not build the media group"; return; }
if [[ -z "${gid:-}" ]]; then record_result "$id" fail "could not build the media group"; return; fi
local doomed="delete-me-from-whitenoise"
if ! wn_b messages send "$mls_gid" "$doomed" >/dev/null 2>&1; then
record_result "$id" fail "wn send failed"; return
fi
if ! amy_json marmot await message "$gid" --match "$doomed" --timeout 90 >/dev/null; then
record_result "$id" fail "amy never received the message to delete"; return
fi
# Both sides key the message by the SAME inner event id, so amy's view of it
# is what we hand back to wn's deleter.
local target
target=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages \
| jq -r --arg c "$doomed" 'select((.content // "") == $c) | .event_id' | head -n 1)
if [[ -z "$target" || "$target" == "null" ]]; then
record_result "$id" fail "amy has no event id for wn's message"; return
fi
if ! wn_b messages delete "$mls_gid" "$target" >/dev/null 2>&1; then
record_result "$id" fail "wn messages delete failed"; return
fi
# The row stays, blanked and flagged: "retracted" and "never arrived" are
# different states to a reader, and only one of them is worth telling them
# about.
local deadline=$(( $(date +%s) + 120 )) gone=0 body="unset"
while [[ $(date +%s) -lt $deadline ]]; do
local row
row=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq_list messages | jq -c --arg t "$target" 'select(.event_id == $t)' | head -n 1)
if [[ -n "$row" ]] && printf '%s' "$row" | jq -e 'select(.deleted == true)' >/dev/null 2>&1; then
gone=1
body=$(printf '%s' "$row" | jq -r '.content')
break
fi
sleep 3
done
if [[ "$gone" -ne 1 ]]; then
record_result "$id" fail "amy never marked wn's message deleted"; return
fi
if [[ -n "$body" ]]; then
record_result "$id" fail "amy flagged the message deleted but still shows '$body'"; return
fi
record_result "$id" pass
}
# --- 28: retention, applied to what wn sends ----------------------------------
# Test 26 proves MDK ACCEPTS a group that requires `0x8005` with our bytes. This
# is the read side, and it is the half the epoch-pinning rule lives in: each
# message keeps the retention of the epoch that DELIVERED it, so a later change
# must not shorten, extend or restore an expiry that already exists.
#
# Driving it from wn is the point. Our own sends pin at persist time from state
# this client just wrote; an inbound message arrives under an epoch the group
# may already have moved past, which is exactly where the fallback used to be
# wrong. (`wn` itself has no retention setter — `wn groups` is list/create/show/
# add-members/remove-members/members/admins/relays/leave/rename/set-avatar-url —
# so amy owns the setting and wn owns the sending.)
test_28_retention_wn_to_amy() {
banner "Test 28 — amy re-times a group; wn's messages pin the epoch that delivered them"
local id="28 retention wn->amy"
local short=60 long=86400
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-Retention-Inbound" --disappearing-secs "$short") || {
record_result "$id" fail "amy group create --disappearing-secs failed"; return
}
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
if [[ -z "$gid" || "$gid" == "null" ]]; then
record_result "$id" fail "amy reported no group id"; return
fi
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy could not invite wn"; return
}
local b_gid
b_gid=$(wait_for_invite B 60) || {
record_result "$id" fail "wn never received the Welcome"; return
}
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
local early="retention-inbound-early"
wn_b messages send "$mls_gid" "$early" >/dev/null 2>&1 || {
record_result "$id" fail "wn could not send under the first policy"; return
}
if ! amy_json marmot await message "$gid" --match "$early" --timeout 90 >/dev/null; then
record_result "$id" fail "amy never received wn's first message"; return
fi
# Now move the policy. The commit opens a new epoch, and only messages
# delivered by that epoch take the new duration.
if ! amy_json marmot group set-retention "$gid" "$long" >/dev/null; then
record_result "$id" fail "amy set-retention failed"; return
fi
sleep 3
wn_b sync >/dev/null 2>&1 || true
local late="retention-inbound-late"
local sent=0 attempt
for attempt in 1 2 3 4 5; do
if wn_b messages send "$mls_gid" "$late" >/dev/null 2>&1; then sent=1; break; fi
wn_b sync >/dev/null 2>&1 || true
sleep 5
done
if [[ "$sent" -ne 1 ]]; then
record_result "$id" fail "wn could not send after the retention change"; return
fi
if ! amy_json marmot await message "$gid" --match "$late" --timeout 120 >/dev/null; then
record_result "$id" fail "amy never received wn's second message"; return
fi
# `expires_at` is the value amy PINNED, not a recomputation: created_at plus
# the duration that applied at the delivering epoch.
local rows early_created early_expiry late_created late_expiry
rows=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null | jq_list messages)
early_created=$(printf '%s' "$rows" | jq -r --arg c "$early" 'select((.content // "") == $c) | .created_at' | head -n 1)
early_expiry=$(printf '%s' "$rows" | jq -r --arg c "$early" 'select((.content // "") == $c) | .expires_at' | head -n 1)
late_created=$(printf '%s' "$rows" | jq -r --arg c "$late" 'select((.content // "") == $c) | .created_at' | head -n 1)
late_expiry=$(printf '%s' "$rows" | jq -r --arg c "$late" 'select((.content // "") == $c) | .expires_at' | head -n 1)
printf 'retention28 early=%s/%s late=%s/%s\n' \
"${early_created:-?}" "${early_expiry:-?}" "${late_created:-?}" "${late_expiry:-?}" >>"$LOG_FILE"
if [[ -z "$early_expiry" || "$early_expiry" == "null" || -z "$late_expiry" || "$late_expiry" == "null" ]]; then
record_result "$id" fail "amy pinned no expiry for one of wn's messages"; return
fi
if [[ "$early_expiry" -ne $(( early_created + short )) ]]; then
record_result "$id" fail "the first message expires at $early_expiry, wanted $(( early_created + short ))"; return
fi
if [[ "$late_expiry" -ne $(( late_created + long )) ]]; then
record_result "$id" fail "the second message expires at $late_expiry, wanted $(( late_created + long ))"; return
fi
record_result "$id" pass
}
# --- 29: disband ---------------------------------------------------------------
# The terminal state, and the one with no way back: there is no un-disband
# commit, no later branch supersedes it, and a replacement conversation is a new
# MLS group with a new id. Two implementations disagreeing about whether a group
# ended is unrecoverable by construction, which is why it is worth a test even
# though it can only run one way.
#
# `group-lifecycle-v1.md` fixes the whole Commit shape — the lifecycle update,
# an admin-policy replacement naming only the committer, and a Remove for every
# other leaf — and MDK validates all of it before applying anything. A Commit
# carrying only the lifecycle update, which is what we used to send, is rejected
# as an unsupported lifecycle transition, so wn ACCEPTING this one is the
# assertion.
#
# What that acceptance looks like through `wn` is narrower than you would hope,
# and the narrowing is not our side being coy:
#
# - the group does not disappear. The spec has a disbanded client keep a
# read-only authenticated tombstone, and MDK keeps listing it.
# - the app-level projection freezes. `groups members` and `groups admins`
# keep reporting the last state in which wn held a leaf, because this Commit
# removes that leaf — so neither moves, however the Commit is handled.
# - `wn` does not print the state that would say it outright. MDK's
# `AppGroupMlsState` carries `lifecycle_state` and `disbanding_enabled`, and
# its uniffi surface hands both to the apps, but the CLI's
# `group_mls_state_json` emits only group_id/epoch/member_count/
# required_app_components.
#
# That leaves the MLS epoch, and it is enough: the disband is the only Commit
# published in the window, both sides are made to agree on the epoch before it,
# and a wn that refused it stays where it was.
#
# One direction only: MDK exposes disband on its uniffi surface (the apps call
# `disband_group`) but `wn groups` has no verb for it, so wn cannot originate
# one here.
test_29_disband_amy_to_wn() {
banner "Test 29 — amy disbands a group; wn accepts the terminal commit"
local id="29 disband amy->wn"
# Its own group, and the LAST thing that happens to it: disband is absorbing,
# so nothing else can be tested in a group afterwards.
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-Disband") || {
record_result "$id" fail "amy group create failed"; return
}
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
if [[ -z "$gid" || "$gid" == "null" ]]; then
record_result "$id" fail "amy reported no group id"; return
fi
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy could not invite wn"; return
}
local b_gid
b_gid=$(wait_for_invite B 60) || {
record_result "$id" fail "wn never received the Welcome"; return
}
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
# A message first, so wn is demonstrably live in the group before the end —
# otherwise a quiet wn afterwards could just mean it never joined.
local alive="before-the-end"
amy_json marmot message send "$gid" "$alive" >/dev/null || {
record_result "$id" fail "amy could not send into the group"; return
}
if ! wait_for_message B "$mls_gid" "$alive" 90; then
record_result "$id" fail "wn never joined the group properly"; return
fi
# Agree on the epoch before committing anything terminal. This is both the
# baseline the assertion below reads against and what a real client does — it
# reads the group before acting on it — and it matters more here than
# anywhere else: an ordinary commit off a stale epoch is survivable because
# convergence settles it, and this one is not, since a disbanded client stops
# processing group traffic by design and can never learn it lost a branch.
local wn_epoch amy_epoch before_epoch=""
local settle=$(( $(date +%s) + 120 ))
while [[ $(date +%s) -lt $settle ]]; do
wn_epoch=$(wn_b_json groups show "$mls_gid" 2>/dev/null | jq -r '.result.mls.epoch // empty')
amy_epoch=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.epoch // empty')
if [[ -n "$wn_epoch" && "$wn_epoch" == "$amy_epoch" ]]; then before_epoch="$amy_epoch"; break; fi
wn_b sync >/dev/null 2>&1 || true
sleep 5
done
if [[ -z "$before_epoch" ]]; then
record_result "$id" fail "amy (epoch ${amy_epoch:-?}) and wn (epoch ${wn_epoch:-?}) never agreed before the disband"
return
fi
# Retry a disband the relay never acknowledged. That is not a protocol
# failure — the commit stays a queued publish obligation and the group stays
# live, exactly as `disbandGroup` reports — and a real client tries again. The
# loopback relay drops a connection often enough under a full-suite run to be
# worth spelling out rather than reading as a conformance failure.
local disbanded=0 attempt
for attempt in 1 2 3; do
if amy_json marmot group disband "$gid" --yes >/dev/null; then disbanded=1; break; fi
sleep 10
done
if [[ "$disbanded" -ne 1 ]]; then
record_result "$id" fail "amy group disband failed"; return
fi
# Terminal here, and the tree is down to the committing leaf — the Remove
# half of the shape, which the reference client cannot show us but our own
# state can.
local after after_epoch members
after=$(amy_json marmot group show "$gid" 2>/dev/null || true)
after_epoch=$(printf '%s' "$after" | jq -r '.epoch // empty')
members=$(printf '%s' "$after" | jq -r '.members | length')
if [[ "$(printf '%s' "$after" | jq -r '.disbanded // false')" != "true" ]]; then
record_result "$id" fail "amy does not read its own group as disbanded"; return
fi
if [[ "$members" != "1" ]]; then
record_result "$id" fail "amy kept $members leaves after the disband; the shape requires only the committer"; return
fi
if [[ -z "$after_epoch" || "$after_epoch" == "$before_epoch" ]]; then
record_result "$id" fail "amy's epoch did not advance past $before_epoch"; return
fi
# Converge, don't snapshot. MDK rotates its own leaf shortly after joining,
# so it can commit between the epoch gate above and the disband below — and
# then the two have forked. The protocol's answer is not "the disband lands
# first time": it is that the REQUEST survives, is regenerated against
# whichever branch was selected, and lands eventually. Asserting the
# race-free happy path made this test fail on a busy machine for a case the
# spec explicitly allows, so the loop re-reads amy's epoch each round —
# regeneration moves it — and drives amy's own sync, which is what carries a
# pass to settlement and re-issues a disband that lost.
local deadline=$(( $(date +%s) + 240 )) accepted=0 saw="" amy_now="$after_epoch"
while [[ $(date +%s) -lt $deadline ]]; do
amy_now=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.epoch // empty')
saw=$(wn_b_json groups show "$mls_gid" 2>/dev/null | jq -r '.result.mls.epoch // empty')
if [[ -n "$saw" && -n "$amy_now" && "$saw" == "$amy_now" ]]; then accepted=1; break; fi
wn_b sync >/dev/null 2>&1 || true
sleep 5
done
printf 'disband29 epoch %s -> %s (amy now %s), wn at %s\n' \
"$before_epoch" "$after_epoch" "${amy_now:-?}" "${saw:-<none>}" >>"$LOG_FILE"
if [[ "$accepted" -ne 1 ]]; then
record_result "$id" fail "wn stayed at epoch ${saw:-<none>} while amy is at ${amy_now:-?} — the disband never converged"
return
fi
# Converged — and amy must still read the group as ended. A disband that
# lost its branch and was never regenerated would agree on an epoch here
# while leaving the group live, which is the failure worth catching.
if [[ "$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.disbanded // false')" != "true" ]]; then
record_result "$id" fail "amy and wn agree on epoch ${saw} but the group is not disbanded"
return
fi
record_result "$id" pass
}
@@ -2651,6 +2651,23 @@
<string name="marmot_create_group">Create Group</string>
<string name="marmot_create_group_title">Create Marmot Group</string>
<string name="marmot_create_group_footer">A new MLS group will be created. You can add members after.</string>
<string name="marmot_retention_title">Disappearing messages</string>
<string name="marmot_retention_off">Off</string>
<string name="marmot_retention_1h">1 hour</string>
<string name="marmot_retention_1d">1 day</string>
<string name="marmot_retention_1w">1 week</string>
<string name="marmot_retention_active">Messages disappear after %1$s</string>
<string name="marmot_retention_footer">Messages are deleted from every member's device after this long. It cannot be changed later.</string>
<string name="marmot_disband_group">Disband group</string>
<string name="marmot_disband_group_confirm">Disband "%1$s" for everyone? The conversation ends for every member and cannot be reopened — a new group would have to be created.</string>
<string name="marmot_disband_group_action">Disband</string>
<string name="marmot_avatar_url">Avatar link</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_avatar_url_footer">An https link every member can load. Leave it empty to use the uploaded image instead.</string>
<string name="marmot_legacy_group_no_avatar_url">This group was created before link avatars were supported, so it can only use an uploaded image. Existing groups cannot be upgraded — create a new group to use a link.</string>
<string name="marmot_enable_encrypted_media">Use encrypted attachments</string>
<string name="marmot_enable_encrypted_media_explainer">Attachments in this group use the older format. Switching adds the encrypted-media component to the group, and every member sees the change. It cannot be switched back.</string>
<string name="marmot_legacy_group_no_disband">This group was created before disbanding was supported and cannot be disbanded. You can still leave it, or remove every other member. Existing groups cannot be upgraded.</string>
<string name="marmot_group_default_name">Marmot Group</string>
<string name="marmot_group_fallback_name">Group %1$s…</string>
<string name="marmot_user_fallback_name">%1$s…</string>
@@ -2668,6 +2685,24 @@
<string name="marmot_group_description_placeholder">Enter group description (optional)</string>
<string name="marmot_edit_info_footer">Changes will be committed to the group via MLS and propagated to all members.</string>
<string name="marmot_group_icon">Group icon</string>
<!-- Marmot kind:1210 group system rows. Rendered as centered captions in the
conversation, from the row's structured fields. %1$s is the member who
committed the change, %2$s the member it concerns, %3$s the new name. -->
<string name="marmot_system_member_added">%1$s added %2$s</string>
<string name="marmot_system_member_added_passive">%1$s joined</string>
<string name="marmot_system_member_removed">%1$s removed %2$s</string>
<string name="marmot_system_member_removed_passive">%1$s was removed</string>
<string name="marmot_system_member_left">%1$s left</string>
<string name="marmot_system_admin_added">%1$s made %2$s an admin</string>
<string name="marmot_system_admin_added_passive">%1$s is now an admin</string>
<string name="marmot_system_admin_removed">%1$s removed %2$s as an admin</string>
<string name="marmot_system_admin_removed_passive">%1$s is no longer an admin</string>
<string name="marmot_system_group_renamed">%1$s renamed the group to %2$s</string>
<string name="marmot_system_group_renamed_passive">The group was renamed to %1$s</string>
<string name="marmot_system_avatar_changed">%1$s changed the group avatar</string>
<string name="marmot_system_avatar_changed_passive">The group avatar changed</string>
<string name="marmot_system_group_disbanded">%1$s disbanded the group</string>
<string name="marmot_system_group_disbanded_passive">The group was disbanded</string>
<string name="marmot_remove_photo">Remove photo</string>
<string name="marmot_keypackage_relays_not_set_title">KeyPackage Relays not set</string>
<string name="marmot_keypackage_relays_not_set_message">You don't have a KeyPackage Relay List yet (MIP-00). This list tells other people where your KeyPackage is published so they can invite you to group chats.\n\nUse your current outbox relays for this?</string>
@@ -0,0 +1,257 @@
/*
* 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.appComponents.agentTextStream.AgentTextStreamFinal
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamStart
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamSubscriber
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStatus
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* What a front end renders for one live agent text stream.
*
* [isConfirmed] is the only thing that licenses showing this as ordinary
* content. Until the durable kind:9 lands and its transcript matches what we
* folded, this is provisional and a renderer MUST make it visibly distinct —
* every record can open individually and the stream still be wrong, if one was
* dropped, reordered or injected.
*/
class AgentStreamPreview(
val streamId: HexKey,
val startEventId: HexKey,
/** The account that anchored the stream — not necessarily the group's agent. */
val author: HexKey,
val text: String,
val status: PreviewStatus,
/** Latest `Status` label, for chrome. Never part of the answer text. */
val statusLabel: String? = null,
/** Latest `ProgressDelta`, for chrome. Never part of the answer text. */
val progressLabel: String? = null,
val isConfirmed: Boolean = false,
)
/**
* Watches one Marmot group for an agent text stream and exposes it as UI state.
*
* The live preview is a progressive enhancement, and every failure here is
* meant to look like "no preview" rather than like a broken group: a group
* with no broker candidate, a candidate that will not connect, a platform with
* no QUIC at all, or a stream type we do not implement all end the same way —
* the group still works and the final kind:9 still arrives as normal chat.
*
* [transport] is null on a platform that cannot open a raw QUIC connection.
* That is a supported configuration, not a degraded one: `receive` explicitly
* does not require implementing the QUIC data plane.
*/
class MarmotAgentStreamWatcher(
private val marmot: MarmotManager,
private val transport: MarmotQuicTransport?,
private val scope: CoroutineScope,
) {
private val mutable = MutableStateFlow<AgentStreamPreview?>(null)
val preview: StateFlow<AgentStreamPreview?> = mutable.asStateFlow()
private val mutex = Mutex()
private var job: Job? = null
private var watchingStreamId: HexKey? = null
private var subscriber: AgentTextStreamSubscriber? = null
/**
* Start (or keep) watching the newest agent text stream in [nostrGroupId].
*
* Idempotent: calling it again for a stream already being watched does
* nothing, so a front end can call it on every feed update.
*/
suspend fun watchLatest(nostrGroupId: HexKey) {
// Resolve first: the durable message may already be in the log (a
// catch-up sync delivers the whole stream at once), and a preview we
// can no longer improve should be settled before we open a socket.
resolveAgainstStoredFinal(nostrGroupId)
if (transport == null) return
val anchor = findLatestStart(nostrGroupId) ?: return
val (startEvent, start) = anchor
// A stream type or route we do not implement is not an error: ignore
// the live route and let the final message do its job.
if (!start.isTextProfile || !start.isQuicRoute || start.brokerCandidates.isEmpty()) return
mutex.withLock {
if (watchingStreamId == start.streamId) return@withLock
job?.cancel()
watchingStreamId = start.streamId
mutable.value = null
job = scope.launch { follow(nostrGroupId, startEvent, start) }
}
}
/**
* A durable kind:9 closed a stream out. Confirms the preview when our fold
* agrees with it, and drops the preview when it does not — a disagreement
* means we rendered something the publisher did not send, so the durable
* message is the only thing that should remain on screen.
*/
fun onFinal(
streamId: HexKey,
transcriptHash: HexKey,
chunkCount: Long,
) {
val current = mutable.value ?: return
if (!current.streamId.equals(streamId, ignoreCase = true)) return
val folded = subscriber
val matches = folded != null && folded.matchesFinal(transcriptHash.hexToByteArray(), chunkCount)
mutable.value = if (matches) AgentStreamPreviewCopy.confirmed(current) else null
if (!matches) {
Log.d("MarmotAgentStreamWatcher") {
"stream ${streamId.take(8)}… did not match its final message — dropping the preview"
}
}
}
/**
* Apply the durable kind:9 for the stream being previewed, if the group's
* log already holds it.
*
* A front end only has to say "the feed moved"; deciding whether a preview
* is confirmed, contradicted or still pending is this class's job, and
* keeping it here is what makes it testable without a UI.
*/
private suspend fun resolveAgainstStoredFinal(nostrGroupId: HexKey) {
val current = mutable.value ?: return
for (line in marmot.loadStoredMessages(nostrGroupId)) {
val parsed = Event.fromJsonOrNull(line) ?: continue
if (parsed.kind != AgentTextStreamStart.FINAL_KIND_TEXT) continue
val final = AgentTextStreamFinal.fromTags(parsed.tags) ?: continue
if (!final.streamId.equals(current.streamId, ignoreCase = true)) continue
onFinal(final.streamId, final.transcriptHash, final.chunkCount)
return
}
}
/** Stop watching and clear the preview. */
fun stop() {
job?.cancel()
job = null
watchingStreamId = null
subscriber = null
mutable.value = null
}
private suspend fun follow(
nostrGroupId: HexKey,
startEvent: Event,
start: AgentTextStreamStart,
) {
val quic = transport ?: return
// The epoch that DELIVERED the anchor, not the group's current one —
// the record key context binds it, and a commit landing in between
// would otherwise derive a key nobody else is using.
val epoch = marmot.storedEpochs(nostrGroupId)[startEvent.id]
val crypto =
try {
marmot.agentTextStreamCrypto(
nostrGroupId = nostrGroupId,
streamId = start.streamId.hexToByteArray(),
startEventId = startEvent.id.hexToByteArray(),
senderPubKey = startEvent.pubKey,
epoch = epoch,
)
} catch (e: Exception) {
Log.w("MarmotAgentStreamWatcher", "cannot derive stream keys for $nostrGroupId", e)
return
}
val folding = AgentTextStreamSubscriber(crypto)
subscriber = folding
// "A receiver tries advertised candidates in listed order"; the first
// that yields the matching stream wins, and one that fails is simply
// skipped.
for (candidate in start.brokerCandidates) {
val stream =
try {
quic.subscribe(candidate, start.streamId.hexToByteArray(), startEvent.id.hexToByteArray())
} catch (e: Exception) {
Log.d("MarmotAgentStreamWatcher") { "candidate $candidate unusable: ${e.message}" }
continue
}
try {
stream.incoming().collect { record ->
folding.accept(record)
mutable.value =
AgentStreamPreview(
streamId = start.streamId,
startEventId = startEvent.id,
author = startEvent.pubKey,
text = folding.previewText,
status = folding.status,
statusLabel = folding.latestStatus,
progressLabel = folding.latestProgress,
isConfirmed = false,
)
}
} catch (e: Exception) {
Log.d("MarmotAgentStreamWatcher") { "stream from $candidate ended: ${e.message}" }
} finally {
runCatching { stream.close() }
}
return
}
}
/** Newest kind:1200 in the group's decrypted log, with its own event. */
private suspend fun findLatestStart(nostrGroupId: HexKey): Pair<Event, AgentTextStreamStart>? {
var best: Pair<Event, AgentTextStreamStart>? = null
for (line in marmot.loadStoredMessages(nostrGroupId)) {
val parsed = Event.fromJsonOrNull(line) ?: continue
val start = AgentTextStreamStart.fromTags(parsed.kind, parsed.tags) ?: continue
if (best == null || parsed.createdAt >= best.first.createdAt) best = parsed to start
}
return best
}
}
private object AgentStreamPreviewCopy {
fun confirmed(p: AgentStreamPreview) =
AgentStreamPreview(
streamId = p.streamId,
startEventId = p.startEventId,
author = p.author,
text = p.text,
status = p.status,
statusLabel = p.statusLabel,
progressLabel = p.progressLabel,
isConfirmed = true,
)
}
@@ -78,8 +78,19 @@ sealed class MarmotIngestResult {
val retainedEpochCount: Int,
) : MarmotIngestResult()
/** Deduplicate / out-of-order commits / unsupported content. Not an error. */
data object Ignored : MarmotIngestResult()
/**
* Deduplicate / out-of-order commits / unsupported content. Not an error.
*
* [reason] names the branch that produced it. Several very different
* situations land here — a replayed event we already merged, a commit held
* for convergence, an app message decrypted on a losing branch, traffic in
* a group we have terminalized — and collapsing them into one unlabelled
* result made a stuck client indistinguishable from a quiet one in the
* logs.
*/
data class Ignored(
val reason: String,
) : MarmotIngestResult()
/** Something blew up. Callers log. */
data class Failure(
@@ -102,22 +113,49 @@ suspend fun MarmotManager.ingest(event: Event): MarmotIngestResult =
when (event) {
is GiftWrapEvent -> ingestGiftWrap(event)
is GroupEvent -> ingestGroupEvent(event)
else -> MarmotIngestResult.Ignored
else -> MarmotIngestResult.Ignored("unhandled kind ${event.kind}")
}
private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult =
private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult {
// A relay `since` cursor cannot skip a backdated event, and NIP-59 wraps
// are backdated by up to two days on purpose — so without a durable marker
// every wrap in that band is unwrapped and re-decided on every single sync.
if (isTerminallyIngested(wrap.id)) return MarmotIngestResult.Ignored("already ingested")
val result = ingestGiftWrapUncached(wrap)
when (result) {
// Joined, or already in the group: nothing more can come of this wrap.
is MarmotIngestResult.JoinedGroup, is MarmotIngestResult.AlreadyInGroup -> markTerminallyIngested(wrap.id)
// A Welcome naming a KeyPackage whose private half we never held can
// never become processable: bundles are generated locally BEFORE the
// KeyPackage is published, so one we do not have is one we never will.
is MarmotIngestResult.Failure ->
if (result.message.contains("No matching KeyPackageBundle")) markTerminallyIngested(wrap.id)
else -> Unit
}
return result
}
private suspend fun MarmotManager.ingestGiftWrapUncached(wrap: GiftWrapEvent): MarmotIngestResult =
try {
// NIP-59 wraps carry two encryption layers (kind:1059 → kind:13 → rumor).
// [unwrapAndUnsealOrNull] peels both so we land directly on the inner
// kind:444 Welcome rumor. Checking `isWelcomeEvent` on the seal itself
// (the old bug) always took the Ignored branch and silently dropped
// every inbound Welcome.
val rumor = wrap.unwrapAndUnsealOrNull(signer) ?: return MarmotIngestResult.Ignored
val rumor = wrap.unwrapAndUnsealOrNull(signer) ?: return MarmotIngestResult.Ignored("gift wrap is not for us")
if (!MarmotInboundProcessor.isWelcomeEvent(rumor) || rumor !is WelcomeEvent) {
return MarmotIngestResult.Ignored
return MarmotIngestResult.Ignored("gift wrap does not carry a Welcome")
}
when (val result = processWelcome(rumor, rumor.nostrGroupId())) {
is WelcomeResult.Joined -> {
// Establish the baseline for this group's system rows without
// writing any: a joiner announcing every existing member as
// newly added would be a timeline full of events that never
// happened.
recordRetentionForCurrentEpoch(result.nostrGroupId)
syncGroupSystemRows(result.nostrGroupId)
MarmotIngestResult.JoinedGroup(
nostrGroupId = result.nostrGroupId,
needsKeyPackageRotation = result.needsKeyPackageRotation,
@@ -141,11 +179,21 @@ private suspend fun MarmotManager.ingestGroupEvent(ge: GroupEvent): MarmotIngest
is GroupEventResult.ApplicationMessage -> {
// MLS ratchets once we decrypt; future reads of the same ciphertext
// would fail — persist the plaintext now so restarts/replays see it.
persistDecryptedMessage(result.groupId, result.innerEventJson)
persistDecryptedMessage(result.groupId, result.innerEventJson, result.epoch)
// Traffic is the natural clock for expiry: a group that is being
// read is a group whose expired messages should already be gone.
pruneExpiredMessages(result.groupId)
MarmotIngestResult.Message(result)
}
is GroupEventResult.CommitProcessed -> {
// The epoch just advanced, so whatever this commit changed about
// the group is now canonical state — which is exactly what a
// kind:1210 row is derived from. Deriving here rather than at
// render time means the rows land in the same log as the messages
// they sit between, in the order they happened.
recordRetentionForCurrentEpoch(result.groupId)
syncGroupSystemRows(result.groupId)
MarmotIngestResult.Commit(result)
}
@@ -153,11 +201,24 @@ private suspend fun MarmotManager.ingestGroupEvent(ge: GroupEvent): MarmotIngest
MarmotIngestResult.ProposalStaged(result.groupId, result.senderLeafIndex)
}
is GroupEventResult.Duplicate,
is GroupEventResult.CommitPending,
-> {
MarmotIngestResult.Ignored
}
is GroupEventResult.Duplicate -> MarmotIngestResult.Ignored("already merged")
// Held, not dropped: a commit we cannot advance onto linearly is
// candidate material for a convergence pass that has to settle before
// it can be applied. Saying so matters — this is the one Ignored that
// means "come back", and a client that never settles repeats it
// forever while looking idle.
is GroupEventResult.CommitPending -> MarmotIngestResult.Ignored("commit held for convergence")
// Decrypted only on a losing branch: real protocol input (it may have
// witnessed for that branch), but never application output.
is GroupEventResult.AppMessageOnCandidateBranch ->
MarmotIngestResult.Ignored("app message on a candidate branch")
// Disbanded or locally unrecoverable — refused before decryption, so
// there is nothing to deliver and nothing to retain.
is GroupEventResult.RefusedByLifecycle ->
MarmotIngestResult.Ignored("refused by lifecycle ${result.lifecycle}")
is GroupEventResult.UndecryptableOuterLayer -> {
MarmotIngestResult.UndecryptableOuter(result.groupId, result.retainedEpochCount)
File diff suppressed because it is too large Load Diff
@@ -18,13 +18,24 @@
* 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.quartz.marmot.mip05PushNotifications
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTag
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTagData
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun <T : Event> TagArrayBuilder<T>.tokens(tokens: List<TokenTagData>) = addAll(TokenTag.assemble(tokens))
fun <T : Event> TagArrayBuilder<T>.token(data: TokenTagData) = add(TokenTag.assemble(data))
/**
* Publishes an event and reports whether the publish obligation succeeded.
*
* `protocol-core/publish-lifecycle.md` sets the bar: at least one endpoint in
* the recipient scope must return an ACKNOWLEDGED ACCEPT. Over the Nostr
* transport that is an `OK true` from a relay. Returning true for "queued",
* "sent", or "no error yet" would defeat the whole rule — the point is that
* some peer can now learn the new epoch, and a write nobody accepted gives no
* such assurance.
*/
fun interface MarmotPublisher {
suspend fun publish(
event: Event,
relays: Set<NormalizedRelayUrl>,
): Boolean
}
@@ -0,0 +1,432 @@
/*
* 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.mip05PushNotifications.InMemoryPushStateStore
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.MarmotPushStateStore
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.NotificationRequestEvent
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushBase64
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushGossip
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushOwnerProof
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushPlatform
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushRecordKind
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushRecordStore
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushRemovalEntry
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushSignedRecord
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushStateCodec
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushTokenEntry
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenEncryption
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenListEvent
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRemovalEvent
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Push token gossip for the groups this client is in
* (`features/push-notifications.md`).
*
* ## What it owns, and what it deliberately does not
*
* It produces and consumes kinds `447`/`448`/`449` and assembles the kind `446`
* trigger rumor. It does NOT publish anything: the trigger's NIP-59 seal, its
* recipient addressing and its publish targets belong to the Nostr binding, and
* the gossip events are ordinary group messages the caller sends like any
* other.
*
* It also does not decide whether push is enabled. A device token and a
* notification server public key both come from the application — a server can
* only wake the app whose platform credentials it holds, so there is no
* protocol-level discovery to do here.
*
* ## Nothing here can affect a group
*
* Every failure in this file is advisory. A malformed entry, an unverifiable
* signature, a removal matching nothing, a stale list — all drop the datum and
* continue. None of it may reject a group message, mutate MLS state, or change
* which commit wins, and the code is shaped so it cannot: the coordinator never
* throws at its callers on bad input, it returns "nothing changed".
*/
class MarmotPushCoordinator(
private val manager: MarmotManager,
/**
* Durable per-group state. The default forgets tombstones on restart,
* which lets a relayed but revoked record win exactly once — acceptable
* for a CLI, not for a phone.
*/
private val stateStore: MarmotPushStateStore = InMemoryPushStateStore(),
) {
private val mutex = Mutex()
private val stores = mutableMapOf<HexKey, PushRecordStore>()
/** The active records this client believes in for a group. */
suspend fun activeRecords(nostrGroupId: HexKey): List<PushTokenEntry> = mutex.withLock { storeFor(nostrGroupId)?.active().orEmpty() }
// ------------------------------------------------------------- producing
/**
* Encrypt this device's token to [serverPubKeyHex], sign the owner proof,
* and build the kind `447` self-update that announces it.
*
* The record is applied locally first so a later kind `448` of ours carries
* it, and so a stale relay of an older record of ours loses on arrival.
*
* @return the inner event to send into the group, or null when this client
* is not a member of the group or holds no leaf in it.
*/
suspend fun buildSelfUpdate(
nostrGroupId: HexKey,
platform: PushPlatform,
deviceToken: ByteArray,
serverPubKeyHex: HexKey,
relayHint: String = "",
ownerTsMillis: Long = TimeUtils.nowMillis(),
): Event? {
val entry =
signOwnRecord(nostrGroupId, platform, deviceToken, serverPubKeyHex, relayHint, ownerTsMillis)
?: return null
mutex.withLock {
val store = storeFor(nostrGroupId) ?: return null
store.applyTokens(listOf(entry), TimeUtils.nowMillis(), memberCheck(nostrGroupId))
persist(nostrGroupId, store)
}
return rumor(TokenRequestEvent.build(listOf(entry)))
}
/** The empty kind `447`: "share the records you hold with me." */
fun buildTokenRequest(): Event = rumor(TokenRequestEvent.buildRequest())
/**
* Build the kind `448` answer to a request: every active record we hold,
* including other members' records, with their signatures untouched.
*
* Null when we hold nothing to say — an empty list response is noise.
* Records beyond the 32-entry cap are dropped rather than split, because a
* responder is a convenience path and the owners will re-announce.
*/
suspend fun buildTokenList(nostrGroupId: HexKey): Event? {
val records = mutex.withLock { storeFor(nostrGroupId)?.active().orEmpty() }
if (records.isEmpty()) return null
return rumor(TokenListEvent.build(records.take(PushGossip.MAX_ENTRIES)))
}
/**
* Sign and build the kind `449` that revokes this device's record on
* [serverPubKeyHex].
*
* [deviceToken] is needed even though the token is not in the removal: the
* fingerprint is, and it is what states which token instance the owner
* meant to revoke.
*/
suspend fun buildRemoval(
nostrGroupId: HexKey,
platform: PushPlatform,
deviceToken: ByteArray,
serverPubKeyHex: HexKey,
ownerTsMillis: Long = TimeUtils.nowMillis(),
): Event? {
val groupIdHex = manager.mlsGroupIdHex(nostrGroupId) ?: return null
val leafIndex = manager.leafIndexOf(nostrGroupId, manager.signer.pubKey) ?: return null
val fingerprint = PushSignedRecord.fingerprintOf(platform, deviceToken)
val ownerSig =
proof(PushRecordKind.REMOVAL, groupIdHex, leafIndex, platform, serverPubKeyHex, fingerprint, ownerTsMillis)
?: return null
val entry =
PushRemovalEntry(
memberIdHex = manager.signer.pubKey,
leafIndex = leafIndex,
platform = platform,
tokenFingerprint = fingerprint,
serverPubKeyHex = serverPubKeyHex,
ownerTsMillis = ownerTsMillis,
ownerSig = ownerSig,
)
mutex.withLock {
val store = storeFor(nostrGroupId) ?: return null
store.applyRemovals(listOf(entry), TimeUtils.nowMillis(), memberCheck(nostrGroupId))
persist(nostrGroupId, store)
}
return rumor(TokenRemovalEvent.build(listOf(entry)))
}
/**
* The kind `446` trigger rumor for a group's active records, or null when
* there is nothing to wake.
*
* [padding] chunks of uniform random bytes are appended to obscure the real
* recipient count from anyone watching the gift wrap's length. They are
* indistinguishable from tokens to an observer and merely fail to decrypt
* at the server — which is why a real token must never be used as padding:
* it would fire a wake with no content behind it.
*
* The caller seals and wraps this to the notification server; nothing here
* publishes.
*/
suspend fun buildTrigger(
nostrGroupId: HexKey,
serverPubKeyHex: HexKey,
padding: Int = 0,
): Event? {
val chunks =
mutex
.withLock { storeFor(nostrGroupId)?.active().orEmpty() }
.filter { it.serverPubKeyHex == serverPubKeyHex }
.map { it.encryptedToken }
if (chunks.isEmpty()) return null
val padded =
(chunks + List(padding) { RandomInstance.bytes(PushSignedRecord.ENCRYPTED_TOKEN_BYTES) })
.take(NotificationRequestEvent.MAX_CHUNKS)
.shuffled()
// A fresh ephemeral key per trigger, so the server cannot link two
// triggers to one sender — and cannot dedup on the outer event id
// either, which is why the spec keys dedup on the content hash.
val ephemeral = RandomInstance.bytes(32)
val ephemeralPubKey = Nip01Crypto.pubKeyCreate(ephemeral).toHexKey()
return RumorAssembler.assembleRumor(ephemeralPubKey, NotificationRequestEvent.build(padded))
}
// ------------------------------------------------------------- consuming
/**
* Feed one decrypted inner app event to the push state.
*
* Returns true when a stored record changed, so a caller can decide whether
* to answer a request or re-persist. A non-push kind, an unreadable
* payload, and an entry that lost its ordering race all return false and
* are indistinguishable on purpose — none of them is an error.
*/
suspend fun apply(
nostrGroupId: HexKey,
innerEvent: Event,
): Boolean =
try {
when (innerEvent.kind) {
TokenRequestEvent.KIND, TokenListEvent.KIND ->
applyChange(nostrGroupId) { store, now, isMember ->
store.applyTokens(PushGossip.decodeTokens(innerEvent.content), now, isMember)
}
TokenRemovalEvent.KIND ->
applyChange(nostrGroupId) { store, now, isMember ->
store.applyRemovals(PushGossip.decodeRemovals(innerEvent.content), now, isMember)
}
else -> false
}
} catch (e: Exception) {
// Push is advisory end to end: a surprise here must never reach the
// ingest path that decides whether the carrying group message was
// valid.
Log.w("MarmotPushCoordinator", "dropping unreadable push payload in $nostrGroupId", e)
false
}
/** True when [innerEvent] is a kind `447` asking others to share their records. */
fun isTokenRequest(innerEvent: Event): Boolean = innerEvent.kind == TokenRequestEvent.KIND && PushGossip.decodeTokens(innerEvent.content).isEmpty()
/**
* Forget a leaf an accepted Commit removed — record, stamp and tombstone.
*
* Nothing that leaf signed can be applied again, so the durable high-water
* mark has no work left to do. A sibling leaf of the same account keeps
* its own records: different key, still a member.
*/
suspend fun forgetLeaf(
nostrGroupId: HexKey,
memberIdHex: HexKey,
leafIndex: Int,
) {
mutex.withLock {
val store = storeFor(nostrGroupId) ?: return
store.forgetLeaf(memberIdHex, leafIndex)
persist(nostrGroupId, store)
}
}
suspend fun forgetGroup(nostrGroupId: HexKey) {
mutex.withLock {
stores.remove(nostrGroupId)
stateStore.clear(nostrGroupId)
}
}
// ------------------------------------------------------------- internals
private suspend fun applyChange(
nostrGroupId: HexKey,
change: (PushRecordStore, Long, (HexKey) -> Boolean) -> Set<*>,
): Boolean =
mutex.withLock {
val store = storeFor(nostrGroupId) ?: return false
val changed = change(store, TimeUtils.nowMillis(), memberCheck(nostrGroupId))
if (changed.isNotEmpty()) persist(nostrGroupId, store)
changed.isNotEmpty()
}
/**
* Membership is read from the MLS tree, never from the carrying event's
* sender: a verified entry applies whoever relayed it, and an entry naming
* a non-member is dropped however it arrived.
*/
private fun memberCheck(nostrGroupId: HexKey): (HexKey) -> Boolean {
val members = manager.memberPubkeys(nostrGroupId).map { it.pubkey }.toSet()
return { it in members }
}
private suspend fun storeFor(nostrGroupId: HexKey): PushRecordStore? {
stores[nostrGroupId]?.let { return it }
val groupIdHex = manager.mlsGroupIdHex(nostrGroupId) ?: return null
val store =
PushRecordStore(
groupIdHex = groupIdHex,
// From the GroupContext, never from anything a sender claims:
// it decides which owner-proof forms are acceptable at all.
currentProfileGroup = manager.groupState(nostrGroupId)?.isCurrentProfile == true,
)
stateStore.load(nostrGroupId)?.let { PushStateCodec.decodeInto(store, it) }
stores[nostrGroupId] = store
return store
}
private suspend fun persist(
nostrGroupId: HexKey,
store: PushRecordStore,
) {
try {
stateStore.save(nostrGroupId, PushStateCodec.encode(store))
} catch (e: Exception) {
Log.w("MarmotPushCoordinator", "could not persist push state for $nostrGroupId", e)
}
}
private suspend fun signOwnRecord(
nostrGroupId: HexKey,
platform: PushPlatform,
deviceToken: ByteArray,
serverPubKeyHex: HexKey,
relayHint: String,
ownerTsMillis: Long,
): PushTokenEntry? {
val groupIdHex = manager.mlsGroupIdHex(nostrGroupId) ?: return null
val leafIndex = manager.leafIndexOf(nostrGroupId, manager.signer.pubKey) ?: return null
val fingerprint = PushSignedRecord.fingerprintOf(platform, deviceToken)
val encryptedTokenBase64 =
try {
TokenEncryption.encrypt(platform, deviceToken, serverPubKeyHex.hexToByteArray())
} catch (e: Exception) {
Log.w("MarmotPushCoordinator", "could not encrypt the device token", e)
return null
}
val hint = PushSignedRecord.normalizeRelayHint(relayHint)
val ownerSig =
proof(
record = PushRecordKind.TOKEN,
groupIdHex = groupIdHex,
leafIndex = leafIndex,
platform = platform,
serverPubKeyHex = serverPubKeyHex,
fingerprint = fingerprint,
ownerTsMillis = ownerTsMillis,
relayHint = hint,
encryptedTokenBase64 = encryptedTokenBase64,
) ?: return null
return PushTokenEntry(
memberIdHex = manager.signer.pubKey,
leafIndex = leafIndex,
platform = platform,
tokenFingerprint = fingerprint,
serverPubKeyHex = serverPubKeyHex,
relayHint = hint,
encryptedToken = requireNotNull(PushBase64.decodeOrNull(encryptedTokenBase64)),
ownerTsMillis = ownerTsMillis,
ownerSig = ownerSig,
)
}
/**
* Ask the account signer for the unpublished kind `451` proof.
*
* [PushOwnerProof.create] re-validates whatever the signer returns before
* copying the signature out, which matters for an external signer: a
* substituted group id or server pubkey would otherwise become a proof that
* silently authorizes the wrong destination.
*/
private suspend fun proof(
record: PushRecordKind,
groupIdHex: HexKey,
leafIndex: Int,
platform: PushPlatform,
serverPubKeyHex: HexKey,
fingerprint: String,
ownerTsMillis: Long,
relayHint: String = "",
encryptedTokenBase64: String = "",
): ByteArray? =
try {
PushOwnerProof.create(
signer = manager.signer,
record = record,
groupIdHex = groupIdHex,
leafIndex = leafIndex,
platform = platform.wireName,
serverPubKeyHex = serverPubKeyHex,
tokenFingerprint = fingerprint,
ownerTsMillis = ownerTsMillis,
relayHint = relayHint,
encryptedTokenBase64 = encryptedTokenBase64,
)
} catch (e: Exception) {
Log.w("MarmotPushCoordinator", "the signer did not produce a usable push owner proof", e)
null
}
/**
* The unsigned inner rumor a caller sends like any other group message.
*
* The coordinator deliberately stops here rather than building the kind:445
* itself: encrypting one advances the group's ratchet, and a message the
* caller then decides not to publish would burn a generation for nothing.
*/
private fun rumor(template: EventTemplate<out Event>): Event {
@Suppress("UNCHECKED_CAST")
return RumorAssembler.assembleRumor(manager.signer.pubKey, template as EventTemplate<Event>)
}
}
@@ -157,6 +157,7 @@ class MarmotSyncPolicy(
val detail =
when (result) {
is MarmotIngestResult.Failure -> " ${result.message}"
is MarmotIngestResult.Ignored -> " (${result.reason})"
else -> ""
}
log("ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}$detail")
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -33,9 +34,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the
* note rather than the cache.
*
* All three kinds apply ONLY edits authored by the edited note's own author: the send side gates
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord)
* is trusted to enforce that, so a foreign-authored edit never rewrites your message.
* All four kinds apply ONLY edits authored by the edited note's own author: the send side gates
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord,
* Marmot) is trusted to enforce that, so a foreign-authored edit never rewrites your message.
*/
/**
@@ -61,6 +62,27 @@ fun Note.latestBuzzEdit(): Note? {
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
}
/**
* The kind-1009 Marmot edit overlaying this message, or null.
*
* Marmot fixes both halves of this rule in `foundation/application-messages.md`
* ("Message edits"): only the original author's account may replace a message,
* and the latest `created_at` wins with the event id breaking a tie. The
* tie-break is not decoration — two devices of one account can stamp the same
* second, and without it two readers would render different text for the same
* message forever.
*
* Authorship is by ACCOUNT, which is what `author?.pubkeyHex` already is for a
* Marmot inner event: a second device of the same account holds a different MLS
* leaf but the same account key, and may edit its own account's message.
*/
fun Note.latestMarmotEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.author?.pubkeyHex == authorHex && it.event?.kind == MarmotAppEvent.KIND_EDIT }
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
}
/** The kind-3302 Concord edit overlaying this message, or null — author-only, newest by CORD-02 §4 send time. */
fun Note.latestConcordEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
@@ -29,6 +29,8 @@ import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.BufferOverflow
@@ -55,6 +57,60 @@ class MarmotGroupChatroom(
* it; when null they fall back to the host relay's NIP-11 icon.
*/
var image = MutableStateFlow<MarmotGroupImage?>(null)
/**
* The group's plain-https avatar (`marmot.group.avatar-url.v1`), or null
* when it has none. It takes precedence over [image]: a group carrying
* both shows this one, and only falls back to the encrypted Blossom blob
* once this is cleared.
*/
var avatarUrl = MutableStateFlow<GroupAvatarUrlV1?>(null)
/**
* False for a legacy MIP-01 group — one that predates the current profile
* and requires `0xf2f1` instead of the `0x8009` account identity proof.
*
* Front ends need this because a legacy group has nowhere to PUT several
* components: lifecycle (`0x800c`, so no disband), the URL avatar
* (`0x8007`), and the encrypted-media policy (`0x800b`) are all
* GroupContext state a legacy group never carried. Offering those actions
* on one produces a commit that is refused before it is built, so the
* honest thing is not to offer them.
*
* Nor can that be fixed by upgrading the group: the identity proof lives in
* each member's own LeafNode and covers that leaf's signature key, so it
* cannot be added to leaves that already exist. See `AccountIdentityProofV2`
* — "There is no fallback and no in-place migration". A legacy room stays a
* legacy room; a new group is the only route.
*
* Defaults to TRUE so a group that has just been created shows its full
* feature set immediately: creation does not run a metadata sync, and every
* group created now is a current-profile one. A restored legacy group is
* corrected by the startup sync before any screen reads this.
*/
var isCurrentProfile = MutableStateFlow(true)
/**
* True once the group carries the `encrypted-media-v2` policy (`0x800b`).
*
* Attachments fall back to MIP-04 without it, so a front end needs this to
* tell an admin the group can be upgraded — and to stop offering the
* upgrade once it has been.
*/
var hasEncryptedMediaPolicy = MutableStateFlow(false)
/**
* Why this group takes no new outbound work, or null when it does.
*
* A durable outbound gate is not a lifecycle state: the member is still in
* the tree and the group is not terminal, but nothing new may be sent —
* an unresolved disband request, a SelfRemove already sent, a realized
* removal. A front end needs it separately from [isCurrentProfile] and the
* lifecycle so it can DISABLE the composer with a reason rather than let a
* send throw and surface as an error after the fact.
*/
var outboundGate = MutableStateFlow<LocalOutboundGate?>(null)
var adminPubkeys = MutableStateFlow<List<HexKey>>(emptyList())
var relays = MutableStateFlow<List<String>>(emptyList())
var memberCount = MutableStateFlow(0)
@@ -119,24 +119,83 @@ class MarmotGroupList(
/**
* True if this inner event should appear as its own bubble in the group
* chat feed. Side-channel kinds (reactions, deletions) must still be
* consumed into LocalCache — they drive the reaction row on the target
* note and, for kind:5, revoke a prior reaction — but they must NOT show
* up as standalone messages.
* chat feed. Side-channel kinds must still be consumed into LocalCache —
* they drive the reaction row on the target note and, for kind:5, revoke a
* prior reaction — but they must NOT show up as standalone messages.
*
* Needed because WhiteNoise emits plain kind:7 reactions (emoji content +
* `e` tag) and kind:5 unreacts inside kind:445, and the Marmot pipeline
* blindly routed every inner event into the chatroom. The reaction then
* rendered as a chat bubble containing just the emoji, with a quoted
* citation of the target message — which reads exactly like a reply.
*
* The same reasoning covers the three kinds that are not chat either:
*
* - **1009 edits** replace a prior message's text in place. Rendering one
* as its own row would show the same sentence twice, and it must not
* advance an unread count — a reader caught up with the original is
* caught up with the edit.
* - **1200 agent-stream anchors** are hidden by their own feature: the
* payload is routing metadata with an empty body, so it would render as
* a blank bubble. What a reader sees is the live preview and then the
* authoritative kind:9.
* 1210 system rows are NOT in this list. They are group-state captions
* rather than messages, but they belong in the conversation in
* chronological order, so the feed carries them and the renderer gives
* them their own style instead of a chat bubble — subject to the
* authorship rule below.
*/
private fun isDisplayableFeedMessage(msg: Note): Boolean {
val kind = msg.event?.kind ?: return true
return kind != MARMOT_INNER_KIND_REACTION && kind != MARMOT_INNER_KIND_DELETION
if (kind == MARMOT_INNER_KIND_SYSTEM_ROW) return isOwnDerivedSystemRow(msg)
return kind !in NON_CHAT_INNER_KINDS
}
/**
* A kind:1210 row is shown only when THIS client derived it.
*
* MLS authenticates that a member sent an inner payload; it says nothing
* about whether the payload is true. A received 1210 is therefore an
* assertion by its sender, with an `actor` and `subject` of the sender's
* choosing — so rendering one would let any member forge an attributed
* history row ("X removed Y") indistinguishable from a real one, in the
* part of the conversation a reader trusts most.
*
* Rows this client derives are diffed from MLS-authenticated group state
* (`MarmotManager.syncGroupSystemRows`) and are always authored by the
* account itself, so authorship is exactly the test. Nothing is lost by
* dropping the sender's version: every client that applied the same
* commits derives the same rows.
*
* The check has to live here rather than at ingest because rows reach the
* feed by two routes — live decryption and the restart re-read of the
* local log — and the log holds received payloads too.
*/
private fun isOwnDerivedSystemRow(msg: Note): Boolean = msg.event?.pubKey == ownerPubKey
companion object {
private const val MARMOT_INNER_KIND_DELETION = 5
private const val MARMOT_INNER_KIND_REACTION = 7
private const val MARMOT_INNER_KIND_EDIT = 1009
private const val MARMOT_INNER_KIND_STREAM_START = 1200
private const val MARMOT_INNER_KIND_SYSTEM_ROW = 1210
// Push token gossip. Routing data for a notification server, addressed
// to the other members' clients rather than to the people in the room —
// a reader must never see a row for one.
private const val MARMOT_INNER_KIND_PUSH_TOKEN_UPDATE = 447
private const val MARMOT_INNER_KIND_PUSH_TOKEN_LIST = 448
private const val MARMOT_INNER_KIND_PUSH_TOKEN_REMOVAL = 449
private val NON_CHAT_INNER_KINDS =
setOf(
MARMOT_INNER_KIND_DELETION,
MARMOT_INNER_KIND_REACTION,
MARMOT_INNER_KIND_EDIT,
MARMOT_INNER_KIND_STREAM_START,
MARMOT_INNER_KIND_PUSH_TOKEN_UPDATE,
MARMOT_INNER_KIND_PUSH_TOKEN_LIST,
MARMOT_INNER_KIND_PUSH_TOKEN_REMOVAL,
)
}
}
@@ -0,0 +1,134 @@
/*
* 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.model
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The kind-1009 overlay rule, resolved off a message's own [Note.edits].
*
* Both halves are read-side on purpose: a sender cannot be trusted to have
* applied them, and this is the only place the app decides which text a reader
* actually sees.
*/
class MarmotEditOverlayTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val context = UserContext { addr -> AddressableNote(addr) }
private fun user(pubkey: String) = User(pubkey, context)
private fun event(
id: String,
pubkey: String,
kind: Int,
content: String,
createdAt: Long,
targetId: String? = null,
) = Event(
id = id,
pubKey = pubkey,
createdAt = createdAt,
kind = kind,
tags = targetId?.let { arrayOf(arrayOf("e", it)) } ?: emptyArray(),
content = content,
sig = "",
)
private fun note(event: Event): Note = Note(event.id).also { it.loadEvent(event, user(event.pubKey), emptyList()) }
private fun target(): Note = note(event("0".repeat(64), alice, MarmotAppEvent.KIND_CHAT, "frist post", 1_800_000_000L))
private fun edit(
id: String,
author: String,
content: String,
createdAt: Long,
targetId: String = "0".repeat(64),
) = note(event(id, author, MarmotAppEvent.KIND_EDIT, content, createdAt, targetId))
@Test
fun `an unedited message has no overlay`() {
assertNull(target().latestMarmotEdit())
}
@Test
fun `the author's own edit overlays their message`() {
val message = target()
message.addEdit(edit("1".repeat(64), alice, "first post", 1_800_000_100L))
assertEquals("first post", message.latestMarmotEdit()?.event?.content)
}
@Test
fun `an edit by another account is ignored`() {
// Only the original author may replace their words. The transport
// cannot enforce this — any member can send a well-formed 1009 naming
// someone else's message — so the reader has to.
val message = target()
message.addEdit(edit("2".repeat(64), bob, "not mine", 1_800_000_100L))
assertNull(message.latestMarmotEdit())
}
@Test
fun `the latest edit wins`() {
val message = target()
message.addEdit(edit("1".repeat(64), alice, "v2", 1_800_000_100L))
message.addEdit(edit("2".repeat(64), alice, "v3", 1_800_000_300L))
message.addEdit(edit("3".repeat(64), alice, "v2b", 1_800_000_200L))
assertEquals("v3", message.latestMarmotEdit()?.event?.content)
}
@Test
fun `a same-second pair resolves by event id identically for every reader`() {
// Two devices of one account can stamp the same second. Without a
// deterministic tie-break two readers would render different text for
// the same message forever, and neither would be wrong.
val stamp = 1_800_000_100L
val ascending = target()
ascending.addEdit(edit("1".repeat(64), alice, "from device A", stamp))
ascending.addEdit(edit("f".repeat(64), alice, "from device B", stamp))
val descending = target()
descending.addEdit(edit("f".repeat(64), alice, "from device B", stamp))
descending.addEdit(edit("1".repeat(64), alice, "from device A", stamp))
assertEquals("from device B", ascending.latestMarmotEdit()?.event?.content)
assertEquals(
ascending.latestMarmotEdit()?.event?.content,
descending.latestMarmotEdit()?.event?.content,
"insertion order must not decide the winner",
)
}
@Test
fun `a non-edit child is not an overlay`() {
// `edits` is a general child list; a reaction or any other kind
// anchored to the message must not be read as replacement text.
val message = target()
message.addEdit(note(event("4".repeat(64), alice, 7, "🍕", 1_800_000_400L)))
assertNull(message.latestMarmotEdit())
}
}
@@ -0,0 +1,108 @@
/*
* 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.model.marmotGroups
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Which inner app events become rows in a Marmot group's conversation.
*
* The kind:1210 rule is a security boundary, not a display preference. MLS
* authenticates that a member SENT a payload; it says nothing about whether the
* payload is TRUE. The reference client draws the same line — its own fuzz
* target asserts that raw 1210 JSON "must not authenticate a payload actor" and
* that a parsed payload stays an unauthenticated projection.
*/
class MarmotGroupFeedVisibilityTest {
private val owner = "a".repeat(64)
private val peer = "b".repeat(64)
private val groupId = "c".repeat(64)
private val context = UserContext { addr -> AddressableNote(addr) }
private fun note(
kind: Int,
pubKey: String,
id: String = "${kind}0".padEnd(64, 'f'),
content: String = "",
): Note {
val event = Event(id, pubKey, 1_800_000_000L, kind, emptyArray(), content, "")
return Note(event.id).also { it.loadEvent(event, User(pubKey, context), emptyList()) }
}
private fun list() = MarmotGroupList(owner)
private fun visibleCount(
list: MarmotGroupList,
note: Note,
): Int {
list.addMessage(groupId, note)
return list.getOrCreateGroup(groupId).messages.size
}
@Test
fun `a chat message is shown`() {
assertEquals(1, visibleCount(list(), note(9, peer, content = "hello")))
}
@Test
fun `a system row this client derived is shown`() {
// Derived rows are diffed from MLS-authenticated state and are always
// authored by the account itself, so authorship is what marks them.
assertEquals(1, visibleCount(list(), note(1210, owner)))
}
@Test
fun `a system row sent by another member is refused`() {
// The forgery this blocks: any member can send a well-formed 1210
// naming someone else as the actor of a removal or a rename, and it
// would render exactly like a real one in the part of the conversation
// a reader trusts most.
assertEquals(0, visibleCount(list(), note(1210, peer)))
}
@Test
fun `the side-channel kinds never become rows`() {
// Reactions, deletions, edits, stream anchors and push token gossip all
// reach LocalCache — they drive other UI — but none is a message.
listOf(5, 7, 1009, 1200, 447, 448, 449).forEach { kind ->
assertEquals(0, visibleCount(list(), note(kind, peer)), "kind $kind must not render as a row")
}
}
@Test
fun `authorship is the only thing that admits a system row`() {
// Not the group, not the arrival path, not the payload's own claims.
val list = list()
val forged = note(1210, peer, id = "1".repeat(64), content = """{"v":1,"system_type":"member_removed","data":{"actor":"$owner"}}""")
list.addMessage(groupId, forged)
assertTrue(list.getOrCreateGroup(groupId).messages.size == 0, "a payload cannot vouch for itself")
assertFalse(list.groupIdForNote(forged.idHex) == groupId)
}
}
@@ -0,0 +1,123 @@
/*
* 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.appComponents.GroupAvatarUrlV1
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
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.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LegacyGroupContractTest {
/**
* What a legacy MIP-01 group can and cannot still do.
*
* Groups created by builds before the current profile existed are still on
* disk, and they can NEVER become current-profile groups: the account
* identity proof lives in a member's own LeafNode and covers that leaf's
* signature key, so it cannot be added to leaves that already exist. See
* `AccountIdentityProofV2` — "There is no fallback and no in-place
* migration". The only route from a legacy room to a current-profile one is
* to create a new group and re-invite.
*
* That makes the legacy contract worth pinning rather than discovering by
* hand: messaging keeps working, and the operations whose state has no
* legacy carrier refuse with a message that says why instead of failing
* somewhere inside the commit.
*/
@Test
fun aLegacyGroupStillTalksButCannotDisbandOrCarryAnAvatar() =
runBlocking<Unit> {
val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!
val signer = NostrSignerInternal(KeyPair())
val manager =
MarmotManager(
signer,
ProbeStateStore(),
publisher = MarmotPublisher { _, _ -> true },
)
val gid = "b".repeat(64)
manager.createGroup(
gid,
MarmotGroupData(nostrGroupId = gid, adminPubkeys = listOf(signer.pubKey), relays = listOf(relay.url)),
)
assertFalse(
manager.groupView(gid)!!.isCurrentProfile,
"createGroup builds the legacy MIP-01 shape; createCurrentProfileGroup is the other one",
)
// Messaging is unaffected. A legacy room is still a usable room.
manager.buildTextMessage(gid, "hello from a legacy room")
// Lifecycle (0x800c) and the URL avatar (0x8007) are GroupContext
// components a legacy group has nowhere to put, so both refuse up
// front and name the reason.
val disband = assertFailsWith<IllegalStateException> { manager.disbandGroup(gid, listOf(relay)) }
assertTrue(
disband.message!!.contains("legacy MIP-01 group"),
"a refusal a tester will read: ${disband.message}",
)
val avatar =
assertFailsWith<IllegalStateException> {
manager.setGroupAvatarUrl(gid, GroupAvatarUrlV1("https://x.invalid/a.png"), listOf(relay))
}
assertTrue(
avatar.message!!.contains("legacy MIP-01 group"),
"a refusal a tester will read: ${avatar.message}",
)
}
private class ProbeStateStore : com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore {
private val states = mutableMapOf<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
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)
}
override suspend fun listGroups(): List<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
}
}
@@ -0,0 +1,343 @@
/*
* 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.appComponents.agentTextStream.AgentTextStreamPublisher
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRecordV1
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.InMemoryAgentTextStreamSequenceStore
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStatus
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicException
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicStream
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport
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.signers.NostrSignerInternal
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The front end's half of an agent text stream: notice the kind:1200 that
* arrived in a group, render the live preview it points at, and hand back to
* the durable kind:9 when it lands.
*
* Everything the renderer needs to be honest is decided here — whether the
* preview may be shown as confirmed, and whether it turned out to be the
* stream the publisher actually sent.
*/
class MarmotAgentStreamWatcherTest {
private val nostrGroupId = "c".repeat(64)
/** A transport whose records the test pushes by hand. */
private class FakeTransport : MarmotQuicTransport {
val records = MutableSharedFlow<AgentTextStreamRecordV1>(replay = 32)
val subscribed = CompletableDeferred<String>()
var failEveryCandidate = false
override suspend fun publish(
candidate: String,
streamId: ByteArray,
startEventId: ByteArray,
): MarmotQuicStream = error("the watcher never publishes")
override suspend fun sendDirect(
candidate: String,
streamId: ByteArray,
startEventId: ByteArray,
): MarmotQuicStream = error("the watcher never sends")
override suspend fun subscribe(
candidate: String,
streamId: ByteArray,
startEventId: ByteArray,
): MarmotQuicStream {
if (failEveryCandidate) {
throw MarmotQuicException(MarmotQuicException.Kind.HandshakeFailed, "no route to $candidate")
}
if (!subscribed.isCompleted) subscribed.complete(candidate)
return object : MarmotQuicStream {
override suspend fun send(record: AgentTextStreamRecordV1) = error("read only")
override fun incoming(): Flow<AgentTextStreamRecordV1> = records
override suspend fun finish() = Unit
override suspend fun close() = Unit
}
}
}
private fun manager() =
MarmotManager(
NostrSignerInternal(KeyPair()),
WatcherStateStore(),
WatcherMessageStore(),
WatcherBundleStore(),
)
private suspend fun aGroupWithAStream(
manager: MarmotManager,
brokers: List<String> = listOf("quic://broker.invalid:4450"),
): Pair<String, String> {
manager.createGroup(
nostrGroupId,
MarmotGroupData(nostrGroupId = nostrGroupId, name = "stream group", relays = listOf("wss://relay.invalid")),
)
val streamId = "a".repeat(64)
val start = manager.buildAgentStreamStart(nostrGroupId, streamId, brokers)
return streamId to start.innerEvent.id
}
@Test
fun aPreviewAppearsAsRecordsArriveAndIsNeverShownAsConfirmed() =
runBlocking {
val manager = manager()
val transport = FakeTransport()
val (streamId, startEventId) = aGroupWithAStream(manager)
val watcher = MarmotAgentStreamWatcher(manager, transport, this)
watcher.watchLatest(nostrGroupId)
withTimeout(5_000) { transport.subscribed.await() }
val crypto = manager.agentTextStreamCrypto(nostrGroupId, hex(streamId), hex(startEventId))
val publisher = AgentTextStreamPublisher.open(crypto, InMemoryAgentTextStreamSequenceStore())
transport.records.emit(publisher.publish(AgentTextStreamRecordV1.TYPE_TEXT_DELTA, "half an ".encodeToByteArray()))
transport.records.emit(publisher.publish(AgentTextStreamRecordV1.TYPE_TEXT_DELTA, "answer".encodeToByteArray()))
val preview = withTimeout(5_000) { watcher.preview.first { it?.text == "half an answer" } }
assertNotNull(preview)
assertEquals(streamId, preview.streamId)
assertEquals(PreviewStatus.LIVE, preview.status)
assertTrue(
!preview.isConfirmed,
"a live preview is provisional — a renderer must be able to tell it apart from durable content",
)
watcher.stop()
}
@Test
fun theFinalMessageConfirmsAPreviewThatMatchesIt() =
runBlocking {
val manager = manager()
val transport = FakeTransport()
val (streamId, startEventId) = aGroupWithAStream(manager)
val watcher = MarmotAgentStreamWatcher(manager, transport, this)
watcher.watchLatest(nostrGroupId)
withTimeout(5_000) { transport.subscribed.await() }
val crypto = manager.agentTextStreamCrypto(nostrGroupId, hex(streamId), hex(startEventId))
val publisher = AgentTextStreamPublisher.open(crypto, InMemoryAgentTextStreamSequenceStore())
transport.records.emit(publisher.publish(AgentTextStreamRecordV1.TYPE_TEXT_DELTA, "the answer".encodeToByteArray()))
withTimeout(5_000) { watcher.preview.first { it?.text == "the answer" } }
watcher.onFinal(streamId, publisher.transcript.hash.asHex(), publisher.transcript.chunkCount)
val confirmed = assertNotNull(withTimeout(5_000) { watcher.preview.first { it?.isConfirmed == true } })
assertEquals("the answer", confirmed.text)
watcher.stop()
}
@Test
fun aFinalThatDisagreesDiscardsThePreviewInsteadOfShowingIt() =
runBlocking {
val manager = manager()
val transport = FakeTransport()
val (streamId, startEventId) = aGroupWithAStream(manager)
val watcher = MarmotAgentStreamWatcher(manager, transport, this)
watcher.watchLatest(nostrGroupId)
withTimeout(5_000) { transport.subscribed.await() }
val crypto = manager.agentTextStreamCrypto(nostrGroupId, hex(streamId), hex(startEventId))
val publisher = AgentTextStreamPublisher.open(crypto, InMemoryAgentTextStreamSequenceStore())
transport.records.emit(publisher.publish(AgentTextStreamRecordV1.TYPE_TEXT_DELTA, "tampered".encodeToByteArray()))
withTimeout(5_000) { watcher.preview.first { it?.text == "tampered" } }
// A transcript that does not match means records were dropped,
// reordered or injected even though each one opened. The durable
// kind:9 is the answer; the preview must go.
watcher.onFinal(streamId, "0".repeat(64), 1)
withTimeout(5_000) { watcher.preview.first { it == null } }
watcher.stop()
}
@Test
fun aFinalAlreadyInTheLogSettlesThePreviewWithoutTheUiSayingSo() =
runBlocking {
val manager = manager()
val transport = FakeTransport()
val (streamId, startEventId) = aGroupWithAStream(manager)
val watcher = MarmotAgentStreamWatcher(manager, transport, this)
watcher.watchLatest(nostrGroupId)
withTimeout(5_000) { transport.subscribed.await() }
val crypto = manager.agentTextStreamCrypto(nostrGroupId, hex(streamId), hex(startEventId))
val publisher = AgentTextStreamPublisher.open(crypto, InMemoryAgentTextStreamSequenceStore())
transport.records.emit(publisher.publish(AgentTextStreamRecordV1.TYPE_TEXT_DELTA, "done".encodeToByteArray()))
withTimeout(5_000) { watcher.preview.first { it?.text == "done" } }
// The durable message lands in the group log the ordinary way. A
// front end only reports "the feed moved"; the watcher does the
// rest.
manager.buildAgentStreamFinal(
nostrGroupId,
streamId,
publisher.transcript.hash.asHex(),
publisher.transcript.chunkCount,
"done",
)
watcher.watchLatest(nostrGroupId)
val confirmed = assertNotNull(withTimeout(5_000) { watcher.preview.first { it?.isConfirmed == true } })
assertEquals("done", confirmed.text)
watcher.stop()
}
@Test
fun aGroupWithNoBrokerCandidateShowsNoPreviewAtAll() =
runBlocking {
val manager = manager()
val transport = FakeTransport()
aGroupWithAStream(manager, brokers = emptyList())
val watcher = MarmotAgentStreamWatcher(manager, transport, this)
watcher.watchLatest(nostrGroupId)
// Zero candidates is valid: the preview is simply unavailable and
// every member still gets the final message.
assertNull(watcher.preview.value)
watcher.stop()
}
@Test
fun anUnreachableBrokerLeavesTheGroupUsableWithoutAPreview() =
runBlocking {
val manager = manager()
val transport = FakeTransport().also { it.failEveryCandidate = true }
aGroupWithAStream(manager)
val watcher = MarmotAgentStreamWatcher(manager, transport, this)
watcher.watchLatest(nostrGroupId)
assertNull(
watcher.preview.value,
"a candidate that will not connect is skipped, not fatal",
)
watcher.stop()
}
@Test
fun aPlatformWithoutQuicSimplyNeverPreviews() =
runBlocking {
val manager = manager()
aGroupWithAStream(manager)
val watcher = MarmotAgentStreamWatcher(manager, transport = null, scope = this)
watcher.watchLatest(nostrGroupId)
assertNull(watcher.preview.value)
watcher.stop()
}
private fun hex(s: String) = ByteArray(s.length / 2) { ((s[it * 2].digitToInt(16) shl 4) or s[it * 2 + 1].digitToInt(16)).toByte() }
private fun ByteArray.asHex() = joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') }
}
private class WatcherStateStore : MlsGroupStateStore {
private val states = mutableMapOf<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
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<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
}
private class WatcherMessageStore : MarmotMessageStore {
private val messages = mutableMapOf<String, MutableList<String>>()
private val epochs = mutableMapOf<String, MutableMap<String, Long>>()
override suspend fun appendMessage(
nostrGroupId: String,
innerEventJson: String,
) {
val log = messages.getOrPut(nostrGroupId) { mutableListOf() }
if (innerEventJson !in log) log.add(innerEventJson)
}
override suspend fun loadMessages(nostrGroupId: String): List<String> = messages[nostrGroupId]?.toList() ?: emptyList()
override suspend fun delete(nostrGroupId: String) {
messages.remove(nostrGroupId)
epochs.remove(nostrGroupId)
}
override suspend fun recordEpoch(
nostrGroupId: String,
innerEventId: String,
epoch: Long,
) {
epochs.getOrPut(nostrGroupId) { mutableMapOf() }[innerEventId] = epoch
}
override suspend fun loadEpochs(nostrGroupId: String): Map<String, Long> = epochs[nostrGroupId]?.toMap() ?: emptyMap()
}
private class WatcherBundleStore : 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
}
}
@@ -0,0 +1,388 @@
/*
* 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.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.marmot.protocolCore.InMemoryPublishObligationStore
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Disbanding a group — `marmot.group.lifecycle.v1`, component `0x800c`.
*
* We already REFUSED work in a disbanded group; nothing could put a group into
* that state from this side, so the enforcement was only ever reachable from a
* peer's commit. These cover the initiator half.
*
* Disband is absorbing: there is no un-disband, no later branch supersedes it,
* and a replacement conversation is a new MLS group. So the guards matter more
* than the happy path — a mistaken disband cannot be undone, and one published
* off unpublished or untrusted state would terminalize the group for everyone
* on a commit its author never confirmed.
*/
class MarmotDisbandTest {
private val nostrGroupId = "d".repeat(64)
private class Fixture(
publisher: MarmotPublisher = ACCEPTING_RELAY,
) {
val signer = NostrSignerInternal(KeyPair())
val manager =
MarmotManager(
signer,
SnapshotStateStore(),
SnapshotMessageStore(),
SnapshotBundleStore(),
publisher = publisher,
)
}
private suspend fun Fixture.createCurrentProfile() =
manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
profile = GroupProfileV1("doomed", ""),
)
@Test
fun `an admin disbands the group and everything after is refused`() =
runBlocking {
val f = Fixture()
f.createCurrentProfile()
f.manager.disbandGroup(nostrGroupId)
// The Commit applied, so the group's own state says disbanded —
// but the LIFECYCLE does not, yet. `group-lifecycle-v1.md` is
// explicit that a disband is never terminalized through ordinary
// linear advancement: admitting it forces `Recovering` even with no
// fork, and only a SELECTED disband Commit moves it to `Disbanded`.
assertTrue(f.manager.groupState(nostrGroupId)?.isDisbanded == true)
assertEquals(GroupLifecycleState.RECOVERING, f.manager.lifecycle(nostrGroupId))
assertTrue(f.manager.isDisbanding(nostrGroupId))
// Outbound work stops immediately all the same — that is the
// `Disbanding` gate, not the lifecycle.
assertFailsWith<IllegalStateException> {
f.manager.buildTextMessage(nostrGroupId, "anyone still here?")
}
// Settle the pass and the group terminalizes for real.
f.manager.driveConvergenceToSettlement(pollMs = 1)
assertEquals(GroupLifecycleState.DISBANDED, f.manager.lifecycle(nostrGroupId))
assertFalse(f.manager.isDisbanding(nostrGroupId), "a resolved request lowers its gate")
}
@Test
fun `disband is absorbing and cannot be issued twice`() =
runBlocking {
val f = Fixture()
f.createCurrentProfile()
f.manager.disbandGroup(nostrGroupId)
val thrown = assertFailsWith<IllegalStateException> { f.manager.disbandGroup(nostrGroupId) }
assertTrue(thrown.message.orEmpty().contains("already disbanded"))
}
@Test
fun `the disband commit carries the whole shape the spec fixes`() =
runBlocking {
// `group-lifecycle-v1.md` fixes every part of this Commit, and a
// peer validates the whole set: the lifecycle update alone — which
// is what this used to send — reads as an unsupported transition
// and is rejected, leaving the group live for everyone else while
// reading as ended here.
val alice = Fixture()
val bob = Fixture()
alice.createCurrentProfile()
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
bob.manager.ingest(welcome!!.giftWrapEvent)
assertEquals(2, alice.manager.memberCount(nostrGroupId))
alice.manager.disbandGroup(nostrGroupId)
assertTrue(alice.manager.groupState(nostrGroupId)?.isDisbanded == true)
// Every leaf but the committer's is gone, and the admin policy is a
// full replacement naming only the committer.
assertEquals(1, alice.manager.memberCount(nostrGroupId))
assertEquals(
listOf(alice.signer.pubKey),
alice.manager.groupView(nostrGroupId)?.adminPubkeys,
)
}
@Test
fun `a witness applies the disband and lands terminal`() =
runBlocking {
// The half that matters for interop: the removed member has to be
// able to APPLY the commit that removes them, read the terminal
// state out of it, and stop — not reject it and sit at the old
// epoch believing the group is still live.
val alice = Fixture()
val bob = Fixture()
alice.createCurrentProfile()
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
bob.manager.ingest(welcome!!.giftWrapEvent)
val commit = alice.manager.disbandGroup(nostrGroupId)
bob.manager.ingest(commit.signedEvent)
// Same rule on the receiving side: admitted, then selected. A
// witness that terminalized on arrival could not tell a disband
// that won from one that lost a race it never saw.
assertEquals(GroupLifecycleState.RECOVERING, bob.manager.lifecycle(nostrGroupId))
bob.manager.driveConvergenceToSettlement(pollMs = 1)
assertEquals(GroupLifecycleState.DISBANDED, bob.manager.lifecycle(nostrGroupId))
assertFailsWith<IllegalStateException> {
bob.manager.buildTextMessage(nostrGroupId, "still here?")
}
Unit
}
@Test
fun `a non-admin member cannot disband`() =
runBlocking {
// Two clients: the creator is the admin, the invitee is not. Peers
// would reject a lifecycle change from a non-admin anyway; refusing
// locally is what stops the invitee burning an epoch on a commit
// nobody will apply.
val alice = Fixture()
val bob = Fixture()
alice.createCurrentProfile()
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
bob.manager.ingest(welcome!!.giftWrapEvent)
val thrown = assertFailsWith<IllegalStateException> { bob.manager.disbandGroup(nostrGroupId) }
assertTrue(thrown.message.orEmpty().contains("Only an admin"))
}
@Test
fun `a legacy group has no carrier for a lifecycle state`() =
runBlocking {
val f = Fixture()
f.manager.createGroup(
nostrGroupId,
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "legacy",
relays = listOf("wss://relay.invalid"),
),
)
val thrown = assertFailsWith<IllegalStateException> { f.manager.disbandGroup(nostrGroupId) }
assertTrue(thrown.message.orEmpty().contains("legacy"))
}
@Test
fun `a disband no relay accepted does not terminalize the group locally`() =
runBlocking {
// Publish-before-apply, on the one commit that cannot be walked
// back: a group disbanded here but nowhere else would be dead for
// us and alive for everyone. The obligation stays queued, the local
// group stays live, and the caller is told it did NOT happen —
// otherwise the UI would announce an ending that never occurred.
val f = Fixture(publisher = MarmotPublisher { _, _ -> false })
f.createCurrentProfile()
f.manager.disbandGroup(nostrGroupId)
assertTrue(f.manager.groupState(nostrGroupId)?.isDisbanded != true)
assertTrue(f.manager.lifecycle(nostrGroupId) != GroupLifecycleState.DISBANDED)
// What a failed publish must NOT do is throw the request away. The
// component calls the gate durable precisely so it "survives
// publication failure", and an admin who ended a conversation does
// not need to be told to click again because a relay blinked.
assertTrue(f.manager.isDisbanding(nostrGroupId))
// And nothing may be sent while it is unresolved. The group is not
// terminal — it may yet come back if the request turns out to be
// impossible — but it is no longer an ordinary live conversation.
assertFailsWith<IllegalStateException> {
f.manager.buildTextMessage(nostrGroupId, "still here")
}
Unit
}
@Test
fun `the gate reaches the front end's group state`() =
runBlocking {
// The UI cannot ask a suspending publish gate from the synchronous
// path that refreshes a conversation, so the gate is mirrored onto
// the chatroom. If that mirror is missing, the composer stays
// enabled on a group that refuses every send and the user finds out
// by tapping.
val f = Fixture()
f.createCurrentProfile()
val chatroom = MarmotGroupChatroom(nostrGroupId)
f.manager.syncMetadataTo(nostrGroupId, chatroom)
assertNull(chatroom.outboundGate.value, "a live group has no gate")
f.manager.disbandGroup(nostrGroupId)
f.manager.syncMetadataTo(nostrGroupId, chatroom)
assertEquals(LocalOutboundGate.DISBANDING, chatroom.outboundGate.value)
}
@Test
fun `rejoining a group we left clears the departure gate`() =
runBlocking {
// Leaving raises a durable `Leaving` gate, and nothing but a
// re-join clears it. That was harmless while gates blocked nothing;
// now that they stop outbound work — and survive restarts — a group
// you left and were invited back to would be readable and
// permanently unsendable.
val alice = Fixture()
val bob = Fixture()
alice.createCurrentProfile()
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
bob.manager.ingest(welcome!!.giftWrapEvent)
bob.manager.leaveGroup(nostrGroupId)
assertFailsWith<IllegalStateException>("a leaving member may not send") {
bob.manager.buildTextMessage(nostrGroupId, "one more thing")
}
// Invited back: a fresh KeyPackage, a fresh Welcome.
val rejoinKp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (_, rejoinWelcome) = alice.manager.addMember(nostrGroupId, rejoinKp, emptyList())
bob.manager.ingest(rejoinWelcome!!.giftWrapEvent)
// The group has to be usable again — that is the whole point of
// being invited back.
bob.manager.buildTextMessage(nostrGroupId, "back again")
Unit
}
@Test
fun `a pending disband request outlives a restart`() =
runBlocking {
// The gate is durable or it is nothing: the crash that happens
// between "the admin pressed disband" and "a relay took the commit"
// is exactly the case it exists for, and an in-memory flag loses
// the intent there and offers the group as live on the next start.
val store = SnapshotStateStore()
val obligations = InMemoryPublishObligationStore()
val first =
MarmotManager(
NostrSignerInternal(KeyPair()),
store,
SnapshotMessageStore(),
SnapshotBundleStore(),
publisher = MarmotPublisher { _, _ -> false },
publishObligationStore = obligations,
)
first.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
profile = GroupProfileV1("doomed", ""),
)
first.disbandGroup(nostrGroupId)
assertTrue(first.isDisbanding(nostrGroupId))
// A fresh manager over the same stores is what a restart looks like.
val restarted =
MarmotManager(
first.signer,
store,
SnapshotMessageStore(),
SnapshotBundleStore(),
publisher = ACCEPTING_RELAY,
publishObligationStore = obligations,
)
restarted.restoreAll()
assertTrue(restarted.isDisbanding(nostrGroupId), "the request must survive the restart")
assertFailsWith<IllegalStateException> {
restarted.buildTextMessage(nostrGroupId, "did it end?")
}
Unit
}
@Test
fun `a disband that loses a branch race is regenerated, not dropped`() =
runBlocking {
// The case terminalizing-on-application could never survive. Alice
// disbands; the branch that wins is an ACTIVE one from bob, so her
// Commit loses. The spec says an authorized client regenerates it
// against the selected state — and the only reason she still can is
// that she never went terminal, because a `Disbanded` client stops
// processing group traffic and could not have learned she lost.
val alice = Fixture()
val bob = Fixture()
alice.createCurrentProfile()
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
bob.manager.ingest(welcome!!.giftWrapEvent)
// Bob is promoted so his own commit is one alice will accept.
alice.manager
.setGroupAdmins(nostrGroupId, listOf(alice.signer.pubKey, bob.signer.pubKey))
.let { bob.manager.ingest(it.signedEvent) }
// Both commit off the same epoch: alice's disband and bob's rename.
val disband = alice.manager.disbandGroup(nostrGroupId)
assertTrue(alice.manager.isDisbanding(nostrGroupId))
val rename = bob.manager.setGroupProfile(nostrGroupId, "still going", "")
// Alice sees bob's competing commit and settles the pass.
alice.manager.ingest(rename.signedEvent)
alice.manager.driveConvergenceToSettlement(pollMs = 1)
// Whatever branch won, the REQUEST is still alive: either it was
// the disband (terminal, gate down) or it was not (gate still up,
// regenerated against the selected state). What must never happen
// is a group that is live for bob and terminal for alice.
val lifecycle = alice.manager.lifecycle(nostrGroupId)
if (lifecycle == GroupLifecycleState.DISBANDED) {
assertFalse(alice.manager.isDisbanding(nostrGroupId))
} else {
assertTrue(
alice.manager.isDisbanding(nostrGroupId),
"a disband that lost its branch must stay pending, not vanish",
)
}
// Either way the commit alice published is the spec's shape.
assertTrue(disband.signedEvent.id.isNotEmpty())
Unit
}
}
@@ -0,0 +1,284 @@
/*
* 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.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemType
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Message edits (kind 1009) and group system rows (kind 1210) through the app
* layer.
*
* Both are places where getting the RULE wrong is invisible until two clients
* disagree: who may replace a message, which of two edits wins, and whether a
* caption gets written once or on every look.
*/
class MarmotEditsAndSystemRowsTest {
private val nostrGroupId = "e".repeat(64)
private class Fixture {
val signer = NostrSignerInternal(KeyPair())
val mlsStore = SnapshotStateStore()
val messageStore = SnapshotMessageStore()
/**
* A commit only becomes canonical state once a relay took it — a
* manager with no publisher discards its pending state and never
* advances the epoch, so there would be nothing for a row to describe.
*/
val manager = MarmotManager(signer, mlsStore, messageStore, SnapshotBundleStore(), publisher = ACCEPTING_RELAY)
}
private suspend fun Fixture.createGroup(name: String = "edits") =
manager.createGroup(
nostrGroupId,
MarmotGroupData(nostrGroupId = nostrGroupId, name = name, relays = listOf("wss://relay.invalid")),
)
private suspend fun MarmotManager.storedEvents(): List<Event> = loadStoredMessages(nostrGroupId).mapNotNull { Event.fromJsonOrNull(it) }
@Test
fun `an author's own edit replaces their message`() =
runBlocking {
val f = Fixture()
f.createGroup()
val original = f.manager.buildTextMessage(nostrGroupId, "frist post")
f.manager.buildMessageEdit(nostrGroupId, original.innerEvent.id, "first post")
val overlays = f.manager.editOverlays(f.manager.storedEvents())
assertEquals("first post", overlays[original.innerEvent.id])
}
@Test
fun `an edit from another account is ignored`() =
runBlocking {
val f = Fixture()
f.createGroup()
val original = f.manager.buildTextMessage(nostrGroupId, "mine")
// Hand-built rather than sent, because the point is a receiver
// refusing it: if authorship were checked only at send time, any
// member could rewrite anyone's words and no reader would notice.
val impostor = "f".repeat(64)
val forged =
MarmotAppEvent.build(
pubKey = impostor,
kind = MarmotAppEvent.KIND_EDIT,
content = "not mine",
createdAt = 1_800_000_000L,
tags = arrayOf(arrayOf("e", original.innerEvent.id)),
)
f.manager.persistDecryptedMessage(nostrGroupId, forged.toJson().dropLast(1) + ",\"sig\":\"\"}")
assertNull(f.manager.editOverlays(f.manager.storedEvents())[original.innerEvent.id])
}
@Test
fun `an author's own kind 5 retracts their message`() =
runBlocking {
val f = Fixture()
f.createGroup()
val doomed = f.manager.buildTextMessage(nostrGroupId, "delete me")
f.manager.buildDeletionMessage(nostrGroupId, listOf(doomed.innerEvent))
assertTrue(doomed.innerEvent.id in f.manager.deletedIds(f.manager.storedEvents()))
}
@Test
fun `a deletion from another account is ignored`() =
runBlocking {
val f = Fixture()
f.createGroup()
val mine = f.manager.buildTextMessage(nostrGroupId, "mine")
// Same shape as the forged edit above, and the same reason: the
// transport lets any member publish a well-formed kind:5 naming
// someone else's message, so the reader is the only place that can
// refuse it. MDK additionally honours an authenticated admin
// moderation grant here; we issue none, so every cross-author
// delete is ignored.
val impostor = "f".repeat(64)
val forged =
MarmotAppEvent.build(
pubKey = impostor,
kind = DeletionEvent.KIND,
content = "",
createdAt = 1_800_000_000L,
tags = arrayOf(arrayOf("e", mine.innerEvent.id)),
)
f.manager.persistDecryptedMessage(nostrGroupId, forged.toJson().dropLast(1) + ",\"sig\":\"\"}")
assertTrue(mine.innerEvent.id !in f.manager.deletedIds(f.manager.storedEvents()))
}
@Test
fun `one kind 5 retracts every message it names`() =
runBlocking {
val f = Fixture()
f.createGroup()
val first = f.manager.buildTextMessage(nostrGroupId, "one")
val second = f.manager.buildTextMessage(nostrGroupId, "two")
val spared = f.manager.buildTextMessage(nostrGroupId, "three")
f.manager.buildDeletionMessage(nostrGroupId, listOf(first.innerEvent, second.innerEvent))
val deleted = f.manager.deletedIds(f.manager.storedEvents())
assertEquals(setOf(first.innerEvent.id, second.innerEvent.id), deleted)
assertTrue(spared.innerEvent.id !in deleted)
}
@Test
fun `a deletion naming a message we do not hold retracts nothing`() =
runBlocking {
// Not invalid — the target may simply not have arrived yet — so it
// contributes nothing rather than being treated as an error. The
// overlay is recomputed from the whole log on every read, so it
// resolves as soon as the target lands.
val f = Fixture()
f.createGroup()
val absent = "9".repeat(64)
val forged =
MarmotAppEvent.build(
pubKey = f.signer.pubKey,
kind = DeletionEvent.KIND,
content = "",
createdAt = 1_800_000_000L,
tags = arrayOf(arrayOf("e", absent)),
)
f.manager.persistDecryptedMessage(nostrGroupId, forged.toJson().dropLast(1) + ",\"sig\":\"\"}")
assertTrue(f.manager.deletedIds(f.manager.storedEvents()).isEmpty())
}
@Test
fun `the latest edit wins`() =
runBlocking {
val f = Fixture()
f.createGroup()
val original = f.manager.buildTextMessage(nostrGroupId, "v1")
val author = f.signer.pubKey
for ((at, text) in listOf(1_800_000_000L to "v2", 1_800_000_100L to "v3", 1_800_000_050L to "v2b")) {
val edit =
MarmotAppEvent.build(
pubKey = author,
kind = MarmotAppEvent.KIND_EDIT,
content = text,
createdAt = at,
tags = arrayOf(arrayOf("e", original.innerEvent.id)),
)
f.manager.persistDecryptedMessage(nostrGroupId, edit.toJson().dropLast(1) + ",\"sig\":\"\"}")
}
assertEquals("v3", f.manager.editOverlays(f.manager.storedEvents())[original.innerEvent.id])
}
@Test
fun `an edit for a message we do not hold overlays nothing`() =
runBlocking {
val f = Fixture()
f.createGroup()
f.manager.buildMessageEdit(nostrGroupId, "9".repeat(64), "orphan")
assertTrue(f.manager.editOverlays(f.manager.storedEvents()).isEmpty())
}
@Test
fun `the first look at a group writes no system rows`() =
runBlocking {
val f = Fixture()
f.createGroup()
// createGroup already established a baseline through the commit
// path; looking again with nothing changed must stay silent.
assertEquals(emptyList(), f.manager.syncGroupSystemRows(nostrGroupId))
assertTrue(f.manager.storedEvents().none { it.kind == MarmotAppEvent.KIND_SYSTEM })
}
@Test
fun `a rename derives one row and only one`() =
runBlocking {
val f = Fixture()
f.createGroup(name = "before")
f.manager.syncGroupSystemRows(nostrGroupId)
f.manager.setGroupProfile(nostrGroupId, "after", "")
val rows = f.manager.storedEvents().filter { it.kind == MarmotAppEvent.KIND_SYSTEM }
assertEquals(1, rows.size, "one rename, one row")
val decoded = MarmotSystemEvent.fromAppEvent(MarmotAppEvent.fromEvent(rows.single()))
assertEquals(MarmotSystemType.GROUP_RENAMED, decoded?.systemType)
assertEquals("after", decoded?.name)
assertEquals(f.signer.pubKey, decoded?.actor)
// Deriving again against the recorded baseline must not re-write
// the caption. Without that, every sync would add a row for a
// change that happened once.
f.manager.syncGroupSystemRows(nostrGroupId)
assertEquals(1, f.manager.storedEvents().count { it.kind == MarmotAppEvent.KIND_SYSTEM })
}
@Test
fun `the baseline survives a restart, so a change across one is still described`() =
runBlocking {
val f = Fixture()
f.createGroup(name = "before")
f.manager.syncGroupSystemRows(nostrGroupId)
// A fresh manager over the same stores: the snapshot is what
// carries "where I left off" across the process boundary, and
// without it a restart would either lose the caption or re-derive
// the group from nothing.
val restarted = MarmotManager(f.signer, f.mlsStore, f.messageStore, SnapshotBundleStore(), publisher = ACCEPTING_RELAY)
restarted.restoreAll()
restarted.setGroupProfile(nostrGroupId, "after", "")
val rows = restarted.loadStoredMessages(nostrGroupId).mapNotNull { Event.fromJsonOrNull(it) }.filter { it.kind == MarmotAppEvent.KIND_SYSTEM }
assertEquals(1, rows.size)
}
@Test
fun `a derived row is surfaced as it happens, not only after a restart`() =
runBlocking {
// Rows used to reach the conversation only when a restart re-read
// the local log, which is the wrong moment to learn that someone
// was removed from the group.
val f = Fixture()
f.createGroup(name = "before")
f.manager.syncGroupSystemRows(nostrGroupId)
val surfaced = mutableListOf<Event>()
f.manager.onSystemRowDerived = { _, row -> surfaced.add(row) }
f.manager.setGroupProfile(nostrGroupId, "after", "")
assertEquals(1, surfaced.size, "a rename must surface exactly one row")
assertEquals(MarmotAppEvent.KIND_SYSTEM, surfaced.single().kind)
// Authored by this client: a derived row is OUR reading of
// authenticated state, and the feed admits a 1210 on exactly that.
assertEquals(f.signer.pubKey, surfaced.single().pubKey)
}
}
@@ -0,0 +1,152 @@
/*
* 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.mip00KeyPackages.KeyPackageUtils
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.assertTrue
/**
* A rotation must publish the same kind of KeyPackage the first publication
* did.
*
* MIP-00 makes us replace a KeyPackage as soon as a Welcome consumes it, so
* rotation is not a rare path — it runs right after the first group we are
* ever invited to. Minting the replacement through the legacy generator meant
* that from that moment on the only KeyPackage on relays for us was a
* MIP-era one, without the account identity proof (`0x8009`) that a
* current-profile peer requires. MDK then refuses it outright
* (`member KeyPackage identity or profile is invalid`) and keeps inviting from
* whatever stale copy it still has cached, so the account silently becomes
* uninvitable one join after it was set up.
*/
class MarmotKeyPackageRotationProfileTest {
private val relay =
RelayUrlNormalizer.normalizeOrNull("wss://example.invalid/")
?: error("test relay must normalize")
@Test
fun aRotatedKeyPackageStaysOnTheCurrentProfile() =
runBlocking {
val signer = NostrSignerInternal(KeyPair())
val manager = MarmotManager(signer, RotationStateStore(), RotationMessageStore(), RotationBundleStore())
val first = manager.generateKeyPackageEvent(listOf(relay))
assertTrue(first.isCurrentProfile(), "the first publication is current-profile")
// A Welcome consumed it, which is what schedules the replacement.
manager.keyPackageRotationManager.markConsumedByEventId(first.id)
assertTrue(manager.needsKeyPackageRotation())
val rotated = manager.rotateConsumedKeyPackages(listOf(relay))
assertEquals(1, rotated.size, "one consumed slot means one replacement")
val replacement = rotated.single()
assertTrue(
replacement.isCurrentProfile(),
"the replacement must carry the account identity proof too, or no current-profile " +
"peer can add us after our first join",
)
assertEquals(
first.dTag(),
replacement.dTag(),
"the replacement lands in the same addressable slot",
)
assertTrue(
replacement.id != first.id,
"the replacement must actually be a different KeyPackage",
)
assertTrue(
KeyPackageUtils.isValid(replacement),
"and it must validate under the same MIP-00 rules a peer applies",
)
assertTrue(
!manager.needsKeyPackageRotation(),
"the slot is no longer pending once its replacement is minted",
)
}
}
// Minimal in-memory stores, matching the ones the other MarmotManager tests
// use; the file-backed implementations live in the platform modules.
private class RotationStateStore : MlsGroupStateStore {
private val states = mutableMapOf<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
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<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
}
private class RotationMessageStore : MarmotMessageStore {
override suspend fun appendMessage(
nostrGroupId: String,
innerEventJson: String,
) = Unit
override suspend fun loadMessages(nostrGroupId: String): List<String> = emptyList()
override suspend fun delete(nostrGroupId: String) = Unit
}
private class RotationBundleStore : 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
}
}
@@ -0,0 +1,204 @@
/*
* 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.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* What happens to a member who leaves.
*
* MIP-03 makes a departure a standalone `SelfRemove` PROPOSAL: the leaver
* cannot evict themselves, because a proposal advances nothing. Until an
* authorized member commits it, the leaver is STILL IN THE TREE — which means
* still holding the group's keys and still able to decrypt everything sent
* after they left. That is the opposite of what leaving is for, so the commit
* is not a nicety; it is the point.
*/
class MarmotLeaveProposalTest {
private val nostrGroupId = "1".repeat(64)
private class Fixture {
val signer = NostrSignerInternal(KeyPair())
val mlsStore = SnapshotStateStore()
val messageStore = SnapshotMessageStore()
val bundleStore = SnapshotBundleStore()
var manager = build()
private fun build() = MarmotManager(signer, mlsStore, messageStore, bundleStore, publisher = ACCEPTING_RELAY)
/** Drop the process and come back over the same durable stores. */
suspend fun restart() {
manager = build()
manager.restoreAll()
}
}
@Test
fun `an admin commits a departing member's SelfRemove and the tree shrinks`() =
runBlocking {
val alice = Fixture()
val bob = Fixture()
alice.manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
profile = GroupProfileV1("departures", ""),
)
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
// A founding add publishes no commit, so there is no echo for
// Alice to re-ingest: the Welcome is the whole delivery.
val (commit, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
assertNull(commit, "the founding add publishes no commit")
bob.manager.ingest(welcome!!.giftWrapEvent)
assertEquals(2, alice.manager.memberCount(nostrGroupId))
// Bob departs. The proposal is all he can produce.
val proposal = bob.manager.leaveGroup(nostrGroupId)
val staged = alice.manager.ingest(proposal.signedEvent)
assertIs<MarmotIngestResult.ProposalStaged>(
staged,
"a peer's SelfRemove must reach the pending pool, not be dropped",
)
assertTrue(
alice.manager.groupManager.hasPendingProposals(nostrGroupId),
"the staged proposal must be visible as pending work",
)
assertTrue(
alice.manager.commitPendingProposals(nostrGroupId, emptyList()) != null,
"there was pending work, so a commit must have been produced",
)
assertEquals(
1,
alice.manager.memberCount(nostrGroupId),
"committing a SelfRemove must actually evict the leaver — otherwise they keep " +
"the group's keys and keep reading everything sent after they left",
)
}
@Test
fun `every member applies the commit that evicts the leaver, not just the committer`() =
runBlocking {
// Three members, because the bug only shows with a WITNESS: alice
// commits carol's departure, and bob has to reach the same state
// from the commit alone.
val alice = Fixture()
val bob = Fixture()
val carol = Fixture()
alice.manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
profile = GroupProfileV1("departures", ""),
)
val (commit, welcomes) =
alice.manager.addMembers(
nostrGroupId,
listOf(
bob.manager.generateKeyPackageEvent(relays = emptyList()),
carol.manager.generateKeyPackageEvent(relays = emptyList()),
),
emptyList(),
)
assertNull(commit, "the founding add publishes no commit, however many invitees it carries")
welcomes.forEach { delivery ->
when (delivery.recipientPubKey) {
bob.signer.pubKey -> bob.manager.ingest(delivery.giftWrapEvent)
carol.signer.pubKey -> carol.manager.ingest(delivery.giftWrapEvent)
}
}
assertEquals(3, alice.manager.memberCount(nostrGroupId))
// Carol departs. Her proposal reaches everyone, as it does on the
// wire — it is published as its own group event.
val proposal = carol.manager.leaveGroup(nostrGroupId)
alice.manager.ingest(proposal.signedEvent)
bob.manager.ingest(proposal.signedEvent)
// Alice, the admin, commits it.
val eviction = alice.manager.commitPendingProposals(nostrGroupId, emptyList())
assertNotNull(eviction, "the staged SelfRemove must produce a commit")
assertEquals(2, alice.manager.memberCount(nostrGroupId))
// Bob applies that commit. He must land exactly where alice is.
bob.manager.ingest(eviction.signedEvent)
assertEquals(
alice.manager.groupEpoch(nostrGroupId),
bob.manager.groupEpoch(nostrGroupId),
"a witness that stays an epoch behind cannot read anything the group sends next",
)
assertEquals(2, bob.manager.memberCount(nostrGroupId))
// And the right person left. An inline SelfRemove is attributed to
// whoever committed it, so getting this wrong evicts the COMMITTER.
val remaining =
bob.manager
.memberPubkeys(nostrGroupId)
.map { it.pubkey }
.toSet()
assertEquals(setOf(alice.signer.pubKey, bob.signer.pubKey), remaining)
}
@Test
fun `a staged SelfRemove survives a restart`() =
runBlocking {
val alice = Fixture()
val bob = Fixture()
alice.manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
profile = GroupProfileV1("durable departures", ""),
)
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (commit, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
assertNull(commit, "the founding add publishes no commit")
bob.manager.ingest(welcome!!.giftWrapEvent)
// Bob departs and alice stages his proposal — then alice's process
// dies before anyone commits it.
alice.manager.ingest(bob.manager.leaveGroup(nostrGroupId).signedEvent)
assertTrue(alice.manager.groupManager.hasPendingProposals(nostrGroupId))
alice.restart()
// The obligation has to come back. Losing it is not losing a
// message — it leaves bob in the tree holding the group's keys,
// with nobody holding the proposal that evicts him.
assertTrue(
alice.manager.groupManager.hasPendingProposals(nostrGroupId),
"a staged SelfRemove must survive a restart, or the leaver never leaves",
)
assertNotNull(alice.manager.commitPendingProposals(nostrGroupId, emptyList()))
assertEquals(1, alice.manager.memberCount(nostrGroupId))
}
}
@@ -111,10 +111,11 @@ class MarmotManagerLeaveRejoinTest {
"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<MarmotIngestResult.Ignored>(alice.manager.ingest(commitEvent1.signedEvent))
// Alice's first add is the FOUNDING add: she was the group's only
// member, so it merges locally under the empty publication
// obligation and no commit is published. There is therefore no echo
// to come back to her — the Welcome above was the whole delivery.
assertNull(commitEvent1, "the founding add publishes no commit")
// Step 3: Alice sends a kind:9 inner event. Bob ingests the kind:445.
val helloBefore = "hello before leave"
@@ -224,7 +225,11 @@ class MarmotManagerLeaveRejoinTest {
val mls = InMemoryMlsGroupStateStore()
val kp = InMemoryKeyPackageBundleStore()
val msg = InMemoryMarmotMessageStore()
return Fixture(MarmotManager(signer, mls, msg, kp), mls, kp, msg)
// Publish-before-apply needs an acknowledged accept, so a manager with
// no publisher can never advance group state. This stands in for a
// relay that accepts everything.
val acceptingRelay = MarmotPublisher { _, _ -> true }
return Fixture(MarmotManager(signer, mls, msg, kp, acceptingRelay), mls, kp, msg)
}
}
@@ -0,0 +1,471 @@
/*
* 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.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
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.relay.normalizer.NormalizedRelayUrl
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.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* `protocol-core/publish-lifecycle.md`: a locally generated group-state change
* MUST NOT become local canonical state until its publish obligation succeeded.
*
* The rule is not "roll back if the publish fails". Applying first and undoing
* afterwards leaves a window in which this client's canonical state is an epoch
* no peer has, and a crash inside that window makes the fork permanent. So
* these tests assert the group never moves at all until an acknowledgement
* arrives.
*/
class MarmotPublishBeforeApplyTest {
private val relay: NormalizedRelayUrl = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!
/** Records what was handed to it, and answers with a fixed verdict. */
private class RecordingPublisher(
private val accepts: Boolean,
) : MarmotPublisher {
val published = mutableListOf<Event>()
override suspend fun publish(
event: Event,
relays: Set<NormalizedRelayUrl>,
): Boolean {
published.add(event)
return accepts
}
}
private class Fixture(
val manager: MarmotManager,
val publisher: RecordingPublisher,
val groupId: String,
val bobKeyPackage: ByteArray,
val bobPubKey: String,
val carolKeyPackage: ByteArray,
val carolPubKey: String,
)
/**
* A group with one founding member already added, so the NEXT commit is an
* ordinary one.
*
* Publish-before-apply governs every commit except creation and the
* founding Add that may follow it, so a test of the rule has to get past
* both first — otherwise it measures the exception it is not about. The
* founding add publishes nothing, so [RecordingPublisher.published] is
* cleared and every later assertion counts only ordinary commits.
*/
private suspend fun foundedFixture(accepts: Boolean): Fixture {
val fx = fixture(accepts)
fx.manager.addMember(
nostrGroupId = fx.groupId,
memberPubKey = fx.bobPubKey,
keyPackageBytes = fx.bobKeyPackage,
keyPackageEventId = "c".repeat(64),
relays = listOf(relay),
)
fx.publisher.published.clear()
return fx
}
private suspend fun fixture(accepts: Boolean): Fixture {
val publisher = RecordingPublisher(accepts)
val signer = NostrSignerInternal(KeyPair())
val manager =
MarmotManager(
signer,
InMemoryStateStore(),
publisher = publisher,
)
val groupId = "a".repeat(64)
manager.createGroup(
groupId,
MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(signer.pubKey),
relays = listOf(relay.url),
),
)
val bob = KeyPair()
val bundle =
manager.groupManager
.getGroup(groupId)!!
.createKeyPackage(bob.pubKey, ByteArray(0))
val carol = KeyPair()
val carolBundle =
manager.groupManager
.getGroup(groupId)!!
.createKeyPackage(carol.pubKey, ByteArray(0))
return Fixture(
manager,
publisher,
groupId,
bundle.keyPackage.toTlsBytes(),
bob.pubKey.toHexKey(),
carolBundle.keyPackage.toTlsBytes(),
carol.pubKey.toHexKey(),
)
}
/**
* Creation is the one exception: a one-member epoch-0 group has no peer
* that failure to publish could fork, so its obligation is empty and
* immediately satisfied.
*/
@Test
fun groupCreationSatisfiesAnEmptyObligation() =
runBlocking<Unit> {
val fx = fixture(accepts = true)
assertEquals(GroupLifecycleState.STABLE, fx.manager.lifecycle(fx.groupId))
assertEquals(
0L,
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch,
)
assertTrue(fx.publisher.published.isEmpty(), "creating a group publishes no group message")
}
/**
* The founding Add is the second half of the creation exception: it is
* merged locally and NOTHING is published, even though a member is joining.
*
* `protocol-core/publish-lifecycle.md`: "When founding creation includes
* initial invitees, the creator next prepares and locally merges one
* founding Add Commit from epoch 0 to epoch 1. That Commit also has an
* empty group-message publication obligation: the creator is the only
* pre-existing member, so no peer can be forked by failure to publish it."
*
* The publisher here REJECTS everything, which is the point: a relay that
* accepts nothing must not be able to stop a group from being founded with
* its initial members.
*/
@Test
fun theFoundingAddMergesLocallyEvenWhenNoRelayAcceptsAnything() =
runBlocking<Unit> {
val fx = fixture(accepts = false)
val (commit, welcome) =
fx.manager.addMember(
nostrGroupId = fx.groupId,
memberPubKey = fx.bobPubKey,
keyPackageBytes = fx.bobKeyPackage,
keyPackageEventId = "c".repeat(64),
relays = listOf(relay),
)
assertEquals(null, commit, "a founding add publishes no commit")
assertTrue(fx.publisher.published.isEmpty(), "and offers none to a relay")
assertEquals(
1L,
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch,
"the founding add is canonical regardless of the relay",
)
assertEquals(
setOf(fx.manager.signer.pubKey, fx.bobPubKey),
fx.manager.groupManager
.getGroup(fx.groupId)!!
.currentMemberIdentities(),
)
// The Welcome is the delivery, and it is produced unconditionally:
// the Add is already canonical, so there is no "epoch nobody
// accepted" that it could be inviting someone into.
assertTrue(welcome != null, "the invitee still gets a Welcome")
assertEquals(GroupLifecycleState.STABLE, fx.manager.lifecycle(fx.groupId))
assertTrue(
fx.manager.publishGate
.pendingFor(fx.groupId)
.isEmpty(),
"no obligation is left behind for a commit that was never owed",
)
}
/**
* The exception stops after the founding Add. The very next commit is
* ordinary and must be published before it applies.
*/
@Test
fun theCommitAfterTheFoundingAddIsOrdinary() =
runBlocking<Unit> {
val fx = foundedFixture(accepts = false)
val (commit, welcome) =
fx.manager.addMember(
nostrGroupId = fx.groupId,
memberPubKey = fx.carolPubKey,
keyPackageBytes = fx.carolKeyPackage,
keyPackageEventId = "d".repeat(64),
relays = listOf(relay),
)
assertTrue(commit != null, "an ordinary add builds a commit to publish")
assertEquals(1, fx.publisher.published.size, "and offers it to the relay")
assertEquals(
1L,
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch,
"which no relay accepted, so the group did not move",
)
assertEquals(null, welcome)
assertEquals(GroupLifecycleState.PENDING_PUBLISH, fx.manager.lifecycle(fx.groupId))
}
/** An acknowledged commit becomes canonical and the group returns to Stable. */
@Test
fun anAcknowledgedCommitBecomesCanonical() =
runBlocking<Unit> {
val fx = foundedFixture(accepts = true)
val before =
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch
fx.manager.addMember(
nostrGroupId = fx.groupId,
memberPubKey = fx.carolPubKey,
keyPackageBytes = fx.carolKeyPackage,
keyPackageEventId = "d".repeat(64),
relays = listOf(relay),
)
assertEquals(1, fx.publisher.published.size)
assertEquals(
before + 1,
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch,
)
assertEquals(GroupLifecycleState.STABLE, fx.manager.lifecycle(fx.groupId))
assertTrue(
fx.manager.publishGate
.pendingFor(fx.groupId)
.isEmpty(),
)
}
/**
* The headline case. No relay accepted, so the group stays exactly where it
* was — same epoch, same GroupContext, byte for byte.
*/
@Test
fun anUnacknowledgedCommitNeverBecomesCanonical() =
runBlocking<Unit> {
val fx = foundedFixture(accepts = false)
val beforeEpoch =
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch
val beforeContext =
fx.manager.groupManager
.snapshot(fx.groupId)!!
.groupContext
.toTlsBytes()
val (_, welcome) =
fx.manager.addMember(
nostrGroupId = fx.groupId,
memberPubKey = fx.carolPubKey,
keyPackageBytes = fx.carolKeyPackage,
keyPackageEventId = "d".repeat(64),
relays = listOf(relay),
)
assertEquals(1, fx.publisher.published.size, "it was still offered to the relay")
assertEquals(
beforeEpoch,
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch,
)
assertContentEquals(
beforeContext,
fx.manager.groupManager
.snapshot(fx.groupId)!!
.groupContext
.toTlsBytes(),
"an unpublished commit must leave the canonical state untouched",
)
// And no Welcome: inviting someone into an epoch no relay accepted
// would point them at a group that exists nowhere else.
assertEquals(null, welcome)
}
/**
* A raised outbound gate blocks all new local group-state work.
*
* `Leaving`, `Disbanding` and a realized `Removed` each mean this client
* has no standing to publish a new commit: it is on its way out, or already
* out. Preparing one anyway would produce an epoch nobody will accept, so
* the attempt fails loudly rather than silently forking.
*/
@Test
fun anOutboundGateBlocksNewCommits() =
runBlocking<Unit> {
val fx = foundedFixture(accepts = true)
assertTrue(fx.manager.publishGate.canPrepareLocalCommit(fx.groupId))
fx.manager.publishGate.raiseGate(fx.groupId, LocalOutboundGate.LEAVING)
assertEquals(LocalOutboundGate.LEAVING, fx.manager.publishGate.outboundGate(fx.groupId))
assertFailsWith<IllegalStateException> {
fx.manager.updateGroupMetadata(
fx.groupId,
MarmotGroupData(nostrGroupId = fx.groupId, adminPubkeys = listOf(fx.manager.signer.pubKey)),
listOf(relay),
)
}
assertTrue(fx.publisher.published.isEmpty(), "a gated commit is never even offered to a relay")
// Cleared, the same commit goes through.
fx.manager.publishGate.clearGate(fx.groupId)
fx.manager.updateGroupMetadata(
fx.groupId,
MarmotGroupData(nostrGroupId = fx.groupId, adminPubkeys = listOf(fx.manager.signer.pubKey)),
listOf(relay),
)
assertEquals(1, fx.publisher.published.size)
}
/**
* A group whose publisher never acknowledges anything can still be read.
* It simply cannot advance — which is the safe direction to fail.
*
* It also cannot start over. "No OK arrived" is not "no peer took it": a
* timeout or a dropped connection leaves us unable to say. Discarding the
* obligation and letting a SECOND commit be prepared for the same epoch is
* how that uncertainty becomes a permanent fork — the peer that did
* receive the first commit is at epoch 1, rejects our second, and the two
* copies never reconcile. So the obligation stays, the group stays held,
* and the retry republishes the SAME bytes.
*/
@Test
fun anUnconfirmedPublishHoldsTheGroupInsteadOfStartingOver() =
runBlocking<Unit> {
val fx = foundedFixture(accepts = false)
val heldEpoch =
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch
fx.manager.addMember(
nostrGroupId = fx.groupId,
memberPubKey = fx.carolPubKey,
keyPackageBytes = fx.carolKeyPackage,
keyPackageEventId = "d".repeat(64),
relays = listOf(relay),
)
assertEquals(GroupLifecycleState.PENDING_PUBLISH, fx.manager.lifecycle(fx.groupId))
assertEquals(
1,
fx.manager.publishGate
.pendingFor(fx.groupId)
.size,
"an unconfirmed obligation stays retryable",
)
// Reading is unaffected; only advancing the group is blocked.
assertEquals(
heldEpoch,
fx.manager.groupManager
.getGroup(fx.groupId)!!
.epoch,
)
// A second, different commit for the same epoch is refused.
assertFailsWith<IllegalStateException> {
fx.manager.addMember(
nostrGroupId = fx.groupId,
memberPubKey = fx.carolPubKey,
keyPackageBytes = fx.carolKeyPackage,
keyPackageEventId = "d".repeat(64),
relays = listOf(relay),
)
}
// The invariant is that no REPLACEMENT COMMIT was minted for the
// held epoch — not that nothing went out. A blocked attempt now
// retries the stuck obligation on its way through, so the relay may
// legitimately see the same event twice; what it must never see is
// a second, different commit for the same epoch, because that is
// the fork publish-before-apply exists to prevent. Counting
// distinct ids says that, where counting sends only said it by
// accident.
assertEquals(
1,
fx.publisher.published
.map { it.id }
.toSet()
.size,
"no replacement commit was offered; a re-send of the same event is not one",
)
}
/** The group's own relay list is the default recipient scope. */
@Test
fun theRecipientScopeComesFromTheGroupsOwnRelayList() =
runBlocking<Unit> {
val fx = fixture(accepts = true)
assertEquals(listOf(relay), fx.manager.groupRelays(fx.groupId))
}
private class InMemoryStateStore : com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore {
private val states = mutableMapOf<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
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<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
epochs: List<ByteArray>,
) {
retained[nostrGroupId] = epochs
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
}
}
@@ -0,0 +1,325 @@
/*
* 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.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
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.assertTrue
/**
* Publish-before-apply is only a real rule if it survives the process.
*
* A commit is recorded, published, and only then applied. A crash inside that
* window has to leave a durable trace, because the alternative is that the next
* launch has no memory of the commit, mints a REPLACEMENT for the same epoch,
* and forks this client against every peer that accepted the first one. So
* these tests restart the manager against the same stores and assert the
* obligation is still there, is retried with the SAME bytes, and only then
* lets the group move.
*/
class MarmotPublishDurabilityTest {
private val relay: NormalizedRelayUrl = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!
/** Answers with a verdict that can be flipped between "runs". */
private class SwitchablePublisher(
var accepts: Boolean,
) : MarmotPublisher {
val published = mutableListOf<Event>()
override suspend fun publish(
event: Event,
relays: Set<NormalizedRelayUrl>,
): Boolean {
published.add(event)
return accepts
}
}
private class MemoryObligationStore : MarmotPublishObligationStore {
val entries = LinkedHashMap<HexKey, ByteArray>()
override suspend fun save(
obligationId: HexKey,
bytes: ByteArray,
) {
entries[obligationId] = bytes
}
override suspend fun delete(obligationId: HexKey) {
entries.remove(obligationId)
}
override suspend fun loadAll(): List<ByteArray> = entries.values.toList()
}
/** Same MLS state across "restarts", like a real store on disk. */
private class MemoryStateStore : MlsGroupStateStore {
private val states = mutableMapOf<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
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<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
epochs: List<ByteArray>,
) {
retained[nostrGroupId] = epochs
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId].orEmpty()
}
private fun manager(
signer: NostrSignerInternal,
store: MlsGroupStateStore,
obligations: MarmotPublishObligationStore,
publisher: MarmotPublisher,
) = MarmotManager(
signer,
store,
publisher = publisher,
publishObligationStore = obligations,
)
@Test
fun anUnresolvedObligationOutlivesTheProcessAndIsRetriedVerbatim() =
runBlocking<Unit> {
val signer = NostrSignerInternal(KeyPair())
val store = MemoryStateStore()
val obligations = MemoryObligationStore()
val publisher = SwitchablePublisher(accepts = false)
val groupId = "b".repeat(64)
val first = manager(signer, store, obligations, publisher)
first.createGroup(
groupId,
MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(signer.pubKey),
relays = listOf(relay.url),
),
)
// Get past the FOUNDING add first. It merges locally under the
// empty publication obligation (`publish-lifecycle.md`) and
// publishes nothing, so it is not a commit publish-before-apply
// governs — a durability test that used it would be testing the
// exception instead of the rule.
val founder = KeyPair()
first.addMember(
nostrGroupId = groupId,
memberPubKey = founder.pubKey.toHexKey(),
keyPackageBytes =
first.groupManager
.getGroup(groupId)!!
.createKeyPackage(founder.pubKey, ByteArray(0))
.keyPackage
.toTlsBytes(),
keyPackageEventId = "f".repeat(64),
relays = listOf(relay),
)
publisher.published.clear()
val bob = KeyPair()
val bundle =
first.groupManager
.getGroup(groupId)!!
.createKeyPackage(bob.pubKey, ByteArray(0))
// The publish is refused, so the group must NOT move and the
// obligation must remain on disk.
runCatching {
first.addMember(
nostrGroupId = groupId,
memberPubKey = bob.pubKey.toHexKey(),
keyPackageBytes = bundle.keyPackage.toTlsBytes(),
keyPackageEventId = "d".repeat(64),
relays = listOf(relay),
)
}
assertEquals(GroupLifecycleState.PENDING_PUBLISH, first.lifecycle(groupId))
assertEquals(1L, first.groupManager.getGroup(groupId)!!.epoch, "the founding add stands; the refused one did not apply")
assertEquals(1, obligations.entries.size, "an unacknowledged commit leaves its obligation durable")
val recordedBytes =
obligations.entries.values
.first()
.copyOf()
val firstAttempt = publisher.published.single()
// Restart against the same stores. The relay accepts this time.
publisher.accepts = true
val second = manager(signer, store, obligations, publisher)
second.restoreAll()
val retry = publisher.published.last()
assertEquals(
firstAttempt.toJson(),
retry.toJson(),
"the retry republishes the same event, not a replacement commit for the same epoch",
)
assertEquals(GroupLifecycleState.STABLE, second.lifecycle(groupId))
assertEquals(2L, second.groupManager.getGroup(groupId)!!.epoch, "the acknowledged commit applies")
assertTrue(obligations.entries.isEmpty(), "a resolved obligation is deleted")
assertTrue(recordedBytes.isNotEmpty())
}
@Test
fun aWedgedGroupRecoversOnTheNextCommitWithoutARestart() =
runBlocking<Unit> {
// `PendingPublish` correctly refuses new commits, but the only
// thing that ever resolved it was `restoreAll` — so a single
// dropped socket left the group unable to commit anything until the
// app was restarted. The next attempt has to BE the recovery.
val signer = NostrSignerInternal(KeyPair())
val store = MemoryStateStore()
val obligations = MemoryObligationStore()
val publisher = SwitchablePublisher(accepts = false)
val groupId = "e".repeat(64)
val manager = manager(signer, store, obligations, publisher)
manager.createGroup(
groupId,
MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(signer.pubKey),
relays = listOf(relay.url),
),
)
val founder = KeyPair()
manager.addMember(
nostrGroupId = groupId,
memberPubKey = founder.pubKey.toHexKey(),
keyPackageBytes =
manager.groupManager
.getGroup(groupId)!!
.createKeyPackage(founder.pubKey, ByteArray(0))
.keyPackage
.toTlsBytes(),
keyPackageEventId = "f".repeat(64),
relays = listOf(relay),
)
// A commit the relay never acknowledged: the group is held.
runCatching { manager.setGroupProfile(groupId, "first try", "", listOf(relay)) }
assertEquals(GroupLifecycleState.PENDING_PUBLISH, manager.lifecycle(groupId))
// The relay is back. Without a restart, the next commit must clear
// the stuck obligation and then land.
publisher.accepts = true
manager.setGroupProfile(groupId, "second try", "", listOf(relay))
assertEquals(GroupLifecycleState.STABLE, manager.lifecycle(groupId))
assertTrue(obligations.entries.isEmpty(), "the stuck obligation resolved on the way through")
assertEquals("second try", manager.groupView(groupId)?.name)
}
@Test
fun aRetryThatFailsAgainKeepsTheGroupHeld() =
runBlocking<Unit> {
val signer = NostrSignerInternal(KeyPair())
val store = MemoryStateStore()
val obligations = MemoryObligationStore()
val publisher = SwitchablePublisher(accepts = false)
val groupId = "c".repeat(64)
val first = manager(signer, store, obligations, publisher)
first.createGroup(
groupId,
MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(signer.pubKey),
relays = listOf(relay.url),
),
)
// Get past the FOUNDING add first. It merges locally under the
// empty publication obligation (`publish-lifecycle.md`) and
// publishes nothing, so it is not a commit publish-before-apply
// governs — a durability test that used it would be testing the
// exception instead of the rule.
val founder = KeyPair()
first.addMember(
nostrGroupId = groupId,
memberPubKey = founder.pubKey.toHexKey(),
keyPackageBytes =
first.groupManager
.getGroup(groupId)!!
.createKeyPackage(founder.pubKey, ByteArray(0))
.keyPackage
.toTlsBytes(),
keyPackageEventId = "f".repeat(64),
relays = listOf(relay),
)
publisher.published.clear()
val carol = KeyPair()
val bundle =
first.groupManager
.getGroup(groupId)!!
.createKeyPackage(carol.pubKey, ByteArray(0))
runCatching {
first.addMember(
nostrGroupId = groupId,
memberPubKey = carol.pubKey.toHexKey(),
keyPackageBytes = bundle.keyPackage.toTlsBytes(),
keyPackageEventId = "e".repeat(64),
relays = listOf(relay),
)
}
val second = manager(signer, store, obligations, publisher)
second.restoreAll()
// Still refused: the group stays held rather than quietly moving
// on, which is what stops a new commit stacking on an epoch peers
// never accepted.
assertEquals(GroupLifecycleState.PENDING_PUBLISH, second.lifecycle(groupId))
assertEquals(1L, second.groupManager.getGroup(groupId)!!.epoch, "still held at the founding epoch")
assertEquals(1, obligations.entries.size)
assertTrue(bundle.keyPackage.toTlsBytes().isNotEmpty())
}
}
@@ -0,0 +1,318 @@
/*
* 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.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.InMemoryPushStateStore
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushBase64
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushGossip
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushOwnerProof
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushPlatform
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushRecordKind
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushSignedRecord
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.PushTokenEntry
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenEncryption
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenListEvent
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRemovalEvent
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
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.Nip01Crypto
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.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Push token gossip through the app layer
* (`features/push-notifications.md`).
*
* The interesting failures here are not parse errors — those are covered in
* quartz — but the ones where the app layer would quietly do the wrong thing:
* announce a record no peer can verify, answer a request with nothing, or
* forget a tombstone across a restart and start waking a revoked device again.
*/
class MarmotPushCoordinatorTest {
private val nostrGroupId = "d".repeat(64)
private val server = "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4"
private val deviceToken = "a-real-looking-apns-token".encodeToByteArray()
private class Fixture {
val signer = NostrSignerInternal(KeyPair())
val mlsStore = SnapshotStateStore()
val messageStore = SnapshotMessageStore()
val stateStore = InMemoryPushStateStore()
val manager = MarmotManager(signer, mlsStore, messageStore, SnapshotBundleStore(), publisher = ACCEPTING_RELAY)
val push = MarmotPushCoordinator(manager, stateStore)
}
private suspend fun Fixture.createGroup() =
manager.createGroup(
nostrGroupId,
MarmotGroupData(nostrGroupId = nostrGroupId, name = "push", relays = listOf("wss://relay.invalid")),
)
private suspend fun Fixture.selfUpdate(
ownerTs: Long = 1_735_680_000_000L,
relayHint: String = "",
): Event =
assertNotNull(
push.buildSelfUpdate(nostrGroupId, PushPlatform.APNS, deviceToken, server, relayHint, ownerTs),
"the group's own member should be able to announce a token",
)
@Test
fun `a self update announces a record every peer can verify`() =
runBlocking {
val f = Fixture()
f.createGroup()
val event = f.selfUpdate(relayHint = "wss://push.example.com")
assertEquals(TokenRequestEvent.KIND, event.kind)
// Unsigned: it is an inner Marmot app payload, and its authority is
// the entry's own owner_sig rather than a Nostr signature.
assertEquals("", event.sig)
val entry = PushGossip.decodeTokens(event.content).single()
assertEquals(f.signer.pubKey, entry.memberIdHex)
assertEquals(PushPlatform.APNS, entry.platform)
assertEquals("wss://push.example.com", entry.relayHint)
assertEquals(PushSignedRecord.fingerprintOf(PushPlatform.APNS, deviceToken), entry.tokenFingerprint)
assertEquals(PushSignedRecord.ENCRYPTED_TOKEN_BYTES, entry.encryptedToken.size)
// The proof is what a peer actually checks, and it binds the group.
val groupIdHex = assertNotNull(f.manager.mlsGroupIdHex(nostrGroupId))
assertTrue(entry.verifyOwner(groupIdHex, currentProfileGroup = false))
assertFalse(entry.verifyOwner("00".repeat(16), currentProfileGroup = false))
}
@Test
fun `the announced record is applied locally so a later list carries it`() =
runBlocking {
val f = Fixture()
f.createGroup()
f.selfUpdate()
val list = assertNotNull(f.push.buildTokenList(nostrGroupId))
assertEquals(TokenListEvent.KIND, list.kind)
assertEquals(1, PushGossip.decodeTokens(list.content).size)
}
@Test
fun `there is no list response when we hold nothing`() =
runBlocking {
val f = Fixture()
f.createGroup()
// An empty kind 448 is noise: it tells a requester nothing it did
// not already know and still costs a group message.
assertNull(f.push.buildTokenList(nostrGroupId))
}
@Test
fun `an empty request is recognised and a self update is not`() =
runBlocking {
val f = Fixture()
f.createGroup()
assertTrue(f.push.isTokenRequest(f.push.buildTokenRequest()))
assertFalse(f.push.isTokenRequest(f.selfUpdate()))
}
@Test
fun `an entry relayed by another member is applied on its own signature`() =
runBlocking {
// Two accounts, one group each, same MLS group id would be ideal —
// but the point is narrower and testable here: applying an entry
// does not consult who carried it, only whether the signature
// verifies and the named member is current.
val f = Fixture()
f.createGroup()
val announced = f.selfUpdate()
val relayed = Fixture()
// A fresh coordinator over the same manager stands in for a peer
// that only ever saw the gossip, never the sender.
val peer = MarmotPushCoordinator(f.manager, InMemoryPushStateStore())
assertTrue(peer.apply(nostrGroupId, announced))
assertEquals(1, peer.activeRecords(nostrGroupId).size)
assertTrue(relayed.stateStore.load(nostrGroupId) == null)
}
@Test
fun `a properly signed entry naming a non-member is still dropped`() =
runBlocking {
val f = Fixture()
f.createGroup()
val groupIdHex = assertNotNull(f.manager.mlsGroupIdHex(nostrGroupId))
// A real proof from an account that simply holds no leaf here. The
// signature verifies; membership is the separate gate, and it has
// to be, or anyone who ever learns a group id could point its
// members' notifications at a server of their choosing.
val outsiderPriv = ByteArray(32).also { it[31] = 7 }
val outsider = Nip01Crypto.pubKeyCreate(outsiderPriv).toHexKey()
val fingerprint = PushSignedRecord.fingerprintOf(PushPlatform.APNS, deviceToken)
val encryptedToken = TokenEncryption.encrypt(PushPlatform.APNS, deviceToken, server.hexToByteArray())
val ownerTs = 1_735_680_000_000L
val tags =
PushOwnerProof.tags(
PushRecordKind.TOKEN,
groupIdHex,
outsider,
0,
PushPlatform.APNS.wireName,
server,
fingerprint,
ownerTs,
"",
)
val entry =
PushTokenEntry(
memberIdHex = outsider,
leafIndex = 0,
platform = PushPlatform.APNS,
tokenFingerprint = fingerprint,
serverPubKeyHex = server,
relayHint = "",
encryptedToken = assertNotNull(PushBase64.decodeOrNull(encryptedToken)),
ownerTsMillis = ownerTs,
ownerSig = Nip01Crypto.sign(PushOwnerProof.eventId(outsider, tags, encryptedToken), outsiderPriv),
)
assertTrue(entry.verifyOwner(groupIdHex, currentProfileGroup = false), "the fixture should sign a real proof")
val carried =
Event("0".repeat(64), f.signer.pubKey, 1L, TokenRequestEvent.KIND, emptyArray(), PushGossip.encodeTokens(listOf(entry)), "")
assertFalse(f.push.apply(nostrGroupId, carried))
assertTrue(f.push.activeRecords(nostrGroupId).isEmpty())
}
@Test
fun `a removal revokes the record and the tombstone survives a restart`() =
runBlocking {
val f = Fixture()
f.createGroup()
val announced = f.selfUpdate(ownerTs = 1_735_680_000_000L)
val removal =
assertNotNull(
f.push.buildRemoval(nostrGroupId, PushPlatform.APNS, deviceToken, server, ownerTsMillis = 1_735_680_001_000L),
)
assertEquals(TokenRemovalEvent.KIND, removal.kind)
assertTrue(f.push.activeRecords(nostrGroupId).isEmpty())
// A member that assembled a kind 448 before the removal delivers it
// after. A restarted client must still refuse it — the tombstone is
// the only durable thing that recognises it as stale, and a relayed
// record's carrying epoch is unbounded.
val restarted = MarmotPushCoordinator(f.manager, f.stateStore)
assertFalse(restarted.apply(nostrGroupId, announced))
assertTrue(restarted.activeRecords(nostrGroupId).isEmpty())
}
@Test
fun `a newer registration clears the tombstone`() =
runBlocking {
val f = Fixture()
f.createGroup()
f.selfUpdate(ownerTs = 1_735_680_000_000L)
f.push.buildRemoval(nostrGroupId, PushPlatform.APNS, deviceToken, server, ownerTsMillis = 1_735_680_001_000L)
f.selfUpdate(ownerTs = 1_735_680_002_000L)
assertEquals(1, f.push.activeRecords(nostrGroupId).size)
}
@Test
fun `a removed leaf loses its records entirely`() =
runBlocking {
val f = Fixture()
f.createGroup()
f.selfUpdate()
val leafIndex = assertNotNull(f.manager.leafIndexOf(nostrGroupId, f.signer.pubKey))
f.push.forgetLeaf(nostrGroupId, f.signer.pubKey, leafIndex)
assertTrue(f.push.activeRecords(nostrGroupId).isEmpty())
}
@Test
fun `a trigger carries the encrypted tokens and nothing else`() =
runBlocking {
val f = Fixture()
f.createGroup()
f.selfUpdate()
val trigger = assertNotNull(f.push.buildTrigger(nostrGroupId, server, padding = 3))
assertEquals(446, trigger.kind)
// A fresh ephemeral key, so the server cannot link two triggers to
// one sender — nor dedup on the outer id, which is why the spec
// keys dedup on the content hash instead.
assertFalse(trigger.pubKey == f.signer.pubKey)
assertEquals(1, trigger.tags.size)
assertEquals(listOf("v", PushGossip.VERSION), trigger.tags.single().toList())
val chunks = assertNotNull(Event.fromJson(trigger.toJson()).let { _ -> f.chunksOf(trigger) })
assertEquals(4, chunks.size)
assertTrue(
chunks.any {
it.toHexKey() ==
f.push
.activeRecords(nostrGroupId)
.single()
.encryptedToken
.toHexKey()
},
)
}
@Test
fun `nothing to wake means no trigger`() =
runBlocking {
val f = Fixture()
f.createGroup()
assertNull(f.push.buildTrigger(nostrGroupId, server))
}
@Test
fun `an unreadable payload changes nothing and does not throw`() =
runBlocking {
val f = Fixture()
f.createGroup()
val junk =
Event("0".repeat(64), f.signer.pubKey, 1L, TokenListEvent.KIND, emptyArray(), "{not json", "")
assertFalse(f.push.apply(nostrGroupId, junk))
assertTrue(f.push.activeRecords(nostrGroupId).isEmpty())
}
private fun Fixture.chunksOf(trigger: Event): List<ByteArray>? =
com.vitorpamplona.quartz.marmot.mip05PushNotifications
.NotificationRequestEvent(
trigger.id,
trigger.pubKey,
trigger.createdAt,
trigger.tags,
trigger.content,
trigger.sig,
).chunks()
}
@@ -0,0 +1,351 @@
/*
* 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.appComponents.MessageRetentionV1
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Disappearing messages — `marmot.group.message-retention.v1`, component
* `0x8005`.
*
* The component's own rules are what these assert, and two of them are easy to
* get wrong in ways nobody notices until a message that should be gone is
* still there:
*
* - every message pins the retention of its OWN source epoch, so changing the
* setting later must not shorten, extend, or restore an existing message's
* expiry; and
* - a retry of the same MLS message reuses the same pinned value, which
* matters because the ratchet rewinds on restart and relays replay.
*
* Expiry is advisory by design: the duration is authenticated but the base is
* the sender's own `created_at`, so it inherits the trust already placed in an
* MLS-authenticated sender and is not a guarantee against a hostile one.
*/
class MarmotRetentionTest {
private val nostrGroupId = "e".repeat(64)
private class Fixture {
val signer = NostrSignerInternal(KeyPair())
val mlsStore = SnapshotStateStore()
val messageStore = SnapshotMessageStore()
val manager = MarmotManager(signer, mlsStore, messageStore, SnapshotBundleStore(), publisher = ACCEPTING_RELAY)
}
private suspend fun Fixture.createGroup(secs: ULong?) =
manager.createGroup(
nostrGroupId,
// Version 3: the legacy `0xF2EE` blob only carries
// `disappearing_message_secs` from v3 on, so that v1/v2 stays
// byte-for-byte what MDK's older parser accepts.
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "retention",
relays = listOf("wss://relay.invalid"),
disappearingMessageSecs = secs,
version = 3,
),
)
private suspend fun MarmotManager.storedIds(): List<String> = loadStoredMessages(nostrGroupId).mapNotNull { Event.fromJsonOrNull(it)?.id }
@Test
fun `a group with no retention keeps its messages`() =
runBlocking {
val f = Fixture()
f.createGroup(null)
val sent = f.manager.buildTextMessage(nostrGroupId, "keep me")
assertEquals(0L, f.manager.retentionSeconds(nostrGroupId))
assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, TimeUtils.now() + 10_000_000).isEmpty())
assertTrue(sent.innerEvent.id in f.manager.storedIds())
}
@Test
fun `a message outlives its retention and is deleted, not merely hidden`() =
runBlocking {
val f = Fixture()
f.createGroup(60uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "gone in a minute")
assertEquals(60L, f.manager.retentionSeconds(nostrGroupId))
val after = (sent.innerEvent.createdAt) + 61
assertEquals(setOf(sent.innerEvent.id), f.manager.pruneExpiredMessages(nostrGroupId, after))
// Gone from the log itself. A disappearing message that is only
// filtered out of a read is still on disk, and this store holds the
// only copy — the ratchet moved past the ciphertext long ago.
assertFalse(sent.innerEvent.id in f.manager.storedIds())
assertTrue(f.messageStore.loadExpiries(nostrGroupId).isEmpty())
}
@Test
fun `a message inside its window is untouched`() =
runBlocking {
val f = Fixture()
f.createGroup(3600uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "still fresh")
assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 60).isEmpty())
assertTrue(sent.innerEvent.id in f.manager.storedIds())
}
@Test
fun `expiry is pinned at the source epoch and a later change does not re-time it`() =
runBlocking {
// The rule that is easiest to get wrong: recomputing expiry from
// the CURRENT setting would let one member shorten everyone's
// history retroactively, or restore what should already be gone.
val f = Fixture()
f.createGroup(60uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "pinned at sixty")
f.manager.updateGroupMetadata(
nostrGroupId,
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "retention",
relays = listOf("wss://relay.invalid"),
disappearingMessageSecs = 86_400uL,
version = 3,
),
)
assertEquals(86_400L, f.manager.retentionSeconds(nostrGroupId))
// Still expires on the old sixty seconds, not the new day.
assertEquals(
setOf(sent.innerEvent.id),
f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 61),
)
}
@Test
fun `re-persisting the same message reuses its pinned expiry`() =
runBlocking {
// The ratchet rewinds on restart and relays replay recent kind:445
// events, so the same message really is persisted twice. If the
// second write re-timed it, a message could keep postponing its own
// expiry every time it was replayed.
val f = Fixture()
f.createGroup(60uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "replayed")
val pinned = f.messageStore.loadExpiries(nostrGroupId)[sent.innerEvent.id]
f.manager.updateGroupMetadata(
nostrGroupId,
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "retention",
relays = listOf("wss://relay.invalid"),
disappearingMessageSecs = 86_400uL,
version = 3,
),
)
f.manager.persistDecryptedMessage(nostrGroupId, sent.innerEvent.toJson())
assertEquals(pinned, f.messageStore.loadExpiries(nostrGroupId)[sent.innerEvent.id])
}
@Test
fun `reading a group expires whatever fell due while it was closed`() =
runBlocking {
// The restart case: nothing is running to notice the moment a
// message falls due, so the read itself has to. The message is
// back-dated, which is also the component's documented caveat —
// the base is the sender's own `created_at`, so expiry is only as
// trustworthy as the MLS-authenticated sender.
val f = Fixture()
f.createGroup(60uL)
val stale =
Event(
id = "1".repeat(64),
pubKey = f.signer.pubKey,
createdAt = TimeUtils.now() - 3600,
kind = 9,
tags = emptyArray(),
content = "sent an hour ago",
sig = "",
)
f.manager.persistDecryptedMessage(nostrGroupId, stale.toJson())
assertFalse(stale.id in f.manager.storedIds())
}
@Test
fun `an expiring client tells the front end which messages went`() =
runBlocking {
// A message gone from disk but still on screen has not disappeared.
val f = Fixture()
f.createGroup(60uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "drop me from the view")
val announced = mutableListOf<String>()
f.manager.onMessagesExpired = { _, ids -> announced.addAll(ids) }
f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 61)
assertEquals(listOf(sent.innerEvent.id), announced)
}
@Test
fun `the current profile reads retention from its own component`() =
runBlocking {
// The legacy blob only carries the setting from v3, so in practice
// a group created by the reference client expresses it as component
// `0x8005` instead. Both have to reach the same answer or a
// disappearing message stops disappearing at the profile boundary.
val f = Fixture()
f.manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
retention = MessageRetentionV1(120uL),
)
assertEquals(120L, f.manager.retentionSeconds(nostrGroupId))
val sent = f.manager.buildTextMessage(nostrGroupId, "two minutes")
assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 60).isEmpty())
assertEquals(
setOf(sent.innerEvent.id),
f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 121),
)
}
@Test
fun `setMessageRetention commits the component and only governs later messages`() =
runBlocking {
// The current profile's mid-life setter. The component explicitly
// allows the change; what it forbids is letting the change reach
// backwards, so the message sent before it must still expire on the
// old duration while the one sent after takes the new one.
val f = Fixture()
f.manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
retention = MessageRetentionV1(60uL),
)
val early = f.manager.buildTextMessage(nostrGroupId, "sixty")
f.manager.setMessageRetention(nostrGroupId, 86_400uL)
assertEquals(86_400L, f.manager.retentionSeconds(nostrGroupId))
val late = f.manager.buildTextMessage(nostrGroupId, "a day")
val expiries = f.manager.messageExpiries(nostrGroupId)
assertEquals(early.innerEvent.createdAt + 60, expiries[early.innerEvent.id])
assertEquals(late.innerEvent.createdAt + 86_400, expiries[late.innerEvent.id])
}
@Test
fun `setMessageRetention with zero turns disappearing messages off`() =
runBlocking {
// Removal is equivalent to zero, and zero means disabled — so a
// message sent after the change carries no expiry at all rather
// than one that fires immediately.
val f = Fixture()
f.manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
retention = MessageRetentionV1(60uL),
)
f.manager.setMessageRetention(nostrGroupId, 0uL)
assertEquals(0L, f.manager.retentionSeconds(nostrGroupId))
val sent = f.manager.buildTextMessage(nostrGroupId, "kept")
assertNull(f.manager.messageExpiries(nostrGroupId)[sent.innerEvent.id])
assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 86_400).isEmpty())
}
@Test
fun `a message delivered under an older epoch keeps that epoch's retention`() =
runBlocking {
// The case the fallback used to get wrong. A kind:445 held back as
// a retained candidate is decrypted under an epoch the group has
// since moved past; pinning it to today's setting is exactly what
// the component forbids.
val f = Fixture()
f.createGroup(60uL)
val epochZero = assertNotNull(f.manager.currentEpoch(nostrGroupId))
f.manager.updateGroupMetadata(
nostrGroupId,
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "retention",
relays = listOf("wss://relay.invalid"),
disappearingMessageSecs = 86_400uL,
version = 3,
),
)
assertEquals(86_400L, f.manager.retentionSeconds(nostrGroupId))
assertTrue(f.manager.currentEpoch(nostrGroupId)!! > epochZero, "the rename must advance the epoch")
// Arrives now, but was delivered by the epoch that still said 60s.
val late =
Event(
id = "2".repeat(64),
pubKey = f.signer.pubKey,
createdAt = TimeUtils.now(),
kind = 9,
tags = emptyArray(),
content = "decrypted late",
sig = "",
)
f.manager.persistDecryptedMessage(nostrGroupId, late.toJson(), epoch = epochZero)
assertEquals(
late.createdAt + 60,
f.messageStore.loadExpiries(nostrGroupId)[late.id],
"a late message must keep its source epoch's retention, not the current one",
)
}
@Test
fun `an unknown epoch falls back to the current retention`() =
runBlocking {
// Not a shrug: a group whose setting never changed has one value at
// every epoch, and that is the overwhelmingly common case.
val f = Fixture()
f.createGroup(60uL)
val orphan =
Event(
id = "3".repeat(64),
pubKey = f.signer.pubKey,
createdAt = TimeUtils.now(),
kind = 9,
tags = emptyArray(),
content = "no history for this epoch",
sig = "",
)
f.manager.persistDecryptedMessage(nostrGroupId, orphan.toJson(), epoch = 9_999L)
assertEquals(orphan.createdAt + 60, f.messageStore.loadExpiries(nostrGroupId)[orphan.id])
}
}
@@ -0,0 +1,144 @@
/*
* 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.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.Event
// In-memory stand-ins for the durable stores a MarmotManager needs.
//
// Shared across the Marmot app-layer tests rather than re-declared per file:
// several of them turn on what survives a restart, and "restart" here means
// building a second manager over the SAME store instance. A per-file copy
// would quietly make each test's restart a different thing.
/** Stands in for a relay that accepts every commit, so epochs actually advance. */
val ACCEPTING_RELAY = MarmotPublisher { _, _ -> true }
class SnapshotStateStore : MlsGroupStateStore {
private val states = mutableMapOf<String, ByteArray>()
private val retained = mutableMapOf<String, List<ByteArray>>()
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<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
retained[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
}
class SnapshotMessageStore : MarmotMessageStore {
private val messages = mutableMapOf<String, MutableList<String>>()
private val snapshots = mutableMapOf<String, String>()
private val expiries = mutableMapOf<String, MutableMap<String, Long>>()
private val epochRetentions = mutableMapOf<String, MutableMap<Long, Long>>()
override suspend fun appendMessage(
nostrGroupId: String,
innerEventJson: String,
) {
val log = messages.getOrPut(nostrGroupId) { mutableListOf() }
if (innerEventJson !in log) log.add(innerEventJson)
}
override suspend fun loadMessages(nostrGroupId: String): List<String> = messages[nostrGroupId]?.toList() ?: emptyList()
override suspend fun delete(nostrGroupId: String) {
messages.remove(nostrGroupId)
snapshots.remove(nostrGroupId)
expiries.remove(nostrGroupId)
epochRetentions.remove(nostrGroupId)
}
override suspend fun recordGroupSnapshot(
nostrGroupId: String,
snapshotJson: String,
) {
snapshots[nostrGroupId] = snapshotJson
}
override suspend fun loadGroupSnapshot(nostrGroupId: String): String? = snapshots[nostrGroupId]
// Disappearing messages. First write wins, mirroring the durable stores:
// an expiry is pinned to its message's own source epoch and a replay must
// not re-time it.
override suspend fun recordExpiry(
nostrGroupId: String,
innerEventId: String,
expiresAtSecs: Long,
) {
expiries.getOrPut(nostrGroupId) { mutableMapOf() }.putIfAbsent(innerEventId, expiresAtSecs)
}
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> = expiries[nostrGroupId]?.toMap() ?: emptyMap()
override suspend fun removeMessages(
nostrGroupId: String,
innerEventIds: Set<String>,
) {
messages[nostrGroupId]?.removeAll { json -> Event.fromJsonOrNull(json)?.id in innerEventIds }
expiries[nostrGroupId]?.keys?.removeAll(innerEventIds)
}
override suspend fun recordEpochRetention(
nostrGroupId: String,
epoch: Long,
retentionSecs: Long,
) {
epochRetentions.getOrPut(nostrGroupId) { mutableMapOf() }.putIfAbsent(epoch, retentionSecs)
}
override suspend fun loadEpochRetentions(nostrGroupId: String): Map<Long, Long> = epochRetentions[nostrGroupId]?.toMap() ?: emptyMap()
}
class SnapshotBundleStore : 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
}
}
@@ -0,0 +1,721 @@
/*
* 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.scenario
import com.vitorpamplona.amethyst.commons.marmot.MarmotIngestResult
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.marmot.MarmotPublisher
import com.vitorpamplona.amethyst.commons.marmot.SnapshotBundleStore
import com.vitorpamplona.amethyst.commons.marmot.SnapshotMessageStore
import com.vitorpamplona.amethyst.commons.marmot.SnapshotStateStore
import com.vitorpamplona.amethyst.commons.marmot.ingest
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* Replays a CGKA conformance scenario vector against our own MLS stack.
*
* The vectors are the reference implementation's, marked `portable` in its
* manifest, and that word is the whole point: a script written for one engine
* and replayed by another. Our interop harness proves we can talk to `wn` over
* a relay; this proves we reach the same GROUP STATE from the same sequence of
* events, which is a different claim and the one MLS conformance is actually
* about.
*
* ## What is simulated, and what is not
*
* There is no relay and no gift-wrap round trip here — the harness covers that.
* Publishing is captured into a queue, `deliver_all` moves the queue into every
* other client's inbox, and `tick` drains an inbox. That mirrors the vector's
* own model, where delivery and processing are separate steps precisely so a
* script can hold a message back.
*
* ## Refusing rather than skipping
*
* A step type the runner does not implement throws [UnsupportedScenarioStep],
* and an expected outcome it cannot check throws [UnsupportedScenarioOutcome].
* Nineteen of the portable vectors need fault injection or group-data steps we
* have not modelled, and a runner that quietly ignored those steps would report
* a pass for a script it did not execute — worse than no coverage, because it
* would look like coverage. The same is true one level up: a vector whose
* conclusion is a `convergence_decision` we do not model is refused, not passed
* on the parts that happen to be checkable.
*/
class MarmotScenarioRunner(
private val vector: ScenarioVector,
) {
private val clients = vector.clients.associateWith { VectorClient(it) }
/** Queued outbound events, drained by `deliver_all`. */
private val inFlight = mutableListOf<Queued>()
/** Messages pulled out of the queue by `withhold_message`, by label. */
private val withheld = mutableMapOf<String, MutableList<Queued>>()
/**
* One event sitting in the delivery queue, with the facts a fault selector
* matches on: who sent it, whether it is an application message or a
* commit, and — for a commit — the publication label the vector gave it.
*/
private class Queued(
val sender: String,
val event: Event,
val messageClass: String,
val publication: String?,
)
/**
* The group label the current step runs against.
*
* `in_group` wraps a step and names a label; everything else runs against
* [DEFAULT_GROUP]. A client can be in several groups at once and the
* isolation vectors are entirely about what does NOT cross between them,
* so a single group per client would test the opposite of the point.
*/
private var currentGroup = DEFAULT_GROUP
/** Which label each created group id belongs to, so joiners can be filed. */
private val labelByGroupId = mutableMapOf<HexKey, String>()
private class VectorClient(
val name: String,
) {
val signer = NostrSignerInternal(KeyPair())
val mlsStore = SnapshotStateStore()
val messageStore = SnapshotMessageStore()
val bundleStore = SnapshotBundleStore()
lateinit var manager: MarmotManager
/** Delivered but not yet processed — `tick` is what processes. */
val inbox = mutableListOf<Event>()
val received = mutableListOf<String>()
/** Group label -> nostr group id, for every group this client is in. */
val groups = mutableMapOf<String, HexKey>()
/** Members this client watched join, by pubkey, since the last `clear_events`. */
val sawJoin = mutableListOf<HexKey>()
/** Members this client watched leave, by pubkey, since the last `clear_events`. */
val sawLeave = mutableListOf<HexKey>()
}
/**
* Whether the next publication by this client should be accepted.
*
* The vector acknowledges a publication in a step AFTER the one that
* created it, but our `commitAndPublish` decides synchronously — publish
* before apply means the relay's answer is what makes the commit canonical.
* So the outcome is read ahead out of the matching `acknowledge_outbound`
* step. It is the same information in the same order, just consulted when
* this implementation needs it.
*/
private val publishOutcomes = mutableMapOf<String, MutableList<Pair<String, Boolean>>>()
/** Publication label -> whether OUR gate confirmed it, for `pending_resolution`. */
private val resolved = mutableMapOf<String, Boolean>()
private fun preScanPublishOutcomes() {
vector.steps.filter { it.type == "acknowledge_outbound" }.forEach { step ->
val client = step.string("client") ?: return@forEach
val accepted = step.string("outcome") == "accepted"
publishOutcomes
.getOrPut(client) { mutableListOf() }
.add(step.string("publication").orEmpty() to accepted)
}
}
/** The publication label the NEXT publish by this client carries, if any. */
private fun peekLabel(client: String): String? = publishOutcomes[client]?.firstOrNull()?.first?.takeIf { it.isNotEmpty() }
private fun nextOutcome(client: String): Boolean {
val queue = publishOutcomes[client] ?: return true
if (queue.isEmpty()) return true
val (label, accepted) = queue.removeAt(0)
if (label.isNotEmpty()) resolved[label] = accepted
return accepted
}
/**
* A manager over this client's stores.
*
* Built through a function rather than inline so `restart_client` can make
* a SECOND one over the SAME stores — which is exactly what a restart is:
* every in-memory ratchet, retained epoch and pending pool is gone, and
* whatever the client still knows has to come back off durable state.
*/
private fun buildManager(client: VectorClient) =
MarmotManager(
client.signer,
client.mlsStore,
client.messageStore,
client.bundleStore,
publisher =
MarmotPublisher { event, _ ->
val label = peekLabel(client.name)
val accepted = nextOutcome(client.name)
if (accepted) inFlight.add(Queued(client.name, event, COMMIT_CLASS, label))
accepted
},
)
suspend fun run() {
vector.unmodelledOutcomes.firstOrNull()?.let { throw UnsupportedScenarioOutcome(it.type) }
preScanPublishOutcomes()
clients.values.forEach { client -> client.manager = buildManager(client) }
vector.steps.forEach { step -> execute(step) }
verify()
}
private suspend fun execute(step: ScenarioVector.Step) {
when (step.type) {
"create_group" -> createGroup(step)
"invite_members" -> inviteMembers(step)
"send_app_message" -> sendAppMessage(step)
"deliver_all" -> deliverAll()
"tick" -> step.strings("clients").ifEmpty { vector.clients }.forEach { tick(it) }
"in_group" -> inGroup(step)
"clear_events" -> clearEvents(step)
"assert" -> assertPredicate(step)
"update_group_data" -> updateGroupData(step)
"remove_members" -> removeMembers(step)
"omit_message" -> omitMessage(step)
"duplicate_message" -> duplicateMessage(step)
"reorder_messages" -> reorderMessages(step)
"withhold_message" -> withholdMessage(step)
"release_withheld" -> releaseWithheld(step)
"restart_client" -> restartClient(step)
"leave" -> leave(step)
// The publication's outcome was consumed when it was made; the step
// itself carries no further state change.
"acknowledge_outbound" -> Unit
// Assertions the trace re-states; `verify()` checks them from the
// expected observations, which is the same information.
"observe", "observe_exact", "await_quiescence" -> Unit
else -> throw UnsupportedScenarioStep(step.type)
}
}
/** Run the wrapped step against the named group label. */
private suspend fun inGroup(step: ScenarioVector.Step) {
val action = step.step("action") ?: error("in_group without an action")
val previous = currentGroup
currentGroup = step.string("group") ?: DEFAULT_GROUP
try {
execute(action)
} finally {
currentGroup = previous
}
}
/**
* Reset the observation counters, as the reference's `clear_events` does.
*
* It is what makes a later `received_payloads` mean "since this point"
* rather than "ever", so dropping it would make every post-clear
* expectation fail against a list that still holds the setup traffic.
*/
private fun clearEvents(step: ScenarioVector.Step) {
step.strings("clients").ifEmpty { vector.clients }.forEach {
client(it).received.clear()
client(it).sawJoin.clear()
client(it).sawLeave.clear()
}
}
/**
* Check an inline `assert` predicate.
*
* The only predicate our vectors use is `payload_count`, and every one of
* them asserts a count of ZERO: it is how forward secrecy and multigroup
* isolation are stated — a payload this client must NOT hold. That makes it
* the highest-value assertion in the set and the last one that should be
* skipped.
*/
private fun assertPredicate(step: ScenarioVector.Step) {
val assertion = step.obj("assertion") ?: error("assert without an assertion")
// `exactly` is the only mode in this set. A different one would mean a
// different comparison (at-least, at-most), so refuse rather than
// silently applying equality to it.
val mode = assertion.string("mode") ?: "exactly"
if (mode != "exactly") throw UnsupportedScenarioStep("assert/mode=$mode")
val predicate = assertion.step("predicate") ?: error("assertion without a predicate")
when (predicate.type) {
"payload_count" -> {
val who = predicate.string("client") ?: error("payload_count without a client")
val payload = predicate.string("payload").orEmpty()
val want = predicate.int("count") ?: 0
val got = client(who).received.count { it == payload }
check(got == want) {
"vector ${vector.name}: $who holds $got copies of '$payload', expected $want"
}
}
else -> throw UnsupportedScenarioStep("assert/${predicate.type}")
}
}
private suspend fun createGroup(step: ScenarioVector.Step) {
val creator = client(step.string("creator") ?: error("create_group without a creator"))
val name = step.string("name").orEmpty()
val groupId = RandomInstance.bytes(32).toHexKey()
val invitees = step.strings("invitees")
creator.manager.createCurrentProfileGroup(
nostrGroupId = groupId,
relays = listOf("wss://vector.invalid"),
profile = if (name.isEmpty()) null else GroupProfileV1(name, ""),
additionalAdmins =
step.strings("initial_admins").map { admin ->
client(admin).signer.pubKey.hexToByteArray()
},
)
labelByGroupId[groupId] = currentGroup
creator.groups[currentGroup] = groupId
addMembers(creator, groupId, invitees)
}
private suspend fun inviteMembers(step: ScenarioVector.Step) {
val inviter = client(step.string("inviter") ?: error("invite_members without an inviter"))
val groupId = inviter.groups[currentGroup] ?: error("${inviter.name} invited before joining a group")
addMembers(inviter, groupId, step.strings("invitees"))
}
private suspend fun addMembers(
inviter: VectorClient,
groupId: HexKey,
invitees: List<String>,
) {
if (invitees.isEmpty()) return
// A KeyPackage per invitee, minted on demand: the vector names
// members, not key material.
val bundles =
invitees.map { inviteeName ->
val invitee = client(inviteeName)
invitee to invitee.manager.generateKeyPackageEvent(relays = emptyList())
}
// ONE commit for the whole batch, as the reference does — N Adds in a
// single Commit and a single Welcome carrying N EncryptedGroupSecrets.
// Adding them one at a time would burn an epoch per invitee and the
// traces would no longer line up.
val (commit, deliveries) =
inviter.manager.addMembers(
nostrGroupId = groupId,
keyPackageEvents = bundles.map { it.second },
relays = emptyList(),
)
// A FOUNDING add publishes no commit, so the publisher stub — which is
// what normally consumes a step's outcome — is never called. The vector
// still labels this step (`pending: "create"`) and acknowledges it,
// because the obligation founding creation owes is the per-invitee
// WELCOME delivery, not a group message: publish-lifecycle.md makes the
// founding Add Commit's own obligation empty and each Welcome a separate
// retryable delivery. Consume the outcome here so it resolves against
// the right label — and so every later publication still lines up with
// its own, since the queue is consumed in order.
val accepted =
if (commit == null) {
nextOutcome(inviter.name)
} else {
true
}
if (!accepted) {
// The founding creation's outbound was rejected. Under the legacy
// profile these vectors are written for, the founding Add is
// publish-gated and the group would stay at epoch 0 with one
// member; under ours it is already canonical and only the Welcome
// delivery failed. That is the single point where the two creation
// lifecycles disagree, so the vector is refused by name instead of
// being replayed into an expectation it cannot meet.
throw LegacyOnlyScenario(
"the founding creation's outbound was rejected, which only holds the group at " +
"epoch 0 under the legacy publish-gated creation lifecycle",
)
}
val byPubKey = bundles.associate { (invitee, _) -> invitee.signer.pubKey to invitee }
deliveries.forEach { delivery ->
// The Welcome goes straight to its recipient's inbox. Gift-wrap
// addressing is the transport's job and the interop harness's test.
byPubKey[delivery.recipientPubKey]?.inbox?.add(delivery.giftWrapEvent)
}
}
/**
* Rename the group — the vector's `update_group_data`.
*
* Only `name` ever appears in these vectors, and the current profile keeps
* it in `marmot.group.profile.v1` (`0x8001`), so this is a profile commit
* that preserves the description rather than a blanket metadata replace.
*/
private suspend fun updateGroupData(step: ScenarioVector.Step) {
val who = client(step.string("client") ?: error("update_group_data without a client"))
val groupId = who.groups[currentGroup] ?: error("${who.name} renamed a group it is not in")
val name = step.string("name").orEmpty()
val description =
who.manager
.groupView(groupId)
?.description
.orEmpty()
who.manager.setGroupProfile(groupId, name, description, emptyList())
}
/**
* Evict members by name. The vector names people; MLS removes leaves, so
* the pubkey is resolved to the leaf index the group actually holds.
*/
private suspend fun removeMembers(step: ScenarioVector.Step) {
val remover = client(step.string("remover") ?: error("remove_members without a remover"))
val groupId = remover.groups[currentGroup] ?: error("${remover.name} evicted from a group it is not in")
val targets = step.strings("members").map { client(it).signer.pubKey }.toSet()
val leaves =
remover.manager
.memberPubkeys(groupId)
.filter { it.pubkey in targets }
.map { it.leafIndex }
check(leaves.size == targets.size) {
"remove_members names ${targets.size} members but only ${leaves.size} are in the group"
}
// One Remove per commit. Every vector in this set evicts exactly one
// member per step, and the step names ONE publication — so a
// multi-member step would publish N commits against one
// acknowledgement and silently mis-align every outcome after it.
// Refuse instead, the same way an unimplemented step is refused.
check(leaves.size == 1) {
"remove_members evicts ${leaves.size} members in one step; this runner commits one " +
"Remove at a time and the vector's single acknowledgement would not line up"
}
remover.manager.removeMember(groupId, leaves.single(), emptyList())
// The evictor watched this departure too. Only ticks diff membership,
// and an eviction the client commits itself never passes through one —
// so without this the actor is the one participant who does not
// remember doing it.
remover.sawLeave.addAll(targets)
}
/**
* Does this queued event match a fault selector?
*
* Every key is a conjunct and an unknown key is refused rather than
* ignored — a selector we silently widen would inject a different fault
* from the one the vector scripted, and still report on the vector's name.
*/
private fun matches(
queued: Queued,
selector: ScenarioVector.Step,
): Boolean {
selector.keys().forEach { key ->
when (key) {
"sender" -> if (selector.string("sender") != queued.sender) return false
"class" -> if (selector.string("class") != queued.messageClass) return false
"publication" -> if (selector.string("publication") != queued.publication) return false
// Handled by the caller: it picks which of the matches to act on.
"occurrence" -> Unit
else -> throw UnsupportedScenarioStep("selector/$key")
}
}
return true
}
/** Indices in [inFlight] the selector names, honouring `occurrence`. */
private fun select(selector: ScenarioVector.Step): List<Int> {
val all = inFlight.indices.filter { matches(inFlight[it], selector) }
val occurrence = selector.int("occurrence") ?: return all
return listOfNotNull(all.getOrNull(occurrence))
}
private fun selectorOf(step: ScenarioVector.Step) = step.obj("selector") ?: error("${step.type} without a selector")
/** Drop a queued message entirely — it never reaches anyone. */
private fun omitMessage(step: ScenarioVector.Step) {
val hit = select(selectorOf(step))
check(hit.isNotEmpty()) { "omit_message matched nothing in a queue of ${inFlight.size}" }
hit.sortedDescending().forEach { inFlight.removeAt(it) }
}
/** Deliver a queued message twice. The receiver must not act on it twice. */
private fun duplicateMessage(step: ScenarioVector.Step) {
val hit = select(selectorOf(step))
check(hit.isNotEmpty()) { "duplicate_message matched nothing in a queue of ${inFlight.size}" }
hit.sortedDescending().forEach { inFlight.add(it + 1, inFlight[it]) }
}
/** Deliver the queue in the order the vector names, not the order it was sent. */
private fun reorderMessages(step: ScenarioVector.Step) {
val order = step.steps("order")
val taken = mutableSetOf<Int>()
val reordered = mutableListOf<Queued>()
order.forEach { selector ->
val index = select(selector).firstOrNull { it !in taken }
checkNotNull(index) { "reorder_messages names a message that is not queued" }
taken.add(index)
reordered.add(inFlight[index])
}
inFlight.indices.filter { it !in taken }.forEach { reordered.add(inFlight[it]) }
inFlight.clear()
inFlight.addAll(reordered)
}
/** Hold a message back under a label; `release_withheld` puts it back. */
private fun withholdMessage(step: ScenarioVector.Step) {
val label = step.string("label") ?: error("withhold_message without a label")
val hit = select(selectorOf(step))
check(hit.isNotEmpty()) { "withhold_message matched nothing in a queue of ${inFlight.size}" }
val held = withheld.getOrPut(label) { mutableListOf() }
hit.sortedDescending().forEach { held.add(0, inFlight.removeAt(it)) }
}
private fun releaseWithheld(step: ScenarioVector.Step) {
val label = step.string("label") ?: error("release_withheld without a label")
val held = withheld.remove(label) ?: error("release_withheld names an unknown label '$label'")
inFlight.addAll(held)
}
/**
* Drop the client's process and bring it back over the same stores.
*
* The point is what does NOT survive: the ratchet position, the retained
* epoch window, any staged commit. A client that reads the same traffic
* correctly only because it kept those in memory is not durable, and the
* fault vectors pair a restart with a replayed queue to catch exactly
* that.
*/
private suspend fun restartClient(step: ScenarioVector.Step) {
val client = client(step.string("client") ?: error("restart_client without a client"))
client.manager = buildManager(client)
// A fresh manager knows nothing until it reads its stores — the same
// call `Account` makes at startup. Skipping it would model a client
// that lost its groups, not one that restarted.
client.manager.restoreAll()
}
/**
* A member departs: a standalone SelfRemove PROPOSAL, not a commit.
*
* The leaver does not advance the group — another authorized member
* commits the proposal — so this queues the proposal for delivery and
* nothing else. It deliberately does not consume a publication outcome:
* the vectors never acknowledge a leave, because there is no commit to
* acknowledge.
*/
private suspend fun leave(step: ScenarioVector.Step) {
val who = client(step.string("client") ?: error("leave without a client"))
val groupId = who.groups[currentGroup] ?: error("${who.name} left a group it is not in")
val proposal = who.manager.leaveGroup(groupId)
inFlight.add(Queued(who.name, proposal.signedEvent, PROPOSAL_CLASS, null))
who.groups.remove(currentGroup)
}
private suspend fun sendAppMessage(step: ScenarioVector.Step) {
val sender = client(step.string("sender") ?: error("send_app_message without a sender"))
val groupId = sender.groups[currentGroup] ?: error("${sender.name} sent before joining a group")
val payload = step.string("payload").orEmpty()
// An application message does NOT go through the publish gate — it
// advances nothing and has nothing to roll back — so the runner queues
// it itself. Leaving that out meant every `send_app_message` built an
// event nobody ever delivered.
val bundle = sender.manager.buildTextMessage(groupId, payload, persistOwn = false)
inFlight.add(Queued(sender.name, bundle.outbound.signedEvent, APPLICATION_CLASS, null))
}
private fun deliverAll() {
val batch = inFlight.toList()
inFlight.clear()
batch.forEach { queued ->
// Broadcast to everyone else, including clients who are not in the
// sending group. Their engine refusing that traffic is precisely
// what multigroup isolation asserts.
clients.values.filter { it.name != queued.sender }.forEach { it.inbox.add(queued.event) }
}
}
private suspend fun tick(clientName: String) {
val client = client(clientName)
val batch = client.inbox.toList()
client.inbox.clear()
// Snapshot membership of the groups this client is ALREADY in, so a
// commit processed below can be attributed as "saw N join". A group
// joined during this tick has no before-state and contributes nothing:
// the joiner did not watch anyone join, it arrived to a membership.
val before = client.groups.values.associateWith { membersOf(client, it) }
batch.forEach { event ->
when (val result = client.manager.ingest(event)) {
is MarmotIngestResult.JoinedGroup ->
client.groups[labelByGroupId[result.nostrGroupId] ?: DEFAULT_GROUP] = result.nostrGroupId
is MarmotIngestResult.Message ->
Event
.fromJsonOrNull(result.inner.innerEventJson)
?.takeIf { it.kind == CHAT_KIND }
?.let { client.received.add(it.content) }
else -> Unit
}
}
// A standalone proposal advances nothing on its own. The reference
// commits what it staged as part of processing, and so must we — a
// SelfRemove nobody commits leaves the departing member in the tree,
// still reading the group. Only an admin may do it; a non-admin's
// commit would be rejected by every peer.
client.groups.values.forEach { groupId ->
val admins =
client.manager
.groupView(groupId)
?.adminPubkeys
.orEmpty()
if (client.signer.pubKey in admins) {
runCatching { client.manager.commitPendingProposals(groupId, emptyList()) }
}
}
before.forEach { (groupId, was) ->
val now = membersOf(client, groupId)
client.sawJoin.addAll(now - was)
// A client evicted from the group reads an empty roster, which
// would otherwise look like watching everybody leave at once. It
// did not watch anything: it lost the group.
if (now.isNotEmpty()) client.sawLeave.addAll(was - now)
}
}
/**
* The membership this client currently sees, or empty when it can no
* longer see the group at all — a client that was just evicted has no
* roster to read, and the vectors reach that state on purpose.
*/
private fun membersOf(
client: VectorClient,
groupId: HexKey,
): Set<HexKey> =
try {
client.manager
.memberPubkeys(groupId)
.map { it.pubkey }
.toSet()
} catch (_: Exception) {
emptySet()
}
/** Compare every client's end state against the vector's expected trace. */
private fun verify() {
val failures = mutableListOf<String>()
vector.pendingResolutions.forEach { expected ->
val confirmed = resolved[expected.publication]
val want = expected.resolution == "confirmed"
if (confirmed == null) {
failures.add("publication '${expected.publication}' never happened")
} else if (confirmed != want) {
failures.add(
"publication '${expected.publication}' resolved " +
"${if (confirmed) "confirmed" else "rolled_back"}, expected ${expected.resolution}",
)
}
}
vector.quiescentClients.forEach { name ->
val client = clients[name] ?: return@forEach
if (client.inbox.isNotEmpty()) failures.add("$name still has ${client.inbox.size} events unprocessed")
}
if (vector.quiescentClients.isNotEmpty() && inFlight.isNotEmpty()) {
failures.add("${inFlight.size} events are still undelivered")
}
vector.observations.forEach { expected ->
val client = clients[expected.client] ?: return@forEach
if (client.groups.isEmpty()) {
failures.add("${expected.client} is in no group")
return@forEach
}
// A per-group fact stated once applies to EVERY group the client
// holds — the isolation vectors put a client in several groups and
// state one epoch and one member count for all of them.
client.groups.forEach { (label, groupId) ->
expected.epoch?.let { want ->
val got = client.manager.groupEpoch(groupId)
if (got != want) failures.add("${expected.client}[$label] epoch $got, expected $want")
}
expected.memberCount?.let { want ->
val got = client.manager.memberCount(groupId)
if (got != want) failures.add("${expected.client}[$label] has $got members, expected $want")
}
expected.groupName?.let { want ->
val got = client.manager.groupView(groupId)?.name
if (got != want) failures.add("${expected.client}[$label] group name '$got', expected '$want'")
}
expected.groupDescription?.let { want ->
val got = client.manager.groupView(groupId)?.description
if (got != want) failures.add("${expected.client}[$label] group description '$got', expected '$want'")
}
}
if (expected.receivedPayloads.isNotEmpty()) {
val got = client.received.sorted()
val want = expected.receivedPayloads.sorted()
if (got != want) failures.add("${expected.client} received $got, expected $want")
}
expected.addedMembers?.let { want ->
val got = names(client.sawJoin)
if (got.sorted() != want.sorted()) {
failures.add("${expected.client} saw $got join, expected $want")
}
}
expected.removedMembers?.let { want ->
val got = names(client.sawLeave)
if (got.sorted() != want.sorted()) {
failures.add("${expected.client} saw $got leave, expected $want")
}
}
}
check(failures.isEmpty()) {
"vector ${vector.name} diverged from its expected trace:\n " + failures.joinToString("\n ")
}
}
private fun client(name: String) = clients[name] ?: error("vector names a client '$name' that its roster does not list")
/** Client names for a list of pubkeys; the vectors talk about people. */
private fun names(pubkeys: List<HexKey>) = pubkeys.mapNotNull { key -> clients.values.firstOrNull { it.signer.pubKey == key }?.name }
private companion object {
const val CHAT_KIND = 9
/** The two message classes a fault selector distinguishes. */
const val APPLICATION_CLASS = "application"
const val COMMIT_CLASS = "commit"
const val PROPOSAL_CLASS = "proposal"
/** The label for a vector that never says `in_group` — most of them. */
const val DEFAULT_GROUP = "default"
}
}
@@ -0,0 +1,245 @@
/*
* 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.scenario
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Replays the reference implementation's own conformance scenarios.
*
* These vectors are marked `portable` in their manifest — written for one
* engine and meant to be replayed by another. That is a different claim from
* what the interop harness tests: the harness proves we can TALK to `wn` over a
* relay, and these prove that the same sequence of events lands us in the same
* group state.
*
* Only the vectors whose steps the runner implements are here. The rest need
* fault injection (withhold/release, partition, duplicate, reorder, restart) or
* group-data and admin-policy steps; `MarmotScenarioRunner` throws
* [UnsupportedScenarioStep] rather than ignoring an unknown step, so widening
* the set means implementing a step, never loosening a check.
*/
class MarmotScenarioVectorTest {
private fun load(name: String): ScenarioVector {
val stream =
javaClass.classLoader?.getResourceAsStream("marmot/vectors/$name")
?: fail("vector $name is missing from test resources")
return ScenarioVector.parse(stream.bufferedReader().use { it.readText() })
}
private fun replay(name: String) =
runBlocking {
val vector = load(name)
MarmotScenarioRunner(vector).run()
}
@Test
fun inviteMember() = replay("invite-member.v1.json")
@Test
fun currentProfileRequiredSet() = replay("current-profile-required-set.v1.json")
@Test
fun latecomerForwardSecrecy() = replay("latecomer-forward-secrecy.v1.json")
@Test
fun multigroupIsolation() = replay("multigroup-isolation.v1.json")
/**
* `publish-fail/v1` is the one vector our creation lifecycle cannot
* replay, and that is a profile difference rather than a defect.
*
* It fails the FOUNDING creation's outbound and then expects alice at
* epoch 0 with one member. That is the legacy lifecycle: MDK resolves a
* vector's profile from `application_profile`, this vector declares none,
* and `None | Some("legacy")` means legacy — where `create_group` returns
* `GroupCreated { pending }` and the founding Add waits on the publish.
* A current-profile client returns `FoundingGroupCreated` with no pending
* publication, because `protocol-core/publish-lifecycle.md` gives the
* founding Add an empty group-message obligation and makes each Welcome an
* independent delivery that "does not affect canonical group state".
*
* So the vector is asserted to be REFUSED, by name and for that reason.
* The alternative — publish-gating our founding Add so this vector passes
* — would mean a group creation that an unreachable relay can silently
* turn into an empty epoch-0 group, which is the bug this fix removed.
* `invite-publish-fail/v1` still runs: a rejected LATER invite is an
* ordinary commit and behaves identically under both profiles.
*/
@Test
fun publishFailIsLegacyOnlyAndIsRefused() {
val failure = assertFailsWith<LegacyOnlyScenario> { replay("publish-fail.v1.json") }
assertTrue(
failure.message!!.contains("founding creation"),
"the refusal must name the founding creation, not just fail",
)
}
@Test
fun invitePublishFail() = replay("invite-publish-fail.v1.json")
/**
* The vectors whose `create_group` names several invitees. The reference
* adds them all in ONE commit — epoch 1, one Welcome carrying an
* EncryptedGroupSecrets per invitee — and so do we now, so the traces line
* up. They were refused as a batching divergence until batched Adds landed.
*/
@Test
fun threeClientMessageExchange() = replay("three-client-message-exchange.v1.json")
@Test
fun conversation() = replay("conversation.v1.json")
/** A rename lands on every member as a commit, not as a hint. */
@Test
fun groupDataUpdate() = replay("group-data-update.v1.json")
/** A client that missed several rounds catches up on one tick. */
@Test
fun deferredTickCatchup() = replay("deferred-tick-catchup.v1.json")
/** Members added at each step see only what came after them. */
@Test
fun incrementalGrowth() = replay("incremental-growth.v1.json")
/**
* The delivery-fault family: a dropped message, and a queue that duplicates
* and reorders before it delivers. What arrives twice must be acted on
* once, and out-of-order arrival must not change the end state.
*/
@Test
fun dropQueued() = replay("drop-queued.v1.json")
@Test
fun queueFaults() = replay("queue-faults.v1.json")
/** An application message from an epoch the group has already left. */
@Test
fun delayedPastEpochAppMessage() = replay("delayed-past-epoch-app-message.v1.json")
/** Evicted and invited back: the second membership is not the first. */
@Test
fun readdAfterEviction() = replay("readd-after-eviction.v1.json")
/**
* A restart in the middle of a replayed, duplicated, reordered queue.
* Nothing may depend on state that only lived in memory.
*/
@Test
fun restartDeliveryFaults() = replay("restart-delivery-faults.v1.json")
/**
* A leaver stops being able to read the group at the commit that evicts
* them — and every OTHER member applies that commit too.
*
* This vector found two real defects. The staged-proposal pool did not
* travel with the group state, so the commit meant to evict the leaver
* carried an empty proposal list and left them in the tree with the keys.
* And a peer's proposal was inlined into that commit, which attributes it
* to the committer, while the committer derived a path-based commit secret
* for a commit that carries no path — so every witness rejected it and
* fell an epoch behind.
*/
@Test
fun leaverRemovalSecrecy() = replay("leaver-removal-secrecy.v1.json")
/**
* `convergence-committer-selected` concludes with a `convergence_decision`
* — which tip the client picked, under which rule, and whether the witness
* quorum was met. Our convergence engine makes that decision but does not
* report it in those terms, so there is nothing to compare against and the
* runner refuses the vector rather than passing it on the observations it
* can check.
*
* Asserted rather than deleted so the gap stays visible: the day the engine
* exposes its decision, this test fails and the vector moves up to [replay].
*/
@Test
fun aConvergenceDecisionIsStillUnmodelled() {
val thrown =
assertFailsWith<UnsupportedScenarioOutcome> {
runBlocking { MarmotScenarioRunner(load("convergence-committer-selected.v1.json")).run() }
}
assertEquals("convergence_decision", thrown.outcomeType)
}
/**
* Every vector must parse to at least one thing to check.
*
* Two expectation shapes ship in this set — `expected_trace.observations`
* and `expected_outcomes` — and reading only the first left seven of the
* nine vectors with nothing to compare against, replaying their steps and
* reporting green. A vector that asserts nothing is worse than a missing
* vector, so this guards the parser rather than any one scenario.
*/
@Test
fun everyVectorStatesSomethingToCheck() {
VECTORS.forEach { name ->
val vector = load(name)
assertTrue(
vector.observations.isNotEmpty() || vector.unmodelledOutcomes.isNotEmpty(),
"$name parsed to zero expectations — it would pass without checking anything",
)
}
}
@Test
fun theVectorsAreTheOnesTheShippingAppsEmbed() {
// A vector refreshed from tip would test us against an engine nobody
// runs. The copies here come from the commit both White Noise clients
// pin, and their own `conformance_version` is what says so.
val vector = load("three-client-message-exchange.v1.json")
assertTrue(
vector.conformanceVersion.startsWith("0.9."),
"unexpected conformance version ${vector.conformanceVersion}",
)
}
private companion object {
/** Every vector this suite ships, so the parser guard covers them all. */
val VECTORS =
listOf(
"invite-member.v1.json",
"current-profile-required-set.v1.json",
"latecomer-forward-secrecy.v1.json",
"multigroup-isolation.v1.json",
"publish-fail.v1.json",
"invite-publish-fail.v1.json",
"three-client-message-exchange.v1.json",
"conversation.v1.json",
"convergence-committer-selected.v1.json",
"group-data-update.v1.json",
"deferred-tick-catchup.v1.json",
"incremental-growth.v1.json",
"drop-queued.v1.json",
"queue-faults.v1.json",
"delayed-past-epoch-app-message.v1.json",
"readd-after-eviction.v1.json",
"restart-delivery-faults.v1.json",
"leaver-removal-secrecy.v1.json",
)
}
}
@@ -0,0 +1,299 @@
/*
* 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.scenario
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
/**
* One CGKA conformance scenario vector, as the reference implementation writes
* them (`crates/cgka-conformance-simulator/vectors/`).
*
* A vector is a script, not a byte fixture: a client roster, an ordered list of
* steps, and the trace an implementation is expected to produce. Their manifest
* marks these `portable`, which is exactly the claim being tested — that a
* second implementation replaying the same script reaches the same state.
*
* Parsed loosely on purpose. The step vocabulary is open (28 types across the
* full set) and only some are implemented; a strict model would fail to LOAD a
* vector the runner is entitled to refuse for a much clearer reason.
*/
class ScenarioVector(
val name: String,
val conformanceVersion: String,
val clients: List<String>,
val steps: List<Step>,
val observations: List<Observation>,
val pendingResolutions: List<PendingResolution> = emptyList(),
val quiescentClients: List<String> = emptyList(),
val unmodelledOutcomes: List<UnmodelledOutcome> = emptyList(),
) {
class Step(
val type: String,
private val raw: JsonObject,
) {
fun string(key: String): String? = (raw[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
fun int(key: String): Int? = (raw[key] as? JsonPrimitive)?.content?.toIntOrNull()
fun strings(key: String): List<String> =
(raw[key] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
.orEmpty()
/**
* A step nested inside this one, as its own [Step].
*
* `in_group` is a wrapper: it names a group label and carries the real
* step under `action`. Treating it as a leaf silently skipped every
* create and every message inside it.
*/
fun step(key: String): Step? =
(raw[key] as? JsonObject)?.let {
Step((it["type"] as? JsonPrimitive)?.content.orEmpty(), it)
}
/** A nested object read as a [Step] so the same accessors work on it. */
fun obj(key: String): Step? = (raw[key] as? JsonObject)?.let { Step(key, it) }
/** A nested array of objects, each read as a [Step]. */
fun steps(key: String): List<Step> =
(raw[key] as? JsonArray)
?.mapNotNull { element ->
(element as? JsonObject)?.let { Step((it["type"] as? JsonPrimitive)?.content.orEmpty(), it) }
}.orEmpty()
/**
* The keys this object carries. A fault selector is checked key by key
* so an unknown one can be refused rather than quietly widening it.
*/
fun keys(): Set<String> = raw.keys
}
/** What one client's state must look like when the script says to look. */
class Observation(
val client: String,
val epoch: Long?,
val memberCount: Int?,
val groupName: String?,
val receivedPayloads: List<String>,
/**
* Members this client must have SEEN JOIN, by client name. Null when
* the vector does not state it — which is not the same as an empty
* list, and an empty list is itself an assertion.
*/
val addedMembers: List<String>? = null,
/** The group description the vector states, when it states one. */
val groupDescription: String? = null,
/**
* Members this client must have seen LEAVE, by client name. Same
* null-vs-empty distinction as [addedMembers]: an empty list is the
* assertion that nobody left.
*/
val removedMembers: List<String>? = null,
)
/**
* A publication the script named, and how the reference says it ended.
*
* `confirmed` means the relay accepted it and the commit became canonical;
* `rolled_back` means it did not and the committer stayed where it was.
* Checking these is the only thing that proves our publish-before-apply
* gate resolved the same way the reference's did.
*/
class PendingResolution(
val client: String,
val publication: String,
val resolution: String,
)
/** The outcome types this runner has no check for, named so it can refuse. */
class UnmodelledOutcome(
val type: String,
)
companion object {
private val parser = Json { ignoreUnknownKeys = true }
fun parse(json: String): ScenarioVector {
val root = parser.parseToJsonElement(json) as JsonObject
val scenario = root["scenario"] as JsonObject
val steps =
(scenario["steps"] as JsonArray).map { element ->
val obj = element as JsonObject
Step(obj.getValue("type").jsonPrimitive.content, obj)
}
// TWO vector shapes ship side by side. The older one nests a
// trace under `expected_trace.observations`; the newer one lists
// typed entries under `expected_outcomes`. Reading only the first
// meant SEVEN of the nine vectors here parsed to zero expectations
// and "passed" without checking anything — the exact failure this
// runner exists to avoid.
val traced =
((root["expected_trace"] as? JsonObject)?.get("observations") as? JsonArray)
?.map { observationOf(it as JsonObject) }
.orEmpty()
val outcomes = (root["expected_outcomes"] as? JsonArray).orEmpty()
val stated = mutableListOf<Observation>()
val resolutions = mutableListOf<PendingResolution>()
val quiescent = mutableListOf<String>()
val unmodelled = mutableListOf<UnmodelledOutcome>()
outcomes.forEach { element ->
val obj = element as JsonObject
when ((obj["type"] as? JsonPrimitive)?.content) {
"client_state" -> stated.add(observationOf(obj))
// A converged set states the same per-client facts for
// several clients at once.
"clients_converged" ->
(obj["clients"] as? JsonArray).orEmpty().forEach { name ->
stated.add(
Observation(
client = (name as JsonPrimitive).content,
epoch = (obj["epoch"] as? JsonPrimitive)?.content?.toLongOrNull(),
memberCount = (obj["member_count"] as? JsonPrimitive)?.content?.toIntOrNull(),
groupName = null,
receivedPayloads = emptyList(),
),
)
}
// A profile assertion is per-client state like any other,
// just stated separately because it is about the group's
// metadata rather than its membership.
"group_profile" ->
stated.add(
Observation(
client = obj.getValue("client").jsonPrimitive.content,
epoch = null,
memberCount = null,
groupName = (obj["name"] as? JsonPrimitive)?.takeIf { it.isString }?.content,
receivedPayloads = emptyList(),
groupDescription = (obj["description"] as? JsonPrimitive)?.takeIf { it.isString }?.content,
),
)
"pending_resolution" ->
resolutions.add(
PendingResolution(
client = obj.getValue("client").jsonPrimitive.content,
publication = obj.getValue("pending").jsonPrimitive.content,
resolution = obj.getValue("resolution").jsonPrimitive.content,
),
)
"no_pending_work" ->
(obj["clients"] as? JsonArray).orEmpty().forEach {
quiescent.add((it as JsonPrimitive).content)
}
else ->
unmodelled.add(
UnmodelledOutcome((obj["type"] as? JsonPrimitive)?.content.orEmpty()),
)
}
}
return ScenarioVector(
name = (root["scenario_name"] as JsonPrimitive).content,
conformanceVersion = (root["conformance_version"] as? JsonPrimitive)?.content.orEmpty(),
clients = (scenario["clients"] as JsonArray).map { (it as JsonPrimitive).content },
steps = steps,
observations = traced + stated,
pendingResolutions = resolutions,
quiescentClients = quiescent,
unmodelledOutcomes = unmodelled,
)
}
private fun observationOf(obj: JsonObject) =
Observation(
client = obj.getValue("client").jsonPrimitive.content,
epoch = (obj["epoch"] as? JsonPrimitive)?.content?.toLongOrNull(),
memberCount = (obj["member_count"] as? JsonPrimitive)?.content?.toIntOrNull(),
groupName = (obj["group_name"] as? JsonPrimitive)?.takeIf { it.isString }?.content,
receivedPayloads =
(obj["received_payloads"] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content }
.orEmpty(),
addedMembers =
(obj["added_members"] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content },
removedMembers =
(obj["removed_members"] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content },
)
}
}
/** Raised when a vector uses a step this runner has not implemented. */
class UnsupportedScenarioStep(
val stepType: String,
) : IllegalStateException(
"scenario step '$stepType' is not implemented — the runner refuses a vector it cannot " +
"faithfully replay rather than reporting a pass it did not earn",
)
/**
* Raised when a vector states an expected outcome this runner cannot check.
*
* Same contract as [UnsupportedScenarioStep], one level up: a vector whose
* conclusion we cannot evaluate has not been conformed to, however cleanly its
* steps replayed. Silently dropping the outcome would turn the vector into an
* expensive no-op that reports green.
*/
class UnsupportedScenarioOutcome(
val outcomeType: String,
) : IllegalStateException(
"expected outcome '$outcomeType' has no check in this runner — the vector is refused " +
"rather than passed on the outcomes that happen to be modelled",
)
/**
* Raised when a vector encodes the LEGACY creation lifecycle, which a
* current-profile client cannot exhibit.
*
* MDK picks the profile from the vector's `application_profile`, and
* `None | Some("legacy")` means legacy. Under that profile `create_group`
* returns `GroupCreated { pending }` and the founding Add is publish-gated, so
* a rejected creation leaves the group at epoch 0 with one member. Under the
* current profile it returns `FoundingGroupCreated` with no pending
* publication at all: `protocol-core/publish-lifecycle.md` gives the founding
* Add an empty group-message obligation, so it is canonical whatever happens
* to the Welcomes.
*
* Almost every vector here declares no profile and is therefore legacy, but
* the two only disagree observably when the FOUNDING creation's outbound
* fails — every other vector replays identically. Rather than weaken the
* implementation to match a profile we do not run, or quietly drop the vector,
* the runner refuses it by name and the test asserts the refusal.
*/
class LegacyOnlyScenario(
val reason: String,
) : IllegalStateException(
"this vector encodes the legacy creation lifecycle and cannot be replayed by a " +
"current-profile client: $reason",
)
@@ -0,0 +1,54 @@
# CGKA scenario vectors
Copied verbatim from the reference implementation:
`marmot-protocol/mdk`, `crates/cgka-conformance-simulator/vectors/`, at the
commit the shipping White Noise apps embed
(`2f44f6b65a19f8818644ccd7027618ba91450c33`, `marmotkit-v0.9.20`).
Their `manifest.v1.json` marks 31 artifacts `portable` — meaning they are
meant to be replayed by an implementation that is not theirs. Three are byte
fixtures we already consume from `quartz/src/commonTest/resources/marmot/
conformance/`. The rest are **scenario scripts**: a client roster, a step
list, and an expected trace.
These sixteen are the subset whose steps `MarmotScenarioRunner` implements:
`create_group`, `invite_members`, `remove_members`, `send_app_message`,
`update_group_data`, `deliver_all`, `tick`, `acknowledge_outbound`, `observe`,
`in_group`, `assert`, `clear_events`, and the queue faults `omit_message`,
`duplicate_message`, `reorder_messages`, `withhold_message`,
`release_withheld`. The rest need `restart_client`, `set_partition`, `leave`,
admin-policy steps, or the `convergence_decision` outcome; the runner refuses
them by name rather than skipping quietly, so adding a step type is what
widens the set.
## Two expectation shapes
A vector states what must be true in one of two ways, and BOTH have to be
read:
- `expected_trace.observations` — the older shape (`publish-fail`,
`three-client-message-exchange`).
- `expected_outcomes` — a list of typed entries: `client_state`,
`clients_converged`, `group_profile`, `pending_resolution`,
`no_pending_work`, and `convergence_decision`. Everything else here uses
this one.
Reading only the first is not a partial check, it is no check: a vector whose
expectations all live in the other shape replays its steps and reports green
having compared nothing. `MarmotScenarioVectorTest.everyVectorStatesSomethingToCheck`
exists to make that failure loud rather than invisible.
An outcome type the runner cannot evaluate raises `UnsupportedScenarioOutcome`
and the vector is refused — `convergence-committer-selected` is refused today
for exactly that reason.
## Refreshing
```bash
cp <mdk>/crates/cgka-conformance-simulator/vectors/<name>.v1.json .
```
Refresh from the commit named in whitenoise-android's
`app/src/main/marmotkit/MARMOT_VERSION` (or whitenoise-ios's
`Packages/MarmotKit/MARMOT_VERSION`) — that is the engine users are running,
which is the thing worth being conformant with.
@@ -0,0 +1,123 @@
{
"scenario_name": "convergence-committer-selected/v1",
"vector_version": "1",
"conformance_version": "0.9.20",
"seed": null,
"scenario": {
"name": "convergence-committer-selected/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol",
"david",
"eve"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "convergence",
"invitees": [
"bob",
"carol"
],
"required_features": [],
"initial_admins": [
"bob"
],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "invite_members",
"inviter": "alice",
"invitees": [
"david"
],
"pending": "alice-invite"
},
{
"type": "invite_members",
"inviter": "bob",
"invitees": [
"eve"
],
"pending": "bob-invite"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "alice-invite",
"outcome": "accepted"
},
{
"type": "acknowledge_outbound",
"client": "bob",
"publication": "bob-invite",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"carol"
]
},
{
"type": "observe",
"clients": [
"carol"
]
}
]
},
"expected_outcomes": [
{
"type": "convergence_decision",
"client": "carol",
"selected_tip_epoch": 2,
"decisive_rule": "tip_committer",
"witness_quorum_met": false
},
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "carol",
"epoch": 2,
"member_count": 4,
"received_payloads": []
}
]
}
@@ -0,0 +1,175 @@
{
"scenario_name": "conversation/v1",
"vector_version": "1",
"conformance_version": "0.9.20",
"seed": null,
"scenario": {
"name": "conversation/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "conversation",
"invitees": [
"bob",
"carol"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "conversation:r1:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "conversation:r1:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "conversation:r1:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "conversation:r2:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "conversation:r2:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "conversation:r2:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "observe_exact",
"clients": [
"alice",
"bob",
"carol"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "alice",
"epoch": 1,
"member_count": 3,
"received_payloads": [
"conversation:r1:bob",
"conversation:r1:carol",
"conversation:r2:bob",
"conversation:r2:carol"
]
},
{
"type": "client_state",
"client": "bob",
"epoch": 1,
"member_count": 3,
"received_payloads": [
"conversation:r1:alice",
"conversation:r1:carol",
"conversation:r2:alice",
"conversation:r2:carol"
]
},
{
"type": "client_state",
"client": "carol",
"epoch": 1,
"member_count": 3,
"received_payloads": [
"conversation:r1:alice",
"conversation:r1:bob",
"conversation:r2:alice",
"conversation:r2:bob"
]
},
{
"type": "clients_converged",
"clients": [
"alice",
"bob",
"carol"
],
"epoch": 1,
"member_count": 3
},
{
"type": "no_pending_work",
"clients": [
"alice",
"bob",
"carol"
]
}
]
}
@@ -0,0 +1,94 @@
{
"scenario_name": "current-profile-required-set/v1",
"vector_version": "1",
"conformance_version": "0.9.20",
"seed": null,
"application_profile": {
"name": "current",
"required_group_context_extensions": [
"0x0006"
],
"required_proposals": [
"0x0008"
],
"required_app_components": [
"0x8003",
"0x8009"
],
"required_group_context_state_components": [
"0x8003"
],
"leaf_only_app_components": [
"0x8009"
]
},
"scenario": {
"name": "current-profile-required-set/v1",
"spec_version": "2",
"clients": [
"alice",
"bob"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "current-profile",
"invitees": [
"bob"
],
"required_features": [],
"pending": "create"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "bob:first-current-message"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob"
]
},
{
"type": "observe",
"clients": [
"alice",
"bob"
]
}
]
},
"expected_outcomes": [
{
"type": "client_state",
"client": "alice",
"epoch": 1,
"member_count": 2,
"received_payloads": [
"bob:first-current-message"
]
},
{
"type": "client_state",
"client": "bob",
"epoch": 1,
"member_count": 2,
"received_payloads": []
}
]
}
@@ -0,0 +1,272 @@
{
"scenario_name": "deferred-tick-catchup/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "deferred-tick-catchup/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "reconnect",
"invitees": [
"bob",
"carol",
"dave"
],
"required_features": [],
"pending": "create",
"initial_admins": [
"alice"
]
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol",
"dave"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "reconnect:warm:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "reconnect:warm:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "reconnect:warm:carol"
},
{
"type": "send_app_message",
"sender": "dave",
"payload": "reconnect:warm:dave"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "reconnect:away:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "reconnect:away:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "reconnect:away:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "update_group_data",
"client": "alice",
"name": "reconnect-renamed",
"pending": "rename"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "rename",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "reconnect:back:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "reconnect:back:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "reconnect:back:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "tick",
"clients": [
"dave"
]
},
{
"type": "observe_exact",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 17,
"client": "alice",
"pending": "rename",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "dave",
"epoch": 2,
"member_count": 4,
"received_payloads": [
"reconnect:warm:alice",
"reconnect:warm:bob",
"reconnect:warm:carol",
"reconnect:away:alice",
"reconnect:away:bob",
"reconnect:away:carol",
"reconnect:back:alice",
"reconnect:back:bob",
"reconnect:back:carol"
]
},
{
"type": "client_state",
"client": "alice",
"epoch": 2,
"member_count": 4,
"received_payloads": [
"reconnect:warm:bob",
"reconnect:warm:carol",
"reconnect:warm:dave",
"reconnect:away:bob",
"reconnect:away:carol",
"reconnect:back:bob",
"reconnect:back:carol"
]
},
{
"type": "group_profile",
"client": "alice",
"name": "reconnect-renamed",
"description": ""
},
{
"type": "group_profile",
"client": "bob",
"name": "reconnect-renamed",
"description": ""
},
{
"type": "group_profile",
"client": "carol",
"name": "reconnect-renamed",
"description": ""
},
{
"type": "group_profile",
"client": "dave",
"name": "reconnect-renamed",
"description": ""
},
{
"type": "clients_converged",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"epoch": 2,
"member_count": 4
},
{
"type": "no_pending_work",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
}
]
}
@@ -0,0 +1,132 @@
{
"scenario_name": "delayed-past-epoch-app-message/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "delayed-past-epoch-app-message/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol",
"david"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "delayed-past-epoch-app",
"invitees": [
"bob",
"carol"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob",
"carol",
"david"
]
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "epoch-one-delayed"
},
{
"type": "withhold_message",
"selector": { "sender": "bob", "class": "application" },
"label": "old-app"
},
{
"type": "invite_members",
"inviter": "alice",
"invitees": [
"david"
],
"pending": "invite-david"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "invite-david",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"carol",
"david"
]
},
{
"type": "release_withheld",
"label": "old-app"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"carol"
]
},
{
"type": "observe",
"clients": [
"carol"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 8,
"client": "alice",
"pending": "invite-david",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "carol",
"epoch": 2,
"member_count": 4,
"received_payloads": [
"epoch-one-delayed"
]
}
]
}
@@ -0,0 +1,95 @@
{
"scenario_name": "drop-queued/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "drop-queued/v1",
"spec_version": "2",
"clients": [
"alice",
"bob"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "drop",
"invitees": [
"bob"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob"
]
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "bob:dropped"
},
{
"type": "omit_message",
"selector": { "sender": "bob", "class": "application" }
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "bob:delivered"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice"
]
},
{
"type": "observe",
"clients": [
"alice"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "alice",
"epoch": 1,
"member_count": 2,
"received_payloads": [
"bob:delivered"
]
}
]
}
@@ -0,0 +1,127 @@
{
"scenario_name": "group-data-update/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "group-data-update/v1",
"spec_version": "2",
"clients": [
"alice",
"bob"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "before",
"invitees": [
"bob"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob"
]
},
{
"type": "update_group_data",
"client": "alice",
"name": "after",
"pending": "rename"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "rename",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "observe",
"clients": [
"alice",
"bob"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 6,
"client": "alice",
"pending": "rename",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "alice",
"epoch": 2,
"member_count": 2,
"received_payloads": []
},
{
"type": "client_state",
"client": "bob",
"epoch": 2,
"member_count": 2,
"received_payloads": []
},
{
"type": "group_profile",
"client": "alice",
"name": "after",
"description": ""
},
{
"type": "group_profile",
"client": "bob",
"name": "after",
"description": ""
},
{
"type": "clients_converged",
"clients": [
"alice",
"bob"
],
"epoch": 2,
"member_count": 2
}
]
}
@@ -0,0 +1,376 @@
{
"scenario_name": "incremental-growth/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "incremental-growth/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "growth-2",
"invitees": [
"bob"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "growth:p0:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "growth:p0:bob"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob"
]
},
{
"type": "invite_members",
"inviter": "alice",
"invitees": [
"carol"
],
"pending": "invite-carol"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "invite-carol",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "update_group_data",
"client": "alice",
"name": "growth-3",
"pending": "rename-3"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "rename-3",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "growth:p1:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "growth:p1:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "growth:p1:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "invite_members",
"inviter": "alice",
"invitees": [
"dave"
],
"pending": "invite-dave"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "invite-dave",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol",
"dave"
]
},
{
"type": "update_group_data",
"client": "alice",
"name": "growth-4",
"pending": "rename-4"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "rename-4",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "growth:p2:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "growth:p2:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "growth:p2:carol"
},
{
"type": "send_app_message",
"sender": "dave",
"payload": "growth:p2:dave"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "carol",
"payload": "growth:p0:alice",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "growth:p0:alice",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "growth:p1:alice",
"count": 0
}
}
},
{
"type": "observe_exact",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 10,
"client": "alice",
"pending": "invite-carol",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 14,
"client": "alice",
"pending": "rename-3",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 23,
"client": "alice",
"pending": "invite-dave",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 27,
"client": "alice",
"pending": "rename-4",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "alice",
"epoch": 5,
"member_count": 4,
"received_payloads": [
"growth:p0:bob",
"growth:p1:bob",
"growth:p1:carol",
"growth:p2:bob",
"growth:p2:carol",
"growth:p2:dave"
]
},
{
"type": "client_state",
"client": "bob",
"epoch": 5,
"member_count": 4,
"received_payloads": [
"growth:p0:alice",
"growth:p1:alice",
"growth:p1:carol",
"growth:p2:alice",
"growth:p2:carol",
"growth:p2:dave"
]
},
{
"type": "client_state",
"client": "carol",
"epoch": 5,
"member_count": 4,
"received_payloads": [
"growth:p1:alice",
"growth:p1:bob",
"growth:p2:alice",
"growth:p2:bob",
"growth:p2:dave"
]
},
{
"type": "client_state",
"client": "dave",
"epoch": 5,
"member_count": 4,
"received_payloads": [
"growth:p2:alice",
"growth:p2:bob",
"growth:p2:carol"
]
},
{
"type": "clients_converged",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"epoch": 5,
"member_count": 4
},
{
"type": "no_pending_work",
"clients": [
"alice",
"bob"
]
}
]
}

Some files were not shown because too many files have changed in this diff Show More