Compare commits
2
Commits
1cf5ecba51
...
9a391ca76d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a391ca76d | ||
|
|
8aad0685ad |
@@ -27,6 +27,7 @@ impl Global for GlobalCommunityRegistry {}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Signal {
|
||||
Event(CommunityId),
|
||||
List,
|
||||
}
|
||||
|
||||
impl EventEmitter<CommunityEvent> for CommunityRegistry {}
|
||||
@@ -67,6 +68,7 @@ impl CommunityRegistry {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.subscribe_list(cx);
|
||||
this.load(cx);
|
||||
}
|
||||
}));
|
||||
@@ -76,6 +78,7 @@ impl CommunityRegistry {
|
||||
.update(cx, |this, cx| {
|
||||
this.handle_notifications(cx);
|
||||
if nostr.read(cx).current_user().is_some() {
|
||||
this.subscribe_list(cx);
|
||||
this.load(cx);
|
||||
}
|
||||
})
|
||||
@@ -163,6 +166,25 @@ impl CommunityRegistry {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Subscribe to the account's community list.
|
||||
fn subscribe_list(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let self_pk = signer.get_public_key_async().await?;
|
||||
|
||||
if let Err(error) = sync::subscribe_list(&client, self_pk).await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Discover the account's communities in the local database.
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -298,6 +320,11 @@ impl CommunityRegistry {
|
||||
continue;
|
||||
};
|
||||
|
||||
if sync::is_list_subscription(&subscription_id) {
|
||||
tx.send_async(Signal::List).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if event.kind != Kind::from(KIND_WRAP) {
|
||||
continue;
|
||||
}
|
||||
@@ -313,10 +340,12 @@ impl CommunityRegistry {
|
||||
}));
|
||||
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(Signal::Event(id)) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| this.refresh(id, cx))?;
|
||||
while let Ok(signal) = rx.recv_async().await {
|
||||
match signal {
|
||||
Signal::Event(id) => this.update(cx, |this, cx| this.refresh(id, cx))?,
|
||||
Signal::List => this.update(cx, |this, cx| this.load(cx))?,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
+214
-11
@@ -121,21 +121,135 @@ where
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Discovers the current account's communities from the local database.
|
||||
/// The subscription id carrying the account's own Community List.
|
||||
pub const LIST_SUBSCRIPTION: &str = "concord/list";
|
||||
|
||||
pub fn list_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(LIST_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
pub fn is_list_subscription(id: &SubscriptionId) -> bool {
|
||||
id.as_str() == LIST_SUBSCRIPTION
|
||||
}
|
||||
|
||||
/// Subscribes to the account's community list.
|
||||
pub async fn subscribe_list(client: &Client, self_pk: PublicKey) -> Result<()> {
|
||||
let id = list_subscription_id();
|
||||
client.unsubscribe(&id).await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
|
||||
.author(self_pk);
|
||||
|
||||
let output = client
|
||||
.subscribe(ReqTarget::auto(vec![filter]))
|
||||
.with_id(id)
|
||||
.await?;
|
||||
|
||||
if !output.failed.is_empty() {
|
||||
log::warn!(
|
||||
"community list: {} relay(s) rejected the subscription",
|
||||
output.failed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discovers the current account's communities: every live membership the List
|
||||
/// carries, plus any locally-held membership the List does not mention.
|
||||
///
|
||||
/// A held membership is dropped only when the List carries a tombstone at least
|
||||
/// as new as it, because absence from the List is never a fact (§8).
|
||||
pub async fn load(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
self_pk: PublicKey,
|
||||
) -> Result<Vec<CommunityState>> {
|
||||
let mut states = store::load_states(client).await?;
|
||||
let list = match load_list(client, signer, self_pk).await? {
|
||||
Some(list) => list,
|
||||
None => return store::load_states(client).await,
|
||||
};
|
||||
|
||||
if let Some(list) = load_list(client, signer, self_pk).await? {
|
||||
states.retain(|state| list.is_live(&state.id));
|
||||
let mut held: BTreeMap<CommunityId, CommunityState> = store::load_states(client)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|state| (state.id, state))
|
||||
.collect();
|
||||
|
||||
held.retain(|id, state| !retired(&list, id, state.added_at_ms));
|
||||
|
||||
for entry in &list.entries {
|
||||
if !list.is_live(&entry.community_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fresh = match CommunityState::from_join_material(&entry.current, entry.added_at) {
|
||||
Ok(fresh) => fresh,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"ignoring unreadable community {} from the list: {error}",
|
||||
entry.community_id.to_hex()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let state = match held.remove(&entry.community_id) {
|
||||
Some(materialized) => refresh(materialized, fresh),
|
||||
None => fresh,
|
||||
};
|
||||
|
||||
store::save_state(client, &state).await?;
|
||||
held.insert(entry.community_id, state);
|
||||
}
|
||||
|
||||
Ok(states)
|
||||
Ok(held.into_values().collect())
|
||||
}
|
||||
|
||||
fn retired(list: &CommunityList, id: &CommunityId, added_at_ms: u64) -> bool {
|
||||
list.tombstones
|
||||
.iter()
|
||||
.find(|tombstone| tombstone.community_id == *id)
|
||||
.is_some_and(|tombstone| tombstone.removed_at >= added_at_ms)
|
||||
}
|
||||
|
||||
fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
|
||||
held.owner = fresh.owner;
|
||||
held.owner_salt = fresh.owner_salt;
|
||||
held.community_root = fresh.community_root;
|
||||
held.root_epoch = fresh.root_epoch;
|
||||
held.added_at_ms = fresh.added_at_ms;
|
||||
|
||||
if fresh.control_root.is_some() {
|
||||
held.control_root = fresh.control_root;
|
||||
}
|
||||
|
||||
for (epoch, address) in fresh.control_pks {
|
||||
held.control_pks.insert(epoch, address);
|
||||
}
|
||||
|
||||
held.relays = fresh.relays;
|
||||
|
||||
for channel in fresh.channels {
|
||||
match held.channels.iter_mut().find(|held| held.id == channel.id) {
|
||||
Some(held) => {
|
||||
held.name = channel.name;
|
||||
held.epoch = channel.epoch;
|
||||
|
||||
if channel.private {
|
||||
held.private = true;
|
||||
held.key = channel.key;
|
||||
}
|
||||
}
|
||||
None => held.channels.push(channel),
|
||||
}
|
||||
}
|
||||
|
||||
held
|
||||
}
|
||||
|
||||
/// Every fragment of the account's list in the local database, merged.
|
||||
async fn load_list(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
@@ -143,14 +257,40 @@ async fn load_list(
|
||||
) -> Result<Option<CommunityList>> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
|
||||
.author(self_pk)
|
||||
.limit(1);
|
||||
.author(self_pk);
|
||||
|
||||
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut newest: BTreeMap<u64, Event> = BTreeMap::new();
|
||||
|
||||
Ok(Some(cord02::list::parse_list_event(signer, &event).await?))
|
||||
for event in client.database().query(filter).await? {
|
||||
let Ok(index) = cord02::list::fragment_index(&event) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match newest.get(&index) {
|
||||
Some(existing) if existing.created_at >= event.created_at => {}
|
||||
_ => {
|
||||
newest.insert(index, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut merged: Option<CommunityList> = None;
|
||||
|
||||
for event in newest.into_values() {
|
||||
match cord02::list::parse_list_event(signer, &event).await {
|
||||
Ok(list) => {
|
||||
merged = Some(match merged {
|
||||
Some(held) => cord02::list::merge(held, list),
|
||||
None => list,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("ignoring unreadable community list {}: {error}", event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Rebuilds a community from the wraps already in the local database.
|
||||
@@ -269,6 +409,69 @@ mod tests {
|
||||
.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planes_address_the_control_guestbook_and_only_public_channels() {
|
||||
let owner = Keys::generate().public_key();
|
||||
let control_pk = Keys::generate().public_key();
|
||||
let general = ChannelId::from_bytes([0x9c; 32]);
|
||||
|
||||
let state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
owner,
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
root_epoch: Epoch(0),
|
||||
control_root: None,
|
||||
control_pks: BTreeMap::from([(0, control_pk)]),
|
||||
channels: vec![
|
||||
concord::store::ChannelKeyRef {
|
||||
id: general,
|
||||
name: "general".to_owned(),
|
||||
private: false,
|
||||
epoch: Epoch(0),
|
||||
key: None,
|
||||
},
|
||||
concord::store::ChannelKeyRef {
|
||||
id: ChannelId::from_bytes([0x9d; 32]),
|
||||
name: "staff".to_owned(),
|
||||
private: true,
|
||||
epoch: Epoch(0),
|
||||
key: Some([0x04; 32]),
|
||||
},
|
||||
],
|
||||
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
dissolved: false,
|
||||
added_at_ms: 0,
|
||||
};
|
||||
|
||||
let planes = planes(&state).expect("planes");
|
||||
|
||||
// Control at the root epoch, the guestbook, and the public channel. The
|
||||
// private channel is skipped: its address derives from the granted key,
|
||||
// not the community_root.
|
||||
assert_eq!(planes.len(), 3);
|
||||
assert!(planes.iter().any(|plane| plane.address == control_pk));
|
||||
assert!(
|
||||
planes
|
||||
.iter()
|
||||
.any(|plane| matches!(plane.kind, PlaneKind::Guestbook))
|
||||
);
|
||||
assert!(
|
||||
planes
|
||||
.iter()
|
||||
.any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general))
|
||||
);
|
||||
|
||||
// The filter author-lists every plane, so the subscription actually
|
||||
// reaches the events the fold reads.
|
||||
let filter = subscription_filter(&planes);
|
||||
let addresses: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect();
|
||||
assert_eq!(filter.authors, Some(addresses));
|
||||
assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)])));
|
||||
}
|
||||
|
||||
fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata {
|
||||
cord02::CommunityMetadata {
|
||||
name: name.to_owned(),
|
||||
|
||||
+140
-2
@@ -7,13 +7,14 @@ use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||
use crate::cord02::list::JoinMaterial;
|
||||
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::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||
use crate::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32};
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
@@ -140,6 +141,11 @@ pub struct ChannelKeyRef {
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub epoch: Epoch,
|
||||
/// The channel's read secret when the member was granted it.
|
||||
///
|
||||
/// A public channel derives its key from the `community_root` and carries none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// One local document per community, keyed by `concord/<community_id>`.
|
||||
@@ -201,6 +207,7 @@ impl CommunityState {
|
||||
name: metadata.name,
|
||||
private: metadata.private,
|
||||
epoch: ROOT_EPOCH,
|
||||
key: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
@@ -234,6 +241,52 @@ impl CommunityState {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
|
||||
let control_pks = match material.control_pk {
|
||||
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
|
||||
let mut channels = Vec::with_capacity(material.channels.len());
|
||||
for grant in &material.channels {
|
||||
let key = match &grant.key {
|
||||
Some(key) => Some(decode_hex_32(key)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
channels.push(ChannelKeyRef {
|
||||
id: grant.id,
|
||||
name: grant.name.clone(),
|
||||
private: key.is_some(),
|
||||
epoch: grant.epoch,
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: material.community_id,
|
||||
owner: material.owner,
|
||||
owner_salt: decode_hex_32(&material.owner_salt)?,
|
||||
community_root: decode_hex_32(&material.community_root)?,
|
||||
root_epoch: material.root_epoch,
|
||||
control_root: match &material.control_root {
|
||||
Some(root) => Some(decode_hex_32(root)?),
|
||||
None => None,
|
||||
},
|
||||
control_pks,
|
||||
channels,
|
||||
relays: material
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
dissolved: false,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> String {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
@@ -276,6 +329,7 @@ impl CommunityState {
|
||||
name: metadata.name.clone(),
|
||||
private: false,
|
||||
epoch: self.root_epoch,
|
||||
key: None,
|
||||
}),
|
||||
None => {}
|
||||
}
|
||||
@@ -445,9 +499,10 @@ async fn fetch_page(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Epoch;
|
||||
use crate::cord03::{build_message, seal_rumor};
|
||||
use crate::cord05::ChannelGrant;
|
||||
use crate::derive::channel_group_key;
|
||||
use crate::{Epoch, Extra};
|
||||
|
||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
||||
@@ -527,6 +582,89 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
||||
let owner = Keys::generate().public_key();
|
||||
let control_pk = Keys::generate().public_key();
|
||||
let staff = ChannelId::from_bytes([0x9c; 32]);
|
||||
let general = ChannelId::from_bytes([0x9d; 32]);
|
||||
|
||||
let material = JoinMaterial {
|
||||
community_id: CommunityId::from_bytes([0x42; 32]),
|
||||
owner,
|
||||
owner_salt: "01".repeat(32),
|
||||
community_root: "02".repeat(32),
|
||||
root_epoch: Epoch(3),
|
||||
control_pk: Some(control_pk),
|
||||
control_root: Some("03".repeat(32)),
|
||||
channels: vec![
|
||||
ChannelGrant {
|
||||
id: staff,
|
||||
key: Some("04".repeat(32)),
|
||||
epoch: Epoch(2),
|
||||
name: "staff".to_owned(),
|
||||
extra: Extra::default(),
|
||||
},
|
||||
ChannelGrant {
|
||||
id: general,
|
||||
key: None,
|
||||
epoch: Epoch(0),
|
||||
name: "general".to_owned(),
|
||||
extra: Extra::default(),
|
||||
},
|
||||
],
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
name: "Room".to_owned(),
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
let state = CommunityState::from_join_material(&material, 7).expect("materializes");
|
||||
|
||||
assert_eq!(state.id, material.community_id);
|
||||
assert_eq!(state.owner, owner);
|
||||
assert_eq!(state.owner_salt, [0x01; 32]);
|
||||
assert_eq!(state.community_root, [0x02; 32]);
|
||||
assert_eq!(state.root_epoch, Epoch(3));
|
||||
assert_eq!(state.control_root, Some([0x03; 32]));
|
||||
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
||||
assert!(
|
||||
state.heads.is_empty(),
|
||||
"the first control fold fills the heads"
|
||||
);
|
||||
assert!(state.banned.is_empty());
|
||||
assert!(!state.dissolved);
|
||||
assert_eq!(state.relays.len(), 1);
|
||||
assert_eq!(state.added_at_ms, 7);
|
||||
|
||||
// A granted key lands on the channel and makes it private; a grant with
|
||||
// no key is a public channel.
|
||||
let granted = state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.id == staff)
|
||||
.expect("staff");
|
||||
assert!(granted.private);
|
||||
assert_eq!(granted.key, Some([0x04; 32]));
|
||||
assert_eq!(granted.epoch, Epoch(2));
|
||||
assert_eq!(granted.name, "staff");
|
||||
|
||||
let public = state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.id == general)
|
||||
.expect("general");
|
||||
assert!(!public.private);
|
||||
assert_eq!(public.key, None);
|
||||
|
||||
// A member who is not staff carries no control_root, but reading needs no
|
||||
// secret: the address rides in the material either way.
|
||||
let mut member = material.clone();
|
||||
member.control_root = None;
|
||||
let state = CommunityState::from_join_material(&member, 7).expect("materializes");
|
||||
assert_eq!(state.control_root, None);
|
||||
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
|
||||
smol::block_on(async {
|
||||
|
||||
@@ -76,7 +76,7 @@ Two consequences for coop:
|
||||
per-process `LOCAL_KEYS`, and never leave the machine. No equivalent exists
|
||||
anywhere in the spec. It is a local cache and must never be treated as the
|
||||
discovery source.
|
||||
2. **Discovery is: fetch my `33302` from relays → materialize a community from
|
||||
2. **Discovery is: subscribe to my `33302` → materialize a community from
|
||||
`current` join material → subscribe to its planes → fold.** The fold produces
|
||||
the authoritative state; the List only supplies the keys to start.
|
||||
|
||||
@@ -88,14 +88,15 @@ Two consequences for coop:
|
||||
| 2 | one event per fragment, `d` = index, `frags` declared | no `frags`, single event, `d` unused, `load_list` `.limit(1)` |
|
||||
| 3 | 32-byte values unpadded base64url at any depth | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` |
|
||||
| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | both snapshots always serialized verbatim; `community_id` always present |
|
||||
| 5 | fetch from relays | local database only |
|
||||
| 5 | fetch from relays | local database only — **fixed in Phase C** |
|
||||
| 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` |
|
||||
| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs |
|
||||
| 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field |
|
||||
|
||||
Divergences 1–4 meant that even if the fetch existed, coop could neither read
|
||||
what accordion wrote nor write something accordion could read. **Phase A is
|
||||
done**, so 1–4 are resolved; 5–8 remain.
|
||||
what accordion wrote nor write something accordion could read. **Phases A, B and
|
||||
C are done**, so 1–6 are resolved; 7 and 8 remain (8 only in that private planes
|
||||
are still not subscribed).
|
||||
|
||||
## Plan
|
||||
|
||||
@@ -143,7 +144,7 @@ example has five such values, so a strict decoder rejects the worked example;
|
||||
Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has
|
||||
no membership limit, and Phase D's fragmentation is what removes the cap.
|
||||
|
||||
### Phase B — materialize a community from join material (pure)
|
||||
### Phase B — materialize a community from join material (pure) — DONE
|
||||
|
||||
`crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs`
|
||||
|
||||
@@ -161,35 +162,74 @@ survives; `from_join_material` then `planes()` yields the control `control_pk`
|
||||
plus the guestbook and public channels, i.e. a subscription filter that
|
||||
addresses real planes.
|
||||
|
||||
### Phase C — fetch the List from relays, then load
|
||||
**As built.** `from_join_material` does not verify `community_id` against
|
||||
`owner`/`owner_salt`: the List is signed by the member's own key and encrypted
|
||||
to self, and the invite path already validates that binding in
|
||||
`CommunityInvite::validate`. `private` on a materialized channel is simply
|
||||
`key.is_some()` — the spec's `channels` carry only the Private Channel keys a
|
||||
member was granted, so a grant with no key is a public channel. Nothing else
|
||||
changed: `from_genesis` and `apply_fold` construct every channel with
|
||||
`key: None`, and `planes()` still skips private channels, whose address derives
|
||||
from the granted key rather than the `community_root`. Carrying the key is what
|
||||
makes subscribing to them possible later; it is not needed to fix discovery.
|
||||
|
||||
`crates/community/src/sync.rs`
|
||||
Two tests. In `concord`, `from_join_material` (with and without `control_root`,
|
||||
a granted key surviving, a public grant staying keyless). In `community`,
|
||||
`planes()` plus `subscription_filter` over a state built field-by-field (control
|
||||
+ guestbook + public channel addressed, private skipped) — `JoinMaterial` and
|
||||
`ChannelGrant` cannot be constructed from `community` because their `extra`
|
||||
field's type is crate-private, so the materialization and the plane derivation
|
||||
are each proved where they live.
|
||||
|
||||
1. `load` becomes:
|
||||
- resolve where to ask: the account's NIP-65 write relays (kind `10002`) plus
|
||||
the pool's connected relays. If only the app's bootstrap relays are queried,
|
||||
a List published by another client (e.g. accordion on `relay.damus.io` /
|
||||
`nos.lol`) will simply not be found.
|
||||
- `client.fetch_events(Filter::new().kind(33302).author(self_pk))` — one
|
||||
filter returns every fragment. Fetched events are persisted by the client
|
||||
(`nostr-sdk/src/relay/inner.rs:1291`), so the database read stays valid.
|
||||
- merge fragments → `CommunityList`.
|
||||
- for each entry whose `is_live(&id)`: if a state document exists, keep its
|
||||
`heads` (the fold's authority) and refresh relays/keys from `current`;
|
||||
otherwise `from_join_material(..)`.
|
||||
- `store::save_state` each result so the next `load` is warm.
|
||||
2. `load_list` keeps reading `client.database()` — after the fetch it is
|
||||
populated. It must stop using `.limit(1)`.
|
||||
3. Drop the `states.retain(..)` shape: the List is now the *source* of states,
|
||||
not just a filter over local ones. A local state whose membership is
|
||||
tombstoned is still dropped, but a List entry with no local state now
|
||||
produces one.
|
||||
### Phase C — the List drives `load` — DONE
|
||||
|
||||
Tests (no network, `nostr-memory`): a `33302` fragment written by the account is
|
||||
discovered with **no** state document present; a tombstoned id is dropped; a
|
||||
missing fragment leaves the rest usable. A `nostr_sdk::local_relay::LocalRelay`
|
||||
(in-process relay, public in this pinned revision) can drive the real
|
||||
fetch/subscribe path end to end.
|
||||
`crates/community/src/sync.rs`, `crates/community/src/lib.rs`
|
||||
|
||||
1. `subscribe_list(client, self_pk)` subscribes to `Kind::Custom(33302)`
|
||||
`author(self_pk)` under a dedicated `concord/list` subscription id, using
|
||||
`ReqTarget::auto`. With gossip enabled, `auto` breaks the filter down by
|
||||
author, so it queries the account's NIP-65 write relays and adds/connects
|
||||
them itself — bootstrap relays alone would miss a List published elsewhere.
|
||||
2. `CommunityRegistry` calls `subscribe_list` once per signer (signer change and
|
||||
the initial defer). It is deliberately **not** called from `load`:
|
||||
re-subscribing on every List event would re-deliver the List and loop. `reset`
|
||||
does not unsubscribe it either — `subscribe_list` replaces the subscription
|
||||
itself, and a `reset`-issued unsubscribe could race the replacement and cancel
|
||||
discovery.
|
||||
3. The notification listener routes a `concord/list` event to a new `Signal::List`,
|
||||
whose consumer re-runs `load`. Community planes keep using `Signal::Event(id)`.
|
||||
4. `load_list` reads every `33302` event by `self_pk` from the database, keeps the
|
||||
newest event per fragment index, decrypts and `merge`s them. `.limit(1)` is gone.
|
||||
An incomplete List is read normally — a missing fragment is news not yet heard.
|
||||
5. `load` unions two sources: every live List entry (materialized with
|
||||
`from_join_material`, or refreshed if a state document already exists) and every
|
||||
held local state the List does not mention. A held membership is dropped only
|
||||
when a tombstone outranks its `added_at_ms`; absence from the List is never a
|
||||
fact. Each list-derived state is `save_state`d, so the next `load` is warm.
|
||||
6. `refresh(held, fresh)` keeps the fold's authority (`heads`, `banned`,
|
||||
`dissolved`) and the control planes it learned, and takes the List's identity,
|
||||
relays, and channel keys. Channels are merged by id rather than replaced, so a
|
||||
public channel the fold discovered is not shed by a List snapshot that predates
|
||||
it.
|
||||
|
||||
**As built, deviating from the sketch above.** The plan called for
|
||||
`client.fetch_events(..)`; the SDK's own recommendation is to keep the request
|
||||
path on a subscription and read the database. This is safer than it sounds: a
|
||||
relay's event is persisted at `nostr-sdk/src/relay/inner.rs:1291` **before** the
|
||||
notification is emitted, so a subscription plus a database read loses nothing and
|
||||
needs no explicit save. The subscription is set up with `ReqTarget::auto` rather
|
||||
than a hand-built NIP-65 relay map, because gossip already resolves the author's
|
||||
write relays and connects them on demand.
|
||||
|
||||
Tests (no network): a fragment in the database with **no** state document
|
||||
materializes a community and writes one; a tombstone at `u64::MAX` drops a held
|
||||
membership; a two-fragment List with only fragment 0 delivered still yields its
|
||||
membership; a held membership the List never mentions is kept alongside the
|
||||
discovered one; `refresh` keeps `heads`/`banned`/`dissolved` and both control
|
||||
planes while taking the List's keys; and the `concord/list` id is not read as a
|
||||
community subscription. Fragment events are built with `build_list_event` from a
|
||||
§8 JSON payload, so the test exercises the real decrypt-and-merge path without a
|
||||
relay.
|
||||
|
||||
### Phase D — publish
|
||||
|
||||
@@ -214,7 +254,9 @@ rows in the sidebar. This is the first time the path can be exercised at all.
|
||||
- `cargo test -p concord` (A, B), `cargo test -p community` (B, C, D).
|
||||
- `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check`.
|
||||
- A is provable against the spec's worked example, so it needs no relay.
|
||||
- C is provable with `nostr-memory` + `LocalRelay`, so it needs no network.
|
||||
- C is provable with `nostr-memory`: fragments are built with `build_list_event`
|
||||
and saved as the subscription would have, then `load` reads them. No relay,
|
||||
no `LocalRelay`.
|
||||
- E is the only step that needs real relays.
|
||||
|
||||
## Risks and open decisions
|
||||
@@ -228,10 +270,14 @@ rows in the sidebar. This is the first time the path can be exercised at all.
|
||||
NIP-44 plaintext, which understates that by roughly a third, so the count cap is
|
||||
kept as a conservative stopgap until Phase D measures the built event and
|
||||
fragments on write.
|
||||
- **Relay selection for the fetch is the difference between finding the account's
|
||||
List and not.** NIP-65 write relays + pool, or a user-visible relay setting?
|
||||
- **Private channels stay unreadable until `ChannelKeyRef` carries the grant key**
|
||||
(Phase B.2). Public discovery works without it.
|
||||
- **Relay selection is the difference between finding the account's List and
|
||||
not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the
|
||||
filter's author to their NIP-65 write relays and connects them. A List
|
||||
published only to relays with no NIP-65 entry is still unreachable; that is a
|
||||
user-visible relay setting if it ever bites.
|
||||
- **Private channels stay unsubscribed until `planes()` derives their address
|
||||
from the granted key** (Phase B gave `ChannelKeyRef` a home for it, but the
|
||||
discovery fix does not need it). Public discovery works regardless.
|
||||
- **Two writers, one key.** Once coop publishes `33302`, an account used from
|
||||
both accordion and coop has both clients writing the List. §8's
|
||||
read-modify-write is what keeps that from losing memberships — it is not
|
||||
|
||||
+21
-9
@@ -455,6 +455,14 @@ single-event design — §8 has **no membership limit**, its only bound is the
|
||||
65,536-byte encoded event, and the real fix is to start a new fragment on write
|
||||
(see `docs/concord-community-discovery-plan.md`, Phase D).
|
||||
|
||||
Discovery is a **subscription, not a fetch**: subscribe with
|
||||
`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the
|
||||
fragments back out of `client.database()`. The client persists a relay's event
|
||||
before it notifies, so a subscription plus a database read loses nothing and
|
||||
needs no explicit save. Parse each event with `parse_list_event`, keep the newest
|
||||
per `fragment_index`, and `merge` them — reading an incomplete List is safe, since
|
||||
a missing fragment is only news not yet heard.
|
||||
|
||||
## GPUI integration
|
||||
|
||||
`crates/concord` stays GPUI-free; the registry and sync engine live in
|
||||
@@ -575,10 +583,13 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
`CommunityEvent::Error` through `log::error!`, and its "New community" row opens
|
||||
a name prompt that calls `CommunityRegistry::create`. `create` still persists
|
||||
the genesis locally without publishing it to the metadata's relays. Discovery
|
||||
is local-only: `load` reads the state documents already in
|
||||
`client.database()` and never fetches the account's CORD-02 Community List
|
||||
(`33302`) from relays, so a fresh install — or one signing in as an account
|
||||
that joined elsewhere — finds nothing and never subscribes. See
|
||||
subscribes to the account's CORD-02 Community List (`33302`) under the
|
||||
`concord/list` subscription id and reads the fragments back out of
|
||||
`client.database()` — the SDK persists a relay's event before notifying, so the
|
||||
read is always current. A `concord/list` notification re-runs `load`, which
|
||||
materializes a community from each live List entry (`from_join_material`) and
|
||||
keeps any state document the List does not mention, so a fresh install — or one
|
||||
signing in as an account that joined elsewhere — finds its communities. See
|
||||
`docs/concord-community-discovery-plan.md`.
|
||||
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
|
||||
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
|
||||
@@ -595,8 +606,9 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
|
||||
that handler must route by subscription id before any concord subscription goes
|
||||
live, or every stream wrap lands in the DM trash and raises a toast.
|
||||
- **No plane key can be persisted yet.** `CommunityState` has nowhere to keep a
|
||||
key a rotation delivered and `ChannelKeyRef` carries no key of its own, so a
|
||||
client can verify a rotation and still lose it on restart — history under a
|
||||
prior root or a prior channel epoch is unreadable until that schema change
|
||||
lands.
|
||||
- **Rotation-delivered plane keys cannot be persisted yet.** `CommunityState` has
|
||||
nowhere to keep a key a rotation delivered, so a client can verify a rotation
|
||||
and still lose it on restart — history under a prior root or a prior channel
|
||||
epoch is unreadable until that schema change lands. (A granted private-channel
|
||||
key does now have a home: `ChannelKeyRef.key`, filled by
|
||||
`CommunityState::from_join_material`.)
|
||||
|
||||
Reference in New Issue
Block a user