Skip to main content

vector_core/community/v2/
control.rs

1//! The v2 Control Plane (CORD-04 over CORD-01/02).
2//!
3//! Editions keep v1's exact grammar — the `vsk/eid/ev/ep/vac` tags, the frozen
4//! `edition_hash` (`community::version`, label `vector-community/v1/edition`,
5//! which upstream froze verbatim), and the fold rules — but ride the v2 stream
6//! envelope: a kind-3308 UNSIGNED rumor inside a **plaintext seal** (20014) at
7//! `control_pk`. Authorship moves from the rumor's own signature (v1) to the
8//! seal's (v2): [`super::stream::open_wrap`] Schnorr-verifies the seal and pins
9//! `rumor.pubkey == seal.pubkey`, so by the time an edition is parsed here its
10//! author is already proven. The plaintext seal is load-bearing: a compaction
11//! re-wraps the signed seal into a new epoch byte-verbatim, and the signature
12//! (over the rumor string) survives — an encrypted seal could not.
13//!
14//! Two v1↔v2 wire deltas, both deliberate:
15//!   - v2 editions carry NO `["v","1"]` protocol tag (frozen derivations
16//!     partition protocol revisions by address; version tags are the rejected
17//!     mechanism).
18//!   - the owner is proven by the self-certifying `community_id` commitment
19//!     ([`super::derive::verify_community_id`]), not an attestation event —
20//!     vsk 7 is retired.
21
22use nostr_sdk::prelude::nip44::v2::ConversationKey;
23use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent};
24use serde::{Deserialize, Serialize};
25
26use super::super::edition::{AuthorityCitation, EditionError, ParsedEdition, TAG_AUTHORITY_CITATION};
27use super::super::{version, ChannelId, CommunityId, Epoch};
28use super::derive::{control_group_key, control_signer_group_key, verify_community_id, GroupKey};
29use super::stream::{self, OpenedStream, SealForm, StreamError};
30use super::{kind, vsk};
31
32const TAG_SUBKIND: &str = "vsk";
33const TAG_ENTITY: &str = "eid";
34const TAG_EVERSION: &str = "ev";
35const TAG_EPREV: &str = "ep";
36
37/// Protocol-wide UTF-8 byte cap on names (community, channel, role).
38pub const MAX_NAME_BYTES: usize = 64;
39/// UTF-8 byte cap on a community description.
40pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
41
42/// Errors from the control plane layer (envelope errors ride inside).
43#[derive(Debug)]
44pub enum ControlError {
45    Stream(StreamError),
46    Edition(EditionError),
47    /// The rumor isn't a kind-3308 edition.
48    NotAnEdition(u16),
49    /// A control edition arrived in an encrypted seal — CORD-02 §5 requires the
50    /// plaintext form (compaction must preserve signatures), so a strict reader
51    /// drops it rather than folding a chain a re-wrap would later fork.
52    NotPlaintextSealed,
53    /// A name/description exceeds its protocol byte cap.
54    OverCap(&'static str),
55}
56
57impl std::fmt::Display for ControlError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            ControlError::Stream(e) => write!(f, "stream: {e}"),
61            ControlError::Edition(e) => write!(f, "edition: {e:?}"),
62            ControlError::NotAnEdition(k) => write!(f, "rumor kind {k} is not a control edition"),
63            ControlError::NotPlaintextSealed => write!(f, "control edition must ride a plaintext seal"),
64            ControlError::OverCap(what) => write!(f, "{what} exceeds its byte cap"),
65        }
66    }
67}
68
69impl std::error::Error for ControlError {}
70
71impl From<StreamError> for ControlError {
72    fn from(e: StreamError) -> Self {
73        ControlError::Stream(e)
74    }
75}
76
77// ── Identity ─────────────────────────────────────────────────────────────────
78
79/// A v2 community's self-certifying identity triple. The id IS the owner
80/// commitment — carry all three together and verify before trusting any claim.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct CommunityIdentity {
83    pub community_id: CommunityId,
84    pub owner_xonly: [u8; 32],
85    pub owner_salt: [u8; 32],
86}
87
88impl CommunityIdentity {
89    /// Mint a fresh identity for `owner` (a new random salt ⇒ a new community).
90    pub fn mint(owner: &PublicKey) -> CommunityIdentity {
91        let owner_xonly = owner.to_bytes();
92        let owner_salt = super::super::random_32();
93        CommunityIdentity {
94            community_id: super::derive::community_id_of(&owner_xonly, &owner_salt),
95            owner_xonly,
96            owner_salt,
97        }
98    }
99
100    /// True iff the commitment reproduces the id — the ONLY valid owner proof.
101    pub fn verify(&self) -> bool {
102        verify_community_id(&self.community_id, &self.owner_xonly, &self.owner_salt)
103    }
104
105    /// The proven owner as a `PublicKey` (call only after [`Self::verify`]).
106    pub fn owner(&self) -> Result<PublicKey, String> {
107        PublicKey::from_slice(&self.owner_xonly).map_err(|e| e.to_string())
108    }
109}
110
111// ── Edition rumors (build + parse) ───────────────────────────────────────────
112
113/// Build the unsigned kind-3308 edition rumor — v1's grammar minus the protocol
114/// `v` tag. Control editions carry no `ms` tag: they fold by version, not time.
115#[allow(clippy::too_many_arguments)]
116pub fn build_edition_rumor(
117    author: PublicKey,
118    vsk: &str,
119    entity_id: &[u8; 32],
120    version: u64,
121    prev_hash: Option<&[u8; 32]>,
122    content: &str,
123    created_at_secs: u64,
124    authority: Option<&AuthorityCitation>,
125) -> UnsignedEvent {
126    let mut tags = vec![
127        Tag::custom(TAG_SUBKIND, [vsk.to_string()]),
128        Tag::custom(TAG_ENTITY, [crate::simd::hex::bytes_to_hex_32(entity_id)]),
129        Tag::custom(TAG_EVERSION, [version.to_string()]),
130    ];
131    if let Some(p) = prev_hash {
132        tags.push(Tag::custom(TAG_EPREV, [crate::simd::hex::bytes_to_hex_32(p)]));
133    }
134    if let Some(a) = authority {
135        tags.push(a.to_tag());
136    }
137    stream::build_rumor_secs(kind::CONTROL, author, content, tags, created_at_secs)
138}
139
140/// Parse an edition from an ALREADY-VERIFIED rumor (one produced by
141/// [`stream::open_wrap`], which proved the seal signature, the author binding,
142/// and the rumor id). No signature lives on the rumor itself — never feed this
143/// a rumor that didn't come through the stream verifier.
144pub fn parse_edition_rumor(rumor: &UnsignedEvent) -> Result<ParsedEdition, ControlError> {
145    if rumor.kind.as_u16() != kind::CONTROL {
146        return Err(ControlError::NotAnEdition(rumor.kind.as_u16()));
147    }
148    // Duplicate machinery tags make signed-bytes → canonical-fields ambiguous
149    // (two clients could pick different duplicates and fork on self_hash).
150    for name in [TAG_SUBKIND, TAG_ENTITY, TAG_EVERSION, TAG_EPREV, TAG_AUTHORITY_CITATION] {
151        let count = rumor
152            .tags
153            .iter()
154            .filter(|t| t.as_slice().first().map(|s| s.as_str() == name).unwrap_or(false))
155            .count();
156        if count > 1 {
157            return Err(ControlError::Edition(EditionError::BadField("duplicate authority tag")));
158        }
159    }
160    let get = |name: &str| -> Option<String> {
161        rumor.tags.iter().find_map(|t| {
162            let s = t.as_slice();
163            (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
164        })
165    };
166    let decode_hash = |hex: &str, field: &'static str| -> Result<[u8; 32], ControlError> {
167        if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
168            return Err(ControlError::Edition(EditionError::BadField(field)));
169        }
170        Ok(crate::simd::hex::hex_to_bytes_32(hex))
171    };
172    let vsk = get(TAG_SUBKIND).ok_or(ControlError::Edition(EditionError::MissingField("vsk")))?;
173    let entity_id = decode_hash(&get(TAG_ENTITY).ok_or(ControlError::Edition(EditionError::MissingField("eid")))?, "eid")?;
174    // Decimal, no leading zeros (CORD-01 Encoding): `u64::from_str` accepts a leading
175    // `+`, and "007"/"7" are distinct signed rumors that fold to the same version — a
176    // same-version convergence fork a strict peer (which drops the padded form)
177    // resolves differently. Reject any non-canonical decimal, matching the `ms` rule.
178    let ev_raw = get(TAG_EVERSION).ok_or(ControlError::Edition(EditionError::MissingField("ev")))?;
179    if !crate::community::edition::is_tag_decimal(&ev_raw) {
180        return Err(ControlError::Edition(EditionError::BadField("ev")));
181    }
182    let version: u64 = ev_raw.parse().map_err(|_| ControlError::Edition(EditionError::BadField("ev")))?;
183    let prev_hash = match get(TAG_EPREV) {
184        Some(h) => Some(decode_hash(&h, "ep")?),
185        None => None,
186    };
187    let self_hash = version::edition_hash(&entity_id, version, prev_hash.as_ref(), rumor.content.as_bytes());
188    Ok(ParsedEdition {
189        author: rumor.pubkey,
190        vsk,
191        entity_id,
192        version,
193        prev_hash,
194        content: rumor.content.clone(),
195        self_hash,
196        created_at: rumor.created_at.as_secs(),
197        inner_id: rumor.id.expect("verified rumors carry their id").to_bytes(),
198        authority: AuthorityCitation::from_tags(&rumor.tags),
199    })
200}
201
202// ── The Control Plane view (CORD-02 §2/§5) ───────────────────────────────────
203
204/// The Control Plane as this member holds it for one epoch.
205///
206/// A SPLIT epoch (the community carries a held `control_pk`) subscribes and
207/// verifies by that address while decrypting under the community_root-derived
208/// read key; its signer is present only when this member holds the epoch's
209/// `control_root` AND it derives to the held address (staff, fail-closed on a
210/// corrupt secret). A LEGACY epoch is the `concord/control` derivation whole —
211/// address, signer, and encryption in one key every member holds.
212#[derive(Clone)]
213pub struct ControlPlane {
214    pk: PublicKey,
215    conv_key: ConversationKey,
216    signer: Option<Keys>,
217    restricted: bool,
218}
219
220impl ControlPlane {
221    /// The CURRENT epoch's view of `community`'s Control Plane.
222    pub fn of(community: &super::community::CommunityV2) -> ControlPlane {
223        let read = control_group_key(&community.community_root, community.id(), community.root_epoch);
224        match community.control_pk {
225            None => ControlPlane {
226                pk: read.pk(),
227                conv_key: read.conv_key().clone(),
228                signer: Some(read.keys().clone()),
229                restricted: false,
230            },
231            Some(pk) => {
232                // A held secret that does not derive to the held address is
233                // corrupt state, not a signer: fail closed to read-only rather
234                // than mint wraps at an address nobody subscribes to.
235                let signer = community.control_root.and_then(|cr| {
236                    let s = control_signer_group_key(&cr, community.id(), community.root_epoch);
237                    (s.pk() == pk).then(|| s.keys().clone())
238                });
239                ControlPlane { pk, conv_key: read.conv_key().clone(), signer, restricted: true }
240            }
241        }
242    }
243
244    /// A whole legacy plane view from its single group key (tests, migrations).
245    pub fn legacy(group: &GroupKey) -> ControlPlane {
246        ControlPlane {
247            pk: group.pk(),
248            conv_key: group.conv_key().clone(),
249            signer: Some(group.keys().clone()),
250            restricted: false,
251        }
252    }
253
254    /// The plane address — what `authors` filters and wrap-author checks match.
255    pub fn pk(&self) -> PublicKey {
256        self.pk
257    }
258
259    pub fn pk_hex(&self) -> String {
260        self.pk.to_hex()
261    }
262
263    /// Whether this member can PUBLISH to the plane (CORD-02 §2).
264    pub fn can_write(&self) -> bool {
265        self.signer.is_some()
266    }
267
268    /// The signer Keys when held (NIP-42 auth as the plane, staff only on a
269    /// split epoch).
270    pub fn signer_keys(&self) -> Option<&Keys> {
271        self.signer.as_ref()
272    }
273
274    /// The WRITE group: address, signing secret, and read conv_key composed for
275    /// the seal/wrap builders. Errs when the epoch is split and this member does
276    /// not hold its `control_root` — publishing there would mint a wrap that
277    /// fails the plane's signature check at every reader and relay.
278    pub fn write_group(&self) -> Result<GroupKey, String> {
279        match &self.signer {
280            Some(keys) => Ok(GroupKey::from_parts(keys.clone(), self.conv_key.clone())),
281            None => Err("only community staff hold this community's write key; ask an admin to re-send your promotion".to_string()),
282        }
283    }
284
285    /// Open a control wrap under this view — [`open_control_edition`] with the
286    /// split's mandatory wrap-signature check on a restricted plane (CORD-01
287    /// Write-Restricted Streams: the signature IS the write gate there).
288    pub fn open(&self, wrap: &Event) -> Result<(ParsedEdition, OpenedStream), ControlError> {
289        let opened = stream::open_wrap_at(wrap, &self.pk, &self.conv_key, self.restricted)?;
290        if opened.seal_form != SealForm::Plaintext {
291            return Err(ControlError::NotPlaintextSealed);
292        }
293        let edition = parse_edition_rumor(&opened.rumor)?;
294        Ok((edition, opened))
295    }
296}
297
298impl std::fmt::Debug for ControlPlane {
299    // No key material in logs — address only.
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        f.debug_struct("ControlPlane")
302            .field("pk", &self.pk_hex())
303            .field("restricted", &self.restricted)
304            .field("writable", &self.can_write())
305            .finish()
306    }
307}
308
309/// The SPLIT write group for a freshly minted epoch (genesis / a Refounding's
310/// new plane): the `control_root`-derived signer with the `community_root`-
311/// derived read key (CORD-02 §5).
312pub fn split_write_group(control_root: &[u8; 32], community_root: &[u8; 32], community_id: &CommunityId, epoch: Epoch) -> GroupKey {
313    let signer = control_signer_group_key(control_root, community_id, epoch);
314    let read = control_group_key(community_root, community_id, epoch);
315    GroupKey::from_parts(signer.keys().clone(), read.conv_key().clone())
316}
317
318// ── Seal / open over the stream ──────────────────────────────────────────────
319
320/// Seal a signed-by-`author_keys` edition rumor into a control-plane wrap.
321/// Local-keys convenience; bunker accounts use [`stream::seal_content`] +
322/// their remote signer + [`stream::wrap_seal`] for identical wire output.
323pub fn seal_control_edition(
324    rumor: &UnsignedEvent,
325    group: &GroupKey,
326    author_keys: &Keys,
327    wrap_at: Timestamp,
328) -> Result<(Event, Keys), ControlError> {
329    let seal = stream::build_seal(rumor, SealForm::Plaintext, group, author_keys)?;
330    Ok(stream::wrap_seal(&seal, group, stream::KIND_WRAP, wrap_at)?)
331}
332
333/// Signer-driven twin of [`seal_control_edition`] for bunker / NIP-55 accounts:
334/// the plaintext seal signs through a [`VectorSigner`]. `author` is the identity
335/// the signer signs as (must equal `my_public_key()`). Wire-identical output.
336pub async fn seal_control_edition_signed<S: crate::signer::VectorSigner + ?Sized>(
337    signer: &S,
338    author: PublicKey,
339    rumor: &UnsignedEvent,
340    group: &GroupKey,
341    wrap_at: Timestamp,
342) -> Result<(Event, Keys), ControlError> {
343    Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Plaintext, group, stream::KIND_WRAP, wrap_at, &[]).await?)
344}
345
346/// Open a control-plane wrap into a verified, parsed edition. Strict on both
347/// gates: the rumor must be kind 3308, and the seal must be the plaintext form.
348pub fn open_control_edition(wrap: &Event, group: &GroupKey) -> Result<(ParsedEdition, OpenedStream), ControlError> {
349    let opened = stream::open_wrap(wrap, group)?;
350    if opened.seal_form != SealForm::Plaintext {
351        return Err(ControlError::NotPlaintextSealed);
352    }
353    let edition = parse_edition_rumor(&opened.rumor)?;
354    Ok((edition, opened))
355}
356
357// ── Entity payloads (vsk 0 / vsk 2) ──────────────────────────────────────────
358
359/// An encrypted-blob image pointer (icon/banner): the media server sees only an
360/// opaque blob; members fetch, decrypt with `key`/`nonce`, and verify `hash`.
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362pub struct ImageRef {
363    pub url: String,
364    pub key: String,
365    pub nonce: String,
366    pub hash: String,
367    /// Unknown fields round-trip (e.g. Vector's `ext`) — editors MUST preserve
368    /// what they don't understand (CORD-02 §6).
369    #[serde(flatten)]
370    pub extra: serde_json::Map<String, serde_json::Value>,
371}
372
373impl ImageRef {
374    /// Bridge to the v1 image-ref shape so the shared download/decrypt/cache
375    /// plumbing serves both protocols. `ext` rides the flattened extras; fall
376    /// back to the URL's extension, else png.
377    pub fn to_community_image(&self) -> crate::community::CommunityImage {
378        let ext = self
379            .extra
380            .get("ext")
381            .and_then(|v| v.as_str())
382            .filter(|s| !s.is_empty())
383            .map(str::to_string)
384            .or_else(|| {
385                std::path::Path::new(&self.url)
386                    .extension()
387                    .and_then(|e| e.to_str())
388                    .map(|s| s.to_lowercase())
389            })
390            .unwrap_or_else(|| "png".to_string());
391        crate::community::CommunityImage {
392            url: self.url.clone(),
393            key: self.key.clone(),
394            nonce: self.nonce.clone(),
395            hash: self.hash.clone(),
396            ext,
397        }
398    }
399}
400
401/// Community metadata — the vsk-0 entity content (CORD-02 §6). `eid` = the
402/// community_id itself; gated by `MANAGE_METADATA`.
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
404pub struct CommunityMetadata {
405    pub name: String,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub description: Option<String>,
408    #[serde(default, skip_serializing_if = "Vec::is_empty")]
409    pub relays: Vec<String>,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub icon: Option<ImageRef>,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub banner: Option<ImageRef>,
414    /// Client-extensible opaque object; folds atomically with the entity.
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub custom: Option<serde_json::Map<String, serde_json::Value>>,
417    /// Reserved-for-protocol unknown top-level fields, round-tripped verbatim.
418    #[serde(flatten)]
419    pub extra: serde_json::Map<String, serde_json::Value>,
420}
421
422/// Channel metadata — the vsk-2 entity content (CORD-03 §2). `eid` = the
423/// channel_id; gated by `MANAGE_CHANNELS`. Absent flags mean false; deletion is
424/// terminal.
425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
426pub struct ChannelMetadata {
427    pub name: String,
428    pub private: bool,
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub voice: Option<bool>,
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub deleted: Option<bool>,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub custom: Option<serde_json::Map<String, serde_json::Value>>,
435    #[serde(flatten)]
436    pub extra: serde_json::Map<String, serde_json::Value>,
437}
438
439/// Enforce the protocol byte caps before an edition is built (a strict reader
440/// may drop over-cap state; never publish what peers would refuse).
441pub fn validate_community_metadata(meta: &CommunityMetadata) -> Result<(), ControlError> {
442    if meta.name.len() > MAX_NAME_BYTES {
443        return Err(ControlError::OverCap("name"));
444    }
445    if meta.description.as_ref().is_some_and(|d| d.len() > MAX_DESCRIPTION_BYTES) {
446        return Err(ControlError::OverCap("description"));
447    }
448    Ok(())
449}
450
451pub fn validate_channel_metadata(meta: &ChannelMetadata) -> Result<(), ControlError> {
452    if meta.name.len() > MAX_NAME_BYTES {
453        return Err(ControlError::OverCap("name"));
454    }
455    Ok(())
456}
457
458// ── Genesis (CORD-02 §1) ─────────────────────────────────────────────────────
459
460/// The two wraps of a community genesis — exactly two owner-signed editions:
461/// the community metadata (vsk 0) and one public `#general` channel (vsk 2).
462/// Nothing more — no default roles, no scaffolding.
463pub struct Genesis {
464    pub identity: CommunityIdentity,
465    /// The community_root minted for epoch 0.
466    pub community_root: [u8; 32],
467    /// The staff write key minted beside it (CORD-02 §2) — held by the owner
468    /// alone until staff are promoted. Its derived pk is the plane's address.
469    pub control_root: [u8; 32],
470    pub general_channel_id: ChannelId,
471    /// `[metadata wrap, #general wrap]`, both sealed at the epoch-0 control_pk.
472    pub wraps: [Event; 2],
473}
474
475impl Genesis {
476    /// The epoch-0 Control Plane address the split minted (CORD-02 §5).
477    pub fn control_pk(&self) -> PublicKey {
478        control_signer_group_key(&self.control_root, &self.identity.community_id, Epoch(0)).pk()
479    }
480}
481
482/// Mint a v2 community: fresh identity (salt-committed to the owner), fresh
483/// community_root + control_root (the split, CORD-02 §2), and the two genesis
484/// editions sealed at the epoch-0 control plane — signed by the control_root-
485/// derived signer, readable under the community_root-derived read key. The
486/// caller persists the secrets and publishes the wraps.
487pub fn genesis(owner_keys: &Keys, mut metadata: CommunityMetadata, at_secs: u64) -> Result<Genesis, ControlError> {
488    validate_community_metadata(&metadata)?;
489    // Relays ride the metadata entity so they can evolve by edit; cap on write.
490    metadata.relays.truncate(super::super::MAX_COMMUNITY_RELAYS);
491
492    let identity = CommunityIdentity::mint(&owner_keys.public_key());
493    let community_root = super::super::random_32();
494    let control_root = super::super::random_32();
495    let general_channel_id = ChannelId(super::super::random_32());
496    let group = split_write_group(&control_root, &community_root, &identity.community_id, Epoch(0));
497
498    let meta_json = serde_json::to_string(&metadata).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
499    let meta_rumor = build_edition_rumor(
500        owner_keys.public_key(),
501        vsk::COMMUNITY_METADATA,
502        &identity.community_id.0,
503        1,
504        None,
505        &meta_json,
506        at_secs,
507        None,
508    );
509
510    let general = ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
511    let general_json = serde_json::to_string(&general).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
512    let general_rumor = build_edition_rumor(
513        owner_keys.public_key(),
514        vsk::CHANNEL_METADATA,
515        &general_channel_id.0,
516        1,
517        None,
518        &general_json,
519        at_secs,
520        None,
521    );
522
523    let (meta_wrap, _) = seal_control_edition(&meta_rumor, &group, owner_keys, Timestamp::from_secs(at_secs))?;
524    let (general_wrap, _) = seal_control_edition(&general_rumor, &group, owner_keys, Timestamp::from_secs(at_secs))?;
525
526    Ok(Genesis {
527        identity,
528        community_root,
529        control_root,
530        general_channel_id,
531        wraps: [meta_wrap, general_wrap],
532    })
533}
534
535/// Signer-driven twin of [`genesis`] for bunker / NIP-55 accounts: mints from the
536/// owner's public key and seals the two genesis editions through a [`VectorSigner`].
537/// `owner_pk` must equal `my_public_key()` (the identity the signer signs as).
538pub async fn genesis_signed<S: crate::signer::VectorSigner + ?Sized>(
539    owner_pk: PublicKey,
540    signer: &S,
541    metadata: CommunityMetadata,
542    at_secs: u64,
543) -> Result<Genesis, ControlError> {
544    genesis_signed_with_primary(owner_pk, signer, metadata, at_secs, None).await
545}
546
547/// [`genesis_signed`] with an OPTIONAL explicit primary-channel id + name — the
548/// migration-only path (§migration) so the v2 twin's #general reuses the v1 primary
549/// channel id and history stitches through the flip. `None` mints a fresh id (the
550/// ordinary create path).
551pub async fn genesis_signed_with_primary<S: crate::signer::VectorSigner + ?Sized>(
552    owner_pk: PublicKey,
553    signer: &S,
554    mut metadata: CommunityMetadata,
555    at_secs: u64,
556    primary: Option<(ChannelId, String)>,
557) -> Result<Genesis, ControlError> {
558    validate_community_metadata(&metadata)?;
559    metadata.relays.truncate(super::super::MAX_COMMUNITY_RELAYS);
560
561    let identity = CommunityIdentity::mint(&owner_pk);
562    let community_root = super::super::random_32();
563    let control_root = super::super::random_32();
564    let (general_channel_id, general_name) = match primary {
565        Some((id, name)) => (id, name),
566        None => (ChannelId(super::super::random_32()), "general".to_string()),
567    };
568    let group = split_write_group(&control_root, &community_root, &identity.community_id, Epoch(0));
569
570    let meta_json = serde_json::to_string(&metadata).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
571    let meta_rumor = build_edition_rumor(owner_pk, vsk::COMMUNITY_METADATA, &identity.community_id.0, 1, None, &meta_json, at_secs, None);
572
573    let general = ChannelMetadata { name: general_name, private: false, ..Default::default() };
574    let general_json = serde_json::to_string(&general).map_err(|e| ControlError::Stream(StreamError::Parse(e.to_string())))?;
575    let general_rumor = build_edition_rumor(owner_pk, vsk::CHANNEL_METADATA, &general_channel_id.0, 1, None, &general_json, at_secs, None);
576
577    let (meta_wrap, _) = seal_control_edition_signed(signer, owner_pk, &meta_rumor, &group, Timestamp::from_secs(at_secs)).await?;
578    let (general_wrap, _) = seal_control_edition_signed(signer, owner_pk, &general_rumor, &group, Timestamp::from_secs(at_secs)).await?;
579
580    Ok(Genesis {
581        identity,
582        community_root,
583        control_root,
584        general_channel_id,
585        wraps: [meta_wrap, general_wrap],
586    })
587}
588
589#[cfg(test)]
590mod tests {
591    use nostr_sdk::prelude::FinalizeEvent;
592    use super::super::super::edition::build_edition_inner;
593    use super::*;
594
595    fn cid() -> CommunityId {
596        CommunityId([0x33; 32])
597    }
598
599    fn group_at(epoch: u64) -> GroupKey {
600        control_group_key(&[0x44; 32], &cid(), Epoch(epoch))
601    }
602
603    fn simple_edition(author: &Keys, version: u64, prev: Option<&[u8; 32]>) -> UnsignedEvent {
604        build_edition_rumor(
605            author.public_key(),
606            vsk::GRANT,
607            &[0x55; 32],
608            version,
609            prev,
610            "{\"member\":\"aa\",\"role_ids\":[]}",
611            1_700_000_000,
612            None,
613        )
614    }
615
616    #[test]
617    fn edition_round_trips_through_the_control_plane() {
618        let owner = Keys::generate();
619        let group = group_at(0);
620        let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
621        let rumor = build_edition_rumor(
622            owner.public_key(),
623            vsk::GRANT,
624            &[0x55; 32],
625            2,
626            Some(&[0x66; 32]),
627            "{\"member\":\"aa\",\"role_ids\":[]}",
628            1_700_000_000,
629            Some(&cite),
630        );
631        let (wrap, _) = seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(1_700_000_001)).unwrap();
632
633        let (edition, opened) = open_control_edition(&wrap, &group).unwrap();
634        assert_eq!(edition.author, owner.public_key());
635        assert_eq!(edition.vsk, vsk::GRANT);
636        assert_eq!(edition.entity_id, [0x55; 32]);
637        assert_eq!(edition.version, 2);
638        assert_eq!(edition.prev_hash, Some([0x66; 32]));
639        assert_eq!(edition.authority.as_ref(), Some(&cite));
640        assert_eq!(opened.seal_form, SealForm::Plaintext);
641        // self_hash matches the canonical recomputation.
642        assert_eq!(
643            edition.self_hash,
644            version::edition_hash(&[0x55; 32], 2, Some(&[0x66; 32]), rumor.content.as_bytes())
645        );
646    }
647
648    #[test]
649    fn v2_edition_tags_carry_no_protocol_version_tag() {
650        // FROZEN: the v2 tag set is exactly vsk/eid/ev(+ep/vac) — a `v` tag is
651        // the rejected versioning mechanism (address partitioning does the job).
652        let owner = Keys::generate();
653        let rumor = simple_edition(&owner, 1, None);
654        assert!(
655            !rumor.tags.iter().any(|t| t.as_slice().first().map(|s| s == "v").unwrap_or(false)),
656            "v2 editions must not carry a protocol version tag"
657        );
658        // And no ms tag — editions fold by version, not time.
659        assert!(!rumor.tags.iter().any(|t| t.as_slice().first().map(|s| s == "ms").unwrap_or(false)));
660    }
661
662    #[test]
663    fn edition_hash_is_identical_across_protocols() {
664        // The edition hash is the ONE construction both protocols share (upstream
665        // froze v1's byte layout, label included). The same logical edition must
666        // hash identically whether built as a v1 signed inner or a v2 rumor —
667        // this is what makes the fold engine shareable.
668        let author = Keys::generate();
669        let entity = [0x55; 32];
670        let content = "{\"member\":\"aa\",\"role_ids\":[]}";
671        let v1_inner = build_edition_inner(author.public_key(), "3", &entity, 2, Some(&[0x66; 32]), content, 100, None)
672            .finalize(&author)
673            .unwrap();
674        let v1_parsed = super::super::super::edition::parse_edition_inner(&v1_inner).unwrap();
675
676        let v2_rumor = build_edition_rumor(author.public_key(), "3", &entity, 2, Some(&[0x66; 32]), content, 100, None);
677        let v2_parsed = parse_edition_rumor(&v2_rumor).unwrap();
678
679        assert_eq!(v1_parsed.self_hash, v2_parsed.self_hash);
680    }
681
682    #[test]
683    fn encrypted_seal_control_edition_is_rejected() {
684        let owner = Keys::generate();
685        let group = group_at(0);
686        let rumor = simple_edition(&owner, 1, None);
687        let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group, &owner).unwrap();
688        let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
689        assert!(matches!(open_control_edition(&wrap, &group), Err(ControlError::NotPlaintextSealed)));
690    }
691
692    #[test]
693    fn non_edition_rumor_is_rejected() {
694        let owner = Keys::generate();
695        let group = group_at(0);
696        let rumor = stream::build_rumor_secs(super::kind::MESSAGE, owner.public_key(), "hi", vec![], 100);
697        let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &owner).unwrap();
698        let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
699        assert!(matches!(open_control_edition(&wrap, &group), Err(ControlError::NotAnEdition(k)) if k == super::kind::MESSAGE));
700    }
701
702    #[test]
703    fn duplicate_machinery_tags_are_rejected() {
704        let owner = Keys::generate();
705        let mut rumor = simple_edition(&owner, 1, None);
706        let dup = Tag::custom("eid", [crate::simd::hex::bytes_to_hex_32(&[0x55; 32])]);
707        let mut tags: Vec<Tag> = rumor.tags.iter().cloned().collect();
708        tags.push(dup);
709        rumor = stream::build_rumor_secs(kind::CONTROL, owner.public_key(), &rumor.content, tags, 100);
710        assert!(matches!(
711            parse_edition_rumor(&rumor),
712            Err(ControlError::Edition(EditionError::BadField("duplicate authority tag")))
713        ));
714    }
715
716    #[test]
717    fn a_non_canonical_ev_version_is_rejected() {
718        let owner = Keys::generate();
719        // A leading-zero (or `+`-prefixed) version is a distinct rumor that folds to
720        // the same numeric version — a same-version fork a strict peer drops.
721        for bad in ["007", "+5", "01"] {
722            let tags = vec![
723                Tag::custom("vsk", [vsk::GRANT]),
724                Tag::custom("eid", [crate::simd::hex::bytes_to_hex_32(&[0x55; 32])]),
725                Tag::custom("ev", [bad]),
726            ];
727            let rumor = stream::build_rumor_secs(kind::CONTROL, owner.public_key(), "{\"member\":\"aa\",\"role_ids\":[]}", tags, 100);
728            assert!(
729                matches!(parse_edition_rumor(&rumor), Err(ControlError::Edition(EditionError::BadField("ev")))),
730                "a non-canonical ev {bad:?} is rejected"
731            );
732        }
733    }
734
735    #[test]
736    fn compaction_rewrap_preserves_the_edition_chain_identity() {
737        // The whole reason control seals are plaintext: carry a signed head into
738        // a new epoch and its self_hash + authorship must be untouched, so a
739        // fresh joiner folds the same chain the old epoch held.
740        let owner = Keys::generate();
741        let e0 = group_at(0);
742        let rumor = simple_edition(&owner, 3, Some(&[0x77; 32]));
743        let (wrap, _) = seal_control_edition(&rumor, &e0, &owner, Timestamp::from_secs(10)).unwrap();
744        let (edition, opened) = open_control_edition(&wrap, &e0).unwrap();
745
746        let e1 = group_at(1);
747        let (rewrapped, _) = stream::rewrap_seal(&opened.seal, &e1, Timestamp::from_secs(20)).unwrap();
748        let (re_edition, _) = open_control_edition(&rewrapped, &e1).unwrap();
749
750        assert_eq!(re_edition.self_hash, edition.self_hash);
751        assert_eq!(re_edition.author, edition.author);
752        assert_eq!(re_edition.inner_id, edition.inner_id, "rumor id survives compaction");
753    }
754
755    #[test]
756    fn editions_opened_from_wraps_fold_to_the_head() {
757        let owner = Keys::generate();
758        let group = group_at(0);
759        let entity = [0x55; 32];
760        let content = |v: u64| format!("{{\"v\":{v}}}");
761
762        // Build a 3-link chain v1 → v2 → v3.
763        let mut prev: Option<[u8; 32]> = None;
764        let mut parsed = Vec::new();
765        for v in 1..=3u64 {
766            let rumor = build_edition_rumor(owner.public_key(), vsk::GRANT, &entity, v, prev.as_ref(), &content(v), 100 + v, None);
767            let (wrap, _) = seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(100 + v)).unwrap();
768            let (edition, _) = open_control_edition(&wrap, &group).unwrap();
769            prev = Some(edition.self_hash);
770            parsed.push(edition);
771        }
772
773        let fold_editions: Vec<version::Edition> = parsed.iter().map(|p| p.to_fold_edition()).collect();
774        let folded = version::fold(&fold_editions, 0, None);
775        assert_eq!(fold_editions[folded.head.expect("chain folds")].version, 3);
776        assert!(!folded.gap);
777
778        // Withhold the middle link: the chain gaps at v1 (fail-closed signal).
779        let partial = [fold_editions[0].clone(), fold_editions[2].clone()];
780        let gapped = version::fold(&partial, 0, None);
781        assert_eq!(partial[gapped.head.expect("genesis edition anchors")].version, 1);
782        assert!(gapped.gap, "a missing middle version is a gap, not a silent skip");
783    }
784
785    #[test]
786    fn genesis_mints_a_verifiable_two_edition_community() {
787        let owner = Keys::generate();
788        let meta = CommunityMetadata {
789            name: "Vector".into(),
790            description: Some("Private messaging, no compromises.".into()),
791            relays: vec!["wss://jskitty.com/nostr".into()],
792            ..Default::default()
793        };
794        let g = genesis(&owner, meta, 1_700_000_000).unwrap();
795
796        // The identity self-certifies and names the owner.
797        assert!(g.identity.verify());
798        assert_eq!(g.identity.owner().unwrap(), owner.public_key());
799
800        // Exactly two editions, both owner-signed, both openable at epoch 0 —
801        // at the SPLIT address (the control_root-derived signer, CORD-02 §2).
802        let group = split_write_group(&g.control_root, &g.community_root, &g.identity.community_id, Epoch(0));
803        assert_eq!(group.pk(), g.control_pk(), "the plane address is the signer's pk");
804        assert_ne!(group.pk(), control_group_key(&g.community_root, &g.identity.community_id, Epoch(0)).pk(), "never the legacy address");
805        let (meta_ed, _) = open_control_edition(&g.wraps[0], &group).unwrap();
806        let (chan_ed, _) = open_control_edition(&g.wraps[1], &group).unwrap();
807        assert_eq!(meta_ed.author, owner.public_key());
808        assert_eq!(chan_ed.author, owner.public_key());
809        assert_eq!(meta_ed.vsk, vsk::COMMUNITY_METADATA);
810        assert_eq!(chan_ed.vsk, vsk::CHANNEL_METADATA);
811        // Metadata's coordinate IS the community id; the channel's its channel id.
812        assert_eq!(meta_ed.entity_id, g.identity.community_id.0);
813        assert_eq!(chan_ed.entity_id, g.general_channel_id.0);
814        // Both are genesis editions: version 1, no prev, no citation (owner is supreme).
815        for e in [&meta_ed, &chan_ed] {
816            assert_eq!(e.version, 1);
817            assert_eq!(e.prev_hash, None);
818            assert_eq!(e.authority, None);
819        }
820        let general: ChannelMetadata = serde_json::from_str(&chan_ed.content).unwrap();
821        assert_eq!(general.name, "general");
822        assert!(!general.private);
823    }
824
825    #[test]
826    fn a_member_view_reads_the_split_plane_but_cannot_write_and_a_bad_wrap_sig_is_refused() {
827        // CORD-01 Write-Restricted Streams / CORD-02 §2: every member holds the
828        // address + read key; only a control_root holder can mint a wrap that
829        // verifies there — and on a restricted view the wrap signature is the
830        // write gate, so the reader MUST check it.
831        let owner = Keys::generate();
832        let g = genesis(&owner, CommunityMetadata { name: "Gate".into(), ..Default::default() }, 1_000).unwrap();
833        let mut member = super::super::community::CommunityV2::from_genesis(&g, "Gate", None, vec![], 0);
834        member.control_root = None; // a member holds the address, never the secret
835        let view = ControlPlane::of(&member);
836        assert!(!view.can_write());
837        let err = view.write_group().unwrap_err();
838        assert!(err.contains("staff"), "a readable refusal, not a panic: {err}");
839
840        // The member READS a staff wrap fine…
841        let (ed, _) = view.open(&g.wraps[0]).unwrap();
842        assert_eq!(ed.author, owner.public_key());
843
844        // …and a corrupt secret fails closed to the same read-only view.
845        member.control_root = Some([0x77u8; 32]);
846        assert!(!ControlPlane::of(&member).can_write(), "a non-deriving secret is corrupt state, not a signer");
847
848        // A wrap whose signature doesn't verify is refused on the restricted
849        // view — the best a read-key holder can do is claim the address with a
850        // signature that cannot check out.
851        let mut json: serde_json::Value = serde_json::from_str(&g.wraps[0].as_json()).unwrap();
852        json["sig"] = serde_json::Value::String("aa".repeat(64));
853        let tampered = Event::from_json(json.to_string()).unwrap();
854        member.control_root = None;
855        assert!(
856            matches!(ControlPlane::of(&member).open(&tampered), Err(ControlError::Stream(StreamError::BadWrapSignature))),
857            "the wrap signature IS the write gate on a restricted stream"
858        );
859    }
860
861    #[test]
862    fn a_forged_identity_fails_the_commitment() {
863        let owner = Keys::generate();
864        let attacker = Keys::generate();
865        let real = CommunityIdentity::mint(&owner.public_key());
866        // An attacker claiming the real community id with their own key + any salt
867        // needs a second preimage — verify() must fail.
868        let forged = CommunityIdentity {
869            community_id: real.community_id,
870            owner_xonly: attacker.public_key().to_bytes(),
871            owner_salt: real.owner_salt,
872        };
873        assert!(!forged.verify());
874    }
875
876    #[test]
877    fn metadata_caps_and_unknown_field_round_trip() {
878        let over_name = CommunityMetadata { name: "x".repeat(MAX_NAME_BYTES + 1), ..Default::default() };
879        assert!(matches!(validate_community_metadata(&over_name), Err(ControlError::OverCap("name"))));
880        let over_desc = CommunityMetadata {
881            name: "ok".into(),
882            description: Some("d".repeat(MAX_DESCRIPTION_BYTES + 1)),
883            ..Default::default()
884        };
885        assert!(matches!(validate_community_metadata(&over_desc), Err(ControlError::OverCap("description"))));
886        // The cap is BYTES, not chars: 22 three-byte chars = 66 bytes > 64.
887        let multibyte = ChannelMetadata { name: "€".repeat(22), private: false, ..Default::default() };
888        assert!(matches!(validate_channel_metadata(&multibyte), Err(ControlError::OverCap("name"))));
889
890        // Round-trip discipline: unknown top-level fields, unknown icon fields,
891        // and the custom object all survive a parse → serialize cycle.
892        let wire = r#"{"name":"Vector","relays":["wss://a"],"icon":{"url":"u","key":"k","nonce":"n","hash":"h","ext":"png"},"custom":{"rules":"Be excellent."},"future_field":{"deep":[1,2]}}"#;
893        let parsed: CommunityMetadata = serde_json::from_str(wire).unwrap();
894        let out = serde_json::to_string(&parsed).unwrap();
895        let reparsed: serde_json::Value = serde_json::from_str(&out).unwrap();
896        assert_eq!(reparsed["future_field"]["deep"][1], 2);
897        assert_eq!(reparsed["icon"]["ext"], "png");
898        assert_eq!(reparsed["custom"]["rules"], "Be excellent.");
899    }
900}