diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index 5f7549b8fe..6efc450133 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.concord_create_action +import com.vitorpamplona.amethyst.commons.resources.concord_create_failed import com.vitorpamplona.amethyst.commons.resources.concord_create_relays import com.vitorpamplona.amethyst.commons.resources.concord_create_relays_desc import com.vitorpamplona.amethyst.commons.resources.concord_create_title @@ -136,7 +137,14 @@ fun ConcordCreateScreen( // Always re-enable — a thrown create would otherwise strand the button. working = false } - if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) + // Replace this form with the new community, as the Marmot and relay-group creators + // do. newStack only popped up to the community route itself, which was not on the + // stack, so Back from the new community reopened a filled-in create form. + if (communityId != null) { + nav.popUpTo(Route.ConcordServer(communityId), Route.ConcordCreate::class) + } else { + accountViewModel.toastManager.toast(Res.string.concord_create_title, Res.string.concord_create_failed) + } } }, enabled = name.value.isNotBlank() && !working, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index bf4d44624d..0ce5c3e505 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -1036,15 +1036,17 @@ private fun ConcordServerRoomCompose( // Community name/icon from the folded Control Plane (bumped via the session revision). val revision by accountViewModel.account.concordSessions.revision .collectAsStateWithLifecycle() - val metadata = + val session = remember(row.communityId, revision) { - accountViewModel.account.concordSessions - .sessionFor(row.communityId) - ?.state - ?.value - ?.metadata + accountViewModel.account.concordSessions.sessionFor(row.communityId) } - val name = metadata?.name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.concord_home_title) + val metadata = remember(session, revision) { session?.state?.value?.metadata } + // Before the Control Plane folds there is no metadata; the name the community was joined under + // still tells the user which one it is. + val name = + metadata?.name?.takeIf { it.isNotBlank() } + ?: session?.entry?.name?.takeIf { it.isNotBlank() } + ?: stringRes(Res.string.concord_home_title) val author = row.newestMessage?.author val noteEvent = row.newestMessage?.event diff --git a/cli/README.md b/cli/README.md index 8bc626321f..6c6453ae35 100644 --- a/cli/README.md +++ b/cli/README.md @@ -678,7 +678,7 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). | | `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). | | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. | -| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). | +| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). Banned members' messages are left out and counted in `hidden_banned`. | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. | | `amy concord invite COMMUNITY --to USER [--expires-in SECS]` | Send a Direct Invite (CORD-05 §6): the bundle giftwrapped as standard NIP-59 (kind-3313 rumor, `k=3313` wrap tag, NIP-40 expiration when `--expires-in` is set) to USER (npub, hex, nprofile or NIP-05) on their kind-10050 relays, else NIP-65 read relays, else the stock set. Carries only the private-channel keys USER's roles grant; refused for a banned recipient. No registry entry, never flips the community Public, cannot be revoked. | | `amy concord invites` | List Direct Invites waiting for this account (sender, community name/icon, expired, catch-up). Read-only: nothing joins or contacts the community's relays. Communities you already hold are hidden unless the invite carries new channel keys on the same base (a catch-up). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 2f6832a86a..864509647d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -158,13 +158,17 @@ object ConcordChannelCommands { // The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates. ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } - val msgs = ConcordActions.channelMessages(wraps, channel, channelId, epoch).takeLast(limit) + // A banned member's messages are hidden, as every client shows the channel (CORD-04 §4); + // the count of what was hidden stays in the output so interop checks can see it arrived. + val (banned, visible) = ConcordActions.channelMessages(wraps, channel, channelId, epoch).partition { state.authority.isBanned(it.author) } + val msgs = visible.takeLast(limit) Output.emit( mapOf( "channel" to channelId, "epoch" to epoch, "plane" to channel.publicKeyHex, "count" to msgs.size, + "hidden_banned" to banned.size, "messages" to msgs.map { mapOf("event_id" to it.id, "author" to it.author, "content" to it.content, "created_at" to it.createdAt) }, ), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 18f47edd00..fb32df16ba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -258,16 +258,22 @@ object ConcordSubscriptionPlanner { accountPubKey: HexKey? = null, stateOf: (ConcordCommunityListEntry) -> ConcordCommunityState?, ): List { - val controlSubs = controlPlaneSubs(entries) + // A community whose Control Plane hasn't folded yet (just joined, or its editions never + // arrived) is fetched without the relay's `since`: that cursor was set by the OTHER + // communities on the relay, and applying it here skips every edition older than it — the + // genesis included — so the community showed no channels until a restart dropped the cursor. + val (folded, unfolded) = entries.partition { stateOf(it) != null } val otherSubs = ArrayList() - otherSubs += auxiliaryPlaneSubs(entries) - for (entry in entries) { - val state = stateOf(entry) ?: continue - otherSubs += channelPlaneSubs(entry, state) + otherSubs += auxiliaryPlaneSubs(folded) + for (entry in folded) { + otherSubs += channelPlaneSubs(entry, stateOf(entry) ?: continue) } - return relayBasedFilters(controlSubs, since, accountPubKey).orEmpty() + relayBasedFilters(otherSubs, since, accountPubKey).orEmpty() + return relayBasedFilters(controlPlaneSubs(folded), since, accountPubKey).orEmpty() + + relayBasedFilters(otherSubs, since, accountPubKey).orEmpty() + + relayBasedFilters(controlPlaneSubs(unfolded), null, accountPubKey).orEmpty() + + relayBasedFilters(auxiliaryPlaneSubs(unfolded), null, accountPubKey).orEmpty() } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index b0ddbebba3..0493032473 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -212,7 +212,11 @@ class ChatroomListKnownFeedFilter( when (account.settings.concordViewMode.value) { ConcordViewMode.INLINE -> account.concordSessions.sessions().flatMap { session -> - val state = session.state.value ?: return@flatMap emptyList() + // Not folded yet (just joined, or its Control Plane never arrived): no + // channels to list, so show the community itself. Skipping it made a + // community whose genesis is missing invisible everywhere, with no way in + // and no way to leave it. + val state = session.state.value ?: return@flatMap listOf(ConcordServerRoomNote(session.entry.id, null)) state.channels.keys.map { channelIdHex -> val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) channel.newestConcordNote(account) ?: channel.placeholderNote() @@ -221,10 +225,14 @@ class ChatroomListKnownFeedFilter( ConcordViewMode.GROUPED -> // One row per joined community, carrying the newest message across ALL its channels. - account.concordSessions.sessions().mapNotNull { session -> - val state = session.state.value ?: return@mapNotNull null + account.concordSessions.sessions().map { session -> + // An unfolded community still gets its row (see INLINE above). + val state = session.state.value val newest = - state.channels.keys + state + ?.channels + ?.keys + .orEmpty() .mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) } .maxByOrNull { it.createdAt() ?: 0L } ConcordServerRoomNote(session.entry.id, newest) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 325314bee5..1c9dad080e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -107,13 +107,19 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" +/** How long a join waits for the new community's session before handing it the wraps it fetched. */ +private const val SESSION_WAIT_MS = 10_000L + /** * How often a joined Concord community's stored invite link is re-resolved to check whether * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is @@ -155,9 +161,38 @@ class AccountConcordActions( entry: ConcordCommunityListEntry, inviteCreator: HexKey? = null, inviteLabel: String? = null, - ) { - if (!persistConcordEntry(entry)) return + fetchedWraps: List = emptyList(), + ): Boolean { + // False when the List could not take the membership (not loaded, or every held fragment is + // full while others are missing, CORD-02 §8). Callers must say so: the community would + // otherwise look joined now and be gone after a restart. + if (!persistConcordEntry(entry)) return false + // The session is built asynchronously from the Community List flow. Every wrap that reaches + // the cache before it exists is kept as an unclaimed note, and the live subscription's copy + // of the same wrap is then deduplicated away, so the community showed "No channels yet" + // until a restart emptied the cache. Wait for the session, then hand it what we fetched. + awaitConcordSession(entry.id) + fetchedWraps.forEach { account.concordSessions.ingest(it) } announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) + return true + } + + /** The community's current name: its folded metadata, else the name it was joined under. */ + private fun currentConcordName( + session: ConcordCommunitySession?, + entry: ConcordCommunityListEntry, + ): String = + session + ?.state + ?.value + ?.metadata + ?.name + ?.takeIf { it.isNotBlank() } ?: entry.name + + private suspend fun awaitConcordSession(communityId: HexKey) { + withTimeoutOrNull(SESSION_WAIT_MS) { + account.concordSessions.revision.first { account.concordSessions.sessionFor(communityId) != null } + } } /** @@ -165,17 +200,25 @@ class AccountConcordActions( * only means "no List" once the relays have been asked (CORD-02 §8 — a write built on an * unloaded List replaces fragments another device published). */ - private suspend fun ensureConcordListLoaded() { - if (!account.concordChannelList.relaysConfirmed) importConcordCommunities() + suspend fun preloadConcordList() { + if (account.concordChannelList.relaysConfirmed) return + // Single-flight: the import is a ~30s drain of every stock/outbox relay. A join starts it + // as soon as it begins, and the join's own List write then waits for that same fetch + // instead of starting a second one. + concordListImport.withLock { + if (!account.concordChannelList.relaysConfirmed) importConcordCommunities() + } } + private val concordListImport = Mutex() + /** * Read-modify-writes the Community List through [change] and publishes the fragments it * produced. Returns false — logged, never thrown into a UI coroutine — when the List can't be * written safely yet (fragments unreadable or not loaded) or a fragment would pass the ceiling. */ private suspend fun writeConcordList(change: suspend (ConcordChannelListState) -> List): Boolean { - ensureConcordListLoaded() + preloadConcordList() return try { account.sendMyPublicAndPrivateOutbox(change(account.concordChannelList)) true @@ -209,7 +252,7 @@ class AccountConcordActions( * Create a new Concord community: mint its genesis (metadata + #general), * publish the owner-signed genesis wraps to [relays] (or our outbox), and add * the secret-bearing entry to the Community List (kind 33302). Returns the new - * community id, or null if not writeable. + * community id, or null if not writeable or no relay accepted the genesis. */ suspend fun createConcordCommunity( name: String, @@ -228,25 +271,38 @@ class AccountConcordActions( val community = ConcordActions.createCommunity(account.signer, name, TimeUtils.now(), description, relayUrls, icon) val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } - community.genesisWraps.forEach { account.client.publish(it, publishTo) } + // The genesis is the community: with no relay holding it, nobody (us included, after a + // restart) can ever fold it. It used to be fired and forgotten, then the community saved + // anyway, so a genesis a relay refused or an app killed mid-send left a community in the + // List that could never load again. Confirm every genesis wrap before saving it. + val landed = + coroutineScope { + community.genesisWraps.map { async { account.client.publishAndConfirm(it, publishTo) } }.awaitAll() + } + if (!landed.all { it }) { + Log.w("Concord") { "createConcordCommunity: no relay in $publishTo accepted the genesis; not creating ${community.communityIdHex}" } + return null + } - joinConcordCommunity( - ConcordCommunityListEntry( - id = community.communityIdHex, - owner = community.ownerPubKey, - ownerSalt = community.ownerSalt.toHexKey(), - root = community.communityRoot.toHexKey(), - rootEpoch = community.rootEpoch, - // The creator is the founding staff member (CORD-02 §2): it keeps the write - // secret and publishes only the derived pubkey to everyone else. - controlPk = community.controlPkHex, - controlRoot = community.controlRoot.toHexKey(), - relays = relayUrls, - name = name, - addedAt = TimeUtils.nowMillis(), - ), - ) - return community.communityIdHex + val saved = + joinConcordCommunity( + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + // The creator is the founding staff member (CORD-02 §2): it keeps the write + // secret and publishes only the derived pubkey to everyone else. + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + relays = relayUrls, + name = name, + addedAt = TimeUtils.nowMillis(), + ), + fetchedWraps = community.genesisWraps, + ) + return community.communityIdHex.takeIf { saved } } // ---- CORD-05 Invite List (kind 13303) ------------------------------------- @@ -412,6 +468,7 @@ class AccountConcordActions( rootEpoch = entry.rootEpoch, controlPk = entry.controlPk, relays = entry.relays, + name = currentConcordName(account.concordSessions.sessionFor(entry.id), entry), ) // Confirmed: a link counted as moved but never stored is a link its // holders can no longer redeem, reported as a success. @@ -458,7 +515,9 @@ class AccountConcordActions( ownerSaltHex = entry.ownerSalt, communityRootHex = entry.root, rootEpoch = entry.rootEpoch, - name = entry.name, + // The folded metadata, not the List entry: the entry keeps the name it was joined + // under, so a renamed community's invites previewed its old name. + name = currentConcordName(session, entry), relays = entry.relays, // The joiner can never derive the Control Plane address, so the bundle carries // it (CORD-05 §1). Null on a legacy community, which has none to carry. @@ -627,6 +686,11 @@ class AccountConcordActions( if (!account.isWriteable()) return ConcordInviteResult.InvalidLink val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink + // Joining ends in a Community List write, which first needs the List loaded (a slow drain of + // every stock and outbox relay). Start it now so it overlaps the bundle and plane fetches + // below instead of running after them. + account.scope.launch { preloadConcordList() } + val relays = (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value).toSet() if (relays.isEmpty()) return ConcordInviteResult.NotReachable @@ -746,7 +810,7 @@ class AccountConcordActions( if (rejoined != null) { if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId) Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" } - joinConcordCommunity(rejoined, creator, label) + if (!joinConcordCommunity(rejoined, creator, label, planeWraps)) return ConcordInviteResult.NotSaved _strandedConcordCommunities.value -= rejoined.id return ConcordInviteResult.Joined(bundle.communityId) } @@ -770,7 +834,7 @@ class AccountConcordActions( // Anchor for stranded recovery (null for a Direct Invite, which has no link). inviteRef = inviteRef, ) - joinConcordCommunity(entry, creator, label) + if (!joinConcordCommunity(entry, creator, label, planeWraps)) return ConcordInviteResult.NotSaved return ConcordInviteResult.Joined(bundle.communityId) } @@ -1401,6 +1465,13 @@ class AccountConcordActions( roleId.toHexKey() } + // An Admin role published before Amethyst wrote `role_id` into Role content is invisible + // to Armada, which then drops this Grant too. Re-issue it at the same id to heal it. + if (existing != null && existing.value.roleId.isNullOrEmpty()) { + val healWrap = ConcordModeration.defineRole(account.signer, cp, communityId.hexToByteArray(), roleIdHex.hexToByteArray(), existing.value, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, healWrap) + } + // Admin carries every management bit, so this Grant makes its member staff: it must // deliver the control_root alongside the rank (CORD-04 §3), or the new admin holds // authority it cannot publish under. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordInviteResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordInviteResult.kt index bd189e9eec..bb33934c49 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordInviteResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordInviteResult.kt @@ -69,4 +69,11 @@ sealed interface ConcordInviteResult { * bundle format this app can't read yet. Retrying can't help. */ data object Incompatible : ConcordInviteResult + + /** + * The invite was good, but the membership could not be written to the Community List: the + * List is not loaded yet, or every fragment this device holds is full while the rest are + * missing (CORD-02 §8). Nothing was announced. Retrying can help once the List loads. + */ + data object NotSaved : ConcordInviteResult } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index a96427d84c..d40647097a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.actions import com.vitorpamplona.amethyst.commons.relays.MutableTime import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -243,4 +244,45 @@ class ConcordSubscriptionPlannerTest { assertTrue(generalPk in otherFilter.authors.orEmpty(), "channel plane missing from the non-control filter") assertTrue(controlPk !in otherFilter.authors.orEmpty(), "Control Plane leaked into the Guestbook/channel filter") } + + @Test + fun aJustJoinedCommunityIsFetchedWithoutTheRelaysSinceCursor() = + runTest { + // Regression: joining a community on a relay that already carries another one asked for its + // Control Plane `since` that relay's EOSE cursor, so the genesis editions (older than the + // cursor) never arrived and the community showed "No channels yet" until an app restart. + fun entryOf(c: NewConcordCommunity) = + com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry( + id = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + root = c.communityRoot.toHexKey(), + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + relays = listOf("wss://r.example"), + name = "x", + ) + val folded = ConcordCommunityFactory.create(owner, "Folded", createdAt = 1L, relays = listOf("wss://r.example")) + val joined = ConcordCommunityFactory.create(owner, "Joined", createdAt = 1L, relays = listOf("wss://r.example")) + val foldedEntry = entryOf(folded) + val joinedEntry = entryOf(joined) + val foldedState = ConcordActions.foldCommunity(folded.genesisWraps, folded.controlPlane, folded.communityId, folded.ownerPubKey) + val relay = RelayUrlNormalizer.normalizeOrNull("wss://r.example")!! + + val filters = + ConcordSubscriptionPlanner.controlIsolatedFilters( + listOf(foldedEntry, joinedEntry), + since = mutableMapOf(relay to MutableTime(1234L)), + stateOf = { if (it.id == foldedEntry.id) foldedState else null }, + ) + + val joinedControl = filters.map { it.filter }.single { joined.controlPlane.address in it.authors.orEmpty() } + assertNull(joinedControl.since, "an unfolded community's Control Plane must be fetched in full") + val joinedGuestbook = ConcordActions.guestbookPlane(joined.communityRoot, joined.communityId, joined.rootEpoch).publicKeyHex + assertNull(filters.map { it.filter }.single { joinedGuestbook in it.authors.orEmpty() }.since) + + // The already-folded community keeps its incremental cursor. + val foldedControl = filters.map { it.filter }.single { folded.controlPlane.address in it.authors.orEmpty() } + assertEquals(1234L, foldedControl.since) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt new file mode 100644 index 0000000000..8d401335be --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt @@ -0,0 +1,231 @@ +/* + * 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.concord + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragments +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeException +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.nip01Core.core.Address +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.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The Community List past one fragment (CORD-02 §8), driven through the app's own + * [ConcordChannelListState] rather than the fragment math alone: a List that outgrows one event + * must split into several, every published event must fit a relay's event ceiling, and another + * device holding only what was published must read back every membership with its secrets. + */ +class ConcordChannelListFragmentationTest { + private val signer = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000009".hexToByteArray())) + + private fun hex( + seed: Int, + salt: Int, + ): HexKey = (seed * 7919 + salt).toString(16).padStart(8, '0').repeat(8) + + /** A realistically heavy membership: staff secrets, two held roots, three private channels. */ + private fun entry(n: Int) = + ConcordCommunityListEntry( + id = hex(n, 1), + owner = hex(n, 2), + ownerSalt = hex(n, 3), + root = hex(n, 4), + rootEpoch = 2, + controlPk = hex(n, 5), + controlRoot = hex(n, 6), + heldRoots = listOf(HeldRoot(0, hex(n, 7)), HeldRoot(1, hex(n, 8), hex(n, 9))), + privateChannels = (0 until 3).map { PrivateChannelKey(hex(n, 20 + it), hex(n, 30 + it), 1, "private-$it") }, + relays = listOf("wss://nos.lol/", "wss://nostr.mom/"), + name = "Community number $n", + addedAt = 1_000L + n, + ) + + /** Holds the List only as published fragments, the way a relay or the offline backup does. */ + private class WireRepository( + var fragments: List = emptyList(), + ) : ConcordListRepository { + override fun concordList(): ConcordCommunityListEvent? = null + + override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) = Unit + + override fun concordListFragments() = fragments + + override fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) { + fragments = fragments.filterNot { it.index() == fragment.index() } + fragment + } + } + + private class StubCache : ICacheProvider { + override fun getAnyChannel(note: Note): Channel? = null + + override val relayHints = HintIndexer() + + override fun getUserIfExists(pubkey: HexKey): User? = null + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = null + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null + + override fun getOrCreateAddressableNote(address: Address): AddressableNote = AddressableNote(address) + + override fun getEventStream(): ICacheEventStream = error("not used") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User? = null + + override fun consumeEmbedded(event: Event) = Unit + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } + + private fun device(wire: List = emptyList()): Pair { + val repo = WireRepository(wire) + val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = repo) + list.markRelaysConfirmed() + return list to repo + } + + private suspend fun joinAll( + list: ConcordChannelListState, + count: Int, + ): List { + val published = ArrayList() + for (n in 0 until count) published += list.follow(entry(n)).map { it as ConcordCommunityListFragmentEvent } + return published + } + + @Test + fun aLargeListSplitsIntoFragmentsThatEachFitAnEvent() = + runTest { + val (list, repo) = device() + val published = joinAll(list, 250) + + val wire = repo.fragments + assertTrue(wire.size >= 2, "250 heavy memberships must not fit one fragment; got ${wire.size}") + for (fragment in published) { + val bytes = fragment.toJson().encodeToByteArray().size + assertTrue(bytes <= ConcordListFragments.EVENT_CEILING_BYTES, "fragment ${fragment.index()} is $bytes bytes, over the ${ConcordListFragments.EVENT_CEILING_BYTES} ceiling") + } + assertEquals(250, list.entries().size) + } + + @Test + fun anotherDeviceReadsEveryMembershipFromThePublishedFragments() = + runTest { + val (first, repo) = device() + joinAll(first, 250) + + val (second, _) = device(repo.fragments) + val read = second.entries().associateBy { it.id } + + assertEquals(250, read.size) + for (n in listOf(0, 1, 124, 248, 249)) { + val want = entry(n) + val got = read.getValue(want.id) + assertEquals(want.root, got.root) + assertEquals(want.controlRoot, got.controlRoot) + assertEquals(want.heldRoots.map { it.key }, got.heldRoots.map { it.key }) + assertEquals(want.privateChannels.map { it.key }, got.privateChannels.map { it.key }) + assertEquals(want.name, got.name) + } + } + + @Test + fun editsOnAnotherDeviceSurviveTheRoundTripAcrossFragments() = + runTest { + val (first, repo) = device() + joinAll(first, 250) + + // The second device leaves a membership that lives in a later fragment and joins a new one. + val (second, secondRepo) = device(repo.fragments) + second.unfollow(entry(240).id) + second.follow(entry(900)) + + // The first device, fed what the second published, converges on the same List. + val (third, _) = device(secondRepo.fragments) + val ids = third.entries().map { it.id }.toSet() + assertEquals(250, ids.size) + assertTrue(entry(240).id !in ids, "the leave must hold across fragments") + assertTrue(entry(900).id in ids) + assertTrue(entry(0).id in ids && entry(249).id in ids) + } + + @Test + fun aDeviceMissingFragmentsJoinsIntoAHeldFragmentWithRoom() = + runTest { + val (first, repo) = device() + joinAll(first, 250) + val wire = repo.fragments.sortedBy { it.index() } + assertTrue(wire.size >= 3) + + // Holding the full fragment 0 and the partly filled last one, but not the middle ones + // (they sit on a relay this device can't reach): the join goes where it fits. + val (partial, partialRepo) = device(listOf(wire.first(), wire.last())) + val written = partial.follow(entry(901)).map { it as ConcordCommunityListFragmentEvent } + assertEquals(listOf(wire.last().index()), written.map { it.index() }, "the new membership belongs in the held fragment with room") + + // Nothing it never held was rewritten: once the middle fragments arrive, nothing is lost. + val merged = (partialRepo.fragments + wire).groupBy { it.index() }.map { (_, copies) -> copies.maxBy { it.createdAt } } + val (reader, _) = device(merged) + val ids = reader.entries().map { it.id }.toSet() + assertEquals(251, ids.size, "every membership from every fragment plus the new join") + assertTrue(entry(901).id in ids) + } + + @Test + fun aDeviceHoldingOnlyFullFragmentsRefusesTheJoinRatherThanOverflowing() = + runTest { + val (first, repo) = device() + joinAll(first, 250) + val fragmentZero = repo.fragments.first { it.index() == 0 } + + // Opening a new fragment is a repack, which needs the complete List (CORD-02 §8); an + // oversized event would be refused by relays. Refusing here is the only safe answer, + // and the caller must surface it. + val (partial, partialRepo) = device(listOf(fragmentZero)) + assertFailsWith { partial.follow(entry(902)) } + assertEquals(listOf(fragmentZero), partialRepo.fragments, "a refused write publishes nothing") + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 2390db807d..799c2bf27a 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3639,6 +3639,7 @@ Rename No channels yet. Create + The community could not be created: no relay accepted it, or your community list could not be updated. Check the relays and try again. Relays Relays that store this community's encrypted messages. Leave empty to use your own. New Concord Channel @@ -3668,6 +3669,7 @@ This invite link has expired and can no longer be used. Ask for a fresh link. This invite link can't be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link. This invite link is invalid or can't be opened with this account. + The invite works, but your community list could not be updated, so you were not joined. Try again in a moment. This invite link has been revoked and can no longer be used. Ask for a new one. Invite links… Invite links @@ -3684,6 +3686,8 @@ %1$d members Ban + Ban member? + They can no longer post, and their messages are hidden from everyone. Other apps may remove the community from their list. They can still read new messages until you also remove them from the community. You can unban them later. Make admin Remove admin Could not update this member's roles. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt index e373ccb0f7..756ca3cb9c 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_home_title import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_banned import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_expired import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_invalid +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_not_saved import com.vitorpamplona.amethyst.commons.ui.components.ConcordInvitePreviewRow import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList @@ -252,6 +253,7 @@ private fun ConcordDirectInviteCard( is ConcordInviteResult.Expired -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_expired) is ConcordInviteResult.Banned -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_banned) is ConcordInviteResult.InvalidLink -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_invalid) + is ConcordInviteResult.NotSaved -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_not_saved) else -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_direct_invite_accept_failed) } } finally { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index a72355ef84..7086c9ef1d 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_banned import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_expired import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_incompatible import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_invalid +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_not_saved import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_revoked import com.vitorpamplona.amethyst.commons.resources.concord_invite_preview_explainer import com.vitorpamplona.amethyst.commons.resources.concord_invite_preview_relays @@ -144,6 +145,8 @@ fun ConcordInviteScreen( RedeemState.Failed(Res.string.concord_invite_failed_expired, canRetry = false) is ConcordInviteResult.NotReachable -> RedeemState.Failed(Res.string.concord_invite_failed, canRetry = true) + is ConcordInviteResult.NotSaved -> + RedeemState.Failed(Res.string.concord_invite_failed_not_saved, canRetry = true) } } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 8df45dbba3..0ee288bd3b 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -63,6 +63,8 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.concord_members_ban +import com.vitorpamplona.amethyst.commons.resources.concord_members_ban_message +import com.vitorpamplona.amethyst.commons.resources.concord_members_ban_title import com.vitorpamplona.amethyst.commons.resources.concord_members_empty import com.vitorpamplona.amethyst.commons.resources.concord_members_make_admin import com.vitorpamplona.amethyst.commons.resources.concord_members_remove @@ -92,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.MutableStateFlow +import org.jetbrains.compose.resources.StringResource import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** @@ -289,9 +292,28 @@ private fun ConcordMemberRow( ) } + // A ban is reversible here, but not for the banned member: clients such as Armada drop the + // community from a banned member's list on sight, so a mis-tap still costs them the community. + var confirmBan by remember { mutableStateOf(false) } + if (confirmBan) { + ConcordConfirmMemberActionDialog( + title = Res.string.concord_members_ban_title, + message = Res.string.concord_members_ban_message, + confirm = Res.string.concord_members_ban, + onConfirm = { + accountViewModel.setConcordBan(communityId, entry.pubkey, ban = true) + confirmBan = false + }, + onDismiss = { confirmBan = false }, + ) + } + var confirmRemove by remember { mutableStateOf(false) } if (confirmRemove) { - ConcordRemoveMemberDialog( + ConcordConfirmMemberActionDialog( + title = Res.string.concord_members_remove_title, + message = Res.string.concord_members_remove_message, + confirm = Res.string.concord_members_remove_confirm, onConfirm = { accountViewModel.removeConcordMember(communityId, entry.pubkey) confirmRemove = false @@ -357,7 +379,8 @@ private fun ConcordMemberRow( DropdownMenuItem( text = { Text(stringRes(if (isBanned) Res.string.concord_members_unban else Res.string.concord_members_ban)) }, onClick = { - accountViewModel.setConcordBan(communityId, entry.pubkey, ban = !isBanned) + // Unbanning restores access, so only a ban asks first. + if (isBanned) accountViewModel.setConcordBan(communityId, entry.pubkey, ban = false) else confirmBan = true expanded = false }, ) @@ -471,17 +494,20 @@ private fun ConcordRolesDialog( /** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */ @Composable -private fun ConcordRemoveMemberDialog( +private fun ConcordConfirmMemberActionDialog( + title: StringResource, + message: StringResource, + confirm: StringResource, onConfirm: () -> Unit, onDismiss: () -> Unit, ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringRes(Res.string.concord_members_remove_title)) }, - text = { Text(stringRes(Res.string.concord_members_remove_message)) }, + title = { Text(stringRes(title)) }, + text = { Text(stringRes(message)) }, confirmButton = { TextButton(onClick = onConfirm) { - Text(stringRes(Res.string.concord_members_remove_confirm), color = MaterialTheme.colorScheme.error) + Text(stringRes(confirm), color = MaterialTheme.colorScheme.error) } }, dismissButton = { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 7f4d0d6661..a69154843a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.concord.cord02Community import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName @@ -309,6 +310,8 @@ object ConcordCommunityList { @SerialName(EXTRAS) val extras: JsonObject = NoExtras, ) + // `name` is always written: Armada refuses to serialize a list entry whose name is not a string. + @OptIn(ExperimentalSerializationApi::class) @Serializable private class JoinMaterialWire( @SerialName("community_id") val communityId: String, @@ -323,7 +326,7 @@ object ConcordCommunityList { WireChannel, > = emptyList(), val relays: List = emptyList(), - val name: String = "", + @EncodeDefault val name: String = "", @SerialName("held_roots") val heldRoots: List< @Serializable(WireHeldRootSerializer::class) WireHeldRoot, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt index 21afc71596..231f722fce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt @@ -142,7 +142,22 @@ class ConcordListFragmentSet private constructor( val targets = HashMap>() for (id in changed) { val holders = inRange.filter { id in ConcordListFragments.idsIn(held.getValue(it).fragment.doc) } - for (i in holders.ifEmpty { listOf(inRange.first()) }) targets.getOrPut(i) { HashSet() }.add(id) + if (holders.isNotEmpty()) { + for (i in holders) targets.getOrPut(i) { HashSet() }.add(id) + continue + } + // A new membership may go into any held fragment (CORD-02 §8 scopes the write to the + // fragment it touches). Take the lowest one it still fits in: always taking the lowest + // refused every join once fragment 0 filled, even with room in another held fragment. + // When none fits, the lowest is kept and the size guard below refuses the write, since + // opening a new fragment is a repack and needs the complete List. + val target = + inRange.firstOrNull { i -> + val ids = targets[i].orEmpty() + id + val plaintext = ConcordListFragments.rewriteFragment(held.getValue(i).fragment.doc, newDoc, ids, declared) + ConcordListFragments.projectedEventBytes(plaintext.encodeToByteArray().size) <= ConcordListFragments.EVENT_CEILING_BYTES + } ?: inRange.first() + targets.getOrPut(target) { HashSet() }.add(id) } for ((i, ids) in targets.entries.sortedBy { it.key }) { val plaintext = ConcordListFragments.rewriteFragment(held.getValue(i).fragment.doc, newDoc, ids, declared) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index 632a7f9e03..14bb259a05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.LenientImagePointerSerializer +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -50,14 +52,21 @@ class InviteChannel( * Field names are pinned to the Concord v2 reference client (snake_case on the * wire) so bundles interoperate. This object is JSON-serialized and encrypted — * into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites). + * + * [rootEpoch], [channels], [relays] and [name] are always written, even at their defaults: + * Armada rejects join material whose `root_epoch` is not a number or whose `name` is not a + * string, and Accordion rejects a bundle missing `root_epoch`, `channels` or `relays`. A + * fresh public community (epoch 0, no private channel grants) was unjoinable from both while + * the encoder dropped them. */ +@OptIn(ExperimentalSerializationApi::class) @Serializable data class CommunityInvite( @SerialName("community_id") val communityId: String, val owner: String, @SerialName("owner_salt") val ownerSalt: String, @SerialName("community_root") val communityRoot: String, - @SerialName("root_epoch") val rootEpoch: Long = 0, + @EncodeDefault @SerialName("root_epoch") val rootEpoch: Long = 0, /** * The Control Plane's signer pubkey at [rootEpoch] (CORD-02 §5): subscribe, * verify, read — never write. Absent = a legacy, pre-split Community; the @@ -71,9 +80,9 @@ data class CommunityInvite( * Roster, and a later base rotation re-delivers the true key. */ @SerialName("control_pk") val controlPk: String? = null, - val channels: List = emptyList(), - val relays: List = emptyList(), - val name: String = "", + @EncodeDefault val channels: List = emptyList(), + @EncodeDefault val relays: List = emptyList(), + @EncodeDefault val name: String = "", @Serializable(with = LenientImagePointerSerializer::class) val icon: ImagePointer? = null, @SerialName("expires_at") val expiresAt: Long? = null, @SerialName("creator_npub") val creatorNpub: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index cad9d70478..4b47df18ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -78,11 +79,14 @@ object ConcordStreamEnvelope { authorSigner: NostrSigner, encrypted: Boolean, ): Event { + // A rumor is unsigned (NIP-59): serialize it without `sig`. applesauce clients read + // `"sig": ""` as a signed event with a bad signature and drop it. + val rumorJson = Rumor.toJson(Rumor.create(rumor)) val content = if (encrypted) { - encryptChecked(rumor.toJson(), stream.conversationKey) + encryptChecked(rumorJson, stream.conversationKey) } else { - rumor.toJson() + rumorJson } val kind = if (encrypted) KIND_SEAL_ENCRYPTED else KIND_SEAL_PLAINTEXT return authorSigner.sign(rumor.createdAt, kind, EMPTY_TAGS, content) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInviteWireTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInviteWireTest.kt new file mode 100644 index 0000000000..b061103c89 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInviteWireTest.kt @@ -0,0 +1,46 @@ +/* + * 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.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlin.test.Test +import kotlin.test.assertEquals + +class CommunityInviteWireTest { + private val hex = "a".repeat(64) + + @Test + fun freshCommunityInviteStillCarriesEveryRequiredField() { + // A genesis public community sits at epoch 0 with no private channel grants: these are + // all Kotlin defaults, and Armada/Accordion refuse the join unless they are on the wire. + val invite = CommunityInvite(communityId = hex, owner = hex, ownerSalt = hex, communityRoot = hex) + val json = ConcordJson.instance.encodeToJsonElement(CommunityInvite.serializer(), invite).jsonObject + + assertEquals(0L, json["root_epoch"]?.jsonPrimitive?.long) + assertEquals("", json["name"]?.jsonPrimitive?.content) + assertEquals(0, json["channels"]?.jsonArray?.size) + assertEquals(0, json["relays"]?.jsonArray?.size) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt index e9fc72f3fb..50e0f9e1fa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt @@ -28,8 +28,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -69,6 +73,21 @@ class ConcordStreamEnvelopeTest { assertEquals(9, opened.rumor.kind) } + /** + * A rumor is unsigned (NIP-59): its JSON carries no `sig`. applesauce-based clients + * (Accordion) treat an object with `"sig": ""` as a signed event whose signature fails, + * and silently dropped every Concord message and Guestbook Join we sent. + */ + @Test + fun sealedRumorJsonCarriesNoSig() = + runTest { + val seal = ConcordStreamEnvelope.seal(chatRumor("no sig"), stream, authorSigner, encrypted = false) + val rumorJson = Json.parseToJsonElement(seal.content).jsonObject + + assertFalse("sig" in rumorJson, "rumor JSON must not carry a sig: ${seal.content}") + assertEquals(chatRumor("no sig").id, rumorJson["id"]?.jsonPrimitive?.content) + } + @Test fun encryptedSealRoundTrips() = runTest {