This commit is contained in:
2026-09-19 08:01:55 +07:00
parent aa3bd71351
commit 907347d002
14 changed files with 264 additions and 277 deletions
Generated
+1
View File
@@ -1318,6 +1318,7 @@ dependencies = [
"data-encoding", "data-encoding",
"hkdf", "hkdf",
"hmac 0.12.1", "hmac 0.12.1",
"log",
"nostr", "nostr",
"nostr-memory", "nostr-memory",
"nostr-sdk", "nostr-sdk",
+2 -1
View File
@@ -107,8 +107,9 @@ impl CommunityRegistry {
/// Create a community owned by the current account and begin tracking it. /// Create a community owned by the current account and begin tracking it.
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) { pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
if nostr.read(cx).current_user().is_none() { if current_user.is_none() {
cx.emit(CommunityEvent::Error( cx.emit(CommunityEvent::Error(
"cannot create a community without an account".to_owned(), "cannot create a community without an account".to_owned(),
)); ));
+3 -35
View File
@@ -14,9 +14,6 @@ use concord::{ChannelId, CommunityId, Epoch, GroupKey};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::UniversalSigner; use state::UniversalSigner;
const SUBSCRIPTION_PREFIX: &str = "concord/";
const STATE_PREFIX: &str = "concord/";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PlaneKind { pub enum PlaneKind {
Control(Epoch), Control(Epoch),
@@ -77,13 +74,13 @@ pub fn subscription_filter(planes: &[Plane]) -> Filter {
} }
pub fn subscription_id(id: &CommunityId) -> SubscriptionId { 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> { pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
subscription_id subscription_id
.as_str() .as_str()
.strip_prefix(SUBSCRIPTION_PREFIX)? .strip_prefix(store::STATE_PREFIX)?
.parse() .parse()
.ok() .ok()
} }
@@ -130,30 +127,7 @@ pub async fn load(
signer: &UniversalSigner, signer: &UniversalSigner,
self_pk: PublicKey, self_pk: PublicKey,
) -> Result<Vec<CommunityState>> { ) -> Result<Vec<CommunityState>> {
let filter = Filter::new().kind(Kind::ApplicationSpecificData); let mut states = store::load_states(client).await?;
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),
}
}
if let Some(list) = load_list(client, signer, self_pk).await? { if let Some(list) = load_list(client, signer, self_pk).await? {
states.retain(|state| list.is_live(&state.id)); states.retain(|state| list.is_live(&state.id));
@@ -162,12 +136,6 @@ pub async fn load(
Ok(states) 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( async fn load_list(
client: &Client, client: &Client,
signer: &UniversalSigner, signer: &UniversalSigner,
+1
View File
@@ -17,6 +17,7 @@ rand.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
anyhow.workspace = true anyhow.workspace = true
log.workspace = true
[dev-dependencies] [dev-dependencies]
nostr-memory.workspace = true nostr-memory.workspace = true
+6 -95
View File
@@ -1,18 +1,16 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result; use anyhow::Result;
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::cord01::{ use crate::cord01::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap, KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
}; };
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}; use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306; pub const KIND_JOIN_LEAVE: u16 = 3306;
@@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join"; const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave"; 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry { pub enum GuestbookEntry {
Join { Join {
@@ -469,73 +432,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo
Ok((snapshot_id, (index, total))) 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> { fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw) canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok()) .and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP)) .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> { fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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::derive::guestbook_group_key;
use crate::{CommunityId, Epoch}; use crate::{CommunityId, Epoch};
+6 -89
View File
@@ -1,18 +1,16 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt;
use anyhow::Result; use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::cord01::{ use crate::cord01::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms, KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
}; };
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::derive::channel_group_key;
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32}; 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_EXPIRATION: &str = "expiration";
const TAG_TIMER: &str = "timer"; 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. /// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplyRef { pub struct ReplyRef {
@@ -579,16 +541,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
.map_err(|_| ChatError::BadTag(name)) .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> { pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else { let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None); return Ok(None);
@@ -614,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> { fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?; let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
EventId::from_slice(&bytes).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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::cord01::StreamError;
const SECRET: [u8; 32] = [0x2du8; 32]; const SECRET: [u8; 32] = [0x2du8; 32];
const AT: u64 = 1_700_000_000_417; const AT: u64 = 1_700_000_000_417;
+4 -4
View File
@@ -121,7 +121,7 @@ fn signing_bytes(
bytes bytes
} }
pub fn edition_hash( fn edition_hash(
entity: &[u8; 32], entity: &[u8; 32],
version: u64, version: u64,
prev: Option<&[u8; 32]>, prev: Option<&[u8; 32]>,
@@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FoldResult { struct FoldResult {
pub head: Option<usize>, pub head: Option<usize>,
pub gap: bool, pub gap: bool,
pub anchored: bool, pub anchored: bool,
} }
/// The highest version whose chain is intact, given a held floor. /// 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(); let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() { 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. /// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> { fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions editions
.iter() .iter()
.enumerate() .enumerate()
+3 -3
View File
@@ -100,7 +100,7 @@ pub struct Role {
} }
impl Role { impl Role {
pub fn parse(content: &str) -> Option<Self> { fn parse(content: &str) -> Option<Self> {
serde_json::from_str(content).ok() serde_json::from_str(content).ok()
} }
@@ -122,7 +122,7 @@ pub struct Grant {
} }
impl Grant { impl Grant {
pub fn parse(content: &str) -> Option<Self> { fn parse(content: &str) -> Option<Self> {
serde_json::from_str(content).ok() 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 entries: Vec<String> = serde_json::from_str(content).ok()?;
let mut banned = Vec::with_capacity(entries.len()); let mut banned = Vec::with_capacity(entries.len());
+2
View File
@@ -1,3 +1,5 @@
mod rumor;
pub mod cord01; pub mod cord01;
pub mod cord02; pub mod cord02;
pub mod cord03; pub mod cord03;
+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))
}
+81 -8
View File
@@ -23,7 +23,8 @@ const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord"; const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e"; const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k"; 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. /// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor( 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( pub async fn query_rumors(
database: &dyn NostrDatabase, client: &Client,
channel: &ChannelId, channel: &ChannelId,
until: Option<Timestamp>, until: Option<Timestamp>,
limit: usize, limit: usize,
@@ -106,7 +107,7 @@ pub async fn query_rumors(
} }
let mut newest: BTreeMap<String, Event> = BTreeMap::new(); 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 { let Some(rumor_id) = event.tags.identifier() else {
continue; continue;
}; };
@@ -297,21 +298,54 @@ pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
Ok(()) Ok(())
} }
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>> pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
where
D: NostrDatabase + ?Sized,
{
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id)) .identifier(state_identifier(id))
.limit(1); .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)?)), Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None), 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( pub async fn backfill(
client: &Client, client: &Client,
channel: &ChannelId, channel: &ChannelId,
@@ -492,4 +526,43 @@ mod tests {
["after the rekey", "still before", "before the rekey"] ["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]);
});
}
} }
+1 -1
View File
@@ -80,7 +80,7 @@ pub struct CommunityEntry {
} }
pub fn dummy_communities() -> &'static [CommunityEntry] { pub fn dummy_communities() -> &'static [CommunityEntry] {
// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md. // TODO(concord): replace with CommunityRegistry communities, see docs/concord-usage.md.
&[ &[
CommunityEntry { CommunityEntry {
name: "Coop Contributors", name: "Coop Contributors",
+33 -19
View File
@@ -251,25 +251,36 @@ Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob
the base64 record). `cargo clippy -p concord --all-targets` and the base64 record). `cargo clippy -p concord --all-targets` and
`cargo fmt -p concord --check` are clean. `cargo fmt -p concord --check` are clean.
### Phase 4 — duplication and hygiene (independent, low risk) ### Phase 4 — duplication and hygiene (independent, low risk) — DONE
1. Add `store::load_states(client)` and delete the app-side state-document scan 1. DONE — `store::load_states(client)` added (with a direct `store` test), the
(`crates/community/src/sync.rs:100-137`). app-side state-document scan in `sync::load` is gone.
2. Export the `concord/` state prefix from concord; delete the app-side copies 2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are
(`store.rs:26`, `sync.rs:15-16`). gone, and subscription ids reuse the exported prefix.
3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs 3. DONE — the shared rumor tag readers and error live in a new `cords::rumor`
`guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError` module (`RumorError`, `tag`, `required`, `value`, `pubkey`,
enums. `optional_citation`), re-exported as `cord03::ChatError` and
4. Remove never-varied parameters where the change is local: `banned_at` from `cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower
`complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>` `RekeyError`, which the plan scoped out.
once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors 4. RETAINED — none of the "never-varied parameters" were removed. Each is
(until)` if no scheduled flow needs them. load-bearing for a flow the fold or a writer already implements (D1):
5. Tighten visibility of internal-only `pub` items in `cord04` - `complete_memberlist`'s `banned_at` is read by the fold and is exercised
(`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`, with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`;
`parse_banlist`, `Role::parse`, `Grant::parse`). `docs/concord-usage.md` already promises to fill it once the banlist head's
6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state` timestamp is plumbed through.
parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired - `cache_rumor -> Result<bool>` is read by `backfill` to drop expired rumors.
up yet" section (`:528-545`) once Phase 2 lands. - `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.
--- ---
@@ -284,7 +295,7 @@ Per D1 these stay, but they should be understood as unwired, not live:
| `cord04::pins` | ~550 | none | | `cord04::pins` | ~550 | none |
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` | | `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` | | guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
| `store` paging / purge / query / load_state | ~180 | `cache_rumor`, `save_state` | | `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): Truly unreferenced even by tests (safe candidates, but kept per D1):
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls, `CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
@@ -310,6 +321,9 @@ Truly unreferenced even by tests (safe candidates, but kept per D1):
- Phase 2 adds the app-level test: seed a `CommunityState` via - Phase 2 adds the app-level test: seed a `CommunityState` via
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made `store::save_state`, drive `CommunityRegistry`, assert a subscription is made
and an inbound wrap folds into the community. 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 ## 6. Immediate unblock
+25 -22
View File
@@ -73,7 +73,7 @@ let editions: Vec<ParsedEdition> = minted
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?; 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 Put the community's relay list into `state.relays` and add those relays to the
@@ -192,7 +192,7 @@ for wrap in &wraps {
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else { let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
continue; continue;
}; };
store::cache_rumor(database, &channel, &opened).await?; store::cache_rumor(&client, &channel, &opened).await?;
rumors.push(rumor); rumors.push(rumor);
} }
@@ -209,13 +209,13 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
Relay history pages through the local cache: Relay history pages through the local cache:
```rust ```rust
let page = store::backfill(client, database, &channel, &held, until, 50).await?; let page = store::backfill(client, &channel, &held, until, 50).await?;
let cached = store::query_rumors(database, &channel, None, 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 `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 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 any other local sweep — the timer is cooperative, so the local store is the
artifact that has to forget. artifact that has to forget.
@@ -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 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 content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
let (wrap, _) = writer.set_pin_list( 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 Reading is verification: `read_list` decodes either content form (public, or
@@ -428,7 +428,8 @@ the NIP-44 size cap, both protocol constants.
## GPUI integration ## 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 entity per community, and moves every decrypt, verification, fold and I/O off
the foreground thread. the foreground thread.
@@ -437,13 +438,13 @@ the foreground thread.
Same shape as `ChatRegistry`: Same shape as `ChatRegistry`:
```rust ```rust
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx); CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
} }
impl ConcordRegistry { impl CommunityRegistry {
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalConcordRegistry>().0.clone() cx.global::<GlobalCommunityRegistry>().0.clone()
} }
} }
``` ```
@@ -452,8 +453,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 subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with
the account. the account.
- `ConcordRegistry` holds `communities: Vec<Entity<Community>>`, an index by - `CommunityRegistry` holds `communities: Vec<Entity<Community>>`, an index by
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`. `CommunityId`, and `tasks: SmallVec<[Task<Result<()>>; 2]>`.
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list - `Community` owns one `CommunityState`, the last `ControlFold`, the member list
and the channel list. Views render `Entity<Community>`; no protocol state and the channel list. Views render `Entity<Community>`; no protocol state
lives in a view. lives in a view.
@@ -468,7 +469,7 @@ A background task never touches an entity. It sends results through a bounded
```rust ```rust
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256); let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
let database = client.database().clone(); let client = client.clone();
// Background: open, verify, fold — no entities. // Background: open, verify, fold — no entities.
self.ingress = Some(cx.background_spawn(async move { self.ingress = Some(cx.background_spawn(async move {
@@ -477,7 +478,7 @@ self.ingress = Some(cx.background_spawn(async move {
continue; continue;
}; };
let (opened, rumor) = cord03::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?; store::cache_rumor(&client, &plane.channel, &opened).await?;
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?; signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
} }
Ok(()) Ok(())
@@ -492,8 +493,8 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
})); }));
``` ```
- `client.database()` is a `&Arc<dyn NostrDatabase>` and `store::save_state` - Every store function takes the `&Client` and reaches the database through
wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`. `client.database()`, so clone the `Client` into the background task.
- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None` - 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 to an `Option<Task<_>>` before respawning it; a signer change replaces both
the listener and the consumer. the listener and the consumer.
@@ -537,11 +538,13 @@ client.subscribe(filter).with_id(sub_id).await?;
## Not wired up yet ## Not wired up yet
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no - **`crates/concord` stays protocol-only; the registry lives in
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to `crates/community`.** `concord` has no subscriptions, no `init`, and no
the plane whose address it carries, and rebuilding a subscription when a plane's `Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
address changes (join, channel added, rekey folded). GPUI integration above is per state document, subscribes when a community's plane set changes, and
the shape to build, not code that exists. re-folds on an inbound wrap. Nothing observes `CommunityEvent` yet, and
`CommunityRegistry::create` persists the genesis locally without publishing it
to the metadata's relays.
- **Account-key writers take any signer, not `&Keys`.** `genesis`, - **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,