This commit is contained in:
2026-09-19 08:39:25 +07:00
parent 0328d35945
commit 1cf5ecba51
9 changed files with 951 additions and 71 deletions
Generated
+1
View File
@@ -1314,6 +1314,7 @@ name = "concord"
version = "1.0.2" version = "1.0.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1",
"chacha20 0.9.1", "chacha20 0.9.1",
"data-encoding", "data-encoding",
"hkdf", "hkdf",
+1
View File
@@ -36,6 +36,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni
# Crypto (NIP-17 encrypted file messages) # Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10" aes-gcm = "0.10"
base64 = "0.22"
sha2 = "0.10" sha2 = "0.10"
data-encoding = "2" data-encoding = "2"
hkdf = "0.12" hkdf = "0.12"
+1
View File
@@ -13,6 +13,7 @@ sha2.workspace = true
chacha20.workspace = true chacha20.workspace = true
hmac.workspace = true hmac.workspace = true
data-encoding.workspace = true data-encoding.workspace = true
base64.workspace = true
rand.workspace = true rand.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
+594 -61
View File
@@ -1,15 +1,17 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry; use std::collections::btree_map::Entry;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
use crate::cord05::{ChannelGrant, CommunityInvite}; use crate::cord05::{ChannelGrant, CommunityInvite};
use crate::{CommunityId, Epoch, Extra}; use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union};
use crate::{ChannelId, CommunityId, Epoch, Extra};
pub const KIND_COMMUNITY_LIST: u16 = 13302; pub const KIND_COMMUNITY_LIST: u16 = 33302;
pub const MAX_MEMBERSHIPS: usize = 50; pub const MAX_MEMBERSHIPS: usize = 50;
#[derive(Debug)] #[derive(Debug)]
@@ -17,6 +19,8 @@ pub enum ListError {
Kind(u16), Kind(u16),
Crypto(String), Crypto(String),
Json(String), Json(String),
Encoding(String),
Fragment(String),
TooManyMemberships(usize), TooManyMemberships(usize),
Oversize(usize), Oversize(usize),
} }
@@ -27,6 +31,8 @@ impl fmt::Display for ListError {
ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"), ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"),
ListError::Crypto(error) => write!(f, "crypto: {error}"), ListError::Crypto(error) => write!(f, "crypto: {error}"),
ListError::Json(error) => write!(f, "json: {error}"), ListError::Json(error) => write!(f, "json: {error}"),
ListError::Encoding(error) => write!(f, "encoding: {error}"),
ListError::Fragment(error) => write!(f, "fragment: {error}"),
ListError::TooManyMemberships(count) => { ListError::TooManyMemberships(count) => {
write!( write!(
f, f,
@@ -48,55 +54,58 @@ impl From<cord01::StreamError> for ListError {
} }
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct JoinMaterial { pub struct JoinMaterial {
pub community_id: CommunityId, pub community_id: CommunityId,
pub owner: PublicKey, pub owner: PublicKey,
pub owner_salt: String, pub owner_salt: String,
pub community_root: String, pub community_root: String,
pub root_epoch: Epoch, pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>, pub control_pk: Option<PublicKey>,
/// Present only when the holder is staff. /// Present only when the holder is staff.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<String>, pub control_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelGrant>, pub channels: Vec<ChannelGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>, pub relays: Vec<String>,
pub name: String, pub name: String,
#[serde(flatten)]
pub extra: Extra, pub extra: Extra,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct CommunityListEntry { pub struct CommunityListEntry {
pub community_id: CommunityId, pub community_id: CommunityId,
pub seed: JoinMaterial, pub seed: JoinMaterial,
pub current: JoinMaterial, pub current: JoinMaterial,
pub added_at: u64, pub added_at: u64,
#[serde(flatten)]
pub extra: Extra, pub extra: Extra,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct Tombstone { pub struct Tombstone {
pub community_id: CommunityId, pub community_id: CommunityId,
pub removed_at: u64, pub removed_at: u64,
#[serde(flatten)]
pub extra: Extra, pub extra: Extra,
} }
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct CommunityList { pub struct CommunityList {
#[serde(default, skip_serializing_if = "Vec::is_empty")] /// How many fragments this List has. Every fragment declares it.
pub frags: u64,
pub entries: Vec<CommunityListEntry>, pub entries: Vec<CommunityListEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<Tombstone>, pub tombstones: Vec<Tombstone>,
#[serde(flatten)]
pub extra: Extra, pub extra: Extra,
} }
impl Default for CommunityList {
fn default() -> Self {
Self {
frags: 1,
entries: Vec::new(),
tombstones: Vec::new(),
extra: Extra::default(),
}
}
}
impl CommunityList { impl CommunityList {
pub fn is_live(&self, community_id: &CommunityId) -> bool { pub fn is_live(&self, community_id: &CommunityId) -> bool {
let added = self let added = self
@@ -115,6 +124,14 @@ impl CommunityList {
} }
} }
pub fn is_complete<I>(&self, held: I) -> bool
where
I: IntoIterator<Item = u64>,
{
let held: BTreeSet<u64> = held.into_iter().collect();
(0..self.frags).all(|index| held.contains(&index))
}
pub fn fits(&self) -> Result<(), ListError> { pub fn fits(&self) -> Result<(), ListError> {
if self.entries.len() > MAX_MEMBERSHIPS { if self.entries.len() > MAX_MEMBERSHIPS {
return Err(ListError::TooManyMemberships(self.entries.len())); return Err(ListError::TooManyMemberships(self.entries.len()));
@@ -138,7 +155,7 @@ pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>)
community_root: invite.community_root.clone(), community_root: invite.community_root.clone(),
root_epoch: invite.root_epoch, root_epoch: invite.root_epoch,
control_pk: invite.control_pk, control_pk: invite.control_pk,
control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)), control_root: control_root.map(|key| HEXLOWER.encode(key)),
channels: invite.channels.clone(), channels: invite.channels.clone(),
relays: invite.relays.clone(), relays: invite.relays.clone(),
name: invite.name.clone(), name: invite.name.clone(),
@@ -149,7 +166,9 @@ pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>)
pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
let mut entries: BTreeMap<CommunityId, CommunityListEntry> = BTreeMap::new(); let mut entries: BTreeMap<CommunityId, CommunityListEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) { for mut entry in held.entries.into_iter().chain(incoming.entries) {
normalize(&mut entry);
match entries.entry(entry.community_id) { match entries.entry(entry.community_id) {
Entry::Vacant(slot) => { Entry::Vacant(slot) => {
slot.insert(entry); slot.insert(entry);
@@ -177,28 +196,34 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
union(&mut extra, incoming.extra); union(&mut extra, incoming.extra);
CommunityList { CommunityList {
frags: held.frags.max(incoming.frags),
entries: entries.into_values().collect(), entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(), tombstones: tombstones.into_values().collect(),
extra, extra,
} }
} }
pub async fn build_list_event<S>(keys: &S, list: &CommunityList) -> Result<Event, ListError> pub async fn build_list_event<S>(
signer: &S,
list: &CommunityList,
fragment: u64,
) -> Result<Event, ListError>
where where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{ {
list.fits()?; list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?; let json = serde_json::to_string(list).map_err(json_error)?;
let content = cord01::seal_to_self(keys, &json).await?; let content = cord01::seal_to_self(signer, &json).await?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize_async(keys) .tag(Tag::identifier(fragment.to_string()))
.finalize_async(signer)
.await .await
.map_err(crypto_error) .map_err(crypto_error)
} }
pub async fn parse_list_event<S>(keys: &S, event: &Event) -> Result<CommunityList, ListError> pub async fn parse_list_event<S>(signer: &S, event: &Event) -> Result<CommunityList, ListError>
where where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized, S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{ {
@@ -206,11 +231,326 @@ where
return Err(ListError::Kind(event.kind.as_u16())); return Err(ListError::Kind(event.kind.as_u16()));
} }
let json = cord01::open_to_self(keys, &event.content).await?; fragment_index(event)?;
let json = cord01::open_to_self(signer, &event.content).await?;
serde_json::from_str(&json).map_err(json_error) serde_json::from_str(&json).map_err(json_error)
} }
pub fn fragment_index(event: &Event) -> Result<u64, ListError> {
let value = event
.tags
.identifier()
.ok_or_else(|| ListError::Fragment("missing d tag".to_owned()))?;
value
.parse()
.map_err(|_| ListError::Fragment(format!("d tag is not a fragment index: {value}")))
}
fn decode_base64(value: &str, field: &str) -> Result<[u8; 32], ListError> {
base64url::decode_32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}")))
}
fn decode_community_id(value: &str) -> Result<CommunityId, ListError> {
Ok(CommunityId::from_bytes(decode_base64(
value,
"community_id",
)?))
}
fn decode_public_key(value: &str, field: &str) -> Result<PublicKey, ListError> {
PublicKey::from_slice(&decode_base64(value, field)?)
.map_err(|error| ListError::Encoding(format!("{field}: {error}")))
}
fn encode_hex_32(value: &str, field: &str) -> Result<String, ListError> {
hex32_to_base64(value).map_err(|error| ListError::Encoding(format!("{field}: {error}")))
}
fn decode_hex_32_base64(value: &str, field: &str) -> Result<String, ListError> {
base64_to_hex32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}")))
}
#[derive(Debug, Serialize, Deserialize)]
struct WireList {
#[serde(default = "one_fragment")]
frags: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
entries: Vec<WireEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tombstones: Vec<WireTombstone>,
#[serde(flatten)]
extra: Extra,
}
fn one_fragment() -> u64 {
1
}
#[derive(Debug, Serialize, Deserialize)]
struct WireEntry {
community_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
seed: Option<WireSnapshot>,
current: WireSnapshot,
added_at: u64,
#[serde(flatten)]
extra: Extra,
}
#[derive(Debug, Serialize, Deserialize)]
struct WireTombstone {
community_id: String,
removed_at: u64,
#[serde(flatten)]
extra: Extra,
}
#[derive(Debug, Serialize, Deserialize)]
struct WireSnapshot {
owner: String,
owner_salt: String,
community_root: String,
root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
control_pk: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
control_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
channels: Vec<WireChannel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
relays: Vec<String>,
name: String,
#[serde(flatten)]
extra: Extra,
}
#[derive(Debug, Serialize, Deserialize)]
struct WireChannel {
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
epoch: Epoch,
#[serde(default)]
name: String,
#[serde(flatten)]
extra: Extra,
}
impl WireList {
fn encode(list: &CommunityList) -> Result<Self, ListError> {
// §8: a retired entry is not written — the tombstone alone carries the
// state, since a membership is live only while an entry outranks its
// removal. The entry stays in the in-memory document.
let mut entries = Vec::with_capacity(list.entries.len());
for entry in &list.entries {
if list.is_live(&entry.community_id) {
entries.push(WireEntry::encode(entry)?);
}
}
let mut tombstones = Vec::with_capacity(list.tombstones.len());
for tombstone in &list.tombstones {
tombstones.push(WireTombstone::encode(tombstone)?);
}
Ok(Self {
frags: list.frags,
entries,
tombstones,
extra: list.extra.clone(),
})
}
fn decode(self) -> Result<CommunityList, ListError> {
let mut entries = Vec::with_capacity(self.entries.len());
for entry in self.entries {
entries.push(entry.decode()?);
}
let mut tombstones = Vec::with_capacity(self.tombstones.len());
for tombstone in self.tombstones {
tombstones.push(tombstone.decode()?);
}
Ok(CommunityList {
frags: self.frags.max(1),
entries,
tombstones,
extra: self.extra,
})
}
}
impl WireEntry {
fn encode(entry: &CommunityListEntry) -> Result<Self, ListError> {
let current = WireSnapshot::encode(&entry.current)?;
let mut seed = entry.seed.clone();
normalize_snapshot(&mut seed, &entry.current);
let seed = if seed == entry.current {
None
} else {
Some(WireSnapshot::encode(&seed)?)
};
Ok(Self {
community_id: base64url::encode(entry.community_id.as_bytes()),
seed,
current,
added_at: entry.added_at,
extra: entry.extra.clone(),
})
}
fn decode(self) -> Result<CommunityListEntry, ListError> {
let community_id = decode_community_id(&self.community_id)?;
let current = self.current.decode(community_id)?;
let seed = match self.seed {
Some(seed) => seed.decode(community_id)?,
None => current.clone(),
};
let mut entry = CommunityListEntry {
community_id,
seed,
current,
added_at: self.added_at,
extra: self.extra,
};
normalize(&mut entry);
Ok(entry)
}
}
impl WireTombstone {
fn encode(tombstone: &Tombstone) -> Result<Self, ListError> {
Ok(Self {
community_id: base64url::encode(tombstone.community_id.as_bytes()),
removed_at: tombstone.removed_at,
extra: tombstone.extra.clone(),
})
}
fn decode(self) -> Result<Tombstone, ListError> {
Ok(Tombstone {
community_id: decode_community_id(&self.community_id)?,
removed_at: self.removed_at,
extra: self.extra,
})
}
}
impl WireSnapshot {
fn encode(material: &JoinMaterial) -> Result<Self, ListError> {
let mut channels = Vec::with_capacity(material.channels.len());
for channel in &material.channels {
channels.push(WireChannel::encode(channel)?);
}
Ok(Self {
owner: base64url::encode(&material.owner.to_bytes()),
owner_salt: encode_hex_32(&material.owner_salt, "owner_salt")?,
community_root: encode_hex_32(&material.community_root, "community_root")?,
root_epoch: material.root_epoch,
control_pk: material
.control_pk
.map(|key| base64url::encode(&key.to_bytes())),
control_root: match &material.control_root {
Some(root) => Some(encode_hex_32(root, "control_root")?),
None => None,
},
channels,
relays: material.relays.clone(),
name: material.name.clone(),
extra: material.extra.clone(),
})
}
fn decode(self, community_id: CommunityId) -> Result<JoinMaterial, ListError> {
let mut channels = Vec::with_capacity(self.channels.len());
for channel in self.channels {
channels.push(channel.decode()?);
}
Ok(JoinMaterial {
community_id,
owner: decode_public_key(&self.owner, "owner")?,
owner_salt: decode_hex_32_base64(&self.owner_salt, "owner_salt")?,
community_root: decode_hex_32_base64(&self.community_root, "community_root")?,
root_epoch: self.root_epoch,
control_pk: match self.control_pk {
Some(value) => Some(decode_public_key(&value, "control_pk")?),
None => None,
},
control_root: match self.control_root {
Some(value) => Some(decode_hex_32_base64(&value, "control_root")?),
None => None,
},
channels,
relays: self.relays,
name: self.name,
extra: self.extra,
})
}
}
impl WireChannel {
fn encode(grant: &ChannelGrant) -> Result<Self, ListError> {
Ok(Self {
id: base64url::encode(grant.id.as_bytes()),
key: match &grant.key {
Some(key) => Some(encode_hex_32(key, "channel key")?),
None => None,
},
epoch: grant.epoch,
name: grant.name.clone(),
extra: grant.extra.clone(),
})
}
fn decode(self) -> Result<ChannelGrant, ListError> {
Ok(ChannelGrant {
id: ChannelId::from_bytes(decode_base64(&self.id, "channel id")?),
key: match self.key {
Some(value) => Some(decode_hex_32_base64(&value, "channel key")?),
None => None,
},
epoch: self.epoch,
name: self.name,
extra: self.extra,
})
}
}
impl Serialize for CommunityList {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
WireList::encode(self)
.map_err(serde::ser::Error::custom)?
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for CommunityList {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
WireList::deserialize(deserializer)?
.decode()
.map_err(serde::de::Error::custom)
}
}
impl Serialize for JoinMaterial {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
WireSnapshot::encode(self)
.map_err(serde::ser::Error::custom)?
.serialize(serializer)
}
}
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
enum Snapshot { enum Snapshot {
Seed, Seed,
@@ -222,6 +562,7 @@ fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) {
held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone(); held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone();
held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone(); held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone();
union(&mut held.extra, incoming.extra); union(&mut held.extra, incoming.extra);
normalize(held);
} }
fn pick<'a>( fn pick<'a>(
@@ -245,21 +586,26 @@ fn pick<'a>(
held held
} }
pub(crate) fn union(into: &mut Extra, other: Extra) { fn normalize(entry: &mut CommunityListEntry) {
for (key, value) in other { entry.seed.community_id = entry.community_id;
let replace = match into.get(&key) { entry.current.community_id = entry.community_id;
Some(existing) => canonical(&value) < canonical(existing), normalize_snapshot(&mut entry.seed, &entry.current);
None => true,
};
if replace {
into.insert(key, value);
}
}
} }
pub(crate) fn canonical<T: Serialize>(value: &T) -> String { fn normalize_snapshot(seed: &mut JoinMaterial, current: &JoinMaterial) {
serde_json::to_string(value).unwrap_or_default() seed.community_id = current.community_id;
seed.name = current.name.clone();
seed.relays = current.relays.clone();
for seed_channel in &mut seed.channels {
if let Some(current_channel) = current
.channels
.iter()
.find(|channel| channel.id == seed_channel.id)
{
seed_channel.name = current_channel.name.clone();
}
}
} }
fn json_error(error: serde_json::Error) -> ListError { fn json_error(error: serde_json::Error) -> ListError {
@@ -365,7 +711,7 @@ mod tests {
} }
#[test] #[test]
fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() { fn a_tombstone_is_terminal_and_the_entry_it_retires_is_never_written() {
let owner = Keys::generate().public_key(); let owner = Keys::generate().public_key();
let joined = entry( let joined = entry(
id(0x11), id(0x11),
@@ -385,22 +731,66 @@ mod tests {
// A stale device re-merging the entry cannot resurrect it. // A stale device re-merging the entry cannot resurrect it.
assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11))); assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11)));
// A re-join genuinely newer than the removal does. // The tombstone alone is written, so the entry's key material leaves the wire.
let rejoined = list(vec![entry( let written = serde_json::to_string(&left).expect("writes");
id(0x11), assert!(
material(id(0x11), owner, "Room", 0), !written.contains("\"entries\""),
material(id(0x11), owner, "Room", 0), "a retired entry is not written"
7_000, );
)]); let reparsed: CommunityList = serde_json::from_str(&written).expect("parses");
let live = merge(left, rejoined); assert_eq!(reparsed.tombstones.len(), 1);
assert!(live.is_live(&id(0x11))); assert!(!reparsed.is_live(&id(0x11)));
// A re-join genuinely newer than the removal does, and is written again.
let rejoined = merge(
reparsed,
list(vec![entry(
id(0x11),
material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, "Room", 0),
7_000,
)]),
);
assert!(rejoined.is_live(&id(0x11)));
assert!(
serde_json::to_string(&rejoined)
.expect("writes")
.contains("\"entries\"")
);
// And the older removal is not re-applied on top of it. // And the older removal is not re-applied on top of it.
assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11))); assert!(merge(rejoined, removal(id(0x11), 6_000)).is_live(&id(0x11)));
} }
#[test] #[test]
fn a_second_device_reconstructs_membership_from_13302() { fn frags_disagreement_resolves_to_the_larger_value_and_completeness_is_by_index() {
let two = CommunityList {
frags: 2,
..Default::default()
};
let three = CommunityList {
frags: 3,
..Default::default()
};
assert_eq!(
merge(two.clone(), three.clone()).frags,
3,
"the larger fragment count wins"
);
assert_eq!(merge(three.clone(), two).frags, 3);
assert!(!three.is_complete([0, 1]));
assert!(three.is_complete([0, 1, 2]));
assert!(three.is_complete([2, 1, 0]), "order does not matter");
assert!(
three.is_complete([0, 1, 2, 7]),
"indices at or above frags are out of range"
);
}
#[test]
fn a_second_device_reconstructs_membership_from_the_list() {
let me = Keys::generate(); let me = Keys::generate();
let owner = Keys::generate().public_key(); let owner = Keys::generate().public_key();
let mine = CommunityList { let mine = CommunityList {
@@ -423,26 +813,32 @@ mod tests {
removed_at: AT, removed_at: AT,
extra: Extra::default(), extra: Extra::default(),
}], }],
extra: Extra::default(), ..Default::default()
}; };
let event = smol::block_on(build_list_event(&me, &mine)).expect("builds"); let event = smol::block_on(build_list_event(&me, &mine, 1)).expect("builds");
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
assert_eq!(fragment_index(&event).expect("a fragment index"), 1);
assert_eq!( assert_eq!(
smol::block_on(parse_list_event(&me, &event)).expect("parses"), smol::block_on(parse_list_event(&me, &event)).expect("parses"),
mine mine
); );
assert!(
!smol::block_on(parse_list_event(&me, &event))
.expect("parses")
.is_live(&id(0x33))
);
// Only the member's own keys open it, and an unreadable list is "no news". // Only the member's own keys open it, and an unreadable list is "no news".
let stranger = Keys::generate(); let stranger = Keys::generate();
assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err()); assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err());
// Unknown fields survive the round trip, so a republish cannot wipe them. // A fragment with no `d` tag is not a fragment at all.
let untagged = EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), event.content.clone())
.finalize(&me)
.expect("signs");
assert!(matches!(
smol::block_on(parse_list_event(&me, &untagged)),
Err(ListError::Fragment(_))
));
// Unknown fields survive the round trip, so a republish cannot wipe them
// — including on a channel, where a dropped field destroys key material.
let mut held = mine.clone(); let mut held = mine.clone();
held.extra held.extra
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]})); .insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
@@ -450,9 +846,19 @@ mod tests {
.current .current
.extra .extra
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}])); .insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
held.entries[0].current.channels = vec![ChannelGrant {
id: ChannelId::from_bytes([0x9c; 32]),
key: Some("55".repeat(32)),
epoch: Epoch(2),
name: "staff".to_owned(),
extra: Extra::default(),
}];
held.entries[0].current.channels[0]
.extra
.insert("read_key".to_owned(), serde_json::json!("aa".repeat(32)));
let rebuilt = smol::block_on(parse_list_event( let rebuilt = smol::block_on(parse_list_event(
&me, &me,
&smol::block_on(build_list_event(&me, &held)).expect("builds"), &smol::block_on(build_list_event(&me, &held, 0)).expect("builds"),
)) ))
.expect("parses"); .expect("parses");
assert_eq!(rebuilt, held); assert_eq!(rebuilt, held);
@@ -472,18 +878,145 @@ mod tests {
.collect(), .collect(),
); );
assert!(matches!( assert!(matches!(
smol::block_on(build_list_event(&me, &crowded)), smol::block_on(build_list_event(&me, &crowded, 0)),
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
)); ));
let oversized = list(vec![entry( let oversized = list(vec![entry(
id(0x11), id(0x11),
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
material(id(0x11), owner, "Room", 0), material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
AT, AT,
)]); )]);
assert!(matches!(oversized.fits(), Err(ListError::Oversize(_)))); assert!(matches!(oversized.fits(), Err(ListError::Oversize(_))));
} }
/// The worked example in `examples.md` §6.2, verbatim. Five of its base64url
/// values leave non-zero trailing bits, so a strict decoder rejects them.
const EXAMPLE: &str = r#"{
"frags": 2,
"entries": [
{
"community_id": "PxpVK3nQ7sB1yTfWm4dLxZ0aRcE9uHgKjNvOpQrStUv",
"current": {
"owner": "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9",
"owner_salt": "qhEwR9tYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI0oP",
"community_root": "d70Xa1QwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U",
"root_epoch": 3,
"control_pk": "DU8vB4nM6qW1eR3tY5uI7oP9aS0dF2gH4jK6lZ8xC0v",
"channels": [
{ "id": "Ch1dQwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U0i",
"key": "K3yAsDfGhJkLzXcVbNm1Qw2Er3Ty4Ui5Op6As7Df8Gh",
"epoch": 2, "name": "staff" }
],
"relays": ["wss://relay.example.com"],
"name": "Example Community"
},
"added_at": 1719800000000
}
],
"tombstones": [
{ "community_id": "u9RfLmWx3PqZtYvBnKjHgFdSaQwErTyUiOp2C4E6G8I", "removed_at": 1722400000000 }
]
}"#;
#[test]
fn the_spec_example_parses_and_the_writer_canonicalizes_it() {
let parsed: CommunityList = serde_json::from_str(EXAMPLE).expect("the spec example parses");
assert_eq!(parsed.frags, 2);
assert_eq!(parsed.tombstones.len(), 1);
let entry = parsed.entries.first().expect("one membership");
assert_eq!(entry.current.root_epoch, Epoch(3));
assert_eq!(entry.current.name, "Example Community");
assert_eq!(entry.current.relays, ["wss://relay.example.com"]);
assert!(
entry.current.control_root.is_none(),
"a non-staff snapshot holds no control_root"
);
assert_eq!(
entry.seed, entry.current,
"an absent seed reads as equal to current"
);
let channel = entry.current.channels.first().expect("a private channel");
assert_eq!(channel.name, "staff");
assert_eq!(channel.epoch, Epoch(2));
assert!(channel.key.is_some());
let written = serde_json::to_string(&parsed).expect("writes");
assert!(written.contains("\"frags\":2"));
assert!(
!written.contains("\"seed\""),
"a seed equal to current is omitted"
);
assert!(
!written.contains("\"current\":{\"community_id\""),
"an embedded snapshot omits community_id and inherits the entry's"
);
// Every writer emits the canonical spelling, so a non-canonical input is
// stabilized here and two devices converge on identical bytes.
let value: serde_json::Value = serde_json::from_str(&written).expect("valid");
let owner = value["entries"][0]["current"]["owner"]
.as_str()
.expect("an owner");
assert_eq!(owner, base64url::encode(&entry.current.owner.to_bytes()));
assert_ne!(owner, "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9");
// Reading its own output is a fixed point.
let again: CommunityList = serde_json::from_str(&written).expect("parses");
assert_eq!(again, parsed);
assert_eq!(serde_json::to_string(&again).expect("writes"), written);
}
#[test]
fn a_rename_rewrites_the_seed_cosmetics_and_collapses_the_snapshot() {
let owner = Keys::generate().public_key();
let channel = ChannelId::from_bytes([0x9c; 32]);
let mut current = material(id(0x11), owner, "New name", 5);
current.channels = vec![ChannelGrant {
id: channel,
key: Some("55".repeat(32)),
epoch: Epoch(4),
name: "new channel".to_owned(),
extra: Extra::default(),
}];
let mut seed = material(id(0x11), owner, "Old name", 1);
seed.relays = vec!["wss://stale.example".to_owned()];
seed.channels = vec![ChannelGrant {
id: channel,
key: Some("55".repeat(32)),
epoch: Epoch(2),
name: "old channel".to_owned(),
extra: Extra::default(),
}];
let written =
serde_json::to_string(&list(vec![entry(id(0x11), seed, current.clone(), AT)]))
.expect("writes");
let value: serde_json::Value = serde_json::from_str(&written).expect("valid");
let written_seed = &value["entries"][0]["seed"];
assert_eq!(written_seed["name"], "New name");
assert_eq!(written_seed["relays"][0], "wss://relay.example");
assert_eq!(written_seed["channels"][0]["name"], "new channel");
assert_eq!(
written_seed["root_epoch"].as_u64(),
Some(1),
"the rewrite touches no key material"
);
// A seed that differs from current only in cosmetics is the same bytes
// after the rewrite, so it is omitted entirely.
let mut stale = material(id(0x11), owner, "Old name", 5);
stale.channels = current.channels.clone();
let collapsed = serde_json::to_string(&list(vec![entry(id(0x11), stale, current, AT)]))
.expect("writes");
assert!(!collapsed.contains("\"seed\""));
}
const AT: u64 = 1_719_800_000_000; const AT: u64 = 1_719_800_000_000;
} }
+1 -1
View File
@@ -11,10 +11,10 @@ use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
use crate::cord02::list::{canonical, union};
use crate::cord02::{ImageRef, MAX_RELAYS}; use crate::cord02::{ImageRef, MAX_RELAYS};
use crate::cord04::{TAG_SUBKIND, vsk}; use crate::cord04::{TAG_SUBKIND, vsk};
use crate::derive::{TOKEN_LEN, verify_community_id}; use crate::derive::{TOKEN_LEN, verify_community_id};
use crate::utils::{canonical, union};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32}; use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301; pub const KIND_BUNDLE: u16 = 33301;
+34
View File
@@ -0,0 +1,34 @@
use anyhow::{Result, anyhow};
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::{Engine as _, alphabet};
/// Unpadded base64url (RFC 4648 §5), 43 characters for 32 bytes: §8's value
/// encoding at any depth.
///
/// The reader tolerates non-zero trailing bits; the writer never emits them.
/// The spec's own worked example (`examples.md` §6.2) contains five such
/// values, and a reader cannot tell a mis-encoded named field from a correctly
/// encoded one, so the boundary is the writer's alone.
const BASE64URL: GeneralPurpose = GeneralPurpose::new(
&alphabet::URL_SAFE,
GeneralPurposeConfig::new()
.with_encode_padding(false)
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
.with_decode_allow_trailing_bits(true),
);
pub(crate) fn encode(bytes: &[u8]) -> String {
BASE64URL.encode(bytes)
}
/// Decodes one 32-byte value, the width every §8 field has.
pub(crate) fn decode_32(value: &str) -> Result<[u8; 32]> {
let bytes = BASE64URL
.decode(value.trim())
.map_err(|error| anyhow!("invalid base64url: {error}"))?;
bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))
}
+35
View File
@@ -1,9 +1,13 @@
pub mod base64url;
pub mod derive; pub mod derive;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use rand::TryRng as _; use rand::TryRng as _;
use rand::rngs::SysRng; use rand::rngs::SysRng;
use serde::Serialize;
use crate::Extra;
/// Uppercase and other non-canonical spellings are rejected. /// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> { pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
@@ -38,3 +42,34 @@ pub(crate) fn random_32() -> Result<[u8; 32]> {
fill_random(&mut bytes)?; fill_random(&mut bytes)?;
Ok(bytes) Ok(bytes)
} }
/// Hex to unpadded base64url for one 32-byte §8 value.
pub(crate) fn hex32_to_base64(value: &str) -> Result<String> {
Ok(base64url::encode(&decode_hex_32(value)?))
}
/// Unpadded base64url to lowercase hex for one 32-byte §8 value.
pub(crate) fn base64_to_hex32(value: &str) -> Result<String> {
Ok(HEXLOWER.encode(&base64url::decode_32(value)?))
}
/// Canonical JSON bytes: the total-order tie-break every content merge uses.
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
/// Unions an unknown-field map. Where both sides carry a key, the
/// lexicographically lowest canonical bytes win, so two devices converge
/// instead of flapping.
pub(crate) fn union(into: &mut Extra, other: Extra) {
for (key, value) in other {
let replace = match into.get(&key) {
Some(existing) => canonical(&value) < canonical(existing),
None => true,
};
if replace {
into.insert(key, value);
}
}
}
+241
View File
@@ -0,0 +1,241 @@
# Concord discovery: why no community ever reaches `subscribe`
Audit + fix plan. Read alongside `docs/concord-usage.md` and
`docs/concord-simplification-plan.md`.
## Symptom
`crates/community/src/lib.rs::subscribe` is never called, so no wrap is ever
subscribed to and the sidebar stays empty. `community load: 0 state document(s)
found` is the only clue.
## Root cause
`CommunityRegistry::load` only ever reads the **local database**. Nothing in the
discovery path touches a relay.
```
community::init
└─ SignerChanged → load
└─ sync::load
├─ store::load_states(client) → client.database().query(..) // local only
└─ load_list(client, ..) → client.database().query(..) // local only, .limit(1)
→ track([])
→ sync_subscriptions: `for community in self.communities` runs zero times
→ subscribe never called
→ no relay is ever queried
→ the database never fills
→ load stays empty forever
```
The loop is self-reinforcing: the local database is populated *by* the
subscriptions that the empty load prevents. That is why an account which belongs
to several communities in another client still shows nothing — a fresh install
has no `concord/*` state document, and coop has no way to ask for one.
Confirmed by inspection:
| Location | What it does |
| --- | --- |
| `crates/community/src/lib.rs:167-191` | `load``sync::load`, then `track(states)` |
| `crates/community/src/sync.rs:125-137` | `load` = `store::load_states` + `load_list` |
| `crates/concord/src/store.rs:314-341` | `load_states` queries `client.database()` only |
| `crates/community/src/sync.rs:139-153` | `load_list` queries `client.database()` only, `.limit(1)` |
| `crates/community/src/lib.rs:233-276` | `sync_subscriptions` skips everything when `communities` is empty |
`subscribe` itself is correct. Do not debug it.
## What the protocol actually says
Read from the spec (`concord-protocol/concord`, the submodule referenced by
accordion.chat): `02.md` §8 and `examples.md` §6.2.
A member's memberships live in the **Community List**, on relays:
- **Kind `33302`**, addressable, NIP-44-encrypted to self, signed by the
member's real key, one event per **fragment** with `d` = the fragment index in
decimal (`"0"`, `"1"`, …). `13302` is explicitly **retired** ("the
single-event Community List, superseded by `33302` once it outgrew one event —
a replaceable kind cannot fragment", `02.md:314`).
- Every 32-byte value at **any depth** is unpadded base64url, not hex. This is
section-scoped: CORD-05 invite fields stay hex (`examples.md` §6.3).
- Join material is the membership subset — `owner, owner_salt, community_root,
root_epoch, control_pk, channels, relays, name`, plus `control_root` when
held. It is the *only* durable home of a member's keys.
- The two snapshots solve opposite problems: `seed` is the earliest epoch held
(backfill anchor), `current` the latest ("so a fresh device reconstructs the
Community instantly with no epoch-by-epoch walk"). `seed` is omitted when
equal to `current`; embedded snapshots omit `community_id` (inherited).
- A client holds the complete List when it holds a fragment at every index below
`frags`; it unions fragments and merges, so a partial read is safe.
Two consequences for coop:
1. **The state document is a coop invention.** `store::{save_state, load_state,
load_states}` write kind `30078` with `d = concord/<id>`, signed by a
per-process `LOCAL_KEYS`, and never leave the machine. No equivalent exists
anywhere in the spec. It is a local cache and must never be treated as the
discovery source.
2. **Discovery is: fetch my `33302` from relays → materialize a community from
`current` join material → subscribe to its planes → fold.** The fold produces
the authoritative state; the List only supplies the keys to start.
## Divergences (coop vs spec)
| # | Spec | coop today |
| --- | --- | --- |
| 1 | kind `33302`, addressable | `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) |
| 2 | one event per fragment, `d` = index, `frags` declared | no `frags`, single event, `d` unused, `load_list` `.limit(1)` |
| 3 | 32-byte values unpadded base64url at any depth | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` |
| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | both snapshots always serialized verbatim; `community_id` always present |
| 5 | fetch from relays | local database only |
| 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` |
| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs |
| 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field |
Divergences 14 meant that even if the fetch existed, coop could neither read
what accordion wrote nor write something accordion could read. **Phase A is
done**, so 14 are resolved; 58 remain.
## Plan
Ordered so each phase is independently reviewable and testable. Nothing here
touches the frozen HKDF derivations or `cord01` envelope semantics.
### Phase A — make the List interoperable (pure, no I/O) — DONE
`crates/concord/src/cords/cord02/list.rs`
1. `KIND_COMMUNITY_LIST` → `33302`; add `frags: u64` to `CommunityList` and
`is_complete(&self, frags) -> bool`.
2. Add a base64url codec for the §8 value set and apply it to every 32-byte
field at every depth. Because `JoinMaterial` currently types `owner` and
`control_pk` as `PublicKey` (nostr's hex serde), this needs either wire
newtypes or `serialize_with`/`deserialize_with` helpers. Keep it local to the
List: `cord05` stays hex.
3. Implement the two §8 MUSTs: omit `community_id` on an embedded snapshot,
omit `seed` when it byte-equals `current`, and rewrite `seed`'s cosmetic
fields (`name`, `relays`, each channel's `name`) from `current` on every
serialization.
4. `build_list_event`/`parse_list_event` take the fragment index and emit/read
the `d` tag.
Tests: round-trip the `examples.md` §6.2 payload verbatim; `merge` convergence
for two devices and mixed-age fragments; `frags` disagreement resolves to the
larger value; a repack does not shed unknown fields.
**As built.** The §8 rules live behind private wire structs (`WireList`,
`WireEntry`, `WireSnapshot`, `WireChannel`), so a writer re-encodes on every
serialization while the public types keep their internal hex/`PublicKey`
spellings and `cord05` stays hex. Three deviations from the sketch above:
- `is_complete` takes the set of fragment indices a client holds, not a count:
a count is wrong when the indices are sparse.
- The reader tolerates non-zero base64url trailing bits. The spec's own §6.2
example has five such values, so a strict decoder rejects the worked example;
the writer still emits the canonical spelling.
- The third omission MUST was implemented too: an entry whose `added_at` does
not outrun its tombstone is not written. It is a serialization rule exactly
like the other two, so it belongs here rather than in Phase D.
`parse_list_event` validates the `d` tag but returns just the `CommunityList`;
`fragment_index(event)` reads the index, which keeps `sync.rs` untouched until
Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has
no membership limit, and Phase D's fragmentation is what removes the cap.
### Phase B — materialize a community from join material (pure)
`crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs`
1. `CommunityState::from_join_material(material: &JoinMaterial, added_at_ms:
u64) -> Result<Self>`: identity/owner/salt/root/root_epoch from the material;
`control_pks = { root_epoch → control_pk }`; `relays` parsed; `channels` from
the grants; `control_root` when present; `heads` empty (the first control
fold fills them); `banned` empty; `dissolved` false.
2. Carry the private channel key: add `key: Option<[u8; 32]>` to
`ChannelKeyRef` (or a parallel map) so a grant's `key` has a home. Without
this, a private channel is silently read-only-until-rekey.
Tests: a material with and without `control_root`; a private grant's key
survives; `from_join_material` then `planes()` yields the control `control_pk`
plus the guestbook and public channels, i.e. a subscription filter that
addresses real planes.
### Phase C — fetch the List from relays, then load
`crates/community/src/sync.rs`
1. `load` becomes:
- resolve where to ask: the account's NIP-65 write relays (kind `10002`) plus
the pool's connected relays. If only the app's bootstrap relays are queried,
a List published by another client (e.g. accordion on `relay.damus.io` /
`nos.lol`) will simply not be found.
- `client.fetch_events(Filter::new().kind(33302).author(self_pk))` — one
filter returns every fragment. Fetched events are persisted by the client
(`nostr-sdk/src/relay/inner.rs:1291`), so the database read stays valid.
- merge fragments → `CommunityList`.
- for each entry whose `is_live(&id)`: if a state document exists, keep its
`heads` (the fold's authority) and refresh relays/keys from `current`;
otherwise `from_join_material(..)`.
- `store::save_state` each result so the next `load` is warm.
2. `load_list` keeps reading `client.database()` — after the fetch it is
populated. It must stop using `.limit(1)`.
3. Drop the `states.retain(..)` shape: the List is now the *source* of states,
not just a filter over local ones. A local state whose membership is
tombstoned is still dropped, but a List entry with no local state now
produces one.
Tests (no network, `nostr-memory`): a `33302` fragment written by the account is
discovered with **no** state document present; a tombstoned id is dropped; a
missing fragment leaves the rest usable. A `nostr_sdk::local_relay::LocalRelay`
(in-process relay, public in this pinned revision) can drive the real
fetch/subscribe path end to end.
### Phase D — publish
`crates/community/src/sync.rs`, `crates/concord/src/store.rs`
1. `create` appends to the List and publishes the fragment read-modify-write per
§8, targeting the metadata's relays.
2. `create` publishes the genesis wraps to those relays. Today it only
`client.database().save_event(wrap)`s, so a created community is invisible to
every other account.
3. Leave uses a tombstone; a repack requires the complete List and is a
non-goal until memberships outgrow one fragment.
### Phase E — verify live
`RUST_LOG=info cargo run -p coop`, sign in with the accordion account that
already belongs to communities. Expect `community {id}: subscribing to ..` and
rows in the sidebar. This is the first time the path can be exercised at all.
## Validation per phase
- `cargo test -p concord` (A, B), `cargo test -p community` (B, C, D).
- `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check`.
- A is provable against the spec's worked example, so it needs no relay.
- C is provable with `nostr-memory` + `LocalRelay`, so it needs no network.
- E is the only step that needs real relays.
## Risks and open decisions
- **Base64url is case-significant and coop's ids are hex everywhere else.**
Confine the codec to `cord02::list`; any normalisation that case-folds will
silently corrupt §8 values. **Resolved in Phase A**: the codec is private to
`list.rs` and never case-folds.
- **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its
only bound is the 65,536-byte *encoded event*. `fits()` still measures the
NIP-44 plaintext, which understates that by roughly a third, so the count cap is
kept as a conservative stopgap until Phase D measures the built event and
fragments on write.
- **Relay selection for the fetch is the difference between finding the account's
List and not.** NIP-65 write relays + pool, or a user-visible relay setting?
- **Private channels stay unreadable until `ChannelKeyRef` carries the grant key**
(Phase B.2). Public discovery works without it.
- **Two writers, one key.** Once coop publishes `33302`, an account used from
both accordion and coop has both clients writing the List. §8's
read-modify-write is what keeps that from losing memberships — it is not
optional.
- **`store::save_state` signs with a per-process random key.** Harmless while it
stays local, but it means the state document can never be published or
compared; if a future phase wants it on the wire, it needs the account signer.
+43 -9
View File
@@ -413,18 +413,47 @@ A member's own memberships, synced across their devices:
```rust ```rust
use concord::cord02::list; use concord::cord02::list;
let material = cord02::list::join_material(&invite, staff.then_some(&control_root)); let material = list::join_material(&invite, staff.then_some(&control_root));
let mut mine = cord02::list::parse_list_event(&my_keys, &event).await?; let mut mine = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
mine = cord02::list::merge(mine, cord02::list::CommunityList { mine = list::merge(mine, list::CommunityList {
entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
..Default::default() ..Default::default() // frags: 1
}); });
let event = cord02::list::build_list_event(&my_keys, &mine).await?; // kind 13302, NIP-44 to self let event = list::build_list_event(&my_keys, &mine, 0).await?; // kind 33302, d = fragment 0
``` ```
Kind `33302` is **addressable and fragmented**: one event per fragment, its `d`
tag the fragment index in decimal. `frags` in the payload declares how many the
List has, and `is_complete(held_indices)` answers whether the client has a
fragment at every index below it. `merge` resolves a `frags` disagreement to the
larger value. (`13302`, the single-event List, is retired by the spec — a
replaceable kind cannot fragment.)
The payload's 32-byte values are **unpadded base64url at every depth**, which is
section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on
every serialization, so its output is always the canonical 43-character spelling;
the reader also accepts non-zero trailing bits, because the spec's own worked
example contains them and no reader can tell a mis-encoded named field from a
correct one. The codec is `utils::base64url` and the wire structs behind the
List's `Serialize`/`Deserialize` are the only callers, so no other encoding path
is touched.
Three write-time rules are folded into serialization, so an in-memory document
and its wire form differ:
- an embedded snapshot omits `community_id` and inherits the entry's;
- `seed` is omitted when it equals `current`, and its cosmetic fields (`name`,
`relays`, each channel's `name`) are overwritten from `current` first, so a
rename collapses the snapshots instead of forking them;
- an entry whose `added_at` does not outrun its tombstone is omitted — the
tombstone alone carries the state.
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a `is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
strictly newer join outruns it. `fits()` is the write gate 50 memberships and strictly newer join outruns it. `fits()` is the write gate: 50 memberships and
the NIP-44 size cap, both protocol constants. the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired
single-event design — §8 has **no membership limit**, its only bound is the
65,536-byte encoded event, and the real fix is to start a new fragment on write
(see `docs/concord-community-discovery-plan.md`, Phase D).
## GPUI integration ## GPUI integration
@@ -545,7 +574,12 @@ client.subscribe(filter).with_id(sub_id).await?;
re-folds on an inbound wrap. The sidebar observes the registry, logs re-folds on an inbound wrap. The sidebar observes the registry, logs
`CommunityEvent::Error` through `log::error!`, and its "New community" row opens `CommunityEvent::Error` through `log::error!`, and its "New community" row opens
a name prompt that calls `CommunityRegistry::create`. `create` still persists a name prompt that calls `CommunityRegistry::create`. `create` still persists
the genesis locally without publishing it to the metadata's relays. the genesis locally without publishing it to the metadata's relays. Discovery
is local-only: `load` reads the state documents already in
`client.database()` and never fetches the account's CORD-02 Community List
(`33302`) from relays, so a fresh install — or one signing in as an account
that joined elsewhere — finds nothing and never subscribes. See
`docs/concord-community-discovery-plan.md`.
- **Account-key writers take any signer, not `&Keys`.** `genesis`, - **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,