.
This commit is contained in:
@@ -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<CommunityListEntry> = emptyList(),
|
||||
val tombstones: List<CommunityListTombstone> = 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<CommunityListChannel> = emptyList(),
|
||||
val relays: List<String> = 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<CommunityListFragment>.listedMemberships(): List<Membership> {
|
||||
val newest = mutableMapOf<String, Pair<Long, Membership>>()
|
||||
|
||||
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<String, Long>()
|
||||
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()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<CommunityListFragment>(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
|
||||
|
||||
Reference in New Issue
Block a user