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
+2 -1
View File
@@ -107,8 +107,9 @@ impl CommunityRegistry {
/// 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 nostr.read(cx).current_user().is_none() {
if current_user.is_none() {
cx.emit(CommunityEvent::Error(
"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 state::UniversalSigner;
const SUBSCRIPTION_PREFIX: &str = "concord/";
const STATE_PREFIX: &str = "concord/";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PlaneKind {
Control(Epoch),
@@ -77,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()
}
@@ -130,30 +127,7 @@ pub async fn load(
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));
@@ -162,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,
+1
View File
@@ -17,6 +17,7 @@ rand.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
log.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
+6 -95
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 {
@@ -469,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};
+6 -89
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 {
@@ -579,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);
@@ -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> {
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;
+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()
+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());
+2
View File
@@ -1,3 +1,5 @@
mod rumor;
pub mod cord01;
pub mod cord02;
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 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,
@@ -492,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]);
});
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ pub struct 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 {
name: "Coop Contributors",