From 8aad0685ad3b28790ad05d4e01c42467ad40eab4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 08:46:49 +0700 Subject: [PATCH] update concord store --- crates/community/src/sync.rs | 63 ++++++++++ crates/concord/src/store.rs | 142 ++++++++++++++++++++++- docs/concord-community-discovery-plan.md | 31 ++++- docs/concord-usage.md | 11 +- 4 files changed, 235 insertions(+), 12 deletions(-) diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 54d5e046..e50303ad 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -269,6 +269,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 = 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(), diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index b72ac62f..7f682fe5 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -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 = 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/`. @@ -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 { + 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 { diff --git a/docs/concord-community-discovery-plan.md b/docs/concord-community-discovery-plan.md index c21e06c1..2dbc0251 100644 --- a/docs/concord-community-discovery-plan.md +++ b/docs/concord-community-discovery-plan.md @@ -94,8 +94,9 @@ Two consequences for coop: | 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 and B +are done**, so 1–4 and 6 are resolved; 5, 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,6 +162,25 @@ 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. +**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. + +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. + ### Phase C — fetch the List from relays, then load `crates/community/src/sync.rs` @@ -230,8 +250,9 @@ rows in the sidebar. This is the first time the path can be exercised at all. 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. +- **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 diff --git a/docs/concord-usage.md b/docs/concord-usage.md index c77068da..71bf2ebf 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -595,8 +595,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`.)