mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+8
-1
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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,
|
||||
|
||||
+90
@@ -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"
|
||||
}
|
||||
}
|
||||
+213
-6
@@ -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() }
|
||||
|
||||
+189
@@ -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"
|
||||
}
|
||||
}
|
||||
+116
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+83
-29
@@ -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() {
|
||||
|
||||
+89
-1
@@ -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}" }
|
||||
}
|
||||
|
||||
+49
-14
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-9
@@ -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
|
||||
|
||||
+9
-4
@@ -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,
|
||||
+113
@@ -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.
|
||||
*
|
||||
|
||||
+112
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-6
@@ -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
|
||||
|
||||
+57
-1
@@ -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(
|
||||
|
||||
+77
-9
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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
|
||||
|
||||
+226
-1
@@ -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"
|
||||
}
|
||||
|
||||
+111
@@ -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
|
||||
}
|
||||
+173
@@ -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)
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -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,
|
||||
|
||||
+75
-6
@@ -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 {
|
||||
|
||||
+10
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-5
@@ -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)
|
||||
}
|
||||
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+168
@@ -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
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-8
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
+65
-5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+112
-26
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+109
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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=$!
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+257
@@ -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,
|
||||
)
|
||||
}
|
||||
+73
-12
@@ -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)
|
||||
|
||||
+1943
-126
File diff suppressed because it is too large
Load Diff
+18
-7
@@ -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
|
||||
}
|
||||
+432
@@ -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>)
|
||||
}
|
||||
}
|
||||
+1
@@ -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")
|
||||
|
||||
+25
-3
@@ -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
|
||||
|
||||
+56
@@ -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)
|
||||
|
||||
+64
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -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())
|
||||
}
|
||||
}
|
||||
+108
@@ -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)
|
||||
}
|
||||
}
|
||||
+123
@@ -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()
|
||||
}
|
||||
}
|
||||
+343
@@ -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
|
||||
}
|
||||
}
|
||||
+388
@@ -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
|
||||
}
|
||||
}
|
||||
+284
@@ -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)
|
||||
}
|
||||
}
|
||||
+152
@@ -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
|
||||
}
|
||||
}
|
||||
+204
@@ -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))
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+471
@@ -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()
|
||||
}
|
||||
}
|
||||
+325
@@ -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())
|
||||
}
|
||||
}
|
||||
+318
@@ -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()
|
||||
}
|
||||
+351
@@ -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])
|
||||
}
|
||||
}
|
||||
+144
@@ -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
|
||||
}
|
||||
}
|
||||
+721
@@ -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"
|
||||
}
|
||||
}
|
||||
+245
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+299
@@ -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
Reference in New Issue
Block a user