diff --git a/PLAN.md b/PLAN.md index e9ad0d0..20d24b2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -128,7 +128,7 @@ Concord is 8 CORDs. Building all at once contradicts requirement 1. | Pins / Threads / WebXDC | Not needed for a first cut. | | Deletes (`kind 5`) | Registered, not folded: a message another client deleted still renders here. Same shape as M6's edits, so ~the same cost when someone asks. | | Replies (`kind 1111`) | Reuse `plane.key.rumor` the same way M6 does; the work is the thread UI, not the wire format. | -| Community List (`33302`) | Cross-device sync; single-device + `AppStorage` covers v1. | +| Community List (`33302`) | **Read-only as of M7** (§12): a Community joined in another client now appears here. Writing the List is still deferred — see M7's "not implemented". | | Dissolution | Rare path. | > **Consequence to be honest about:** the v1 Control fold does **not** enforce authorization. A `control_root` holder could publish forged metadata or channels and v1 would display it. This is bounded (only staff hold `control_root`, and the spec itself calls it "a spam gate, never authority") but it **is** a real gap. Document it in code and label the feature beta in the UI. @@ -205,6 +205,7 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue | `ConcordModels.kt` | `Membership`, `CommunityInvite`, `CommunityMeta`, `ChannelMeta`, `ConcordChannel`, `ConcordMessage`, `ControlEdition` | | `ConcordPlane.kt` | `SealForm`, `ChatBinding`, `PlaneKey` + `channelPlaneKey` / `guestbookPlaneKey` / `controlPlaneKey`, and the `rumor()` / `wrap()` / `unwrap()` extensions — the CORD-01 stack with the CORD-03 §3 binding built in | | `ConcordInvite.kt` | `CommunityInvite` JSON validation, `$BASE/invite/#` decoder, relay dictionary | +| `ConcordCommunityList.kt` | **M7.** CORD-02 §8's wire models and the *reader's* merges: union fragments, newest entry wins, tombstones subtract. Produces `Membership`s through the invite path's own `problems()` / `toMembership()` | | `ConcordStore.kt` | Membership persistence (`AppStorage.setSecret`), LMDB index events, Control edition storage | | `ConcordControl.kt` | Control fold: group by `eid`, take highest `ev` with intact `ep` chain; project `vsk 0` / `vsk 2` | | `ConcordManager.kt` | Subscriptions, relay connect, notification routing, send-message, join/leave | @@ -276,6 +277,13 @@ Wired in M6: | `composeApp/.../screens/chat/ChatMessage.kt` | `MessageReactions` takes `(emojis, total)` instead of `List`, so Channels can share it | | `composeApp/.../screens/communities/ChannelScreen.kt` | long-press action row, reaction chips, the Edit flow and its banner | +Wired in M7: + +| File | Change | +|---|---| +| `shared/.../concord/ConcordCommunityList.kt` | new — §8 wire models, `listedMemberships()` | +| `shared/.../concord/ConcordManager.kt` | `adoptCommunityList()`, called first in `sync()` | + --- ## 6. Crypto layer — exact algorithm @@ -926,6 +934,56 @@ gains two more steps because of it. --- +### M7 — Community List discovery — **done** + +An invite hands the keys to *one* device. The only path from "joined in another client" to "visible +here" is CORD-02 §8's Community List: kind `33302`, NIP-44-encrypted to self, fragmented, whose +entries carry the whole *join material* — keys included. + +What shipped: + +- **`ConcordCommunityList.kt`** — the §8 wire models plus the reader's merges, in the same shape as + `ConcordInvite.kt`: one file per wire concern. +- **`ConcordManager.adoptCommunityList()`**, called at the top of `sync()` so adopted Communities are + subscribed by the same loop that subscribes the rest. Fetch is `Filter(kind 33302, author = us)` + over `ReqTarget.auto` — the pattern `RelayManager.fetchMsgRelays` already uses for finding a + self-published document — then `signer.nip44DecryptAsync(me, content)` per event and a union. +- **§8's encoding, which is the one place this format differs from every other Concord document:** + every 32-byte value is unpadded base64url at *any* depth, join material and each `channels` entry + included, where the rest of the protocol is lowercase hex (CORD-01, Encoding). Values are decoded + strictly — exactly 32 bytes or the entry is dropped — then normalised to hex, so nothing downstream + knows the difference. +- **Reuse, not a parallel copy.** A decoded entry is rebuilt as a `CommunityInvite`, which means the + invite path's own `problems()` runs on it: the entry must *prove* its `community_id` as + `sha256("concord/community" ‖ owner ‖ owner_salt)`, and its channel ids and keys must be 32 bytes. + `toMembership()` then does the projection. Entry data that fails any of that is dropped, not + rendered. +- **Only the merges a reader needs.** The newest entry per Community wins, and a Community whose + tombstone is at least as new as its entry reads as left. `seed`, the canonical-bytes tiebreak, + fragment repacks and the 65 KiB ceiling are all *writer* rules — they exist to keep two devices + byte-identical, and a client that never republishes cannot flap. Fragments are unioned, so a + multi-fragment List works; `frags` itself is ignored. +- **Add-only.** A tombstone for a Community this device does not hold is honoured; nothing already + joined here is ever removed, because Coop does not write the List and so cannot tell which of two + states is newer. +- **One log line**, because every failure mode here is silent: the fetch returning nothing, an + undecryptable fragment, and an entry failing self-certification all look identical from the UI. + `Concord: Community List gave N fragment(s), M membership(s), K new` is the difference between "the + feature is broken" and "there was nothing to find". + +**Not implemented, deliberately:** writing the List. So a Community joined *in Coop* still does not +appear in another client, and Coop never republishes a fragment. §8 is explicit about the cost of +getting a writer wrong — a client that drops a field it does not understand "destroys that material +on every device its holder owns the moment it republishes" — so the read path is proven first. +Also absent: `seed`-based epoch backfill (moot while CORD-06 rekeys are deferred) and live +subscription to `33302` (discovery runs at startup, like everything else in `sync()`). + +**Verified:** compilation on both targets — `:shared:compileKotlinIosSimulatorArm64` and +`:shared:compileDebugKotlinAndroid`. Nothing else: the parse is exercised only by a real List, which +requires the identity that minted it. + +--- + ## 13. Risks and open decisions ### 13.1 Spec conflicts — resolve before writing byte-exact code @@ -958,6 +1016,7 @@ gains two more steps because of it. | 18 | **`restored` must not flip back on reset.** If `reset()` set it false, the Communities screen would spin forever after a logout, because nothing re-runs `restore()` until the notification pump is rebuilt and the pump has no restart path. | `restored` means "memberships have been loaded", which stays true after they are dropped. Stated in a comment at the assignment. | | 19 | **M6's edit shape is illustrative, not normative.** `examples.md` §2.5 says so in as many words: "The CORDs register the kind but don't yet pin its fields; this shape is illustrative." The reaction shape (§2.3) is pinned — NIP-25 with `e`/`p`/`k` — but the edit's `e`-alone layout is a guess the spec invites others to make differently. | Unfixable from here; it is a spec gap, not an implementation choice. Both shapes are one call to `publish` with a different tag list, so a revision is a one-place change (§11's frozen-constants discipline). Report it upstream rather than papering over it. | | 20 | **Enforcement of edits is local.** Our fold refuses an edit whose author is not the message's, but nothing stops another client rendering a forged one — and nothing *should*, since a receiver-side check is the whole enforcement model. | This is the protocol, not a bug: enforcement is rejection ("an action that does not trace to the owner is not authority"). The one thing to keep is the check itself, so a hostile edit never lands *here*. | +| 21 | **M7 discovery can fail three ways, all silently.** (a) the other client never published a `33302` at all; (b) it published to relays `ReqTarget.auto` does not reach, since Coop's client knows only its bootstrap plus indexer set; (c) the entry fails the self-certifying `community_id` check, which drops it by design. | (c) is correct behaviour. (a) and (b) are indistinguishable from the UI, which is why `adoptCommunityList` logs fragment/entry counts — an empty List and an unreachable one must not look the same. If (b) turns out to be the case, the fix is to add the user's own NIP-65 relays to the client before the fetch, the way `MessageManager.connectMsgRelays` does for chat rooms. | ### 13.3 Closed decision diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCommunityList.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCommunityList.kt new file mode 100644 index 0000000..5a27426 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordCommunityList.kt @@ -0,0 +1,129 @@ +package su.reya.coop.concord + +import kotlin.io.encoding.Base64 +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * §8's 32-byte values are unpadded base64url rather than hex — the Community List is the one + * encrypted document CORD-01 lets choose its own encoding. Padding is tolerated on read. + */ +private val listBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL) + +/** One fragment's plaintext. `frags` and `seed` are read by writers, not here. */ +@Serializable +internal data class CommunityListFragment( + val entries: List = emptyList(), + val tombstones: List = emptyList(), +) + +@Serializable +internal data class CommunityListEntry( + /** base64url, and case-significant: decoded, never case-folded. */ + @SerialName("community_id") val communityId: String = "", + /** The freshest snapshot. `seed`, the backfill anchor, is not read here. */ + val current: CommunityListSnapshot? = null, + /** Unix ms. Tiebreaks against the tombstone and between two entries. */ + @SerialName("added_at") val addedAt: Long = 0L, +) + +@Serializable +internal data class CommunityListTombstone( + @SerialName("community_id") val communityId: String = "", + @SerialName("removed_at") val removedAt: Long = 0L, +) + +/** + * The invite bundle's *membership* subset (CORD-05), in §8's base64url: never the icon, never the + * link fields. + */ +@Serializable +internal data class CommunityListSnapshot( + val owner: String = "", + @SerialName("owner_salt") val ownerSalt: String = "", + @SerialName("community_root") val communityRoot: String = "", + @SerialName("root_epoch") val rootEpoch: ULong = 0u, + @SerialName("control_pk") val controlPk: String? = null, + val channels: List = emptyList(), + val relays: List = emptyList(), + val name: String? = null, +) + +@Serializable +internal data class CommunityListChannel( + val id: String = "", + val key: String = "", + val epoch: ULong = 0u, + val name: String? = null, +) + +/** + * Unions fragments into one membership per Community (CORD-02 §8). + * + * Only the merges a *reader* needs: the newest entry per Community wins, and a Community whose + * tombstone is at least as new as its entry reads as left. `seed`, the canonical-bytes tiebreak and + * repacks exist to keep two writers byte-identical — a client that never republishes cannot flap. + */ +internal fun List.listedMemberships(): List { + val newest = mutableMapOf>() + + for (entry in flatMap { it.entries }) { + val membership = entry.toMembership() ?: continue + val held = newest[membership.communityId] + if (held == null || entry.addedAt > held.first) { + newest[membership.communityId] = entry.addedAt to membership + } + } + + val removed = mutableMapOf() + for (tombstone in flatMap { it.tombstones }) { + val id = tombstone.communityId.listId() ?: continue + removed[id] = maxOf(removed[id] ?: 0L, tombstone.removedAt) + } + + return newest + .filter { (id, entry) -> (removed[id] ?: 0L) < entry.first } + .values.map { it.second } +} + +/** One entry as a [Membership], reusing the invite bundle's own self-certification. */ +private fun CommunityListEntry.toMembership(): Membership? { + val id = communityId.listId() ?: return null + val invite = (current ?: return null).toInvite(id) ?: return null + if (invite.problems().isNotEmpty()) return null + return invite.toMembership(invite.relaySet()) +} + +/** A snapshot normalised to the hex [CommunityInvite] the invite path already validates. */ +private fun CommunityListSnapshot.toInvite(communityIdHex: String): CommunityInvite? { + val ownerHex = owner.listId() ?: return null + val saltHex = ownerSalt.listId() ?: return null + val rootHex = communityRoot.listId() ?: return null + + // Strict: a malformed control key is a malformed entry, not a key to quietly drop null. Dropping + // it to null would leave the Control Plane unaddressable with nothing to show for it. + val controlHex = controlPk?.let { it.listId() ?: return null } + + // Lenient by contrast: one unreadable Channel key costs that Channel, not the membership. + val granted = channels.mapNotNull { channel -> + val id = channel.id.listId() ?: return@mapNotNull null + val key = channel.key.listId() ?: return@mapNotNull null + InviteChannel(id = id, key = key, epoch = channel.epoch, name = channel.name) + } + + return CommunityInvite( + communityId = communityIdHex, + owner = ownerHex, + ownerSalt = saltHex, + communityRoot = rootHex, + rootEpoch = rootEpoch, + controlPk = controlHex, + channels = granted, + relays = relays, + name = name, + ) +} + +/** §8's unpadded base64url for a 32-byte value, or null when it is not one. */ +private fun String.listId(): String? = + runCatching { listBase64.decode(this) }.getOrNull()?.takeIf { it.size == 32 }?.toHex() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt index 49f2b42..0fcfda7 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordKind.kt @@ -70,6 +70,12 @@ object ConcordKind { /** Community List — one addressable event per fragment, NIP-44 to self. */ const val COMMUNITY_LIST = 33302 + /** + * The single-event Community List [COMMUNITY_LIST] superseded. Retired and never written; + * queried only so "no List exists" can be told apart from "a client still speaks the old shape". + */ + const val RETIRED_COMMUNITY_LIST = 13302 + /** Invite List — replaceable, NIP-44 to self. */ const val INVITE_LIST = 13303 } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt index 7218d46..9347141 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/concord/ConcordManager.kt @@ -181,8 +181,10 @@ class ConcordManager(private val nostr: Nostr) { refreshPlanes() } - /** Network half of startup: connect every Community's relays and subscribe to its planes. */ + /** Network half of startup: adopt what other clients hold, then connect and subscribe. */ suspend fun sync() { + adoptCommunityList() + for (membership in _memberships.value) { try { subscribeCommunity(membership) @@ -194,6 +196,98 @@ class ConcordManager(private val nostr: Nostr) { } } + /** + * Adopts the memberships this identity holds on other clients (CORD-02 §8). + * + * The Community List is the only way a Community joined elsewhere can appear here: an invite + * hands keys to one device, while the List is the member's own self-encrypted vault on their + * own relays. Add-only — a tombstone for a Community we do not hold is honoured, but nothing + * this device joined is dropped, because Coop never writes the List and so cannot tell which + * of the two states is newer. + */ + private suspend fun adoptCommunityList() { + val client = nostr.client + if (client == null) { + println("Concord: Community List skipped — no client yet") + return + } + val store = store + if (store == null) { + println("Concord: Community List skipped — storage was never attached") + return + } + + val me = try { + nostr.signer.getPublicKeyAsync() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println("Concord: Community List skipped — could not read the signer: ${e.message}") + return + } + if (me == null) { + println("Concord: Community List skipped — the signer has no public key") + return + } + + val filter = Filter() + .kind(Kind(ConcordKind.COMMUNITY_LIST.toUShort())) + .author(me) + + val events = try { + client.fetchEvents(ReqTarget.auto(listOf(filter)), timeout = 8.seconds).toVec() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println("Concord: Community List fetch failed: ${e.message}") + return + } + + println( + "Concord: Community List for ${me.toHex().take(8)}… came back with " + + "${events.size} event(s) from ${client.relays().size} relay(s)" + ) + + val fragments = events.mapNotNull { event -> + try { + val plaintext = nostr.signer.nip44DecryptAsync(me, event.content()) + concordJson.decodeFromString(plaintext) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println( + "Concord: Community List fragment ${event.id().toHex().take(8)}… " + + "(d=${event.tagValue(ConcordTag.D)}) is unusable: ${e.message}" + ) + null + } + } + + val known = _memberships.value.mapTo(mutableSetOf()) { it.communityId } + val listed = fragments.listedMemberships() + val adopted = listed.filterNot { it.communityId in known } + + println( + "Concord: Community List gave ${fragments.size} fragment(s), " + + "${listed.size} membership(s), ${adopted.size} new" + ) + + if (adopted.isEmpty()) return + + val updated = _memberships.value + adopted + try { + store.saveMemberships(updated) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + println("Concord: could not store the adopted memberships: ${e.message}") + return + } + + _memberships.value = updated + refreshPlanes() + } + /** * Fetches and decrypts the bundle behind a public invite link, so the UI can show what joining * would mean. Nothing is joined, nothing is subscribed and no presence is announced here