restructure

This commit is contained in:
2026-09-17 12:54:29 +07:00
parent fdabfcf026
commit 621a0a27c1
17 changed files with 327 additions and 297 deletions
@@ -6,13 +6,13 @@ use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
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,
};
use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306;
@@ -532,8 +532,8 @@ fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
#[cfg(test)]
mod tests {
use super::*;
use crate::cord01::build_rumor_secs;
use crate::derive::guestbook_group_key;
use crate::stream::build_rumor_secs;
use crate::{CommunityId, Epoch};
const ROOT: [u8; 32] = [0x5au8; 32];
@@ -5,8 +5,8 @@ use std::fmt;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::invite::{ChannelGrant, CommunityInvite};
use crate::stream::{self, NIP44_MAX_PLAINTEXT};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
use crate::cord05::{ChannelGrant, CommunityInvite};
use crate::{CommunityId, Epoch, Extra};
pub const KIND_COMMUNITY_LIST: u16 = 13302;
@@ -42,8 +42,8 @@ impl fmt::Display for ListError {
impl std::error::Error for ListError {}
impl From<stream::StreamError> for ListError {
fn from(error: stream::StreamError) -> Self {
impl From<cord01::StreamError> for ListError {
fn from(error: cord01::StreamError) -> Self {
ListError::Crypto(error.to_string())
}
}
@@ -187,7 +187,7 @@ pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, List
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = stream::seal_to_self(keys, json.as_bytes())?;
let content = cord01::seal_to_self(keys, json.as_bytes())?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize(keys)
@@ -199,7 +199,7 @@ pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, Lis
return Err(ListError::Kind(event.kind.as_u16()));
}
let json = stream::open_to_self(keys, &event.content)?;
let json = cord01::open_to_self(keys, &event.content)?;
serde_json::from_slice(&json).map_err(json_error)
}
@@ -1,22 +1,25 @@
pub mod guestbook;
pub mod list;
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
use crate::cord04::roles::{
AuthorityEdition, CommunityRoles, Grant, MAX_BANLIST, Permissions, Role, Roster, citation_ok,
fold_roster,
};
use crate::cord04::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
build_edition, fold_head, parse_edition, vsk,
};
use crate::derive::{
banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator,
invite_links_locator, pins_locator, verify_community_id,
};
use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
build_edition, fold_head, parse_edition, vsk,
};
use crate::roles::{
AuthorityEdition, CommunityRoles, Grant, MAX_BANLIST, Permissions, Role, Roster, citation_ok,
fold_roster,
};
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
pub const MAX_NAME_BYTES: usize = 64;
@@ -731,12 +734,13 @@ mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::chat::{self, build_message, seal_rumor};
use crate::cord03::{self, build_message, seal_rumor};
use crate::cord04::fold;
use crate::cord04::pins;
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
use crate::derive::{channel_group_key, grant_locator};
use crate::edition::fold;
use crate::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
use crate::store::{CommunityState, load_state, save_state};
use crate::{Extra, RoleId, pins};
use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000;
@@ -1102,7 +1106,7 @@ mod tests {
None,
);
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals");
let opened = chat::open(&wrap, &group, &channel, ROOT_EPOCH)
let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH)
.expect("opens")
.0;
let entry = pins::build_entry(&opened, &group, &channel).expect("pins");
@@ -5,15 +5,15 @@ use std::fmt;
use anyhow::Result;
use nostr_sdk::prelude::*;
use crate::derive::channel_group_key;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
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,
};
use crate::derive::channel_group_key;
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
pub const KIND_MESSAGE: u16 = 9;
@@ -1,3 +1,6 @@
pub mod pins;
pub mod roles;
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::fmt;
@@ -7,8 +10,8 @@ use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::cord01::build_rumor_secs;
use crate::decode_hex_32;
use crate::stream::build_rumor_secs;
pub const KIND_CONTROL: u16 = 3308;
@@ -9,9 +9,9 @@ use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::chat::{ChatAction, ChatRumor, KIND_COMMENT, KIND_EDIT, KIND_MESSAGE};
use crate::edition::canonical_decimal;
use crate::stream::{self, OpenedStream, SealForm, resolve_ms_strict};
use crate::cord01::{self, OpenedStream, SealForm, resolve_ms_strict};
use crate::cord03::{ChatAction, ChatRumor, KIND_COMMENT, KIND_EDIT, KIND_MESSAGE};
use crate::cord04::canonical_decimal;
use crate::{ChannelId, Epoch, Extra, GroupKey, decode_hex_lower};
pub const PIN_MAX_ENTRIES: usize = 25;
@@ -315,7 +315,7 @@ fn disclosed_keys(opened: &OpenedStream, group: &GroupKey) -> Result<MessageKeys
pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin> {
let seal = &entry.seal;
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
if seal.kind.as_u16() != cord01::KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
return None;
}
@@ -376,7 +376,7 @@ fn verify_edit_bundle(
let seal = &bundle.seal;
// Checkable before any crypto: nobody else may revise another member's words.
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
if seal.kind.as_u16() != cord01::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
return None;
}
@@ -456,7 +456,7 @@ fn serialize_sealed(
}
let inner = encode_form(entries)?;
let sealed = stream::seal_bytes(group.conversation(), inner.as_bytes())
let sealed = cord01::seal_bytes(group.conversation(), inner.as_bytes())
.map_err(|error| PinError::Seal(error.to_string()))?;
let content = serde_json::json!({ "epoch": epoch.to_string(), "sealed": sealed }).to_string();
@@ -526,7 +526,7 @@ pub fn read_list(content: &str, unseal: impl Fn(Epoch) -> Option<GroupKey>) -> R
};
};
let Ok(inner) = stream::open_bytes(group.conversation(), sealed) else {
let Ok(inner) = cord01::open_bytes(group.conversation(), sealed) else {
return EMPTY;
};
@@ -554,7 +554,7 @@ mod tests {
use nostr::nips::nip44::v2::{self, ConversationKey};
use super::*;
use crate::chat::{ChatRumor, build_delete, build_edit, build_message, open, seal_rumor};
use crate::cord03::{ChatRumor, build_delete, build_edit, build_message, open, seal_rumor};
use crate::derive::channel_group_key;
const AT_MS: u64 = 1_700_000_000_000;
@@ -657,7 +657,7 @@ mod tests {
assert!(verify_entry(&forged, &channel()).is_none());
// A rumor carrying a claimed id that is not its own is refused.
let plaintext = stream::open_bytes(&conversation(), &opened.seal.content).expect("opens");
let plaintext = cord01::open_bytes(&conversation(), &opened.seal.content).expect("opens");
let mut value: serde_json::Value = serde_json::from_slice(&plaintext).expect("json");
value["id"] = serde_json::Value::String("00".repeat(32));
@@ -668,7 +668,7 @@ mod tests {
)
.expect("encrypts");
let content = BASE64.encode(&raw);
let seal = EventBuilder::new(Kind::Custom(stream::KIND_SEAL_ENCRYPTED), &content)
let seal = EventBuilder::new(Kind::Custom(cord01::KIND_SEAL_ENCRYPTED), &content)
.custom_created_at(opened.seal.created_at)
.finalize(&author)
.expect("signs");
@@ -4,10 +4,10 @@ use anyhow::{Result, bail};
use nostr_sdk::prelude::PublicKey;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::derive::{banlist_locator, grant_locator};
use crate::edition::{
use crate::cord04::{
AuthorityCitation, EditionMeta, EntityHead, Floors, ParsedEdition, fold_head, vsk,
};
use crate::derive::{banlist_locator, grant_locator};
use crate::{ChannelId, CommunityId, Extra, RoleId, decode_hex_32};
pub const MAX_ROLES_PER_COMMUNITY: usize = 100;
@@ -759,7 +759,7 @@ mod tests {
use nostr_sdk::prelude::Keys;
use super::*;
use crate::edition::{EditionFields, build_edition, parse_edition};
use crate::cord04::{EditionFields, build_edition, parse_edition};
const COMMUNITY: [u8; 32] = [0xc0; 32];
const AT: u64 = 1_700_000_000;
@@ -10,11 +10,11 @@ use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::control::{ImageRef, MAX_RELAYS};
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::edition::{TAG_SUBKIND, vsk};
use crate::list::{canonical, union};
use crate::stream::{self, NIP44_MAX_PLAINTEXT, StreamError};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301;
@@ -594,7 +594,7 @@ pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, Invite
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = stream::seal_to_self(keys, json.as_bytes())?;
let content = cord01::seal_to_self(keys, json.as_bytes())?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys)
@@ -606,7 +606,7 @@ pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, Invit
return Err(InviteError::Kind(event.kind.as_u16()));
}
let json = stream::open_to_self(keys, &event.content)?;
let json = cord01::open_to_self(keys, &event.content)?;
serde_json::from_slice(&json).map_err(json_error)
}
@@ -626,14 +626,14 @@ fn merge_entry(held: &InviteEntry, incoming: &InviteEntry) -> InviteEntry {
}
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
Ok(stream::seal_bytes(
Ok(cord01::seal_bytes(
&ConversationKey::new(*bundle_key),
json.as_bytes(),
)?)
}
fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
let plaintext = stream::open_bytes(&ConversationKey::new(*bundle_key), content)?;
let plaintext = cord01::open_bytes(&ConversationKey::new(*bundle_key), content)?;
String::from_utf8(plaintext).map_err(|_| InviteError::BadFragment("bundle is not utf8"))
}
@@ -941,7 +941,7 @@ mod tests {
assert!(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::chat::KIND_MESSAGE), "hello")
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
.finalize_unsigned(recipient.public_key());
let wrap = GiftWrapBuilder::new(recipient.public_key(), rumor)
.finalize(&recipient)
@@ -7,17 +7,17 @@ use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize};
use crate::control::CommunityIdentity;
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
use crate::cord02::CommunityIdentity;
use crate::cord04::roles::CommunityRoles;
use crate::cord04::{
AuthorityCitation, KIND_CONTROL, TAG_SUBKIND, canonical_decimal, citation_from, citation_tag,
vsk,
};
use crate::derive::{
base_rekey_group_key, channel_rekey_group_key, control_group_key, control_signer_group_key,
dissolved_group_key, epoch_key_commitment, recipient_locator,
};
use crate::edition::{
AuthorityCitation, KIND_CONTROL, TAG_SUBKIND, canonical_decimal, citation_from, citation_tag,
vsk,
};
use crate::roles::CommunityRoles;
use crate::stream::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
use crate::{ChannelId, CommunityId, Epoch, GroupKey, random_32};
pub const KIND_REKEY: u16 = 3303;
@@ -325,7 +325,7 @@ pub fn open_blob(
) -> Result<KeyDelivery, RekeyError> {
let conversation =
ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?;
let plaintext = stream::open_bytes(&conversation, &blob.wrapped)?;
let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?;
parse_blob_plaintext(&plaintext, scope, epoch, community_id)
}
@@ -347,7 +347,7 @@ fn seal_to(
plaintext: &[u8],
) -> Result<String, RekeyError> {
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
Ok(stream::seal_bytes(&conversation, plaintext)?)
Ok(cord01::seal_bytes(&conversation, plaintext)?)
}
#[derive(Debug, Clone)]
@@ -539,7 +539,7 @@ pub fn compact(
let mut wraps = Vec::with_capacity(seals.len());
for seal in seals {
wraps.push(stream::rewrap_seal(seal, read, signer, at)?.0);
wraps.push(cord01::rewrap_seal(seal, read, signer, at)?.0);
}
Ok(wraps)
@@ -592,7 +592,7 @@ pub fn build_rekey_rumor(
tags.push(Tag::custom(TAG_SEVER, ["1"]));
}
Ok(stream::build_rumor_secs(
Ok(cord01::build_rumor_secs(
KIND_REKEY, rotator, &content, tags, at_secs,
))
}
@@ -633,11 +633,11 @@ pub fn build_rekey_chunks(
at_secs,
)?;
let seal = stream::build_seal(&rumor, SealForm::Encrypted, group, rotator)?;
let (wrap, _) = stream::wrap_seal(
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?;
let (wrap, _) = cord01::wrap_seal(
&seal,
group,
stream::KIND_WRAP,
cord01::KIND_WRAP,
Timestamp::from_secs(at_secs),
&[],
)?;
@@ -704,7 +704,7 @@ pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError
prev_commit,
chunk: parse_chunk(rumor)?,
blobs,
citation: tag(rumor, crate::edition::TAG_CITATION)?.and_then(citation_from),
citation: tag(rumor, crate::cord04::TAG_CITATION)?.and_then(citation_from),
severed,
})
}
@@ -719,7 +719,7 @@ pub fn dissolved_tombstone_rumor(
community_id: &CommunityId,
at_secs: u64,
) -> UnsignedEvent {
stream::build_rumor_secs(
cord01::build_rumor_secs(
KIND_CONTROL,
owner,
"",
@@ -738,11 +738,11 @@ pub fn seal_dissolved(
at_secs: u64,
) -> Result<Event, RekeyError> {
let group = dissolved_group_key(community_id).map_err(crypto_error)?;
let seal = stream::build_seal(rumor, SealForm::Plaintext, &group, owner)?;
let (wrap, _) = stream::wrap_seal(
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?;
let (wrap, _) = cord01::wrap_seal(
&seal,
&group,
stream::KIND_WRAP,
cord01::KIND_WRAP,
Timestamp::from_secs(at_secs),
&[],
)?;
@@ -756,7 +756,7 @@ pub fn open_dissolved(
community_id: &CommunityId,
) -> Result<DissolvedTombstone, RekeyError> {
let group = dissolved_group_key(community_id).map_err(crypto_error)?;
let opened = stream::open_wrap(wrap, &group)?;
let opened = cord01::open_wrap(wrap, &group)?;
if !is_tombstone(&opened.rumor, community_id) {
return Err(RekeyError::NotADissolution);
@@ -859,13 +859,13 @@ mod tests {
use std::collections::BTreeSet;
use super::*;
use crate::control::{
use crate::cord01::KIND_WRAP;
use crate::cord02::{
CommunityMetadata, ControlWriter, Edition, ROOT_EPOCH, fold_control, genesis, open_edition,
};
use crate::cord04::roles::{Grant, Permissions, Role, RoleScope};
use crate::cord04::{EditionFields, Floors, build_edition};
use crate::derive::{community_id_of, grant_locator};
use crate::edition::{EditionFields, Floors, build_edition};
use crate::roles::{Grant, Permissions, Role, RoleScope};
use crate::stream::KIND_WRAP;
use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000;
@@ -1074,7 +1074,7 @@ mod tests {
.expect("builds");
assert_eq!(chunks.len(), 1);
let opened = stream::open_wrap(&chunks[0], &group).expect("opens");
let opened = cord01::open_wrap(&chunks[0], &group).expect("opens");
let chunk = parse_rekey_chunk(&opened).expect("parses");
assert_eq!(
chunk.rotator,
@@ -1486,7 +1486,7 @@ mod tests {
at_secs: AT,
});
let seal =
stream::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals");
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");
@@ -1497,7 +1497,7 @@ mod tests {
compact(std::slice::from_ref(&seal), &read, &signer, AT + 1).expect("compacts");
assert_eq!(compacted.len(), 1);
let reopened = stream::open_wrap_at(&compacted[0], &signer.pk(), read.conversation(), true)
let reopened = cord01::open_wrap_at(&compacted[0], &signer.pk(), read.conversation(), true)
.expect("opens");
assert_eq!(
reopened.seal.sig, seal.sig,
@@ -1508,7 +1508,7 @@ mod tests {
// Only a plaintext seal can be carried forward.
let encrypted =
stream::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals");
cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals");
assert!(matches!(
compact(&[encrypted], &read, &signer, AT + 1),
Err(RekeyError::Stream(StreamError::NotRewrappable))
@@ -1550,7 +1550,7 @@ mod tests {
// 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(
&stream::build_rumor_secs(
&cord01::build_rumor_secs(
KIND_CONTROL,
owner.public_key(),
"",
@@ -1580,10 +1580,10 @@ mod tests {
owner: owner.public_key(),
owner_salt: other_salt,
};
let seal = stream::open_wrap(&wrap, &dissolved_group_key(&community_id).expect("derives"))
let seal = cord01::open_wrap(&wrap, &dissolved_group_key(&community_id).expect("derives"))
.expect("opens")
.seal;
let replayed = stream::wrap_seal(
let replayed = cord01::wrap_seal(
&seal,
&dissolved_group_key(&other_id).expect("derives"),
KIND_WRAP,
+9
View File
@@ -0,0 +1,9 @@
//! 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.
pub mod cord01;
pub mod cord02;
pub mod cord03;
pub mod cord04;
pub mod cord05;
pub mod cord06;
+9 -143
View File
@@ -1,146 +1,12 @@
pub mod chat;
pub mod control;
pub mod derive;
pub mod edition;
pub mod guestbook;
pub mod invite;
pub mod list;
pub mod pins;
pub mod rekey;
pub mod roles;
mod cords;
mod types;
mod utils;
pub mod store;
pub mod stream;
use std::fmt;
use std::str::FromStr;
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
pub use utils::derive::{self, GroupKey};
use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER;
pub use derive::GroupKey;
use rand::TryRng as _;
use rand::rngs::SysRng;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
macro_rules! hex_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name([u8; 32]);
impl $name {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_hex(&self) -> String {
HEXLOWER.encode(&self.0)
}
}
impl From<[u8; 32]> for $name {
fn from(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", stringify!($name), self.to_hex())
}
}
impl FromStr for $name {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
Ok(Self(decode_hex_32(value)?))
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
};
}
hex_id! {
/// A self-certifying commitment to the owner's key, never on the wire.
CommunityId
}
hex_id! {
ChannelId
}
hex_id! {
/// Both a Role's entity coordinate and the field it repeats in its own content.
RoleId
}
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
)]
pub struct Epoch(pub u64);
impl fmt::Display for Epoch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
decode_hex_lower::<32>(value)
}
pub(crate) fn decode_hex_lower<const N: usize>(value: &str) -> Result<[u8; N]> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.map_err(|error| anyhow!("invalid hex: {error}"))?;
let decoded: [u8; N] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected {N} bytes, got {}", bytes.len()))?;
if HEXLOWER.encode(&decoded) != value {
bail!("hex must be lowercase and canonical");
}
Ok(decoded)
}
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
SysRng
.try_fill_bytes(bytes)
.map_err(|error| anyhow!("os rng: {error}"))
}
pub(crate) fn random_32() -> Result<[u8; 32]> {
let mut bytes = [0u8; 32];
fill_random(&mut bytes)?;
Ok(bytes)
}
pub(crate) use types::Extra;
pub(crate) use utils::{decode_hex_32, decode_hex_lower, fill_random, random_32};
+11 -10
View File
@@ -6,13 +6,13 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::chat::{self, ChatRumor, plane_keys};
use crate::control::{
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::cord02::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::cord03::{self, ChatRumor, plane_keys};
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
use crate::derive::control_signer_group_key;
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
use crate::stream::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -31,7 +31,8 @@ pub async fn cache_rumor(
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<bool> {
if chat::expiration_of(&opened.rumor)?.is_some_and(|expiration| expiration <= Timestamp::now())
if cord03::expiration_of(&opened.rumor)?
.is_some_and(|expiration| expiration <= Timestamp::now())
{
return Ok(false);
}
@@ -73,7 +74,7 @@ pub async fn purge_expired(
continue;
};
let Ok(Some(expiration)) = chat::expiration_of(&rumor) else {
let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
continue;
};
@@ -374,7 +375,7 @@ fn advance(
continue;
};
let Ok((opened, rumor)) = chat::open(wrap, group, channel, *epoch) else {
let Ok((opened, rumor)) = cord03::open(wrap, group, channel, *epoch) else {
continue;
};
@@ -419,11 +420,11 @@ mod tests {
use super::*;
use crate::Epoch;
use crate::chat::{build_message, seal_rumor};
use crate::derive::channel_group_key;
use crate::stream::{
use crate::cord01::{
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
};
use crate::cord03::{build_message, seal_rumor};
use crate::derive::channel_group_key;
const SECRET: [u8; 32] = [0x07u8; 32];
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
+98
View File
@@ -0,0 +1,98 @@
use std::fmt;
use std::str::FromStr;
use anyhow::Result;
use data_encoding::HEXLOWER;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::decode_hex_32;
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
macro_rules! hex_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name([u8; 32]);
impl $name {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_hex(&self) -> String {
HEXLOWER.encode(&self.0)
}
}
impl From<[u8; 32]> for $name {
fn from(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", stringify!($name), self.to_hex())
}
}
impl FromStr for $name {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
Ok(Self(decode_hex_32(value)?))
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
};
}
hex_id! {
/// A self-certifying commitment to the owner's key, never on the wire.
CommunityId
}
hex_id! {
ChannelId
}
hex_id! {
/// Both a Role's entity coordinate and the field it repeats in its own content.
RoleId
}
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
)]
pub struct Epoch(pub u64);
impl fmt::Display for Epoch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
+40
View File
@@ -0,0 +1,40 @@
pub mod derive;
use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER;
use rand::TryRng as _;
use rand::rngs::SysRng;
/// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
decode_hex_lower::<32>(value)
}
pub(crate) fn decode_hex_lower<const N: usize>(value: &str) -> Result<[u8; N]> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.map_err(|error| anyhow!("invalid hex: {error}"))?;
let decoded: [u8; N] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected {N} bytes, got {}", bytes.len()))?;
if HEXLOWER.encode(&decoded) != value {
bail!("hex must be lowercase and canonical");
}
Ok(decoded)
}
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
SysRng
.try_fill_bytes(bytes)
.map_err(|error| anyhow!("os rng: {error}"))
}
pub(crate) fn random_32() -> Result<[u8; 32]> {
let mut bytes = [0u8; 32];
fill_random(&mut bytes)?;
Ok(bytes)
}
+69 -60
View File
@@ -13,32 +13,41 @@ folded independently by every client.
## Modules
Files follow the CORD documents. The frozen derivations of Appendix A and the id
vocabulary are shared substrate — every document calls them — so they live outside
`cords` in `utils::derive` (re-exported as `derive`) and `types`.
| Module | Owns |
| --- | --- |
| `cord01` | Private Streams: the seal/wrap envelope and the NIP-44 helpers |
| `cord02` | Communities: identity, epochs, metadata, the Control Plane fold and writer |
| `cord02::guestbook` | Joins, leaves, kicks, snapshots, the member list |
| `cord02::list` | The Community List (a member's own memberships, across devices) |
| `cord03` | Channels: Channel metadata and the Chat Plane |
| `cord04` | Roles: chained editions, parse/hash/fold, the roster, permissions, the banlist |
| `cord04::pins` | Pin Lists, and the key disclosure a keyless reader verifies |
| `cord05` | Invite bundles, links, the Direct Invite, the Invite List |
| `cord06` | Key rotations, refounding, compaction, dissolution |
| `derive` | Every frozen HKDF derivation and coordinate |
| `stream` | The CORD-01 envelope: seal, wrap, open, and the NIP-44 helpers |
| `edition` | Chained, versioned editions: parse, hash, fold, floors |
| `roles` | Permissions, roles, grants, the banlist, the authority fixpoint |
| `control` | The Control Plane: genesis, the fold, the writer, metadata |
| `chat` | The Chat Plane: message/reaction/edit/delete builders and the fold |
| `guestbook` | Joins, leaves, kicks, snapshots, the member list |
| `invite` | Invite bundles, links, the Direct Invite, the Invite List |
| `list` | The Community List (a member's own memberships, across devices) |
| `rekey` | Key rotations, refounding, compaction, dissolution |
| `pins` | Pin Lists, and the key disclosure a keyless reader verifies |
| `store` | Local rumor cache, the community state document, relay paging |
`CommunityId`, `ChannelId`, `RoleId`, `Epoch` and `Extra` (crate-internal) come from
the private `types` module and are re-exported at the crate root.
CORD-07 (audio/video) is unimplemented. CORD-08's timer has no file of its own: it
lives in the metadata it reads (`cord02`) and the fold it filters (`cord03`).
Read `CommunityId` as "this community", `ChannelId` as "this channel", `Epoch` as
"which key generation". Nothing else in the API needs internal state.
## Creating a community
```rust
use concord::control::{self, CommunityMetadata};
use concord::cord02::{self, CommunityMetadata};
use concord::store::{self, CommunityState, save_state};
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
let minted = control::genesis(&owner_keys, &metadata, now_secs)?;
let minted = cord02::genesis(&owner_keys, &metadata, now_secs)?;
// minted.identity — community_id, owner, owner_salt (verify() recomputes it)
// minted.wraps — the two owner-signed genesis editions, already sealed
@@ -53,14 +62,14 @@ join:
```rust
use concord::derive::{control_group_key, control_signer_group_key};
use concord::edition::ParsedEdition;
use concord::cord04::ParsedEdition;
let read = control_group_key(&minted.community_root, &minted.identity.community_id, Epoch(0))?;
let signer = control_signer_group_key(&minted.control_root, &minted.identity.community_id, Epoch(0))?;
let editions: Vec<ParsedEdition> = minted
.wraps
.iter()
.map(|wrap| control::open_edition(wrap, &read, &signer.pk(), true))
.map(|wrap| cord02::open_edition(wrap, &read, &signer.pk(), true))
.collect::<Result<_, _>>()?;
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
@@ -75,12 +84,12 @@ client explicitly — coop's client is a gossip client with no background refres
An invite link resolves to a bundle:
```rust
use concord::invite::{self, BundleState, invite_bundle_key};
use concord::cord05::{self, BundleState, invite_bundle_key};
let link = invite::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr
let link = cord05::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr
// The crate does no I/O: fetch the naddr from the fragment's relays, then:
let invite = match invite::parse_bundle_event(&event, &link.link_signer, &invite_bundle_key(&link.token))? {
let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite_bundle_key(&link.token))? {
BundleState::Live(invite) => invite, // validate() already ran
BundleState::Revoked => return Ok(None), // a tombstone at the coordinate
};
@@ -89,7 +98,7 @@ let invite = match invite::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) = invite::unwrap_direct_invite(&wrap, &my_keys)?;
let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys)?;
```
Either way the invite carries `community_id`, `owner`, `owner_salt`,
@@ -101,26 +110,26 @@ Then publish a join so the member list sees the member before any backfill:
```rust
use concord::derive::guestbook_group_key;
use concord::guestbook;
use concord::cord02::guestbook;
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
let rumor = guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
let (wrap, _) = guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?;
let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?;
client.send_event(&wrap).to(&relays).await?;
```
## Reading the Control Plane
```rust
use concord::control::{self, ControlFold};
use concord::cord02::{self, ControlFold};
let editions: Vec<ParsedEdition> = wraps
.iter()
.filter_map(|wrap| control::open_edition(wrap, &read, &control_pk, true).ok())
.filter_map(|wrap| cord02::open_edition(wrap, &read, &control_pk, true).ok())
.collect();
let control: ControlFold =
control::fold_control(&owner, &community_id, &editions, &state.floors(), &state.banned);
cord02::fold_control(&owner, &community_id, &editions, &state.floors(), &state.banned);
state.apply_fold(&control);
```
@@ -145,12 +154,12 @@ fold; they are its memory.
## Sending a message
```rust
use concord::chat::{self, build_message};
use concord::cord03::{self, build_message};
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) = chat::seal_rumor(&rumor, &plane, &my_keys, false)?;
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false)?;
client.send_event(&wrap).to(&relays).await?;
```
@@ -171,7 +180,7 @@ about an existing `EventId` rather than a mutation.
## Reading a channel
```rust
use concord::chat::{self, fold, plane_keys};
use concord::cord03::{self, fold, plane_keys};
let planes = plane_keys(&held, &channel)?; // &[(Epoch, secret)]
let mut rumors = Vec::new();
@@ -180,7 +189,7 @@ for wrap in &wraps {
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) else {
continue;
};
let Ok((opened, rumor)) = chat::open(wrap, group, &channel, *epoch) else {
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
continue;
};
store::cache_rumor(database, &channel, &opened).await?;
@@ -217,11 +226,11 @@ as an inline row only when its author passes
## Membership
```rust
let states = guestbook::coalesce(&rumors, now_ms, Some(&refounder_pk), |actor, target, citation| {
let states = cord02::guestbook::coalesce(&rumors, now_ms, Some(&refounder_pk), |actor, target, citation| {
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
&& control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK)
});
let members = guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
```
- `observed` is npub → ms for every author this client has seen publish anything
@@ -254,52 +263,52 @@ and pass the head from the current fold, so the chain cannot silently fork.
Wrappers: `set_community_metadata`, `set_channel_metadata`, `set_role`,
`set_grant`, `set_banlist`, `set_registry`, `set_pin_list`, plus raw `publish`.
A ban is a `set_banlist` followed by a base rekey; a kick is a `set_grant` with an
empty `role_ids` followed by `guestbook::build_kick`.
empty `role_ids` followed by `cord02::guestbook::build_kick`.
## Pins
```rust
use concord::pins;
use concord::cord04::pins;
let entry = pins::build_entry(&opened_message, &plane, &channel)?;
let entry = cord04::pins::build_entry(&opened_message, &plane, &channel)?;
let head_content = control.pin_content(&community_id, &channel).unwrap_or("");
let read = pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok());
let content = pins::publishable(&read, channel_is_private, &plane, epoch)?;
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)?;
```
Reading is verification: `read_list` decodes either content form (public, or
sealed under the channel key at the named epoch), and
`pins::verify_entry(entry, &channel)` returns a `VerifiedPin` with the proven
`cord04::pins::verify_entry(entry, &channel)` returns a `VerifiedPin` with the proven
author, words and time — no history and no old keys needed. `read.sealed` means
the list is sealed under an epoch this client never held: show it as unavailable,
and never write from it (`publishable` refuses). `pins::killed_by(&pin, &delete)`
and never write from it (`publishable` refuses). `cord04::pins::killed_by(&pin, &delete)`
answers whether a folded kind-5 erases an entry.
## Invites
```rust
use concord::derive::{invite_bundle_key, TOKEN_LEN};
use concord::invite::{self, InviteEntry, InviteTombstone};
use concord::cord05::{self, InviteEntry, InviteTombstone};
let token: [u8; TOKEN_LEN] = /* 16 bytes from any CSPRNG */;
let bundle_key = invite_bundle_key(&token);
let link_signer = Keys::generate();
let bundle = invite::build_bundle_event(&link_signer, &invite, &bundle_key)?;
let url = invite::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?;
let bundle = cord05::build_bundle_event(&link_signer, &invite, &bundle_key)?;
let url = cord05::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?;
```
A link is a coordinate plus a fragment: the naddr fetches the bundle, the token
unlocks it, and the fragment names the relays to fetch from.
`invite::stock_relays()` is what a fragment with no relays of its own means.
`cord05::stock_relays()` is what a fragment with no relays of its own means.
The `link_signer` secret is what lets the creator refresh or retire the link, so
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 = invite::parse_invite_list(&my_keys, &event)?;
let mut list = cord05::parse_invite_list(&my_keys, &event)?;
list.entries.push(InviteEntry {
token: HEXLOWER.encode(&token),
signer_sk: link_signer.secret_key().to_secret_hex(),
@@ -310,7 +319,7 @@ list.entries.push(InviteEntry {
expires_at: None,
extra: Default::default(),
});
let event = invite::build_invite_list(&my_keys, &list)?; // kind 13303
let event = cord05::build_invite_list(&my_keys, &list)?; // kind 13303
// Retiring is a tombstone, never a deletion: it beats a stale copy terminally.
list.tombstones.push(InviteTombstone {
@@ -331,10 +340,10 @@ seals one blob per remaining member:
```rust
use concord::derive::epoch_key_commitment;
use concord::rekey::{self, RekeyScope};
use concord::cord06::{self, RekeyScope};
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
let plan = rekey::plan_refounding(Epoch(epoch + 1))?;
let plan = cord06::plan_refounding(Epoch(epoch + 1))?;
// A base rotation delivers the new control-plane keys beside the root; a channel
// rotation delivers only that channel's fresh key.
@@ -350,12 +359,12 @@ let (control_pk, control_root) = match scope {
let blobs = members
.iter()
.map(|member| {
rekey::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root)
cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root)
})
.collect::<Result<Vec<_>, _>>()?;
let rekey_group = rekey::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
let wraps = rekey::build_rekey_chunks(
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
let wraps = cord06::build_rekey_chunks(
&my_keys,
&rekey_group,
scope,
@@ -369,7 +378,7 @@ let wraps = rekey::build_rekey_chunks(
)?;
```
On the receiving side, `rekey::parse_rekey_chunk(&opened)` per wrap, then
On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then
`collect_rotations(&chunks)`, then `am_i_removed(&rotation, &me)` — which is
`None` until every chunk is held, because an incomplete set is never a removal. A
member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the key
@@ -379,11 +388,11 @@ matches the key they already hold. Two concurrent rotations settle on `fork_winn
Dissolution is owner-only and terminal:
```rust
let rumor = rekey::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs);
let wrap = rekey::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?;
let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs);
let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?;
// A receiver seals the community read-only on sight.
if rekey::verify_dissolved(&wrap, &identity) {
if cord06::verify_dissolved(&wrap, &identity) {
state.dissolved = true;
}
```
@@ -393,15 +402,15 @@ if rekey::verify_dissolved(&wrap, &identity) {
A member's own memberships, synced across their devices:
```rust
use concord::list;
use concord::cord02::list;
let material = list::join_material(&invite, staff.then_some(&control_root));
let mut mine = list::parse_list_event(&my_keys, &event)?;
mine = list::merge(mine, list::CommunityList {
entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
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 event = list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self
let event = cord02::list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self
```
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
@@ -430,7 +439,7 @@ impl ConcordRegistry {
}
```
Call it after `chat::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and
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.
@@ -458,7 +467,7 @@ self.ingress = Some(cx.background_spawn(async move {
let Some(plane) = planes.iter().find(|plane| plane.group.pk() == wrap.pubkey) else {
continue;
};
let (opened, rumor) = chat::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?;
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
}