Skip to main content

vector_core/community/v2/
community.rs

1//! The v2 in-memory community — the service/DB working type.
2//!
3//! Deliberately v2-native rather than reusing v1's [`crate::community::Community`]:
4//! a v1 `Channel` assumes an independent per-channel key, but a v2 Public Channel
5//! derives its key from the `community_root` (CORD-03), so the two don't share a
6//! shape. Both protocols converge instead at the FACADE, where each produces the
7//! same untyped JSON summary the SDK consumes — so dual-stack costs no SDK type
8//! change (a `version` field is the only tell).
9
10use nostr_sdk::prelude::PublicKey;
11
12use super::super::{ChannelId, CommunityId, Epoch};
13use super::control::{CommunityIdentity, Genesis, ImageRef};
14use super::invite::CommunityInvite;
15
16/// A channel as the v2 service holds it. A Public channel's secret is the
17/// `community_root` (`key == None`); a Private channel carries its own
18/// independent key at its own monotonic epoch.
19#[derive(Debug, Clone)]
20pub struct ChannelV2 {
21    pub id: ChannelId,
22    pub name: String,
23    pub private: bool,
24    /// The independent key of a Private channel; `None` for Public (derive from
25    /// the community_root).
26    pub key: Option<[u8; 32]>,
27    pub epoch: Epoch,
28    /// Folded vsk-2 fields Vector doesn't drive but MUST carry through its own
29    /// editions (CORD-02 §6) — an edition replaces the entity, so dropping these
30    /// on a rename would wipe them for every member.
31    pub voice: Option<bool>,
32    pub meta_custom: Option<serde_json::Map<String, serde_json::Value>>,
33    pub meta_extra: serde_json::Map<String, serde_json::Value>,
34}
35
36impl ChannelV2 {
37    /// The full vsk-2 document rebuilt from held state — the base every local
38    /// channel edit MUST start from (CORD-02 §6 preservation).
39    pub fn metadata(&self) -> super::control::ChannelMetadata {
40        super::control::ChannelMetadata {
41            name: self.name.clone(),
42            private: self.private,
43            voice: self.voice,
44            deleted: None,
45            custom: self.meta_custom.clone(),
46            extra: self.meta_extra.clone(),
47        }
48    }
49}
50
51/// A v2 community in memory: its self-certifying identity, base access key, and
52/// channels. Persisted via [`crate::db::community`]'s v2 helpers into the shared
53/// community tables (with the migration-65 columns).
54#[derive(Debug, Clone)]
55pub struct CommunityV2 {
56    pub identity: CommunityIdentity,
57    /// The base `@everyone` access key at `root_epoch` — holding it IS membership.
58    pub community_root: [u8; 32],
59    pub root_epoch: Epoch,
60    /// The CURRENT epoch's Control Plane signer pubkey (CORD-02 §2) — HELD,
61    /// never derived: it derives from a `control_root` only the owner and staff
62    /// hold, and arrives in invites, base rekey blobs, and the Community List.
63    /// Present = a split epoch (subscribe/verify by this address, decrypt under
64    /// the community_root-derived read key); absent = a LEGACY pre-split epoch,
65    /// whose Control Plane folds at the member-derivable `concord/control`
66    /// address (CORD-02 §5).
67    pub control_pk: Option<PublicKey>,
68    /// The CURRENT epoch's staff write secret (`control_root`, CORD-02 §2) —
69    /// held only by the owner and staff, delivered on promotion inside the
70    /// staff-making Grant (CORD-04 §3) or in a 136-byte base rekey blob
71    /// (CORD-06 §1). Absent for regular members and on legacy epochs.
72    /// Possession gates publishing to the Control Plane, never authority.
73    pub control_root: Option<[u8; 32]>,
74    pub name: String,
75    pub description: Option<String>,
76    /// Folded vsk-0 icon/banner. Held so a local edit republishes the FULL
77    /// metadata document — an edition replaces the entity, so an editor that
78    /// doesn't carry these forward wipes them for everyone (CORD-02 §6).
79    pub icon: Option<ImageRef>,
80    pub banner: Option<ImageRef>,
81    /// Folded vsk-0 client-extensible object + unknown top-level fields —
82    /// carried through our own editions verbatim (CORD-02 §6).
83    pub meta_custom: Option<serde_json::Map<String, serde_json::Value>>,
84    pub meta_extra: serde_json::Map<String, serde_json::Value>,
85    pub relays: Vec<String>,
86    pub channels: Vec<ChannelV2>,
87    pub dissolved: bool,
88    /// Local wall-clock of first acquisition (ms), for display ordering.
89    pub created_at_ms: u64,
90}
91
92impl CommunityV2 {
93    /// Build the owner's fresh community from a [`Genesis`] (the two genesis
94    /// editions are the caller's to publish). The `#general` channel is Public.
95    pub fn from_genesis(g: &Genesis, name: &str, description: Option<String>, relays: Vec<String>, created_at_ms: u64) -> CommunityV2 {
96        CommunityV2 {
97            identity: g.identity.clone(),
98            community_root: g.community_root,
99            root_epoch: Epoch(0),
100            control_pk: Some(g.control_pk()),
101            control_root: Some(g.control_root),
102            name: name.to_string(),
103            description,
104            icon: None,
105            banner: None,
106            meta_custom: None,
107            meta_extra: Default::default(),
108            relays,
109            channels: vec![ChannelV2 {
110                id: g.general_channel_id,
111                name: "general".to_string(),
112                private: false,
113                key: None,
114                epoch: Epoch(0),
115                voice: None,
116                meta_custom: None,
117                meta_extra: Default::default(),
118            }],
119            dissolved: false,
120            created_at_ms,
121        }
122    }
123
124    /// Reconstruct a member's community from an accepted invite bundle. The
125    /// bundle's owner commitment MUST already have been verified
126    /// ([`CommunityInvite::validate`]) — this re-checks it fail-closed anyway.
127    /// A channel is treated as Private iff its bundle key differs from the
128    /// community_root (a Public channel's "key" is the root; a Private one
129    /// carries its own).
130    pub fn from_bundle(bundle: &CommunityInvite, created_at_ms: u64) -> Result<CommunityV2, String> {
131        // A bundle is attacker-crafted input (a fetched link, or a Direct Invite
132        // from any npub), so bound BEFORE allocating: `validate` enforces the
133        // 256-channel cap AND the owner commitment (CORD-05 §1). Never
134        // `Vec::with_capacity(bundle.channels.len())` on unbounded input.
135        bundle.validate().map_err(|e| e.to_string())?;
136        let community_id = CommunityId(parse_hex32(&bundle.community_id, "community_id")?);
137        let owner_xonly = parse_hex32(&bundle.owner, "owner")?;
138        let owner_salt = parse_hex32(&bundle.owner_salt, "owner_salt")?;
139        let identity = CommunityIdentity { community_id, owner_xonly, owner_salt };
140        let community_root = parse_hex32(&bundle.community_root, "community_root")?;
141
142        let mut channels = Vec::with_capacity(bundle.channels.len());
143        for g in &bundle.channels {
144            let id = ChannelId(parse_hex32(&g.id, "channel id")?);
145            let key = parse_hex32(&g.key, "channel key")?;
146            let private = key != community_root;
147            channels.push(ChannelV2 {
148                id,
149                name: g.name.clone(),
150                private,
151                key: private.then_some(key),
152                epoch: Epoch(g.epoch),
153                voice: None,
154                meta_custom: None,
155                meta_extra: Default::default(),
156            });
157        }
158
159        // The split epoch's Control address, taken on trust (CORD-05 §1: it
160        // derives from a secret the joiner never holds, so nothing in the
161        // bundle can prove it; a wrong one is eclipse-class self-harm by the
162        // inviter, healed by the next verified base rotation). A malformed
163        // value degrades to the legacy view rather than refusing the bundle.
164        let control_pk = bundle
165            .control_pk
166            .as_deref()
167            .and_then(|h| PublicKey::from_hex(h).ok());
168
169        Ok(CommunityV2 {
170            identity,
171            community_root,
172            root_epoch: Epoch(bundle.root_epoch),
173            control_pk,
174            // The bundle never carries the staff secret; a staffer's own
175            // devices receive it via the Community List, a Grant's
176            // control_wrap, or a 136-byte base blob.
177            control_root: None,
178            name: bundle.name.clone(),
179            description: None,
180            // Mint-time snapshot so the community has an icon the moment it's
181            // joined; the Control fold is the authority and overwrites it.
182            icon: bundle.icon.clone(),
183            banner: None,
184            meta_custom: None,
185            meta_extra: Default::default(),
186            relays: bundle.relays.clone(),
187            channels,
188            dissolved: false,
189            created_at_ms,
190        })
191    }
192
193    /// The `community_id` this community is anchored on.
194    pub fn id(&self) -> &CommunityId {
195        &self.identity.community_id
196    }
197
198    /// The full vsk-0 metadata document rebuilt from held state — the base every
199    /// local edit MUST start from, so changing one field can't wipe the rest
200    /// (CORD-02 §6), foreign clients' `custom`/`extra` fields included.
201    pub fn metadata(&self) -> super::control::CommunityMetadata {
202        super::control::CommunityMetadata {
203            name: self.name.clone(),
204            description: self.description.clone(),
205            relays: self.relays.clone(),
206            icon: self.icon.clone(),
207            banner: self.banner.clone(),
208            custom: self.meta_custom.clone(),
209            extra: self.meta_extra.clone(),
210        }
211    }
212
213    /// The proven owner (the identity self-certifies at construction).
214    pub fn owner(&self) -> Result<PublicKey, String> {
215        self.identity.owner()
216    }
217
218    pub fn channel(&self, id: &ChannelId) -> Option<&ChannelV2> {
219        self.channels.iter().find(|c| c.id.0 == id.0)
220    }
221
222    /// The channels an invite bundle may grant to `audience` (CORD-05 §1/§2).
223    ///
224    /// Public channels always ride (the joiner derives them from the
225    /// `community_root` anyway). A Private channel rides only for a MEMBER the
226    /// roster shows entitled, and only if we hold its key — a keyless one can't
227    /// be granted, and carrying the root placeholder would make the joiner
228    /// address a private channel at the public plane.
229    ///
230    /// A LINK has no recipient, so its audience holds no Role by construction
231    /// and is entitled to no Private channel at all.
232    pub fn vendable_channels<'a>(
233        &'a self,
234        roster: &crate::community::roles::CommunityRoles,
235        owner_hex: Option<&str>,
236        audience: Option<&str>,
237        with: &[String],
238        without: &[String],
239    ) -> Vec<&'a ChannelV2> {
240        self.channels
241            .iter()
242            .filter(|c| {
243                if !c.private {
244                    return true;
245                }
246                if c.key.is_none() {
247                    return false;
248                }
249                match audience {
250                    None => false,
251                    Some(m) => roster.is_entitled(
252                        owner_hex,
253                        m,
254                        &crate::simd::hex::bytes_to_hex_32(&c.id.0),
255                        with,
256                        without,
257                    ),
258                }
259            })
260            .collect()
261    }
262
263    /// The ONE channel the chat list surfaces for this community (multi-channel
264    /// UI is a later cut, mirroring v1's single-channel groups): a readable
265    /// `#general` when present, else the oldest readable channel, else the first.
266    /// A keyless Private channel is skipped — it can't render a single message.
267    pub fn primary_channel(&self) -> Option<&ChannelV2> {
268        let readable = |c: &&ChannelV2| !(c.private && c.key.is_none());
269        self.channels
270            .iter()
271            .filter(readable)
272            .find(|c| c.name.eq_ignore_ascii_case("general"))
273            .or_else(|| self.channels.iter().find(readable))
274            .or_else(|| self.channels.first())
275    }
276
277    /// The encryption secret + epoch that address a channel's Chat Plane: the
278    /// `community_root` at `root_epoch` for a Public channel, the channel's own
279    /// key at its own epoch for a Private one (CORD-03 §1).
280    pub fn channel_secret(&self, ch: &ChannelV2) -> ([u8; 32], Epoch) {
281        match ch.key {
282            Some(k) if ch.private => (k, ch.epoch),
283            _ => (self.community_root, self.root_epoch),
284        }
285    }
286
287    /// Every `(secret, epoch)` pair to query for a channel's history — for the
288    /// first cut this is the single current head; the multi-epoch archive
289    /// (across rekeys) layers on once rotation lands in the service.
290    pub fn channel_read_coords(&self, ch: &ChannelV2) -> Vec<([u8; 32], Epoch)> {
291        // A keyless PRIVATE channel is UNREADABLE — never derive it from the root
292        // (that would address a private channel at the public plane, a leak). Its key
293        // rides the rekey plane; it surfaces only once follow_rekeys delivers it.
294        if ch.private && ch.key.is_none() {
295            return Vec::new();
296        }
297        vec![self.channel_secret(ch)]
298    }
299
300    /// The untyped summary the facade hands the SDK (protocol-agnostic shape +
301    /// a `version` tell). Kept deliberately close to v1's summary keys so a
302    /// consumer treats both uniformly.
303    pub fn to_summary_json(&self) -> serde_json::Value {
304        serde_json::json!({
305            "id": crate::simd::hex::bytes_to_hex_32(&self.identity.community_id.0),
306            "version": 2,
307            "name": self.name,
308            "description": self.description,
309            "relays": self.relays,
310            "owner": crate::simd::hex::bytes_to_hex_32(&self.identity.owner_xonly),
311            "dissolved": self.dissolved,
312            "channels": self.channels.iter().map(|c| serde_json::json!({
313                "id": crate::simd::hex::bytes_to_hex_32(&c.id.0),
314                "name": c.name,
315                "private": c.private,
316            })).collect::<Vec<_>>(),
317        })
318    }
319}
320
321fn parse_hex32(hex: &str, field: &str) -> Result<[u8; 32], String> {
322    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
323        return Err(format!("{field} is not 32-byte hex"));
324    }
325    Ok(crate::simd::hex::hex_to_bytes_32(hex))
326}
327
328#[cfg(test)]
329mod tests {
330    use super::super::invite::ChannelGrant;
331    use super::*;
332    use nostr_sdk::prelude::Keys;
333
334    #[test]
335    fn genesis_yields_a_public_general_channel() {
336        let owner = Keys::generate();
337        let meta = super::super::control::CommunityMetadata { name: "Test".into(), ..Default::default() };
338        let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
339        let c = CommunityV2::from_genesis(&g, "Test", None, vec!["wss://r".into()], 42);
340
341        assert!(c.identity.verify());
342        assert_eq!(c.owner().unwrap(), owner.public_key());
343        assert_eq!(c.channels.len(), 1);
344        let ch = &c.channels[0];
345        assert!(!ch.private);
346        assert_eq!(ch.key, None, "a public channel stores no key");
347        // A public channel's secret is the community_root at the root epoch.
348        assert_eq!(c.channel_secret(ch), (c.community_root, Epoch(0)));
349    }
350
351    #[test]
352    fn primary_channel_prefers_a_readable_general() {
353        let owner = Keys::generate();
354        let meta = super::super::control::CommunityMetadata { name: "T".into(), ..Default::default() };
355        let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
356        let mut c = CommunityV2::from_genesis(&g, "T", None, vec!["wss://r".into()], 0);
357        // Genesis mints #general — it is the primary.
358        assert_eq!(c.primary_channel().unwrap().name, "general");
359
360        // A renamed general → the first READABLE channel wins; a keyless private
361        // channel (unreadable) is skipped even when it sits first.
362        c.channels[0].name = "lobby".into();
363        c.channels.insert(0, ChannelV2 { id: ChannelId([9u8; 32]), name: "sekrit".into(), private: true, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
364        assert_eq!(c.primary_channel().unwrap().name, "lobby");
365
366        // A readable channel NAMED general beats position.
367        c.channels.push(ChannelV2 { id: ChannelId([8u8; 32]), name: "General".into(), private: false, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
368        assert_eq!(c.primary_channel().unwrap().name, "General");
369    }
370
371    #[test]
372    fn metadata_document_rebuilds_the_full_entity() {
373        let owner = Keys::generate();
374        let meta = super::super::control::CommunityMetadata { name: "Test".into(), ..Default::default() };
375        let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
376        let mut c = CommunityV2::from_genesis(&g, "Test", Some("desc".into()), vec!["wss://r".into()], 42);
377        let mut extra = serde_json::Map::new();
378        extra.insert("ext".into(), serde_json::Value::String("png".into()));
379        c.icon = Some(ImageRef {
380            url: "https://blossom.example/i".into(),
381            key: "k".into(),
382            nonce: "n".into(),
383            hash: "h".into(),
384            extra,
385        });
386
387        // An edition replaces the entity, so the edit base MUST be the full held
388        // document — a name-only edit built from it keeps the icon (CORD-02 §6).
389        let mut custom = serde_json::Map::new();
390        custom.insert("theme".into(), serde_json::Value::String("solarpunk".into()));
391        c.meta_custom = Some(custom.clone());
392        c.meta_extra.insert("future_field".into(), serde_json::Value::Bool(true));
393        let doc = c.metadata();
394        assert_eq!(doc.name, "Test");
395        assert_eq!(doc.description.as_deref(), Some("desc"));
396        assert_eq!(doc.icon, c.icon);
397        assert_eq!(doc.banner, None);
398        assert_eq!(doc.relays, c.relays);
399        assert_eq!(doc.custom, Some(custom), "client-extensible custom rides the edit base");
400        assert_eq!(doc.extra.get("future_field"), Some(&serde_json::Value::Bool(true)), "unknown fields ride too");
401    }
402
403    #[test]
404    fn channel_metadata_document_preserves_undriven_fields() {
405        let owner = Keys::generate();
406        let meta = super::super::control::CommunityMetadata { name: "T".into(), ..Default::default() };
407        let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
408        let mut c = CommunityV2::from_genesis(&g, "T", None, vec!["wss://r".into()], 0);
409        // A foreign client marked #general as a voice channel with custom fields.
410        c.channels[0].voice = Some(true);
411        let mut custom = serde_json::Map::new();
412        custom.insert("bitrate".into(), serde_json::Value::from(64000));
413        c.channels[0].meta_custom = Some(custom.clone());
414        c.channels[0].meta_extra.insert("vnd_field".into(), serde_json::Value::from("x"));
415
416        // Our rename rebuilds from the held document: voice/custom/extra survive.
417        let mut doc = c.channels[0].metadata();
418        doc.name = "lounge".into();
419        assert_eq!(doc.voice, Some(true), "a rename must not wipe the voice flag");
420        assert_eq!(doc.custom, Some(custom));
421        assert_eq!(doc.extra.get("vnd_field"), Some(&serde_json::Value::from("x")));
422        assert_eq!(doc.deleted, None);
423    }
424
425    #[test]
426    fn from_bundle_verifies_owner_and_classifies_channels() {
427        let owner = Keys::generate();
428        let identity = CommunityIdentity::mint(&owner.public_key());
429        let root = [0x11u8; 32];
430        let hex = crate::simd::hex::bytes_to_hex_32;
431
432        let priv_key = [0x22u8; 32];
433        let bundle = CommunityInvite {
434            community_id: hex(&identity.community_id.0),
435            owner: hex(&identity.owner_xonly),
436            owner_salt: hex(&identity.owner_salt),
437            community_root: hex(&root),
438            root_epoch: 0,
439            control_pk: None,
440            channels: vec![
441                // Public: key == root.
442                ChannelGrant { id: hex(&[0xa1; 32]), key: hex(&root), epoch: 0, name: "general".into() },
443                // Private: key != root.
444                ChannelGrant { id: hex(&[0xa2; 32]), key: hex(&priv_key), epoch: 1, name: "mods".into() },
445            ],
446            relays: vec!["wss://r".into()],
447            name: "Test".into(),
448            icon: None,
449            expires_at: None,
450            creator_npub: None,
451            label: None,
452            extra: Default::default(),
453        };
454
455        let c = CommunityV2::from_bundle(&bundle, 99).unwrap();
456        assert_eq!(c.owner().unwrap(), owner.public_key());
457        assert!(!c.channels[0].private);
458        assert!(c.channels[1].private);
459        assert_eq!(c.channels[1].key, Some(priv_key));
460        // Public channel reads under the root; private under its own key/epoch.
461        assert_eq!(c.channel_secret(&c.channels[0]), (root, Epoch(0)));
462        assert_eq!(c.channel_secret(&c.channels[1]), (priv_key, Epoch(1)));
463    }
464
465    #[test]
466    fn from_bundle_rejects_an_out_of_range_epoch() {
467        // Epochs aren't covered by the owner commitment, so a hostile bundle can set
468        // root_epoch = u64::MAX to push a downstream `epoch + 1` toward overflow. The
469        // validate bound refuses it even though the owner commitment itself verifies.
470        let owner = Keys::generate();
471        let identity = CommunityIdentity::mint(&owner.public_key());
472        let hex = crate::simd::hex::bytes_to_hex_32;
473        let root = [0x11u8; 32];
474        let bundle = CommunityInvite {
475            community_id: hex(&identity.community_id.0),
476            owner: hex(&identity.owner_xonly),
477            owner_salt: hex(&identity.owner_salt),
478            community_root: hex(&root),
479            root_epoch: u64::MAX,
480            control_pk: None,
481            channels: vec![ChannelGrant { id: hex(&[0xa1; 32]), key: hex(&root), epoch: 0, name: "general".into() }],
482            relays: vec!["wss://r".into()],
483            name: "Overflow".into(),
484            icon: None,
485            expires_at: None,
486            creator_npub: None,
487            label: None,
488            extra: Default::default(),
489        };
490        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an out-of-range epoch is refused");
491    }
492
493    #[test]
494    fn from_bundle_rejects_a_forged_owner_commitment() {
495        let owner = Keys::generate();
496        let attacker = Keys::generate();
497        let identity = CommunityIdentity::mint(&owner.public_key());
498        let hex = crate::simd::hex::bytes_to_hex_32;
499        // Claim the real id but the attacker's key — the commitment won't reproduce it.
500        let bundle = CommunityInvite {
501            community_id: hex(&identity.community_id.0),
502            owner: hex(&attacker.public_key().to_bytes()),
503            owner_salt: hex(&identity.owner_salt),
504            community_root: hex(&[0x11; 32]),
505            root_epoch: 0,
506            control_pk: None,
507            channels: vec![],
508            relays: vec![],
509            name: "X".into(),
510            icon: None,
511            expires_at: None,
512            creator_npub: None,
513            label: None,
514            extra: Default::default(),
515        };
516        assert!(CommunityV2::from_bundle(&bundle, 0).is_err());
517    }
518}