6 Commits
Author SHA1 Message Date
reya 1cf5ecba51 refactor 2026-09-19 08:39:25 +07:00
reya 0328d35945 add community creation flow 2026-09-19 08:14:03 +07:00
reya 907347d002 update 2026-09-19 08:01:55 +07:00
reya aa3bd71351 update concord backend 2026-09-19 07:46:09 +07:00
reya d3b8fa08de use signer for community backend 2026-09-19 07:40:07 +07:00
reya 9081675494 refactor concord 2026-09-19 07:24:01 +07:00
27 changed files with 2278 additions and 666 deletions
Generated
+3
View File
@@ -1314,10 +1314,12 @@ name = "concord"
version = "1.0.2"
dependencies = [
"anyhow",
"base64 0.22.1",
"chacha20 0.9.1",
"data-encoding",
"hkdf",
"hmac 0.12.1",
"log",
"nostr",
"nostr-memory",
"nostr-sdk",
@@ -9342,6 +9344,7 @@ dependencies = [
"chat",
"chat_ui",
"common",
"community",
"device",
"gpui-pre",
"instant",
+1
View File
@@ -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"
+7
View File
@@ -70,6 +70,13 @@ impl Community {
&self.state
}
pub fn name(&self) -> String {
match &self.control.community {
Some(metadata) => metadata.name.clone(),
None => self.state.id.to_hex(),
}
}
pub fn control(&self) -> &ControlFold {
&self.control
}
+39 -5
View File
@@ -3,6 +3,7 @@ use std::collections::HashMap;
use anyhow::Result;
use concord::CommunityId;
use concord::cord01::KIND_WRAP;
pub use concord::cord02::CommunityMetadata;
use concord::store::CommunityState;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use nostr_sdk::prelude::*;
@@ -103,6 +104,38 @@ impl CommunityRegistry {
self.index.get(id).cloned()
}
/// Create a community owned by the current account and begin tracking it.
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
if current_user.is_none() {
cx.emit(CommunityEvent::Error(
"cannot create a community without an account".to_owned(),
));
return;
}
let signer = nostr.read(cx).signer();
let client = nostr.read(cx).client();
let task =
cx.background_spawn(async move { sync::create(&client, &signer, &metadata).await });
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(_state) => this.update(cx, |this, cx| this.load(cx))?,
Err(error) => {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
}
Ok(())
}));
}
/// Forget the current account and cancel everything in flight.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
@@ -171,7 +204,9 @@ impl CommunityRegistry {
self.observers
.push(cx.observe(&community, |this, _community, cx| {
this.sync_subscriptions(cx);
cx.notify();
}));
self.index.insert(id, community.clone());
self.communities.push(community);
}
@@ -197,9 +232,10 @@ impl CommunityRegistry {
/// Re-subscribe every community whose held planes moved.
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
for community in self.communities.clone() {
let client = nostr.read(cx).client();
let (id, key, state) = {
let community = community.read(cx);
(
@@ -224,9 +260,9 @@ impl CommunityRegistry {
let subscription = sync::subscription_id(&id);
let filter = sync::subscription_filter(&planes);
let relays = key.relays().to_vec();
self.synced.insert(id, key);
let client = client.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = subscribe(&client, &subscription, &relays, filter).await {
this.update(cx, |_this, cx| {
@@ -292,6 +328,7 @@ async fn subscribe(
relays: &[RelayUrl],
filter: Filter,
) -> Result<()> {
log::info!("community {id}: subscribing to {relays:?}");
client.unsubscribe(id).await?;
for url in relays {
@@ -300,9 +337,6 @@ async fn subscribe(
}
}
// Concord wraps share kind 1059 with NIP-59 gift wraps, so an automatic
// target sends gossip after the plane authors as if they were DM peers.
// The community's own relays are the routing relays, so target them.
let target = if relays.is_empty() {
ReqTarget::auto(vec![filter])
} else {
+148 -39
View File
@@ -6,15 +6,14 @@ use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST};
use concord::cord02::{self, ControlFold};
use concord::cord04::AuthorityCitation;
use concord::cord04::roles::{Permissions, citation_ok};
use concord::derive::{channel_group_key, control_group_key, guestbook_group_key};
use concord::derive::{
channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key,
};
use concord::store::{self, CommunityState};
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
use nostr_sdk::prelude::*;
use state::UniversalSigner;
const SUBSCRIPTION_PREFIX: &str = "concord/";
const STATE_PREFIX: &str = "concord/";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PlaneKind {
Control(Epoch),
@@ -75,13 +74,13 @@ pub fn subscription_filter(planes: &[Plane]) -> Filter {
}
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
SubscriptionId::new(format!("{SUBSCRIPTION_PREFIX}{}", id.to_hex()))
SubscriptionId::new(format!("{}{}", store::STATE_PREFIX, id.to_hex()))
}
pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
subscription_id
.as_str()
.strip_prefix(SUBSCRIPTION_PREFIX)?
.strip_prefix(store::STATE_PREFIX)?
.parse()
.ok()
}
@@ -93,36 +92,42 @@ pub struct Snapshot {
pub members: BTreeSet<PublicKey>,
}
/// Mints a community owned by `signer` and persists it locally.
pub async fn create<S>(
client: &Client,
signer: &S,
metadata: &cord02::CommunityMetadata,
) -> Result<CommunityState>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let at_secs = Timestamp::now().as_secs();
let genesis = cord02::genesis(signer, metadata, at_secs).await?;
let id = genesis.identity.community_id;
let read = control_group_key(&genesis.community_root, &id, cord02::ROOT_EPOCH)?;
let address = control_signer_group_key(&genesis.control_root, &id, cord02::ROOT_EPOCH)?.pk();
let mut editions = Vec::with_capacity(genesis.wraps.len());
for wrap in &genesis.wraps {
editions.push(cord02::open_edition(wrap, &read, &address, true)?);
client.database().save_event(wrap).await?;
}
let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?;
store::save_state(client, &state).await?;
Ok(state)
}
/// Discovers the current account's communities from the local database.
pub async fn load(
client: &Client,
signer: &UniversalSigner,
self_pk: PublicKey,
) -> Result<Vec<CommunityState>> {
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
for event in client.database().query(filter).await? {
let Some(id) = state_document_of(&event) else {
continue;
};
match newest.get(&id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(id, event);
}
}
}
let mut states = Vec::with_capacity(newest.len());
for event in newest.into_values() {
match serde_json::from_str::<CommunityState>(&event.content) {
Ok(state) => states.push(state),
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
}
}
let mut states = store::load_states(client).await?;
if let Some(list) = load_list(client, signer, self_pk).await? {
states.retain(|state| list.is_live(&state.id));
@@ -131,12 +136,6 @@ pub async fn load(
Ok(states)
}
fn state_document_of(event: &Event) -> Option<CommunityId> {
let identifier = event.tags.identifier()?;
let hex = identifier.strip_prefix(STATE_PREFIX)?;
hex.parse().ok()
}
async fn load_list(
client: &Client,
signer: &UniversalSigner,
@@ -151,9 +150,7 @@ async fn load_list(
return Ok(None);
};
let json = signer.nip44_decrypt_async(&self_pk, &event.content).await?;
Ok(Some(serde_json::from_str(&json)?))
Ok(Some(cord02::list::parse_list_event(signer, &event).await?))
}
/// Rebuilds a community from the wraps already in the local database.
@@ -259,3 +256,115 @@ fn observe(observed: &mut BTreeMap<PublicKey, u64>, author: PublicKey, at_ms: u6
.and_modify(|seen| *seen = (*seen).max(at_ms))
.or_insert(at_ms);
}
#[cfg(test)]
mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
fn client() -> Client {
ClientBuilder::default()
.database(MemoryDatabase::unbounded())
.build()
}
fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata {
cord02::CommunityMetadata {
name: name.to_owned(),
relays: vec![relay.to_owned()],
..cord02::CommunityMetadata::default()
}
}
/// What `CommunityRegistry` needs from a created community: a state document
/// `load` finds, a control plane the subscription filter actually addresses,
/// and a fold that survives an inbound control edit.
#[test]
fn creating_a_community_persists_a_state_that_subscribes_and_folds() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let created = create(&client, &signer, &metadata("coop", "wss://relay.example"))
.await
.expect("creates");
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
assert_eq!(loaded, vec![created.clone()]);
// The subscription filter must address the genesis wraps, or the registry
// would listen to a plane nothing is ever published on.
let planes = planes(&created).expect("planes");
let wraps = client
.database()
.query(subscription_filter(&planes))
.await
.expect("queries");
assert_eq!(wraps.len(), created.heads.len());
assert!(wraps.iter().all(|wrap| wrap.kind == Kind::from(KIND_WRAP)));
let snapshot = fold(&client, &created)
.await
.expect("folds")
.expect("a control plane");
assert_eq!(snapshot.state.channels.len(), 1);
assert_eq!(snapshot.members, BTreeSet::from([keys.public_key()]));
assert_eq!(
snapshot
.control
.community
.as_ref()
.map(|metadata| metadata.name.as_str()),
Some("coop")
);
// An inbound control edit made by the owner folds over the created state.
let community_head = created
.heads
.iter()
.find(|head| head.entity == *created.id.as_bytes())
.expect("a community head");
let writer = cord02::ControlWriter {
author: created.owner,
read: control_group_key(&created.community_root, &created.id, cord02::ROOT_EPOCH)
.expect("a reading key"),
signer: control_signer_group_key(
&created.control_root.expect("a control root"),
&created.id,
cord02::ROOT_EPOCH,
)
.expect("a signing key"),
};
let (wrap, _) = writer
.set_community_metadata(
&keys,
&created.id,
&metadata("coop two", "wss://relay.example"),
Some(community_head),
None,
Timestamp::now().as_secs() + 1,
)
.await
.expect("publishes");
client.database().save_event(&wrap).await.expect("saves");
let updated = fold(&client, &created)
.await
.expect("folds")
.expect("a control plane");
assert_eq!(
updated
.control
.community
.as_ref()
.map(|metadata| metadata.name.as_str()),
Some("coop two")
);
});
}
}
+2
View File
@@ -13,10 +13,12 @@ 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
anyhow.workspace = true
log.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
+39 -19
View File
@@ -3,8 +3,8 @@ use std::fmt;
use data_encoding::BASE64;
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
use nostr_sdk::prelude::{
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
UnsignedEvent,
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
};
use crate::derive::GroupKey;
@@ -198,32 +198,52 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u
}
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
seal_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
plaintext,
)
pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
check_plaintext_cap(plaintext.len())?;
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
signer
.nip44_encrypt_async(&address, plaintext)
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))
}
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> {
open_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
content,
)
pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
signer
.nip44_decrypt_async(&address, content)
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))
}
pub fn build_seal(
pub async fn build_seal<S>(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
author: &Keys,
) -> Result<Event, StreamError> {
author: &S,
) -> Result<Event, StreamError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at)
.finalize(author)
.finalize_async(author)
.await
.map_err(|error| StreamError::Sign(error.to_string()))
}
@@ -450,7 +470,7 @@ mod tests {
}
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
build_seal(rumor, form, &group(0), author).expect("seals")
smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals")
}
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
+26 -104
View File
@@ -1,18 +1,16 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::cord01::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
};
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
pub use crate::cords::rumor::RumorError as GuestbookError;
use crate::cords::rumor::{optional_citation, pubkey, required, value};
use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306;
@@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave";
#[derive(Debug)]
pub enum GuestbookError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for GuestbookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
GuestbookError::NotEncryptedSealed => {
write!(f, "guestbook rumor must ride an encrypted seal")
}
GuestbookError::UnknownKind(kind) => {
write!(f, "not a guestbook rumor kind: {kind}")
}
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(error: StreamError) -> Self {
GuestbookError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry {
Join {
@@ -183,18 +146,21 @@ pub fn build_snapshot_chunks(
.collect()
}
pub fn seal_rumor(
pub async fn seal_rumor<S>(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
) -> Result<(Event, Keys), GuestbookError> {
author: &S,
) -> Result<(Event, Keys), GuestbookError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
}
@@ -466,73 +432,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo
Ok((snapshot_id, (index, total)))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(GuestbookError::BadTag(TAG_CITATION))
}
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP))
}
fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], GuestbookError> {
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, GuestbookError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(GuestbookError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
fields
.get(1)
.map(String::as_str)
.ok_or(GuestbookError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cord01::build_rumor_secs;
use crate::cord01::{StreamError, build_rumor_secs};
use crate::cord04::TAG_CITATION;
use crate::derive::guestbook_group_key;
use crate::{CommunityId, Epoch};
@@ -556,7 +470,9 @@ mod tests {
}
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
.expect("seals")
.0;
open(&wrap, &group()).expect("opens").1
}
@@ -826,7 +742,9 @@ mod tests {
);
assert!(matches!(
open(
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
&smol::block_on(seal_rumor(&bad_ms, &group(), &member))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::Stream(StreamError::BadMs))
@@ -835,7 +753,9 @@ mod tests {
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!(
open(
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
&smol::block_on(seal_rumor(&bad_verb, &group(), &member))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::BadTag(TAG_CONTENT))
@@ -854,7 +774,7 @@ mod tests {
);
assert!(matches!(
open(
&seal_rumor(&ambiguous, &group(), &moderator)
&smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
.expect("seals")
.0,
&group()
@@ -876,7 +796,9 @@ mod tests {
);
assert!(matches!(
open(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
&smol::block_on(seal_rumor(&rumor, &group(), &moderator))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::BadTag(TAG_SNAP))
+610 -64
View File
@@ -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,31 +196,359 @@ 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 fn build_list_event(keys: &Keys, 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.as_bytes())?;
let content = cord01::seal_to_self(signer, &json).await?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize(keys)
.tag(Tag::identifier(fragment.to_string()))
.finalize_async(signer)
.await
.map_err(crypto_error)
}
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError> {
pub async fn parse_list_event<S>(signer: &S, event: &Event) -> Result<CommunityList, ListError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_COMMUNITY_LIST {
return Err(ListError::Kind(event.kind.as_u16()));
}
let json = cord01::open_to_self(keys, &event.content)?;
fragment_index(event)?;
serde_json::from_slice(&json).map_err(json_error)
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)]
@@ -215,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>(
@@ -238,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 {
@@ -358,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),
@@ -378,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(
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)));
// 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,
)]),
);
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 {
@@ -416,23 +813,32 @@ mod tests {
removed_at: AT,
extra: Extra::default(),
}],
extra: Extra::default(),
..Default::default()
};
let event = 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!(parse_list_event(&me, &event).expect("parses"), mine);
assert!(
!parse_list_event(&me, &event)
.expect("parses")
.is_live(&id(0x33))
assert_eq!(fragment_index(&event).expect("a fragment index"), 1);
assert_eq!(
smol::block_on(parse_list_event(&me, &event)).expect("parses"),
mine
);
// Only the member's own keys open it, and an unreadable list is "no news".
let stranger = Keys::generate();
assert!(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();
held.extra
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
@@ -440,8 +846,21 @@ mod tests {
.current
.extra
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
let rebuilt =
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses");
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, 0)).expect("builds"),
))
.expect("parses");
assert_eq!(rebuilt, held);
// The write gate refuses an over-cap or oversized List before publishing.
@@ -459,18 +878,145 @@ mod tests {
.collect(),
);
assert!(matches!(
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;
}
+190 -146
View File
@@ -4,7 +4,9 @@ pub mod list;
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent,
};
use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
@@ -114,17 +116,24 @@ pub struct CommunityGenesis {
pub wraps: Vec<Event>,
}
pub fn genesis(
owner: &Keys,
pub async fn genesis<S>(
owner: &S,
metadata: &CommunityMetadata,
at_secs: u64,
) -> Result<CommunityGenesis> {
) -> Result<CommunityGenesis>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?;
let owner_key = owner
.get_public_key_async()
.await
.map_err(|error| anyhow::anyhow!("signer: {error}"))?;
let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
owner: owner.public_key(),
community_id: community_id_of(&owner_key.to_bytes(), &owner_salt),
owner: owner_key,
owner_salt,
};
@@ -167,7 +176,7 @@ pub fn genesis(
let mut wraps = Vec::with_capacity(editions.len());
for edition in &editions {
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?);
}
Ok(CommunityGenesis {
@@ -211,12 +220,15 @@ pub struct Edition<'a> {
}
impl ControlWriter {
pub fn publish(
pub async fn publish<S>(
&self,
keys: &Keys,
keys: &S,
edition: Edition<'_>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rumor = build_edition(EditionFields {
author: self.author,
subkind: edition.subkind,
@@ -231,20 +243,23 @@ impl ControlWriter {
});
let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?;
Ok((wrap, EntityHead::from(&parsed)))
}
pub fn set_community_metadata(
pub async fn set_community_metadata<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
metadata: &CommunityMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = encode_metadata(metadata)?;
self.publish(
@@ -258,17 +273,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_channel_metadata(
pub async fn set_channel_metadata<S>(
&self,
keys: &Keys,
keys: &S,
channel: &ChannelId,
metadata: &ChannelMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if metadata.name.len() > MAX_NAME_BYTES {
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
}
@@ -286,16 +305,20 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_role(
pub async fn set_role<S>(
&self,
keys: &Keys,
keys: &S,
role: &Role,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if role.name.len() > MAX_NAME_BYTES {
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
}
@@ -313,17 +336,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_grant(
pub async fn set_grant<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
grant: &Grant,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = grant.to_content()?;
self.publish(
@@ -337,17 +364,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_banlist(
pub async fn set_banlist<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
banned: &BTreeSet<PublicKey>,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if banned.len() > MAX_BANLIST {
bail!("banlist exceeds {MAX_BANLIST} entries");
}
@@ -366,19 +397,23 @@ impl ControlWriter {
},
at_secs,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub fn set_registry(
pub async fn set_registry<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
creator: &PublicKey,
links: &[PublicKey],
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let entries: Vec<String> = links
.iter()
.take(MAX_REGISTRY_LINKS)
@@ -397,20 +432,24 @@ impl ControlWriter {
},
at_secs,
)
.await
}
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
#[allow(clippy::too_many_arguments)]
pub fn set_pin_list(
pub async fn set_pin_list<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
channel: &ChannelId,
content: &str,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
self.publish(
keys,
Edition {
@@ -422,6 +461,7 @@ impl ControlWriter {
},
at_secs,
)
.await
}
}
@@ -708,14 +748,17 @@ fn authorized_head<'a>(
selection.head.map(|index| authorized[index])
}
fn seal_edition(
async fn seal_edition<S>(
edition: &UnsignedEvent,
owner: &Keys,
owner: &S,
read: &GroupKey,
signer: &GroupKey,
at_secs: u64,
) -> Result<Event> {
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
) -> Result<Event>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?;
let (wrap, _) = wrap_seal_with(
&seal,
@@ -731,6 +774,8 @@ fn seal_edition(
#[cfg(test)]
mod tests {
use nostr_sdk::prelude::Keys;
use super::*;
use crate::cord03::{self, build_message, seal_rumor};
use crate::cord04::pins;
@@ -768,7 +813,7 @@ mod tests {
#[test]
fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
@@ -797,33 +842,31 @@ mod tests {
.get(minted.channel_id.as_bytes())
.expect("head");
let (community_wrap, _) = writer
.set_community_metadata(
&owner,
&community_id,
&CommunityMetadata {
relays: vec!["wss://relay.example".to_owned()],
..metadata("coop two")
},
Some(community_head),
None,
AT + 1,
)
.expect("publishes");
let (channel_wrap, _) = writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "lobby".to_owned(),
private: false,
..ChannelMetadata::default()
},
Some(channel_head),
None,
AT + 2,
)
.expect("publishes");
let (community_wrap, _) = smol::block_on(writer.set_community_metadata(
&owner,
&community_id,
&CommunityMetadata {
relays: vec!["wss://relay.example".to_owned()],
..metadata("coop two")
},
Some(community_head),
None,
AT + 1,
))
.expect("publishes");
let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "lobby".to_owned(),
private: false,
..ChannelMetadata::default()
},
Some(channel_head),
None,
AT + 2,
))
.expect("publishes");
let mut edited = genesis_editions.clone();
edited.extend(open_all(
@@ -875,7 +918,7 @@ mod tests {
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
let owner = Keys::generate();
let member = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
@@ -896,21 +939,20 @@ mod tests {
extra: Extra::default(),
};
let (role_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::ROLE,
entity: *role_id.as_bytes(),
content: &role.to_content().expect("serializes"),
head: None,
citation: None,
},
AT + 1,
)
.expect("publishes");
let (grant_wrap, _) = writer
.publish(
let (role_wrap, _) = smol::block_on(writer.publish(
&owner,
Edition {
subkind: vsk::ROLE,
entity: *role_id.as_bytes(),
content: &role.to_content().expect("serializes"),
head: None,
citation: None,
},
AT + 1,
))
.expect("publishes");
let (grant_wrap, _) = smol::block_on(
writer.publish(
&owner,
Edition {
subkind: vsk::GRANT,
@@ -927,8 +969,9 @@ mod tests {
citation: None,
},
AT + 2,
)
.expect("publishes");
),
)
.expect("publishes");
let mut base = open_all(&minted.wraps, &read, &signer.pk());
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
@@ -959,36 +1002,34 @@ mod tests {
};
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
let (uncited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: None,
},
AT + 3,
)
.expect("publishes");
let (cited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: Some(AuthorityCitation {
entity: grant.entity,
version: grant.version,
hash: grant.self_hash,
}),
},
AT + 4,
)
.expect("publishes");
let (uncited, _) = smol::block_on(member_writer.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: None,
},
AT + 3,
))
.expect("publishes");
let (cited, _) = smol::block_on(member_writer.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: Some(AuthorityCitation {
entity: grant.entity,
version: grant.version,
hash: grant.self_hash,
}),
},
AT + 4,
))
.expect("publishes");
// Uncited, the edit claims an authority the member never showed.
let mut forged = base.clone();
@@ -1023,7 +1064,7 @@ mod tests {
#[test]
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
@@ -1042,7 +1083,7 @@ mod tests {
AT * 1_000,
None,
);
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals");
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals");
let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH)
.expect("opens")
.0;
@@ -1064,17 +1105,16 @@ mod tests {
read: read.clone(),
signer: signer.clone(),
};
let (pin_wrap, _) = writer
.set_pin_list(
&owner,
&community_id,
&channel,
&content,
None,
None,
AT + 1,
)
.expect("publishes");
let (pin_wrap, _) = smol::block_on(writer.set_pin_list(
&owner,
&community_id,
&channel,
&content,
None,
None,
AT + 1,
))
.expect("publishes");
let mut editions = open_all(&minted.wraps, &read, &signer.pk());
editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
@@ -1109,7 +1149,7 @@ mod tests {
#[test]
fn the_timer_is_never_guessed_and_the_write_caps_hold() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
@@ -1124,10 +1164,16 @@ mod tests {
&owner_pk,
&community_id,
&open_all(
&[writer
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1)
.expect("publishes")
.0],
&[smol::block_on(writer.set_community_metadata(
&owner,
&community_id,
metadata,
None,
None,
AT + 1,
))
.expect("publishes")
.0],
&writer.read,
&writer.signer.pk(),
),
@@ -1163,8 +1209,7 @@ mod tests {
.map(|_| Keys::generate().public_key())
.collect();
assert!(
writer
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2))
.is_err()
);
@@ -1179,20 +1224,19 @@ mod tests {
assert!(grant.to_content().is_err());
assert!(
writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "x".repeat(MAX_NAME_BYTES + 1),
private: false,
..ChannelMetadata::default()
},
None,
None,
AT + 3,
)
.is_err()
smol::block_on(writer.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "x".repeat(MAX_NAME_BYTES + 1),
private: false,
..ChannelMetadata::default()
},
None,
None,
AT + 3,
))
.is_err()
);
}
}
+19 -96
View File
@@ -1,18 +1,16 @@
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::fmt;
use anyhow::Result;
use nostr_sdk::prelude::*;
use crate::cord01::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal,
channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal,
};
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
pub use crate::cords::rumor::RumorError as ChatError;
use crate::cords::rumor::{optional_citation, pubkey, tag, value};
use crate::derive::channel_group_key;
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
@@ -36,42 +34,6 @@ const TAG_TARGET_AUTHOR: &str = "p";
const TAG_EXPIRATION: &str = "expiration";
const TAG_TIMER: &str = "timer";
#[derive(Debug)]
pub enum ChatError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// Neither a delete nor a timer notice may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for ChatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChatError::Stream(error) => write!(f, "stream: {error}"),
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
ChatError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
impl std::error::Error for ChatError {}
impl From<StreamError> for ChatError {
fn from(error: StreamError) -> Self {
ChatError::Stream(error)
}
}
/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplyRef {
@@ -292,19 +254,22 @@ pub fn build_typing(
}
/// `ephemeral` picks the 21059 wrap, which relays must not store.
pub fn seal_rumor(
pub async fn seal_rumor<S>(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
author: &S,
ephemeral: bool,
) -> Result<(Event, Keys), ChatError> {
) -> Result<(Event, Keys), ChatError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16();
if !is_chat_kind(kind) {
return Err(ChatError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
let wrap_kind = if ephemeral {
KIND_WRAP_EPHEMERAL
} else {
@@ -576,16 +541,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
.map_err(|_| ChatError::BadTag(name))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(ChatError::BadTag(TAG_CITATION))
}
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None);
@@ -611,51 +566,16 @@ fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, ChatError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(ChatError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
fields
.get(1)
.map(String::as_str)
.ok_or(ChatError::BadTag(name))
}
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cord01::StreamError;
const SECRET: [u8; 32] = [0x2du8; 32];
const AT: u64 = 1_700_000_000_417;
@@ -674,7 +594,9 @@ mod tests {
}
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
seal_rumor(rumor, group, author, false).expect("seals").0
smol::block_on(seal_rumor(rumor, group, author, false))
.expect("seals")
.0
}
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
@@ -949,7 +871,8 @@ mod tests {
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
// chat rumor however well-formed it looks.
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals");
let seal =
smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
@@ -971,7 +894,7 @@ mod tests {
AT,
);
assert!(matches!(
seal_rumor(&ghost, &group, &alice, false),
smol::block_on(seal_rumor(&ghost, &group, &alice, false)),
Err(ChatError::UnknownKind(3300))
));
+4 -4
View File
@@ -121,7 +121,7 @@ fn signing_bytes(
bytes
}
pub fn edition_hash(
fn edition_hash(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
@@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FoldResult {
struct FoldResult {
pub head: Option<usize>,
pub gap: bool,
pub anchored: bool,
}
/// The highest version whose chain is intact, given a held floor.
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() {
@@ -311,7 +311,7 @@ pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>)
}
/// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
+6 -4
View File
@@ -583,7 +583,7 @@ mod tests {
at_ms,
None,
);
let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals");
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
}
@@ -699,7 +699,7 @@ mod tests {
AT_MS + 5_000,
None,
);
let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals");
let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals");
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
@@ -718,7 +718,8 @@ mod tests {
AT_MS + 6_000,
None,
);
let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals");
let (wrap, _) =
smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals");
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
@@ -805,7 +806,8 @@ mod tests {
None,
AT_MS + 1_000,
);
let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals");
let (wrap, _) =
smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals");
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
assert_eq!(
+3 -3
View File
@@ -100,7 +100,7 @@ pub struct Role {
}
impl Role {
pub fn parse(content: &str) -> Option<Self> {
fn parse(content: &str) -> Option<Self> {
serde_json::from_str(content).ok()
}
@@ -122,7 +122,7 @@ pub struct Grant {
}
impl Grant {
pub fn parse(content: &str) -> Option<Self> {
fn parse(content: &str) -> Option<Self> {
serde_json::from_str(content).ok()
}
@@ -135,7 +135,7 @@ impl Grant {
}
}
pub fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
let entries: Vec<String> = serde_json::from_str(content).ok()?;
let mut banned = Vec::with_capacity(entries.len());
+44 -21
View File
@@ -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;
@@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
})
}
pub fn build_direct_invite(
inviter: &Keys,
pub async fn build_direct_invite<S>(
inviter: &S,
recipient: &PublicKey,
invite: &CommunityInvite,
) -> Result<Event, InviteError> {
) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
{
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json)
.finalize_unsigned(inviter.public_key());
let author = inviter.get_public_key_async().await.map_err(crypto_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author);
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
@@ -469,15 +472,22 @@ pub fn build_direct_invite(
GiftWrapBuilder::new(*recipient, rumor)
.extra_tags(tags)
.finalize(inviter)
.finalize_async(inviter)
.await
.map_err(crypto_error)
}
pub fn unwrap_direct_invite(
/// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too.
pub async fn unwrap_direct_invite<S>(
wrap: &Event,
recipient: &Keys,
) -> Result<(PublicKey, CommunityInvite), InviteError> {
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?;
recipient: &S,
) -> Result<(PublicKey, CommunityInvite), InviteError>
where
S: AsyncNip44,
{
let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap)
.await
.map_err(crypto_error)?;
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
return Err(InviteError::BadEvent("rumor is not a direct invite"));
@@ -590,25 +600,32 @@ pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList
}
}
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
pub async fn build_invite_list<S>(keys: &S, list: &InviteList) -> Result<Event, InviteError>
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.as_bytes())?;
let content = cord01::seal_to_self(keys, &json).await?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys)
.finalize_async(keys)
.await
.map_err(crypto_error)
}
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::Kind(event.kind.as_u16()));
}
let json = cord01::open_to_self(keys, &event.content)?;
let json = cord01::open_to_self(keys, &event.content).await?;
serde_json::from_slice(&json).map_err(json_error)
serde_json::from_str(&json).map_err(json_error)
}
/// An entry is immutable once minted, so two copies should agree.
@@ -920,7 +937,12 @@ mod tests {
let recipient = Keys::generate();
let invite = bundle();
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds");
let wrap = smol::block_on(build_direct_invite(
&inviter,
&recipient.public_key(),
&invite,
))
.expect("builds");
assert_eq!(wrap.kind, Kind::GiftWrap);
assert_ne!(
wrap.pubkey,
@@ -932,13 +954,14 @@ mod tests {
"the k tag is what makes an invite indexable"
);
let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps");
let (sender, opened) =
smol::block_on(unwrap_direct_invite(&wrap, &recipient)).expect("unwraps");
assert_eq!(sender, inviter.public_key());
assert_eq!(opened.community_id, invite.community_id);
// Somebody else's wrap is not ours to open...
let stranger = Keys::generate();
assert!(unwrap_direct_invite(&wrap, &stranger).is_err());
assert!(smol::block_on(unwrap_direct_invite(&wrap, &stranger)).is_err());
// ...and a wrap that opens to some other kind is not an invite.
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
@@ -947,7 +970,7 @@ mod tests {
.finalize(&recipient)
.expect("wraps");
assert!(matches!(
unwrap_direct_invite(&wrap, &recipient),
smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
Err(InviteError::BadEvent(_))
));
}
+100 -74
View File
@@ -2,9 +2,10 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent};
use data_encoding::{BASE64, HEXLOWER};
use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent,
};
use serde::{Deserialize, Serialize};
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
@@ -298,34 +299,48 @@ pub fn blob_locator(
))
}
pub fn build_blob(
rotator: &Keys,
pub async fn build_blob<S>(
rotator: &S,
recipient: &PublicKey,
scope: RekeyScope,
epoch: Epoch,
new_key: &[u8; 32],
control_pk: Option<&[u8; 32]>,
control_root: Option<&[u8; 32]>,
) -> Result<RekeyBlob, RekeyError> {
) -> Result<RekeyBlob, RekeyError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?;
let rotator_pk = rotator.get_public_key_async().await.map_err(crypto_error)?;
let wrapped = rotator
.nip44_encrypt_async(recipient, &BASE64.encode(&plaintext))
.await
.map_err(crypto_error)?;
Ok(RekeyBlob {
locator: blob_locator(&rotator.public_key(), recipient, scope, epoch),
wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?,
locator: blob_locator(&rotator_pk, recipient, scope, epoch),
wrapped,
})
}
pub fn open_blob(
recipient: &Keys,
pub async fn open_blob<S>(
recipient: &S,
rotator: &PublicKey,
scope: RekeyScope,
epoch: Epoch,
blob: &RekeyBlob,
community_id: &CommunityId,
) -> Result<KeyDelivery, RekeyError> {
let conversation =
ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?;
let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?;
) -> Result<KeyDelivery, RekeyError>
where
S: AsyncNip44 + ?Sized,
{
let text = recipient
.nip44_decrypt_async(rotator, &blob.wrapped)
.await
.map_err(crypto_error)?;
let plaintext = BASE64.decode(text.as_bytes()).map_err(crypto_error)?;
parse_blob_plaintext(&plaintext, scope, epoch, community_id)
}
@@ -341,15 +356,6 @@ pub fn find_my_blobs<'a>(
blobs.iter().filter(move |blob| blob.locator == wanted)
}
fn seal_to(
secret: &SecretKey,
recipient: &PublicKey,
plaintext: &[u8],
) -> Result<String, RekeyError> {
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
Ok(cord01::seal_bytes(&conversation, plaintext)?)
}
#[derive(Debug, Clone)]
pub struct RekeyChunk {
pub rotator: PublicKey,
@@ -598,8 +604,8 @@ pub fn build_rekey_rumor(
}
#[allow(clippy::too_many_arguments)]
pub fn build_rekey_chunks(
rotator: &Keys,
pub async fn build_rekey_chunks<S>(
rotator: &S,
group: &GroupKey,
scope: RekeyScope,
new_epoch: Epoch,
@@ -609,7 +615,11 @@ pub fn build_rekey_chunks(
citation: Option<&AuthorityCitation>,
severed: bool,
at_secs: u64,
) -> Result<Vec<Event>, RekeyError> {
) -> Result<Vec<Event>, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?;
let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect();
if groups.is_empty() {
@@ -621,7 +631,7 @@ pub fn build_rekey_chunks(
for (index, group_blobs) in groups.into_iter().enumerate() {
let rumor = build_rekey_rumor(
rotator.public_key(),
rotator_key,
scope,
new_epoch,
prev_epoch,
@@ -633,7 +643,7 @@ pub fn build_rekey_chunks(
at_secs,
)?;
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?;
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?;
let (wrap, _) = cord01::wrap_seal(
&seal,
group,
@@ -731,14 +741,17 @@ pub fn dissolved_tombstone_rumor(
)
}
pub fn seal_dissolved(
pub async fn seal_dissolved<S>(
rumor: &UnsignedEvent,
community_id: &CommunityId,
owner: &Keys,
owner: &S,
at_secs: u64,
) -> Result<Event, RekeyError> {
) -> Result<Event, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let group = dissolved_group_key(community_id).map_err(crypto_error)?;
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?;
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?;
let (wrap, _) = cord01::wrap_seal(
&seal,
&group,
@@ -858,6 +871,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError {
mod tests {
use std::collections::BTreeSet;
use nostr_sdk::prelude::Keys;
use super::*;
use crate::cord01::KIND_WRAP;
use crate::cord02::{
@@ -912,16 +927,16 @@ mod tests {
let scope = RekeyScope::Channel(channel());
let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| {
open_blob(
smol::block_on(open_blob(
keys,
&rotator.public_key(),
scope,
epoch,
blob,
&community_id,
)
))
};
let blob = build_blob(
let blob = smol::block_on(build_blob(
&rotator,
&recipient.public_key(),
scope,
@@ -929,7 +944,7 @@ mod tests {
&key,
None,
None,
)
))
.expect("builds");
assert_eq!(
@@ -962,7 +977,7 @@ mod tests {
.to_bytes();
let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| {
build_blob(
smol::block_on(build_blob(
&rotator,
&recipient.public_key(),
RekeyScope::Base,
@@ -970,7 +985,7 @@ mod tests {
&key,
pk,
root,
)
))
.expect("builds")
};
@@ -1043,7 +1058,7 @@ mod tests {
let community_id = community();
let blob_for = |recipient: &Keys, key: [u8; 32]| {
build_blob(
smol::block_on(build_blob(
&rotator,
&recipient.public_key(),
scope,
@@ -1051,7 +1066,7 @@ mod tests {
&key,
None,
None,
)
))
.expect("builds")
};
let mine = blob_for(&me, [0xAA; 32]);
@@ -1059,7 +1074,7 @@ mod tests {
let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives");
let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY);
let chunks = build_rekey_chunks(
let chunks = smol::block_on(build_rekey_chunks(
&rotator,
&group,
scope,
@@ -1070,7 +1085,7 @@ mod tests {
None,
false,
AT,
)
))
.expect("builds");
assert_eq!(chunks.len(), 1);
@@ -1129,14 +1144,14 @@ mod tests {
.next()
.expect("located");
assert_eq!(
open_blob(
smol::block_on(open_blob(
&me,
&rotator.public_key(),
scope,
epoch,
located,
&community_id
)
))
.expect("opens")
.new_key,
[0xAA; 32]
@@ -1269,26 +1284,26 @@ mod tests {
content: String,
at_secs: u64,
) -> Event {
writer
.publish(
owner,
Edition {
subkind,
entity,
content: &content,
head: None,
citation: None,
},
at_secs,
)
.expect("publishes")
.0
smol::block_on(writer.publish(
owner,
Edition {
subkind,
entity,
content: &content,
head: None,
citation: None,
},
at_secs,
))
.expect("publishes")
.0
}
#[test]
fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() {
let owner = Keys::generate();
let minted = genesis(&owner, &CommunityMetadata::default(), AT).expect("mints");
let minted =
smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints");
let community_id = minted.identity.community_id;
let read =
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives");
@@ -1413,7 +1428,7 @@ mod tests {
.map(|_| {
let member = Keys::generate();
build_blob(
smol::block_on(build_blob(
&rotator,
&member.public_key(),
scope,
@@ -1421,12 +1436,12 @@ mod tests {
&[0xCD; 32],
None,
None,
)
))
.expect("builds")
})
.collect();
let chunks = build_rekey_chunks(
let chunks = smol::block_on(build_rekey_chunks(
&rotator,
&group,
scope,
@@ -1437,7 +1452,7 @@ mod tests {
None,
false,
AT,
)
))
.expect("builds");
assert_eq!(chunks.len(), 1, "a full send chunk is one event");
@@ -1452,7 +1467,7 @@ mod tests {
wrapped: "x".to_owned(),
});
let chunks = build_rekey_chunks(
let chunks = smol::block_on(build_rekey_chunks(
&rotator,
&group,
scope,
@@ -1463,7 +1478,7 @@ mod tests {
None,
false,
AT,
)
))
.expect("builds");
assert_eq!(chunks.len(), 2, "one over the cap splits across two events");
@@ -1485,8 +1500,13 @@ mod tests {
content: "{}",
at_secs: AT,
});
let seal =
cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals");
let seal = smol::block_on(cord01::build_seal(
&rumor,
SealForm::Plaintext,
&prior_read,
&owner,
))
.expect("seals");
let refounding = plan_refounding(Epoch(1)).expect("plans");
let read = refounding.read(&community_id).expect("derives");
@@ -1507,8 +1527,13 @@ mod tests {
assert_eq!(reopened.author, owner.public_key());
// Only a plaintext seal can be carried forward.
let encrypted =
cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals");
let encrypted = smol::block_on(cord01::build_seal(
&rumor,
SealForm::Encrypted,
&prior_read,
&owner,
))
.expect("seals");
assert!(matches!(
compact(&[encrypted], &read, &signer, AT + 1),
Err(RekeyError::Stream(StreamError::NotRewrappable))
@@ -1527,7 +1552,8 @@ mod tests {
};
let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT);
let wrap = seal_dissolved(&rumor, &community_id, &owner, AT).expect("seals");
let wrap =
smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals");
assert!(verify_dissolved(&wrap, &identity));
assert_eq!(
@@ -1538,18 +1564,18 @@ mod tests {
// Anyone holding the community id finds the address, but only the committed
// owner's signature counts.
let impostor = Keys::generate();
let forged = seal_dissolved(
let forged = smol::block_on(seal_dissolved(
&dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT),
&community_id,
&impostor,
AT,
)
))
.expect("seals");
assert!(!verify_dissolved(&forged, &identity));
// The spec's all-zero `eid` is refused: it would let one owner's genuine
// tombstone be re-wrapped at another of their communities and kill it.
let zeroed = seal_dissolved(
let zeroed = smol::block_on(seal_dissolved(
&cord01::build_rumor_secs(
KIND_CONTROL,
owner.public_key(),
@@ -1563,7 +1589,7 @@ mod tests {
&community_id,
&owner,
AT,
)
))
.expect("seals");
assert!(matches!(
open_dissolved(&zeroed, &community_id),
+1 -2
View File
@@ -1,5 +1,4 @@
//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and
//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file.
mod rumor;
pub mod cord01;
pub mod cord02;
+96
View File
@@ -0,0 +1,96 @@
use std::fmt;
use nostr_sdk::prelude::*;
use crate::cord01::StreamError;
use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from};
use crate::decode_hex_32;
#[derive(Debug)]
pub enum RumorError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// Neither a delete nor a timer notice may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for RumorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RumorError::Stream(error) => write!(f, "stream: {error}"),
RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"),
RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"),
RumorError::MissingTag(name) => write!(f, "missing tag: {name}"),
RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"),
RumorError::BadTag(name) => write!(f, "malformed tag: {name}"),
RumorError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
impl std::error::Error for RumorError {}
impl From<StreamError> for RumorError {
fn from(error: StreamError) -> Self {
RumorError::Stream(error)
}
}
pub fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, RumorError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(RumorError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
pub fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], RumorError> {
tag(rumor, name)?.ok_or(RumorError::MissingTag(name))
}
pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> {
fields
.get(1)
.map(String::as_str)
.ok_or(RumorError::BadTag(name))
}
pub fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, RumorError> {
let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name))
}
pub fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, RumorError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(RumorError::BadTag(TAG_CITATION))
}
+86 -9
View File
@@ -23,7 +23,8 @@ const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k";
const STATE_PREFIX: &str = "concord/";
/// The `concord/` namespace for locally-keyed documents.
pub const STATE_PREFIX: &str = "concord/";
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor(
@@ -91,7 +92,7 @@ pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp)
}
pub async fn query_rumors(
database: &dyn NostrDatabase,
client: &Client,
channel: &ChannelId,
until: Option<Timestamp>,
limit: usize,
@@ -106,7 +107,7 @@ pub async fn query_rumors(
}
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
for event in database.query(filter).await? {
for event in client.database().query(filter).await? {
let Some(rumor_id) = event.tags.identifier() else {
continue;
};
@@ -297,21 +298,54 @@ pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
Ok(())
}
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
where
D: NostrDatabase + ?Sized,
{
pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id))
.limit(1);
match database.query(filter).await?.into_iter().next() {
match client.database().query(filter).await?.into_iter().next() {
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None),
}
}
/// The newest state document per community carried in the local database.
pub async fn load_states(client: &Client) -> Result<Vec<CommunityState>> {
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
for event in client.database().query(filter).await? {
let Some(id) = state_document_of(&event) else {
continue;
};
match newest.get(&id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(id, event);
}
}
}
let mut states = Vec::with_capacity(newest.len());
for event in newest.into_values() {
match serde_json::from_str::<CommunityState>(&event.content) {
Ok(state) => states.push(state),
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
}
}
Ok(states)
}
fn state_document_of(event: &Event) -> Option<CommunityId> {
let identifier = event.tags.identifier()?;
let hex = identifier.strip_prefix(STATE_PREFIX)?;
hex.parse().ok()
}
pub async fn backfill(
client: &Client,
channel: &ChannelId,
@@ -461,7 +495,11 @@ mod tests {
at_ms,
None,
);
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
relay.insert(
smol::block_on(seal_rumor(&rumor, &group, &author, false))
.expect("seals")
.0,
);
}
let mut seen = BTreeSet::new();
@@ -488,4 +526,43 @@ mod tests {
["after the rekey", "still before", "before the rekey"]
);
}
#[test]
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
smol::block_on(async {
let client = ClientBuilder::default()
.database(nostr_memory::MemoryDatabase::unbounded())
.build();
let state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
owner: Keys::generate().public_key(),
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::new(),
channels: Vec::new(),
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
dissolved: false,
added_at_ms: 7,
};
save_state(&client, &state).await.expect("saves");
// A cached rumor is also an application-specific document, but not a
// state document, so the prefix keeps it out of the state scan.
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
.tags([Tag::identifier("deadbeef")])
.finalize(&*LOCAL_KEYS)
.expect("builds");
client.database().save_event(&other).await.expect("saves");
let loaded = load_states(&client).await.expect("loads");
assert_eq!(loaded, vec![state]);
});
}
}
+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;
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);
}
}
}
+1
View File
@@ -12,6 +12,7 @@ state = { path = "../state" }
device = { path = "../device" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
community = { path = "../community" }
settings = { path = "../settings" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
+74 -10
View File
@@ -5,9 +5,10 @@ use std::rc::Rc;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::TimestampExt;
use community::{CommunityEvent, CommunityMetadata, CommunityRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
};
@@ -20,12 +21,13 @@ use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::input::{Input, InputState};
use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem};
use ui::nav_item::NavItem;
use ui::scroll::Scrollbar;
use ui::{
Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers,
v_flex,
Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex,
title_bar_drag_handlers, v_flex,
};
use crate::Command;
@@ -34,7 +36,7 @@ mod entry;
mod tree;
pub(crate) use entry::RoomEntry;
use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities};
use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection};
/// Sidebar.
pub struct Sidebar {
@@ -58,6 +60,7 @@ impl Sidebar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let settings = AppSettings::global(cx).read(cx).entity().clone();
let chat = ChatRegistry::global(cx);
let communities = CommunityRegistry::global(cx);
let mut subscriptions = smallvec![];
@@ -74,6 +77,14 @@ impl Sidebar {
this.restore_state(cx);
}));
subscriptions.push(
cx.subscribe(&communities, |_this, _communities, event, _cx| {
if let CommunityEvent::Error(error) = event {
log::error!("community: {error}");
}
}),
);
Self {
focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(),
@@ -145,6 +156,36 @@ impl Sidebar {
self.pinned_rooms.contains(&room_id)
}
fn new_community(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name"));
window.open_modal(cx, move |this, _window, _cx| {
let name_input = name_input.clone();
this.width(px(380.))
.confirm()
.title("New community")
.child(Input::new(&name_input))
.on_ok(move |_event, _window, cx| {
let name = name_input.read(cx).value().trim().to_owned();
if name.is_empty() {
return false;
}
let metadata = CommunityMetadata {
name,
..CommunityMetadata::default()
};
CommunityRegistry::global(cx)
.update(cx, |registry, cx| registry.create(metadata, cx));
true
})
});
}
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow> {
let chat = ChatRegistry::global(cx);
let chat = chat.read(cx);
@@ -197,7 +238,9 @@ impl Sidebar {
}
}
let communities = dummy_communities();
let registry = CommunityRegistry::global(cx);
let communities = registry.read(cx).communities();
rows.push(SidebarRow::Section {
section: TreeSection::Community,
count: communities.len(),
@@ -213,9 +256,15 @@ impl Sidebar {
rows.extend(
communities
.iter()
.map(|entry| SidebarRow::Community { entry, depth: 1 }),
.cloned()
.map(|community| SidebarRow::Community {
community,
depth: 1,
}),
);
}
rows.push(SidebarRow::NewCommunity { depth: 1 });
}
let messages = chat.rooms(&RoomKind::Ongoing, cx);
@@ -345,13 +394,28 @@ impl Sidebar {
)
.into_any_element()
}
SidebarRow::Community { entry, depth } => TreeRow::new(
SidebarRow::Community { community, depth } => {
let community = community.read(cx);
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
community.name(),
)
.depth(*depth)
.avatar(community.id().to_hex())
.into_any_element()
}
SidebarRow::NewCommunity { depth } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
entry.name,
TreeRowKind::Hint,
"New community",
)
.depth(*depth)
.avatar(entry.name)
.icon(IconName::Plus)
.on_click(cx.listener(|this, _event, window, cx| {
this.new_community(window, cx);
}))
.into_any_element(),
SidebarRow::Hint { text, depth } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
+5 -17
View File
@@ -1,6 +1,7 @@
use std::rc::Rc;
use chat::Room;
use community::Community;
use gpui::prelude::FluentBuilder;
use gpui::{
App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
@@ -66,7 +67,10 @@ pub enum SidebarRow {
pinned: bool,
},
Community {
entry: &'static CommunityEntry,
community: Entity<Community>,
depth: u8,
},
NewCommunity {
depth: u8,
},
Hint {
@@ -75,22 +79,6 @@ pub enum SidebarRow {
},
}
pub struct CommunityEntry {
pub name: &'static str,
}
pub fn dummy_communities() -> &'static [CommunityEntry] {
// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md.
&[
CommunityEntry {
name: "Coop Contributors",
},
CommunityEntry {
name: "Nostr Design",
},
]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TreeRowKind {
Section,
+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 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.
+358
View File
@@ -0,0 +1,358 @@
# Concord backend audit and simplification plan
Audit of `crates/concord`, triggered by `CommunityRegistry` never reaching
`subscribe`: `sync::load` found zero community state documents. Tracing that
surfaced two separate things: the app only uses a fraction of the crate, and the
crate's writers take a concrete `nostr::Keys`, which the app's signer can never
produce.
Sizes: ~10,500 lines total — ~6,750 production, ~3,750 tests.
## Decisions taken
- **D1 — Keep the unwired protocol surface.** `cord05`/`cord06`/`pins`/paging
stay in the tree for future use. No mass deletion. (Findings are recorded in
§4 for reference only.)
- **D2 — Replace `&Keys` with a signer boundary** for account-key operations.
Verified feasible against the pinned SDK; design in §2.
---
## 1. `&Keys` cannot be replaced by a public key — but it can be replaced by a signer
The original question was whether functions like `genesis` only need
`signer.get_public_key_async()`. They do not: they sign.
- `cord02::genesis` (`cords/cord02/mod.rs:117`) → `seal_edition` (`:711`) →
`build_seal` (`cord01.rs:217`), which signs the seal (`.finalize(author)`,
`cord01.rs:226`), and `wrap_seal_with` (`:247`), which signs the wrap.
- Self-addressed documents use NIP-44 to self: `seal_to_self`
(`cord01.rs:201`) derives a conversation key from `keys.secret_key()`.
A public key can produce neither a Schnorr signature nor an ECDH key, so
"public-key-only" is impossible. The real defect is the **concrete type**: the
app holds `state::UniversalSigner` (async, possibly NIP-46), and a `nostr::Keys`
can never be conjured from it. `docs/concord-usage.md:535-536` already records
this as a deliberate migration pass.
### What the pinned SDK actually provides
Pinned rev `b230cec` (`nostr` 0.45.4 / `nostr-sdk` 0.45.2):
- There is **no `NostrSigner` trait in this revision.** The async signer surface
is three traits, all in the `nostr` crate:
- `AsyncGetPublicKey` — `nostr/src/key/public_key.rs:39`
- `AsyncSignEvent` — `nostr/src/event/mod.rs:366`
- `AsyncNip44` — `nostr/src/nips/nip44/traits.rs:30`
- `Keys` implements all three (`nostr/src/key/mod.rs:298,309,342`), so tests and
local key holders keep working.
- `UniversalSigner` already implements all three with
`Error = UniversalSignerError` (`crates/state/src/signer.rs:148-191`).
- SDK helpers accept them:
- `EventBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized`
(`nostr/src/event/builder.rs:171-193`)
- `GiftWrapBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44`
(`nostr/src/nips/nip59.rs:334-355`)
- `UnwrappedGift::from_gift_wrap_async` — `T: AsyncNip44` (`nip59.rs:84-90`)
So the answer is yes: pass a signer. `UniversalSigner` works as-is.
### Per-function bounds, not a bundle
Each function should request only the capabilities it uses. The SDK itself is
designed this way (`UnsignedEvent::finalize_async` takes only `AsyncSignEvent`,
`EventBuilder::finalize_async` takes `AsyncGetPublicKey + AsyncSignEvent`,
NIP-59 takes all three).
| Operation | Bounds |
| --- | --- |
| Sign a seal/edition/rekey wrap, author already known | `AsyncSignEvent` |
| Build an event where the author comes from the signer | `AsyncGetPublicKey + AsyncSignEvent` |
| To-self documents (Community List, Invite List) | `AsyncGetPublicKey + AsyncNip44`, plus `AsyncSignEvent` when the document is itself an event |
| Decrypt-only (`parse_list_event`, `unwrap_direct_invite`) | `AsyncNip44` |
| Rekey blob encrypt (`build_blob`) | `AsyncGetPublicKey + AsyncNip44` (no signing) |
| Rekey blob open (`open_blob`) | `AsyncNip44` |
| Direct invite build (`GiftWrapBuilder`) | all three |
Use generics (`S: AsyncSignEvent + ?Sized`), never `&dyn`: the traits carry
associated `Error` types, so `dyn AsyncSignEvent` would force the concrete error
at every call site (`dyn AsyncSignEvent<Error = UniversalSignerError>`),
defeating the abstraction. The SDK uses generics throughout for this reason.
Do **not** define a supertrait bundle
`trait Signer: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 {}`: all three
supertraits declare an associated `Error`, so `Self::Error` becomes ambiguous,
and the bundle forces NIP-44 onto purely-signing callers (and vice versa).
Inside concord, replace `builder.finalize(&keys)` with
`builder.finalize_async(signer).await`. `finalize_async` fetches the signer's
public key and uses it as the event author, exactly as `finalize` did, so the
bytes are unchanged for every caller that passes a matching signer.
We deliberately **do not** pre-check the author against `rumor.pubkey` inside
`build_seal`. The seal's author is the signer's own public key, matching the old
`finalize` semantics; a signer that does not match the rumor is still caught by
`open_wrap_at` as `AuthorMismatch` (`cord01.rs:328`). Pre-checking would also
make it impossible to construct the hostile seals the cord suite relies on as
test vectors (`cord01.rs` `hostile_wraps_are_dropped_in_order`).
If the repeated `<S as ...>::Error: Error + Send + Sync + 'static` bounds
become too noisy, the only stable-Rust way to shorten them is an owned
error-erased trait (as the app already does with
`crates/state/src/signer.rs:64-138`). That trades precision for brevity; keep
per-function bounds unless the noise proves unmanageable.
### What must NOT go through the signer
- **Group-key NIP-44.** `cord01::{seal_bytes, open_bytes, wrap_seal,
wrap_seal_with, rewrap_seal}` encrypt under a `ConversationKey` derived from
HKDF group secrets. `AsyncNip44` can only ECDH against a public key, so group
encryption stays on `ConversationKey` / `GroupKey::keys()`.
- **Wrap signatures.** Wraps are signed by the derived group signer key
(`GroupKey::keys()`), not the account.
- **Locally held raw secrets.** `cord05::{build_bundle_event, build_revocation}`
take a generated `link_signer` whose secret the app stores as
`signer_sk` (`docs/concord-usage.md:306-321`). `&Keys` is correct there; the
app has the secret itself.
- **Local database artifacts.** `store::{cache_rumor, save_state}` sign with the
internal random `LOCAL_KEYS` (`store.rs:18`). No user signer involved.
### Call-site inventory
Account-key sites to migrate:
| Site | Today | After | Bounds |
| --- | --- | --- | --- |
| `cord02::genesis` (`cord02/mod.rs:117`) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `ControlWriter::{publish, set_*}` (`cord02/mod.rs:214-425`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `seal_edition` (`cord02/mod.rs:711`, internal) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord01::build_seal` (`cord01.rs:217`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord01::{seal_to_self, open_to_self}` (`:201,209`) | `keys: &Keys` | `&S`, async | `AsyncGetPublicKey + AsyncNip44` |
| `guestbook::seal_rumor` (`guestbook.rs:186`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three |
| `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` |
| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | **done** | build: all three (`Sized`); unwrap: `AsyncNip44` (`Sized`) |
| `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` |
| `cord06::build_blob` (`:302`) | `rotator: &Keys` | **done** | `AsyncGetPublicKey + AsyncNip44` |
| `cord06::open_blob` (`:319`) | `recipient: &Keys` | **done** | `AsyncNip44` |
| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` |
Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all
`cord01` wrap functions, `GroupKey::keys()`, `store::LOCAL_KEYS`.
### Async ripple and tests
Every migrated function becomes `async`. `smol` is already a dev-dependency of
concord (`crates/concord/Cargo.toml:21-23`), so affected `#[test]`s become
`smol::block_on(...)` wrappers. The app's call sites are already async
background tasks.
### Known constraints
- `GiftWrapBuilder::finalize_async` and `UnwrappedGift::from_gift_wrap_async`
are generic over `S: Sized` (no `?Sized`), so those functions must stay
generic, never `&dyn`.
- Every converted `S::Error` must be `Error + Send + Sync + 'static` for the
SDK helpers' `Error::other` (`nostr/src/error.rs:100-105`) and for
`anyhow`; `Keys::AsyncGetPublicKey::Error = Infallible`,
`Keys::AsyncSignEvent::Error = nostr::Error`, `UniversalSignerError`
(`crates/state/src/signer.rs:10-32`) all qualify.
- `AsyncGetPublicKey` is worth requiring alongside `AsyncSignEvent` wherever the
author is embedded in the payload: `sign_event` signs the id of the given
unsigned event without rewriting its pubkey, so a mismatched signer is only
caught later by signature verification.
---
## 2. Migration plan
### Phase 1 — replace `&Keys` with per-function signer bounds (no behavior change) — DONE
1. No new module: change the signatures listed in the inventory table to
generics over the SDK traits (`S: AsyncGetPublicKey + AsyncSignEvent`,
`S: AsyncSignEvent`, `S: AsyncGetPublicKey + AsyncNip44`, or `S: AsyncNip44`).
2. Migrate the live path only: `cord01::build_seal`, `cord01::{seal_to_self,
open_to_self}`, `seal_edition`, `genesis`, `ControlWriter`, `guestbook::
seal_rumor`, `cord03::seal_rumor`, `list::{build,parse}_list_event`.
3. Update `docs/concord-usage.md` examples to take a signer.
4. Update concord tests to `smol::block_on`; `&Keys` keeps working because it
implements all three traits.
Validation: `cargo test -p concord` — 46 passed, 0 failed. The one behavior
change from the plan sketch is the dropped up-front author check in §1.
`cord01::{seal_to_self, open_to_self}` now take `&str` and return `String`
(NIP-44 is UTF-8 text), so the `list` and invite-list callers read the plaintext
with `serde_json::from_str`.
**Unplanned but forced:** `cord01::{build_seal, seal_to_self, open_to_self}` are
shared helpers, so the unwired callers had to be migrated in the same pass to
keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and
`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part).
`cord05::{build_direct_invite, unwrap_direct_invite}` and
`cord06::{build_blob, open_blob}` were untouched by Phase 1 — they use the NIP-59
and group-key paths, not the migrated helpers — and were migrated in Phase 3.
### Phase 2 — app uses the signer — DONE
1. `sync::create(client, signer, metadata)` (`crates/community/src/sync.rs`) runs
`cord02::genesis`, opens the genesis editions, persists the state with
`store::save_state`, and also stores the genesis wraps so the control plane
folds locally. It is generic over `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized`
(the bounds `genesis` needs and no more, per D2); the app passes its
`UniversalSigner`, so no secret material is exposed and NIP-46 accounts work
too.
`CommunityRegistry::create(metadata, cx)` (`crates/community/src/lib.rs`) is
the GPUI wrapper: it refuses when no account is signed in, otherwise runs the
task off-thread and refreshes tracking, so `sync::load` now returns one state
and `subscribe` finally fires.
2. The app-side reimplementation of `list::parse_list_event`
(`crates/community/src/sync.rs:154-156`) is deleted; `load_list` calls the
real `cord02::list::parse_list_event`.
3. Relays in the metadata are persisted but the genesis is **not** published yet;
`create` is local-only. Wiring genesis/broadcast through the relay pool is the
next app step, not part of this phase.
Validation: `cargo test -p community` (1 passed), `cargo test -p concord`
(46 passed), `cargo clippy -p community --all-targets`, `cargo fmt -p community
--check`, and `cargo check --workspace --all-targets` are all clean.
**Deviation from the plan sketch:** the planned "drive `CommunityRegistry`" test
is instead a `sync`-layer test, `sync::tests::
creating_a_community_persists_a_state_that_subscribes_and_folds`. A GPUI-level
test cannot construct a `NostrRegistry` — it opens LMDB at `config_dir()` and
connects bootstrap relays in `NostrRegistry::new`, which is private and not
injectable — so the test drives a `Client` on an in-memory database
(`nostr-memory`, already a dev-dependency) directly. It asserts the whole
contract the registry depends on: `create` persists a state `load` returns, the
subscription filter addresses the genesis wraps, `fold` yields the created
community, and an inbound control edit folds over it.
### Phase 3 — migrate the remaining unwired writers — DONE
`cord05::{build_direct_invite, unwrap_direct_invite}` and
`cord06::{build_blob, open_blob}` now take a signer. The NIP-59 pair keeps a
`Sized` `S` (`AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` to build,
`AsyncNip44` to unwrap) because the SDK's `GiftWrapBuilder::finalize_async` and
`UnwrappedGift::from_gift_wrap_async` are `Sized`-bounded. The blob pair is
`AsyncGetPublicKey + AsyncNip44` to build and `AsyncNip44` to open, with `?Sized`.
The blobs forced one behavior change, because a signer's NIP-44 is text-only
(`nip44_encrypt_async(public_key, &str)`) while the blob plaintext is a
fixed-width binary record. `build_blob` now carries that record base64-encoded
inside the NIP-44 envelope and `open_blob` decodes it again. The record layout,
the `locator`, and the envelope are unchanged; only the bytes inside the envelope
differ. There are no golden vectors for blobs and no producer or consumer other
than these two functions, so the round-trip stays self-consistent; cord06 remains
unwired and persists nothing.
Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob
`a_full_send_chunk_stays_within_a_relay_event` size assertion still holds under
the base64 record). `cargo clippy -p concord --all-targets` and
`cargo fmt -p concord --check` are clean.
### Phase 4 — duplication and hygiene (independent, low risk) — DONE
1. DONE — `store::load_states(client)` added (with a direct `store` test), the
app-side state-document scan in `sync::load` is gone.
2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are
gone, and subscription ids reuse the exported prefix.
3. DONE — the shared rumor tag readers and error live in a new `cords::rumor`
module (`RumorError`, `tag`, `required`, `value`, `pubkey`,
`optional_citation`), re-exported as `cord03::ChatError` and
`cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower
`RekeyError`, which the plan scoped out.
4. RETAINED — none of the "never-varied parameters" were removed. Each is
load-bearing for a flow the fold or a writer already implements (D1):
- `complete_memberlist`'s `banned_at` is read by the fold and is exercised
with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`;
`docs/concord-usage.md` already promises to fill it once the banlist head's
timestamp is plumbed through.
- `cache_rumor -> Result<bool>` is read by `backfill` to drop expired rumors.
- `coalesce`'s `snapshot_authority` gates which snapshots apply; passing
`None` today is a policy, not a dead parameter.
- `seal_rumor(ephemeral)` and the `until` cursors on `backfill`/`query_rumors`
select protocol modes and paging.
5. DONE — tightened `cord04` visibility: `edition_hash`, `fold`, `FoldResult`,
`bootstrap_head`, `parse_banlist`, `Role::parse` and `Grant::parse` are no
longer `pub`. `HeadSelection` stays `pub` because the public `fold_head`
returns it.
6. DONE — doc drift fixed: the store takes `&Client` throughout (including
`load_state`/`load_states`/`query_rumors`, not just the writers), `backfill`
arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and
registry names, and the "Not wired up yet" registry bullet.
### Phase 5 — sidebar calls `create` — DONE
The last blocker was that nothing invoked `CommunityRegistry::create`; the
running app logged `community load: 0 state document(s) found` and `subscribe`
never ran. The sidebar now:
1. Renders `CommunityRegistry::communities()` instead of the hardcoded
`dummy_communities()`. `SidebarRow::Community` carries an `Entity<Community>`,
labelled with `Community::name()` (control-fold metadata, falling back to the
community id until the first fold).
2. Adds a "New community" row to the Community section that opens a name prompt
and calls `CommunityRegistry::create` with default metadata. Relays stay empty,
so the subscription resolves through `ReqTarget::auto` against the pool's
relays rather than a manual target that `add_relay` might not have connected.
3. Observes the registry, so a `track` or fold re-render reaches the list, and
subscribes to `CommunityEvent::Error`, which is now logged
(`log::error!("community: {error}")`) instead of vanishing. A `cx.notify()` in
the registry's per-community observer propagates the fold that fills in the
name.
Validation: `cargo check -p workspace -p community --all-targets`,
`cargo test -p community` (1 passed), `cargo clippy -p workspace -p community
--all-targets`, and `cargo fmt -p workspace -p community --check` are clean.
Still local-only: the genesis is persisted but not published to relays, so a
second account cannot discover the community yet.
---
## 3. Retained-by-decision surface (reference only)
Per D1 these stay, but they should be understood as unwired, not live:
| Module | Approx. prod LOC | App use |
| --- | --- | --- |
| `cord06` rotation/refounding/dissolution | ~850 | none |
| `cord05` invites/links/direct/list | ~650 | none (types only, via unused `list::join_material`) |
| `cord04::pins` | ~550 | none |
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
| `store` paging / purge / query / load_state(s) | ~180 | `cache_rumor`, `save_state`, `load_states` |
Truly unreferenced even by tests (safe candidates, but kept per D1):
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
`CommunityRoles::{roles, is_empty}`.
---
## 4. Non-goals
- No mass deletion of unwired modules (D1).
- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01`
envelope semantics. The one exception Phase 3 forced is the blob plaintext
encoding (base64 inside the envelope, see Phase 3); the blob record layout and
`locator` are untouched.
- No group-key encryption through the signer.
- Tests move only alongside the code they cover.
## 5. Validation
- `cargo test -p concord` after each phase; `cargo test --workspace` before
landing.
- Phase 1 is behavior-preserving: the existing cord test suite is the oracle.
- Phase 2 adds the app-level test: seed a `CommunityState` via
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
and an inbound wrap folds into the community.
- Phase 4: `cargo test -p concord -p community` (47 + 1 passed),
`cargo clippy -p concord -p community --all-targets`, and
`cargo fmt -p concord -p community --check` are all clean.
## 6. Immediate unblock
Option 2 (the clean path, using `UniversalSigner`) landed in Phase 2. Option 1
(exposing the local `Keys` from `crates/state/src/lib.rs:254`) is obsolete.
+106 -49
View File
@@ -47,7 +47,7 @@ use concord::cord02::{self, CommunityMetadata};
use concord::store::{self, CommunityState, save_state};
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
let minted = cord02::genesis(&owner_keys, &metadata, now_secs)?;
let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?;
// minted.identity — community_id, owner, owner_salt (verify() recomputes it)
// minted.wraps — the two owner-signed genesis editions, already sealed
@@ -73,7 +73,7 @@ let editions: Vec<ParsedEdition> = minted
.collect::<Result<_, _>>()?;
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
save_state(database, &state).await?;
save_state(&client, &state).await?;
```
Put the community's relay list into `state.relays` and add those relays to the
@@ -98,7 +98,7 @@ let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite
A Direct Invite arrives as a NIP-59 gift wrap addressed to the member:
```rust
let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys)?;
let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys).await?;
```
Either way the invite carries `community_id`, `owner`, `owner_salt`,
@@ -114,7 +114,7 @@ use concord::cord02::guestbook;
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?;
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys).await?;
client.send_event(&wrap).to(&relays).await?;
```
@@ -159,7 +159,7 @@ use concord::derive::channel_group_key;
let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel
let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer);
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false)?;
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false).await?;
client.send_event(&wrap).to(&relays).await?;
```
@@ -192,7 +192,7 @@ for wrap in &wraps {
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
continue;
};
store::cache_rumor(database, &channel, &opened).await?;
store::cache_rumor(&client, &channel, &opened).await?;
rumors.push(rumor);
}
@@ -209,13 +209,13 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
Relay history pages through the local cache:
```rust
let page = store::backfill(client, database, &channel, &held, until, 50).await?;
let cached = store::query_rumors(database, &channel, None, 50).await?;
let page = store::backfill(client, &channel, &held, until, 50).await?;
let cached = store::query_rumors(&client, &channel, None, 50).await?;
```
`backfill` walks newest-first across every held epoch, caches what it opens, and
stops on a short page. `query_rumors` is the read path when the group keys are
gone. Run `store::purge_expired(database, &channel, now)` on the same cadence as
gone. Run `store::purge_expired(client, &channel, now)` on the same cadence as
any other local sweep — the timer is cooperative, so the local store is the
artifact that has to forget.
@@ -253,7 +253,7 @@ let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.c
let head = control.floors.get(entity).cloned();
let (wrap, new_head) = writer.set_community_metadata(
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs)?;
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs).await?;
```
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it
@@ -275,7 +275,7 @@ let head_content = control.pin_content(&community_id, &channel).unwrap_or("");
let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok());
let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
let (wrap, _) = writer.set_pin_list(
&my_keys, &community_id, &channel, &content, head, citation, now_secs)?;
&my_keys, &community_id, &channel, &content, head, citation, now_secs).await?;
```
Reading is verification: `read_list` decodes either content form (public, or
@@ -308,7 +308,7 @@ keep it against the token in the member's own Invite List — a local document
encrypted to self, exactly like the Community List:
```rust
let mut list = cord05::parse_invite_list(&my_keys, &event)?;
let mut list = cord05::parse_invite_list(&my_keys, &event).await?;
list.entries.push(InviteEntry {
token: HEXLOWER.encode(&token),
signer_sk: link_signer.secret_key().to_secret_hex(),
@@ -319,7 +319,7 @@ list.entries.push(InviteEntry {
expires_at: None,
extra: Default::default(),
});
let event = cord05::build_invite_list(&my_keys, &list)?; // kind 13303
let event = cord05::build_invite_list(&my_keys, &list).await?; // kind 13303
// Retiring is a tombstone, never a deletion: it beats a stale copy terminally.
list.tombstones.push(InviteTombstone {
@@ -356,12 +356,16 @@ let (control_pk, control_root) = match scope {
RekeyScope::Channel(_) => (None, None),
};
let blobs = members
.iter()
.map(|member| {
cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root)
})
.collect::<Result<Vec<_>, _>>()?;
let mut blobs = Vec::with_capacity(members.len());
for member in &members {
blobs.push(
cord06::build_blob(
&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root,
)
.await?,
);
}
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
let wraps = cord06::build_rekey_chunks(
@@ -375,7 +379,8 @@ let wraps = cord06::build_rekey_chunks(
citation,
false,
now_secs,
)?;
)
.await?;
```
On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then
@@ -385,11 +390,15 @@ member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the k
only if the plaintext binds to the scope and epoch they expect and its `prevcommit`
matches the key they already hold. Two concurrent rotations settle on `fork_winner`.
The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is
text-only, so `build_blob` carries it base64-encoded inside the envelope.
`open_blob` mirrors that, so the record layout and the `locator` are unchanged.
Dissolution is owner-only and terminal:
```rust
let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs);
let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?;
let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs).await?;
// A receiver seals the community read-only on sight.
if cord06::verify_dissolved(&wrap, &identity) {
@@ -404,22 +413,52 @@ 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)?;
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)?; // 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
`crates/concord` stays GPUI-free. The UI layer adds a registry global and one
`crates/concord` stays GPUI-free; the registry and sync engine live in
`crates/community`. That layer adds a registry global and one
entity per community, and moves every decrypt, verification, fold and I/O off
the foreground thread.
@@ -428,13 +467,13 @@ the foreground thread.
Same shape as `ChatRegistry`:
```rust
pub fn init(window: &mut Window, cx: &mut App) {
ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx);
pub fn init(cx: &mut App) {
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
}
impl ConcordRegistry {
impl CommunityRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalConcordRegistry>().0.clone()
cx.global::<GlobalCommunityRegistry>().0.clone()
}
}
```
@@ -443,8 +482,8 @@ Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and
subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with
the account.
- `ConcordRegistry` holds `communities: Vec<Entity<Community>>`, an index by
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`.
- `CommunityRegistry` holds `communities: Vec<Entity<Community>>`, an index by
`CommunityId`, and `tasks: SmallVec<[Task<Result<()>>; 2]>`.
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
and the channel list. Views render `Entity<Community>`; no protocol state
lives in a view.
@@ -459,7 +498,7 @@ A background task never touches an entity. It sends results through a bounded
```rust
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
let database = client.database().clone();
let client = client.clone();
// Background: open, verify, fold — no entities.
self.ingress = Some(cx.background_spawn(async move {
@@ -468,7 +507,7 @@ self.ingress = Some(cx.background_spawn(async move {
continue;
};
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?;
store::cache_rumor(&client, &plane.channel, &opened).await?;
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
}
Ok(())
@@ -483,8 +522,8 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
}));
```
- `client.database()` is a `&Arc<dyn NostrDatabase>` and `store::save_state`
wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`.
- Every store function takes the `&Client` and reaches the database through
`client.database()`, so clone the `Client` into the background task.
- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None`
to an `Option<Task<_>>` before respawning it; a signer change replaces both
the listener and the consumer.
@@ -493,8 +532,9 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
- Do the first load in `cx.defer_in(window, ...)` so `init` returns before the
first relay request.
- NIP-46 signing is async: call `signer.get_public_key_async()` /
`sign_event_async` inside the background task. The builders still take
`&Keys`, so run them where device keys are available.
`sign_event_async` inside the background task. Every account-key writer takes
any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await`
it there rather than requiring device keys.
### Subscriptions
@@ -527,13 +567,30 @@ client.subscribe(filter).with_id(sub_id).await?;
## Not wired up yet
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
the plane whose address it carries, and rebuilding a subscription when a plane's
address changes (join, channel added, rekey folded). GPUI integration above is
the shape to build, not code that exists.
- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate
pass over the builders, not a per-call patch.
- **`crates/concord` stays protocol-only; the registry lives in
`crates/community`.** `concord` has no subscriptions, no `init`, and no
`Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
per state document, subscribes when a community's plane set changes, and
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. 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`,
`build_invite_list` / `parse_invite_list`) and `cord06` blob writers
(`build_blob` / `open_blob`) are `async` and generic over the SDK's
`AsyncGetPublicKey` / `AsyncSignEvent` / `AsyncNip44` traits, so a `Keys` and an
app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`,
`unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are.
Group-key and locally-held-secret writers (`cord01` wrap functions,
`cord05::build_bundle_event`, `store`) still take the raw key material they
genuinely need.
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
that handler must route by subscription id before any concord subscription goes