mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
fix(concord): read/write the kind-13302 list in Armada's wire format
Amethyst serialized the joined-community list (kind 13302) as a bare JSON
array of flat camelCase entries, so it could not parse the document Soapbox
Armada actually publishes — a community created in Armada never appeared in
Amethyst even after the 13302 reached a shared relay.
Rewrites the codec to Armada's communityList.ts shape:
{ entries: [ { community_id, seed: JoinMaterial, current: JoinMaterial,
added_at } ], tombstones: [ { community_id, removed_at } ] }, where
JoinMaterial is the snake_case per-snapshot key bundle (community_id, owner,
owner_salt, community_root, root_epoch, channels[], relays, name, held_roots?,
refounder?). Hydration prefers current over seed; liveness is derived from
tombstones (an entry is dropped only when removed strictly after it was added).
New create/join entries now stamp added_at (ms) so the liveness tiebreak works.
Adds interop tests that decode a real Armada document and exercise the
tombstone-after-add drop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
@@ -1837,6 +1837,7 @@ class Account(
|
||||
rootEpoch = community.rootEpoch,
|
||||
relays = relayUrls,
|
||||
name = name,
|
||||
addedAt = TimeUtils.now() * 1000,
|
||||
),
|
||||
)
|
||||
return community.communityIdHex
|
||||
@@ -1901,6 +1902,7 @@ class Account(
|
||||
rootEpoch = bundle.rootEpoch,
|
||||
relays = bundle.relays,
|
||||
name = bundle.name,
|
||||
addedAt = TimeUtils.now() * 1000,
|
||||
)
|
||||
joinConcordCommunity(entry)
|
||||
return bundle.communityId
|
||||
|
||||
+128
-8
@@ -23,8 +23,8 @@ 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.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
|
||||
/** A past root key for a specific epoch, kept so historical channel keys stay derivable. */
|
||||
@Serializable
|
||||
@@ -39,6 +39,7 @@ class PrivateChannelKey(
|
||||
val channelId: String,
|
||||
val key: String,
|
||||
val epoch: Long,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,8 @@ class PrivateChannelKey(
|
||||
* needed to re-derive the community's planes on any device: identity ([id],
|
||||
* [owner], [ownerSalt]), the current access [root] at [rootEpoch] plus past
|
||||
* [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached
|
||||
* display [name].
|
||||
* display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks
|
||||
* liveness against tombstones.
|
||||
*/
|
||||
@Serializable
|
||||
class ConcordCommunityListEntry(
|
||||
@@ -59,6 +61,7 @@ class ConcordCommunityListEntry(
|
||||
val privateChannels: List<PrivateChannelKey> = emptyList(),
|
||||
val relays: List<String> = emptyList(),
|
||||
val name: String = "",
|
||||
val addedAt: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -67,10 +70,97 @@ class ConcordCommunityListEntry(
|
||||
* lets a client return to the groups the user signed up for. Replaceable and
|
||||
* NIP-44-encrypted to the member's own key, so relays store only ciphertext.
|
||||
*
|
||||
* (Channels are not listed here: once the [root] is held, folding the Control
|
||||
* Plane yields the community's channels.)
|
||||
* The plaintext document is wire-compatible with Soapbox Armada's `communityList.ts`:
|
||||
* `{ "entries": [ { "community_id", "seed": JoinMaterial, "current": JoinMaterial,
|
||||
* "added_at" } ], "tombstones": [ { "community_id", "removed_at" } ] }`, where
|
||||
* [JoinMaterialWire] is the snake_case per-snapshot key bundle. Liveness is derived —
|
||||
* an entry is dropped only when a later tombstone removes it — and each entry keeps a
|
||||
* [CommunityListEntryWire.seed] (backfill anchor) plus [CommunityListEntryWire.current]
|
||||
* (latest) snapshot; we hydrate from `current`, falling back to `seed`.
|
||||
*
|
||||
* (Channels are not listed here beyond their private keys: once the [root] is held,
|
||||
* folding the Control Plane yields the community's channels.)
|
||||
*/
|
||||
object ConcordCommunityList {
|
||||
// ---- wire DTOs (snake_case, Armada communityList.ts) ----------------------
|
||||
|
||||
@Serializable
|
||||
private class WireChannel(
|
||||
val id: String,
|
||||
val key: String,
|
||||
val epoch: Long,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private class WireHeldRoot(
|
||||
val epoch: Long,
|
||||
val key: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private class JoinMaterialWire(
|
||||
@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,
|
||||
val channels: List<WireChannel> = emptyList(),
|
||||
val relays: List<String> = emptyList(),
|
||||
val name: String = "",
|
||||
@SerialName("held_roots") val heldRoots: List<WireHeldRoot> = emptyList(),
|
||||
val refounder: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private class CommunityListEntryWire(
|
||||
@SerialName("community_id") val communityId: String,
|
||||
val seed: JoinMaterialWire? = null,
|
||||
val current: JoinMaterialWire? = null,
|
||||
@SerialName("added_at") val addedAt: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private class CommunityTombstoneWire(
|
||||
@SerialName("community_id") val communityId: String,
|
||||
@SerialName("removed_at") val removedAt: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private class CommunityListDoc(
|
||||
val entries: List<CommunityListEntryWire> = emptyList(),
|
||||
val tombstones: List<CommunityTombstoneWire> = emptyList(),
|
||||
)
|
||||
|
||||
private fun ConcordCommunityListEntry.toJoinMaterial() =
|
||||
JoinMaterialWire(
|
||||
communityId = id,
|
||||
owner = owner,
|
||||
ownerSalt = ownerSalt,
|
||||
communityRoot = root,
|
||||
rootEpoch = rootEpoch,
|
||||
channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||
relays = relays,
|
||||
name = name,
|
||||
heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) },
|
||||
)
|
||||
|
||||
private fun JoinMaterialWire.toEntry(addedAt: Long) =
|
||||
ConcordCommunityListEntry(
|
||||
id = communityId,
|
||||
owner = owner,
|
||||
ownerSalt = ownerSalt,
|
||||
root = communityRoot,
|
||||
rootEpoch = rootEpoch,
|
||||
heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) },
|
||||
privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) },
|
||||
relays = relays,
|
||||
name = name,
|
||||
addedAt = addedAt,
|
||||
)
|
||||
|
||||
// ---- build / codec --------------------------------------------------------
|
||||
|
||||
/** Builds the encrypted kind-13302 list event from [entries], signed by [signer]. */
|
||||
suspend fun build(
|
||||
signer: NostrSigner,
|
||||
@@ -81,13 +171,43 @@ object ConcordCommunityList {
|
||||
return signer.sign(createdAt, ConcordCommunityListEvent.KIND, emptyArray(), content)
|
||||
}
|
||||
|
||||
/** Serializes [entries] to the plaintext JSON that gets NIP-44 self-encrypted. */
|
||||
fun encode(entries: List<ConcordCommunityListEntry>): String = ConcordJson.instance.encodeToString(ListSerializer(ConcordCommunityListEntry.serializer()), entries)
|
||||
/** Serializes [entries] to the plaintext JSON document that gets NIP-44 self-encrypted. */
|
||||
fun encode(entries: List<ConcordCommunityListEntry>): String {
|
||||
val doc =
|
||||
CommunityListDoc(
|
||||
entries =
|
||||
entries.map { e ->
|
||||
val jm = e.toJoinMaterial()
|
||||
CommunityListEntryWire(
|
||||
communityId = e.id,
|
||||
seed = jm,
|
||||
current = jm,
|
||||
addedAt = e.addedAt,
|
||||
)
|
||||
},
|
||||
tombstones = emptyList(),
|
||||
)
|
||||
return ConcordJson.instance.encodeToString(CommunityListDoc.serializer(), doc)
|
||||
}
|
||||
|
||||
/** Parses the decrypted plaintext JSON back into entries, or empty on failure. */
|
||||
/**
|
||||
* Parses the decrypted plaintext JSON document back into live entries, or empty on
|
||||
* failure. An entry is live unless a tombstone for the same community removed it
|
||||
* strictly after it was added; hydration prefers `current`, falling back to `seed`.
|
||||
*/
|
||||
fun decode(json: String): List<ConcordCommunityListEntry> =
|
||||
try {
|
||||
ConcordJson.instance.decodeFromString(ListSerializer(ConcordCommunityListEntry.serializer()), json)
|
||||
val doc = ConcordJson.instance.decodeFromString(CommunityListDoc.serializer(), json)
|
||||
val latestRemoval = HashMap<String, Long>()
|
||||
for (t in doc.tombstones) {
|
||||
val prev = latestRemoval[t.communityId]
|
||||
if (prev == null || t.removedAt > prev) latestRemoval[t.communityId] = t.removedAt
|
||||
}
|
||||
doc.entries.mapNotNull { e ->
|
||||
val removedAt = latestRemoval[e.communityId]
|
||||
if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null
|
||||
(e.current ?: e.seed)?.toEntry(e.addedAt)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
+64
@@ -69,6 +69,70 @@ class ConcordCommunityListTest {
|
||||
assertTrue(ConcordCommunityList.parse(event, other).isEmpty()) // wrong key ⇒ nothing
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesArmadaWireDocument() {
|
||||
// A document as Soapbox Armada writes it (communityList.ts): {entries:[{community_id,
|
||||
// seed, current, added_at}], tombstones:[]} with snake_case JoinMaterial.
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"community_id": "${"11".repeat(32)}",
|
||||
"seed": {
|
||||
"community_id": "${"11".repeat(32)}",
|
||||
"owner": "${"0f".repeat(32)}",
|
||||
"owner_salt": "${"aa".repeat(32)}",
|
||||
"community_root": "${"bb".repeat(32)}",
|
||||
"root_epoch": 0,
|
||||
"channels": [],
|
||||
"relays": ["wss://relay.ditto.pub"],
|
||||
"name": "Soapbox"
|
||||
},
|
||||
"current": {
|
||||
"community_id": "${"11".repeat(32)}",
|
||||
"owner": "${"0f".repeat(32)}",
|
||||
"owner_salt": "${"aa".repeat(32)}",
|
||||
"community_root": "${"cc".repeat(32)}",
|
||||
"root_epoch": 2,
|
||||
"channels": [
|
||||
{ "id": "${"ee".repeat(32)}", "key": "${"dd".repeat(32)}", "epoch": 2, "name": "secret" }
|
||||
],
|
||||
"relays": ["wss://relay.ditto.pub"],
|
||||
"name": "Soapbox",
|
||||
"held_roots": [ { "epoch": 1, "key": "${"bb".repeat(32)}" } ]
|
||||
},
|
||||
"added_at": 1700000000000
|
||||
}
|
||||
],
|
||||
"tombstones": []
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val entries = ConcordCommunityList.decode(json)
|
||||
assertEquals(1, entries.size)
|
||||
val e = entries[0]
|
||||
assertEquals("11".repeat(32), e.id)
|
||||
assertEquals("Soapbox", e.name)
|
||||
assertEquals("cc".repeat(32), e.root) // hydrated from `current`, not `seed`
|
||||
assertEquals(2L, e.rootEpoch)
|
||||
assertEquals(1700000000000L, e.addedAt)
|
||||
assertEquals(listOf("wss://relay.ditto.pub"), e.relays)
|
||||
assertEquals(1, e.privateChannels.size)
|
||||
assertEquals("ee".repeat(32), e.privateChannels[0].channelId)
|
||||
assertEquals("secret", e.privateChannels[0].name)
|
||||
assertEquals(1, e.heldRoots.size)
|
||||
assertEquals(1L, e.heldRoots[0].epoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tombstoneAfterAddDropsEntry() {
|
||||
val jm = """{"community_id":"${"11".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"aa".repeat(32)}","community_root":"${"bb".repeat(32)}","root_epoch":0,"channels":[],"relays":[],"name":"Gone"}"""
|
||||
val json =
|
||||
"""{"entries":[{"community_id":"${"11".repeat(32)}","seed":$jm,"current":$jm,"added_at":100}],"tombstones":[{"community_id":"${"11".repeat(32)}","removed_at":200}]}"""
|
||||
assertTrue(ConcordCommunityList.decode(json).isEmpty()) // removed after add ⇒ not live
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeKeepsFreshestEpochPerCommunity() {
|
||||
val a = listOf(entry("11".repeat(32), "Old", epoch = 1))
|
||||
|
||||
Reference in New Issue
Block a user