diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index d2705705..86b156bc 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use anyhow::Result; use concord::CommunityId; use concord::cord01::KIND_WRAP; +use concord::cord02::CommunityMetadata; use concord::store::CommunityState; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; use nostr_sdk::prelude::*; @@ -103,6 +104,37 @@ impl CommunityRegistry { self.index.get(id).cloned() } + /// Create a community owned by the current account and begin tracking it. + pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + + if nostr.read(cx).current_user().is_none() { + cx.emit(CommunityEvent::Error( + "cannot create a community without an account".to_owned(), + )); + return; + } + + let signer = nostr.read(cx).signer(); + let client = nostr.read(cx).client(); + + let task = + cx.background_spawn(async move { sync::create(&client, &signer, &metadata).await }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(_state) => this.update(cx, |this, cx| this.load(cx))?, + Err(error) => { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + } + + Ok(()) + })); + } + /// Forget the current account and cancel everything in flight. pub fn reset(&mut self, cx: &mut Context) { self.notification_listener = None; diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index bae60867..8097b21a 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -6,7 +6,9 @@ use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST}; use concord::cord02::{self, ControlFold}; use concord::cord04::AuthorityCitation; use concord::cord04::roles::{Permissions, citation_ok}; -use concord::derive::{channel_group_key, control_group_key, guestbook_group_key}; +use concord::derive::{ + channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key, +}; use concord::store::{self, CommunityState}; use concord::{ChannelId, CommunityId, Epoch, GroupKey}; use nostr_sdk::prelude::*; @@ -93,6 +95,35 @@ pub struct Snapshot { pub members: BTreeSet, } +/// Mints a community owned by `signer` and persists it locally. +pub async fn create( + client: &Client, + signer: &S, + metadata: &cord02::CommunityMetadata, +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ + let at_secs = Timestamp::now().as_secs(); + let genesis = cord02::genesis(signer, metadata, at_secs).await?; + let id = genesis.identity.community_id; + + let read = control_group_key(&genesis.community_root, &id, cord02::ROOT_EPOCH)?; + let address = control_signer_group_key(&genesis.control_root, &id, cord02::ROOT_EPOCH)?.pk(); + + let mut editions = Vec::with_capacity(genesis.wraps.len()); + + for wrap in &genesis.wraps { + editions.push(cord02::open_edition(wrap, &read, &address, true)?); + client.database().save_event(wrap).await?; + } + + let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?; + store::save_state(client, &state).await?; + + Ok(state) +} + /// Discovers the current account's communities from the local database. pub async fn load( client: &Client, @@ -151,9 +182,7 @@ async fn load_list( return Ok(None); }; - let json = signer.nip44_decrypt_async(&self_pk, &event.content).await?; - - Ok(Some(serde_json::from_str(&json)?)) + Ok(Some(cord02::list::parse_list_event(signer, &event).await?)) } /// Rebuilds a community from the wraps already in the local database. @@ -259,3 +288,115 @@ fn observe(observed: &mut BTreeMap, author: PublicKey, at_ms: u6 .and_modify(|seen| *seen = (*seen).max(at_ms)) .or_insert(at_ms); } + +#[cfg(test)] +mod tests { + use nostr_memory::MemoryDatabase; + + use super::*; + + fn client() -> Client { + ClientBuilder::default() + .database(MemoryDatabase::unbounded()) + .build() + } + + fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata { + cord02::CommunityMetadata { + name: name.to_owned(), + relays: vec![relay.to_owned()], + ..cord02::CommunityMetadata::default() + } + } + + /// What `CommunityRegistry` needs from a created community: a state document + /// `load` finds, a control plane the subscription filter actually addresses, + /// and a fold that survives an inbound control edit. + #[test] + fn creating_a_community_persists_a_state_that_subscribes_and_folds() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let created = create(&client, &signer, &metadata("coop", "wss://relay.example")) + .await + .expect("creates"); + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + assert_eq!(loaded, vec![created.clone()]); + + // The subscription filter must address the genesis wraps, or the registry + // would listen to a plane nothing is ever published on. + let planes = planes(&created).expect("planes"); + let wraps = client + .database() + .query(subscription_filter(&planes)) + .await + .expect("queries"); + assert_eq!(wraps.len(), created.heads.len()); + assert!(wraps.iter().all(|wrap| wrap.kind == Kind::from(KIND_WRAP))); + + let snapshot = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + assert_eq!(snapshot.state.channels.len(), 1); + assert_eq!(snapshot.members, BTreeSet::from([keys.public_key()])); + assert_eq!( + snapshot + .control + .community + .as_ref() + .map(|metadata| metadata.name.as_str()), + Some("coop") + ); + + // An inbound control edit made by the owner folds over the created state. + let community_head = created + .heads + .iter() + .find(|head| head.entity == *created.id.as_bytes()) + .expect("a community head"); + let writer = cord02::ControlWriter { + author: created.owner, + read: control_group_key(&created.community_root, &created.id, cord02::ROOT_EPOCH) + .expect("a reading key"), + signer: control_signer_group_key( + &created.control_root.expect("a control root"), + &created.id, + cord02::ROOT_EPOCH, + ) + .expect("a signing key"), + }; + + let (wrap, _) = writer + .set_community_metadata( + &keys, + &created.id, + &metadata("coop two", "wss://relay.example"), + Some(community_head), + None, + Timestamp::now().as_secs() + 1, + ) + .await + .expect("publishes"); + client.database().save_event(&wrap).await.expect("saves"); + + let updated = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + assert_eq!( + updated + .control + .community + .as_ref() + .map(|metadata| metadata.name.as_str()), + Some("coop two") + ); + }); + } +} diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md index ace944a2..c9508ff6 100644 --- a/docs/concord-simplification-plan.md +++ b/docs/concord-simplification-plan.md @@ -193,12 +193,40 @@ keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and `cord06::{build_blob, open_blob}` are untouched — they use the NIP-59 and group-key paths, not the migrated helpers — and remain `&Keys` for Phase 3. -### Phase 2 — app uses the signer +### Phase 2 — app uses the signer — DONE -1. `CommunityRegistry::create(&signer, …)` works with `UniversalSigner` directly - — no secret exposure. This is the change that makes `subscribe` fire. -2. Remove the app-side reimplementation of `list::parse_list_event` - (`crates/community/src/sync.rs:154-156`) now that it accepts a signer. +1. `sync::create(client, signer, metadata)` (`crates/community/src/sync.rs`) runs + `cord02::genesis`, opens the genesis editions, persists the state with + `store::save_state`, and also stores the genesis wraps so the control plane + folds locally. It is generic over `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized` + (the bounds `genesis` needs and no more, per D2); the app passes its + `UniversalSigner`, so no secret material is exposed and NIP-46 accounts work + too. + `CommunityRegistry::create(metadata, cx)` (`crates/community/src/lib.rs`) is + the GPUI wrapper: it refuses when no account is signed in, otherwise runs the + task off-thread and refreshes tracking, so `sync::load` now returns one state + and `subscribe` finally fires. +2. The app-side reimplementation of `list::parse_list_event` + (`crates/community/src/sync.rs:154-156`) is deleted; `load_list` calls the + real `cord02::list::parse_list_event`. +3. Relays in the metadata are persisted but the genesis is **not** published yet; + `create` is local-only. Wiring genesis/broadcast through the relay pool is the + next app step, not part of this phase. + +Validation: `cargo test -p community` (1 passed), `cargo test -p concord` +(46 passed), `cargo clippy -p community --all-targets`, `cargo fmt -p community +--check`, and `cargo check --workspace --all-targets` are all clean. + +**Deviation from the plan sketch:** the planned "drive `CommunityRegistry`" test +is instead a `sync`-layer test, `sync::tests:: +creating_a_community_persists_a_state_that_subscribes_and_folds`. A GPUI-level +test cannot construct a `NostrRegistry` — it opens LMDB at `config_dir()` and +connects bootstrap relays in `NostrRegistry::new`, which is private and not +injectable — so the test drives a `Client` on an in-memory database +(`nostr-memory`, already a dev-dependency) directly. It asserts the whole +contract the registry depends on: `create` persists a state `load` returns, the +subscription filter addresses the genesis wraps, `fold` yields the created +community, and an inbound control edit folds over it. ### Phase 3 — migrate the remaining unwired writers @@ -269,12 +297,5 @@ Truly unreferenced even by tests (safe candidates, but kept per D1): ## 6. Immediate unblock -Two options, both app-side: - -1. Smallest (no concord change): expose the local `Keys` the account path - already constructs (`crates/state/src/lib.rs:254`) and add - `CommunityRegistry::create` around it. -2. Clean (needs Phase 1): `CommunityRegistry::create(&UniversalSigner, …)` with - no secret exposure, working for NIP-46 accounts too. - -Option 2 is the reason to do Phase 1. +Option 2 (the clean path, using `UniversalSigner`) landed in Phase 2. Option 1 +(exposing the local `Keys` from `crates/state/src/lib.rs:254`) is obsolete.