refactor
This commit is contained in:
Generated
+1
@@ -1314,6 +1314,7 @@ name = "concord"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"chacha20 0.9.1",
|
||||
"data-encoding",
|
||||
"hkdf",
|
||||
|
||||
@@ -36,6 +36,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
data-encoding = "2"
|
||||
hkdf = "0.12"
|
||||
|
||||
@@ -13,6 +13,7 @@ sha2.workspace = true
|
||||
chacha20.workspace = true
|
||||
hmac.workspace = true
|
||||
data-encoding.workspace = true
|
||||
base64.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
|
||||
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;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -17,6 +19,8 @@ pub enum ListError {
|
||||
Kind(u16),
|
||||
Crypto(String),
|
||||
Json(String),
|
||||
Encoding(String),
|
||||
Fragment(String),
|
||||
TooManyMemberships(usize),
|
||||
Oversize(usize),
|
||||
}
|
||||
@@ -27,6 +31,8 @@ impl fmt::Display for ListError {
|
||||
ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"),
|
||||
ListError::Crypto(error) => write!(f, "crypto: {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) => {
|
||||
write!(
|
||||
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 community_id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: String,
|
||||
pub community_root: String,
|
||||
pub root_epoch: Epoch,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_pk: Option<PublicKey>,
|
||||
/// Present only when the holder is staff.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_root: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channels: Vec<ChannelGrant>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub relays: Vec<String>,
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommunityListEntry {
|
||||
pub community_id: CommunityId,
|
||||
pub seed: JoinMaterial,
|
||||
pub current: JoinMaterial,
|
||||
pub added_at: u64,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Tombstone {
|
||||
pub community_id: CommunityId,
|
||||
pub removed_at: u64,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
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>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tombstones: Vec<Tombstone>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
impl Default for CommunityList {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frags: 1,
|
||||
entries: Vec::new(),
|
||||
tombstones: Vec::new(),
|
||||
extra: Extra::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommunityList {
|
||||
pub fn is_live(&self, community_id: &CommunityId) -> bool {
|
||||
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> {
|
||||
if self.entries.len() > MAX_MEMBERSHIPS {
|
||||
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(),
|
||||
root_epoch: invite.root_epoch,
|
||||
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(),
|
||||
relays: invite.relays.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 {
|
||||
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) {
|
||||
Entry::Vacant(slot) => {
|
||||
slot.insert(entry);
|
||||
@@ -177,28 +196,34 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
|
||||
union(&mut extra, incoming.extra);
|
||||
|
||||
CommunityList {
|
||||
frags: held.frags.max(incoming.frags),
|
||||
entries: entries.into_values().collect(),
|
||||
tombstones: tombstones.into_values().collect(),
|
||||
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
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
list.fits()?;
|
||||
|
||||
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)
|
||||
.finalize_async(keys)
|
||||
.tag(Tag::identifier(fragment.to_string()))
|
||||
.finalize_async(signer)
|
||||
.await
|
||||
.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
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
@@ -206,11 +231,326 @@ where
|
||||
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)
|
||||
}
|
||||
|
||||
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)]
|
||||
enum Snapshot {
|
||||
Seed,
|
||||
@@ -222,6 +562,7 @@ fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) {
|
||||
held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone();
|
||||
held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone();
|
||||
union(&mut held.extra, incoming.extra);
|
||||
normalize(held);
|
||||
}
|
||||
|
||||
fn pick<'a>(
|
||||
@@ -245,21 +586,26 @@ fn pick<'a>(
|
||||
held
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
fn normalize(entry: &mut CommunityListEntry) {
|
||||
entry.seed.community_id = entry.community_id;
|
||||
entry.current.community_id = entry.community_id;
|
||||
normalize_snapshot(&mut entry.seed, &entry.current);
|
||||
}
|
||||
|
||||
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
fn normalize_snapshot(seed: &mut JoinMaterial, current: &JoinMaterial) {
|
||||
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 {
|
||||
@@ -365,7 +711,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 joined = entry(
|
||||
id(0x11),
|
||||
@@ -385,22 +731,66 @@ mod tests {
|
||||
// A stale device re-merging the entry cannot resurrect it.
|
||||
assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11)));
|
||||
|
||||
// A re-join genuinely newer than the removal does.
|
||||
let rejoined = list(vec![entry(
|
||||
// The tombstone alone is written, so the entry's key material leaves the wire.
|
||||
let written = serde_json::to_string(&left).expect("writes");
|
||||
assert!(
|
||||
!written.contains("\"entries\""),
|
||||
"a retired entry is not written"
|
||||
);
|
||||
let reparsed: CommunityList = serde_json::from_str(&written).expect("parses");
|
||||
assert_eq!(reparsed.tombstones.len(), 1);
|
||||
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,
|
||||
)]);
|
||||
let live = merge(left, rejoined);
|
||||
assert!(live.is_live(&id(0x11)));
|
||||
)]),
|
||||
);
|
||||
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.
|
||||
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]
|
||||
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 owner = Keys::generate().public_key();
|
||||
let mine = CommunityList {
|
||||
@@ -423,26 +813,32 @@ mod tests {
|
||||
removed_at: AT,
|
||||
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!(fragment_index(&event).expect("a fragment index"), 1);
|
||||
assert_eq!(
|
||||
smol::block_on(parse_list_event(&me, &event)).expect("parses"),
|
||||
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".
|
||||
let stranger = Keys::generate();
|
||||
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();
|
||||
held.extra
|
||||
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
|
||||
@@ -450,9 +846,19 @@ mod tests {
|
||||
.current
|
||||
.extra
|
||||
.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(
|
||||
&me,
|
||||
&smol::block_on(build_list_event(&me, &held)).expect("builds"),
|
||||
&smol::block_on(build_list_event(&me, &held, 0)).expect("builds"),
|
||||
))
|
||||
.expect("parses");
|
||||
assert_eq!(rebuilt, held);
|
||||
@@ -472,18 +878,145 @@ mod tests {
|
||||
.collect(),
|
||||
);
|
||||
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
|
||||
));
|
||||
|
||||
let oversized = list(vec![entry(
|
||||
id(0x11),
|
||||
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
|
||||
material(id(0x11), owner, "Room", 0),
|
||||
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
|
||||
AT,
|
||||
)]);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
|
||||
use crate::cord02::list::{canonical, union};
|
||||
use crate::cord02::{ImageRef, MAX_RELAYS};
|
||||
use crate::cord04::{TAG_SUBKIND, vsk};
|
||||
use crate::derive::{TOKEN_LEN, verify_community_id};
|
||||
use crate::utils::{canonical, union};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||
|
||||
pub const KIND_BUNDLE: u16 = 33301;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
pub mod base64url;
|
||||
pub mod derive;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Extra;
|
||||
|
||||
/// Uppercase and other non-canonical spellings are rejected.
|
||||
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)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 1–4 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 1–4 are resolved; 5–8 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
@@ -413,18 +413,47 @@ A member's own memberships, synced across their devices:
|
||||
```rust
|
||||
use concord::cord02::list;
|
||||
|
||||
let material = cord02::list::join_material(&invite, staff.then_some(&control_root));
|
||||
let mut mine = cord02::list::parse_list_event(&my_keys, &event).await?;
|
||||
mine = cord02::list::merge(mine, cord02::list::CommunityList {
|
||||
entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
|
||||
..Default::default()
|
||||
let material = list::join_material(&invite, staff.then_some(&control_root));
|
||||
let mut mine = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
|
||||
mine = list::merge(mine, list::CommunityList {
|
||||
entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: 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
|
||||
strictly newer join outruns it. `fits()` is the write gate — 50 memberships and
|
||||
the NIP-44 size cap, both protocol constants.
|
||||
strictly newer join outruns it. `fits()` is the write gate: 50 memberships and
|
||||
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
|
||||
|
||||
@@ -545,7 +574,12 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
re-folds on an inbound wrap. The sidebar observes the registry, logs
|
||||
`CommunityEvent::Error` through `log::error!`, and its "New community" row opens
|
||||
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`,
|
||||
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
|
||||
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
|
||||
|
||||
Reference in New Issue
Block a user