use signer for community backend
This commit is contained in:
@@ -3,6 +3,7 @@ use std::collections::HashMap;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use concord::CommunityId;
|
use concord::CommunityId;
|
||||||
use concord::cord01::KIND_WRAP;
|
use concord::cord01::KIND_WRAP;
|
||||||
|
use concord::cord02::CommunityMetadata;
|
||||||
use concord::store::CommunityState;
|
use concord::store::CommunityState;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -103,6 +104,37 @@ impl CommunityRegistry {
|
|||||||
self.index.get(id).cloned()
|
self.index.get(id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a community owned by the current account and begin tracking it.
|
||||||
|
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
|
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.
|
/// Forget the current account and cancel everything in flight.
|
||||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.notification_listener = None;
|
self.notification_listener = None;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST};
|
|||||||
use concord::cord02::{self, ControlFold};
|
use concord::cord02::{self, ControlFold};
|
||||||
use concord::cord04::AuthorityCitation;
|
use concord::cord04::AuthorityCitation;
|
||||||
use concord::cord04::roles::{Permissions, citation_ok};
|
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::store::{self, CommunityState};
|
||||||
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -93,6 +95,35 @@ pub struct Snapshot {
|
|||||||
pub members: BTreeSet<PublicKey>,
|
pub members: BTreeSet<PublicKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mints a community owned by `signer` and persists it locally.
|
||||||
|
pub async fn create<S>(
|
||||||
|
client: &Client,
|
||||||
|
signer: &S,
|
||||||
|
metadata: &cord02::CommunityMetadata,
|
||||||
|
) -> Result<CommunityState>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
|
let at_secs = Timestamp::now().as_secs();
|
||||||
|
let genesis = cord02::genesis(signer, metadata, at_secs).await?;
|
||||||
|
let id = genesis.identity.community_id;
|
||||||
|
|
||||||
|
let read = control_group_key(&genesis.community_root, &id, cord02::ROOT_EPOCH)?;
|
||||||
|
let address = control_signer_group_key(&genesis.control_root, &id, cord02::ROOT_EPOCH)?.pk();
|
||||||
|
|
||||||
|
let mut editions = Vec::with_capacity(genesis.wraps.len());
|
||||||
|
|
||||||
|
for wrap in &genesis.wraps {
|
||||||
|
editions.push(cord02::open_edition(wrap, &read, &address, true)?);
|
||||||
|
client.database().save_event(wrap).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?;
|
||||||
|
store::save_state(client, &state).await?;
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
/// Discovers the current account's communities from the local database.
|
/// Discovers the current account's communities from the local database.
|
||||||
pub async fn load(
|
pub async fn load(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -151,9 +182,7 @@ async fn load_list(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = signer.nip44_decrypt_async(&self_pk, &event.content).await?;
|
Ok(Some(cord02::list::parse_list_event(signer, &event).await?))
|
||||||
|
|
||||||
Ok(Some(serde_json::from_str(&json)?))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuilds a community from the wraps already in the local database.
|
/// Rebuilds a community from the wraps already in the local database.
|
||||||
@@ -259,3 +288,115 @@ fn observe(observed: &mut BTreeMap<PublicKey, u64>, author: PublicKey, at_ms: u6
|
|||||||
.and_modify(|seen| *seen = (*seen).max(at_ms))
|
.and_modify(|seen| *seen = (*seen).max(at_ms))
|
||||||
.or_insert(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")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
`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.
|
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
|
1. `sync::create(client, signer, metadata)` (`crates/community/src/sync.rs`) runs
|
||||||
— no secret exposure. This is the change that makes `subscribe` fire.
|
`cord02::genesis`, opens the genesis editions, persists the state with
|
||||||
2. Remove the app-side reimplementation of `list::parse_list_event`
|
`store::save_state`, and also stores the genesis wraps so the control plane
|
||||||
(`crates/community/src/sync.rs:154-156`) now that it accepts a signer.
|
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
|
### 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
|
## 6. Immediate unblock
|
||||||
|
|
||||||
Two options, both app-side:
|
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.
|
||||||
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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user