diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index c554200..abe2b14 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -5133,6 +5133,7 @@ impl ConnectionManager { group_public_key: payload.group_public_key, admin: payload.admin, created_at: now_ms(), + canonical_root_post_id: payload.canonical_root_post_id, }; let _ = storage.create_group_key(&record, None); diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 1d058ae..98ba145 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1721,6 +1721,7 @@ impl Node { group_public_key: gk.group_public_key, admin: self.node_id, member_keys: vec![mk], + canonical_root_post_id: gk.canonical_root_post_id, }) } Err(e) => { @@ -1758,6 +1759,120 @@ impl Node { /// Create a group key for a circle (called on circle creation). async fn create_group_key_for_circle(&self, circle_name: &str) -> anyhow::Result<()> { + self.create_group_key_inner(circle_name, None).await + } + + // ---- Groups (v0.6.2) ---- + + /// Create a new group anchored at `root_post_id`. Unlike circles, groups + /// are many-way: every member can post to the group once they've + /// received the wrapped group seed. Returns the `(GroupId, circle_name)` + /// pair used internally; the circle_name is synthesised from the root + /// post id so there's no user-visible naming step. + pub async fn create_group_from_post( + &self, + root_post_id: PostId, + initial_members: Vec, + ) -> anyhow::Result<(crate::types::GroupId, String)> { + let circle_name = format!("group:{}", hex::encode(&root_post_id[..6])); + + // Create the backing circle row + initialize group key with + // canonical_root_post_id set, then add each initial member (which + // wraps + distributes the key). + { + let storage = self.storage.get().await; + storage.create_circle(&circle_name)?; + } + self.create_group_key_inner(&circle_name, Some(root_post_id)).await?; + + for member in initial_members { + if member == self.node_id { + continue; + } + if let Err(e) = self.add_to_circle(circle_name.clone(), member).await { + warn!(member = hex::encode(member), error = %e, "failed to add group member"); + } + } + + let group_id = { + let storage = self.storage.get().await; + storage.get_group_key_by_circle(&circle_name)? + .map(|gk| gk.group_id) + .ok_or_else(|| anyhow::anyhow!("group key missing after creation"))? + }; + + info!( + root = hex::encode(root_post_id), + group_id = hex::encode(group_id), + circle_name = %circle_name, + "Created group from post" + ); + Ok((group_id, circle_name)) + } + + /// Post to a group anchored at `root_post_id`. Any member holding the + /// group seed can call this. Encrypts the content with the group key and + /// records a `ThreadMeta` link from the new post back to the root so + /// `list_group_posts_by_root` can later cluster all contributions. + pub async fn post_to_group( + &self, + root_post_id: PostId, + content: String, + attachment_data: Vec<(Vec, String)>, + ) -> anyhow::Result<(PostId, Post, PostVisibility)> { + let circle_name = { + let storage = self.storage.get().await; + storage.get_group_by_canonical_root(&root_post_id)? + .map(|gk| gk.circle_name) + .ok_or_else(|| anyhow::anyhow!("no group found for canonical root post"))? + }; + + let result = self.create_post_with_visibility( + content, + VisibilityIntent::Circle(circle_name), + attachment_data, + ).await?; + + // Link the new post back to the canonical root so the group can be + // reconstructed by `list_group_posts_by_root`. + { + let storage = self.storage.get().await; + storage.store_thread_meta(&crate::types::ThreadMeta { + post_id: result.0, + parent_post_id: root_post_id, + })?; + } + + Ok(result) + } + + /// List all posts that belong to the group rooted at `root_post_id`. + /// Reads the ThreadMeta parent index + returns the full posts. Callers + /// decrypt as needed (same as any other GroupEncrypted content). + pub async fn list_group_posts_by_root( + &self, + root_post_id: PostId, + ) -> anyhow::Result> { + let storage = self.storage.get().await; + let child_ids = storage.get_thread_children(&root_post_id)?; + let mut out = Vec::with_capacity(child_ids.len()); + for pid in child_ids { + if let Some((post, vis)) = storage.get_post_with_visibility(&pid)? { + out.push((pid, post, vis)); + } + } + Ok(out) + } + + // ---- end Groups ---- + + /// Shared group-key creation used by both circles (canonical_root=None) + /// and groups (canonical_root=Some). + async fn create_group_key_inner( + &self, + circle_name: &str, + canonical_root_post_id: Option, + ) -> anyhow::Result<()> { let (seed, pubkey) = crypto::generate_group_keypair(); let group_id = crypto::compute_group_id(&pubkey); let now = std::time::SystemTime::now() @@ -1771,6 +1886,7 @@ impl Node { group_public_key: pubkey, admin: self.node_id, created_at: now, + canonical_root_post_id, }; let storage = self.storage.get().await; @@ -1812,6 +1928,7 @@ impl Node { group_public_key: pubkey, admin: self.node_id, member_keys: vec![mk], + canonical_root_post_id, }; self.network.push_group_key(member, &payload).await; } @@ -1844,7 +1961,7 @@ impl Node { } match crypto::rotate_group_key(&self.default_posting_secret, gk.epoch, &all_members) { Ok((new_seed, new_pubkey, new_epoch, member_keys)) => { - Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string())) + Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id)) } Err(e) => { warn!(error = %e, "Failed to rotate group key"); @@ -1853,7 +1970,7 @@ impl Node { } }; - if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name)) = rotate_result { + if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root)) = rotate_result { // Update storage { let storage = self.storage.get().await; @@ -1876,6 +1993,7 @@ impl Node { group_public_key: new_pubkey, admin: self.node_id, member_keys: vec![mk.clone()], + canonical_root_post_id: canonical_root, }; self.network.push_group_key(&mk.member, &payload).await; } diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index fa3f5cf..99fb4d1 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -395,6 +395,11 @@ pub struct GroupKeyDistributePayload { pub group_public_key: [u8; 32], pub admin: NodeId, pub member_keys: Vec, + /// v0.6.2: when set, this record is a group rooted at the given public + /// post. Absent on v0.6.1 nodes — deserializes to `None` and behaves + /// like a traditional circle. + #[serde(default)] + pub canonical_root_post_id: Option, } /// Member requests current group key (bi-stream request) diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index ac0a331..171758c 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -283,9 +283,11 @@ impl Storage { group_public_key BLOB NOT NULL, group_seed BLOB, admin BLOB NOT NULL, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + canonical_root_post_id BLOB ); CREATE INDEX IF NOT EXISTS idx_group_keys_circle ON group_keys(circle_name); + CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id); CREATE TABLE IF NOT EXISTS group_member_keys ( group_id BLOB NOT NULL, member BLOB NOT NULL, @@ -648,6 +650,19 @@ impl Storage { )?; } + // v0.6.2: add canonical_root_post_id to group_keys. When set, the + // record is a group (many-way, anchored at a public root post); + // when NULL it's a traditional circle (one-way, admin-only). + let has_canonical_root = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('group_keys') WHERE name='canonical_root_post_id'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_canonical_root == 0 { + self.conn.execute_batch( + "ALTER TABLE group_keys ADD COLUMN canonical_root_post_id BLOB DEFAULT NULL; + CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id);" + )?; + } + // Add device_role column to peers if missing (Active CDN replication) let has_device_role = self.conn.prepare( "SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'" @@ -2149,7 +2164,7 @@ impl Storage { pub fn create_group_key(&self, record: &GroupKeyRecord, group_seed: Option<&[u8; 32]>) -> anyhow::Result<()> { self.conn.execute( - "INSERT OR REPLACE INTO group_keys (group_id, circle_name, epoch, group_public_key, group_seed, admin, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT OR REPLACE INTO group_keys (group_id, circle_name, epoch, group_public_key, group_seed, admin, created_at, canonical_root_post_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ record.group_id.as_slice(), record.circle_name, @@ -2158,14 +2173,40 @@ impl Storage { group_seed.map(|s| s.as_slice()), record.admin.as_slice(), record.created_at as i64, + record.canonical_root_post_id.as_ref().map(|r| r.as_slice()), ], )?; Ok(()) } + fn row_to_group_key( + gid: Vec, + circle_name: String, + epoch: i64, + gpk: Vec, + admin: Vec, + created_at: i64, + canonical_root: Option>, + ) -> anyhow::Result { + let canonical_root_post_id = match canonical_root { + Some(b) => Some(blob_to_postid(b)?), + None => None, + }; + Ok(GroupKeyRecord { + group_id: blob_to_nodeid(gid)?, + circle_name, + epoch: epoch as u64, + group_public_key: <[u8; 32]>::try_from(gpk.as_slice()) + .map_err(|_| anyhow::anyhow!("invalid group public key"))?, + admin: blob_to_nodeid(admin)?, + created_at: created_at as u64, + canonical_root_post_id, + }) + } + pub fn get_group_key(&self, group_id: &GroupId) -> anyhow::Result> { let result = self.conn.query_row( - "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at FROM group_keys WHERE group_id = ?1", + "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE group_id = ?1", params![group_id.as_slice()], |row| { let gid: Vec = row.get(0)?; @@ -2174,20 +2215,13 @@ impl Storage { let gpk: Vec = row.get(3)?; let admin: Vec = row.get(4)?; let created_at: i64 = row.get(5)?; - Ok((gid, circle_name, epoch, gpk, admin, created_at)) + let canonical_root: Option> = row.get(6)?; + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) }, ); match result { - Ok((gid, circle_name, epoch, gpk, admin, created_at)) => { - Ok(Some(GroupKeyRecord { - group_id: blob_to_nodeid(gid)?, - circle_name, - epoch: epoch as u64, - group_public_key: <[u8; 32]>::try_from(gpk.as_slice()) - .map_err(|_| anyhow::anyhow!("invalid group public key"))?, - admin: blob_to_nodeid(admin)?, - created_at: created_at as u64, - })) + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => { + Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?)) } Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.into()), @@ -2196,7 +2230,7 @@ impl Storage { pub fn get_group_key_by_circle(&self, circle_name: &str) -> anyhow::Result> { let result = self.conn.query_row( - "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at FROM group_keys WHERE circle_name = ?1", + "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE circle_name = ?1", params![circle_name], |row| { let gid: Vec = row.get(0)?; @@ -2205,20 +2239,39 @@ impl Storage { let gpk: Vec = row.get(3)?; let admin: Vec = row.get(4)?; let created_at: i64 = row.get(5)?; - Ok((gid, circle_name, epoch, gpk, admin, created_at)) + let canonical_root: Option> = row.get(6)?; + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) }, ); match result { - Ok((gid, circle_name, epoch, gpk, admin, created_at)) => { - Ok(Some(GroupKeyRecord { - group_id: blob_to_nodeid(gid)?, - circle_name, - epoch: epoch as u64, - group_public_key: <[u8; 32]>::try_from(gpk.as_slice()) - .map_err(|_| anyhow::anyhow!("invalid group public key"))?, - admin: blob_to_nodeid(admin)?, - created_at: created_at as u64, - })) + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => { + Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?)) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Look up a group by its canonical root post id. Returns None if the + /// record has no canonical_root_post_id (i.e. it's a circle). + pub fn get_group_by_canonical_root(&self, root_post_id: &PostId) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE canonical_root_post_id = ?1", + params![root_post_id.as_slice()], + |row| { + let gid: Vec = row.get(0)?; + let circle_name: String = row.get(1)?; + let epoch: i64 = row.get(2)?; + let gpk: Vec = row.get(3)?; + let admin: Vec = row.get(4)?; + let created_at: i64 = row.get(5)?; + let canonical_root: Option> = row.get(6)?; + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) + }, + ); + match result { + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => { + Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?)) } Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.into()), @@ -5450,6 +5503,7 @@ mod tests { group_public_key: pubkey, admin, created_at: 1000, + canonical_root_post_id: None, }; s.create_group_key(&record, Some(&seed)).unwrap(); @@ -5507,6 +5561,49 @@ mod tests { assert!(s.get_group_seed(&group_id, 1).unwrap().is_none()); } + #[test] + fn group_lookup_by_canonical_root() { + let s = temp_storage(); + let admin = make_node_id(1); + let group_id = [43u8; 32]; + let pubkey = [100u8; 32]; + let root = make_post_id(99); + + let record = crate::types::GroupKeyRecord { + group_id, + circle_name: "group:test".to_string(), + epoch: 1, + group_public_key: pubkey, + admin, + created_at: 1000, + canonical_root_post_id: Some(root), + }; + s.create_group_key(&record, None).unwrap(); + + // Lookup by root returns the group. + let got = s.get_group_by_canonical_root(&root).unwrap().unwrap(); + assert_eq!(got.group_id, group_id); + assert_eq!(got.canonical_root_post_id, Some(root)); + + // A different root returns None. + let other = make_post_id(7); + assert!(s.get_group_by_canonical_root(&other).unwrap().is_none()); + + // A circle (no canonical_root) is not returned when looking up by root. + let circle_record = crate::types::GroupKeyRecord { + group_id: [44u8; 32], + circle_name: "friends".to_string(), + epoch: 1, + group_public_key: [101u8; 32], + admin, + created_at: 1000, + canonical_root_post_id: None, + }; + s.create_group_key(&circle_record, None).unwrap(); + // The circle has no root, so it's invisible to the root lookup. + assert!(s.get_group_by_canonical_root(&make_post_id(0)).unwrap().is_none()); + } + #[test] fn group_seeds_map() { let s = temp_storage(); @@ -5522,6 +5619,7 @@ mod tests { group_public_key: pubkey, admin, created_at: 1000, + canonical_root_post_id: None, }; s.create_group_key(&record, Some(&seed)).unwrap(); s.store_group_seed(&group_id, 1, &seed).unwrap(); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 863dbbe..77bd92f 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -177,7 +177,13 @@ pub struct GroupMemberKey { pub wrapped_group_key: Vec, } -/// A group key record (circle ↔ group key binding) +/// A group key record (circle ↔ group key binding). +/// +/// v0.6.2: `canonical_root_post_id` distinguishes **groups** (many-way, +/// anchored at a public root post; any member can post) from **circles** +/// (one-way, admin-only, `None`). The encryption primitives are identical; +/// the flag is a UX + query hint so UIs can cluster group posts under +/// their root. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GroupKeyRecord { pub group_id: GroupId, @@ -186,6 +192,10 @@ pub struct GroupKeyRecord { pub group_public_key: [u8; 32], pub admin: NodeId, pub created_at: u64, + /// When set, this record represents a group rooted at the given public + /// post. When `None`, the record is a traditional circle. + #[serde(default)] + pub canonical_root_post_id: Option, } /// Visibility of a post — separate from Post struct so it doesn't affect PostId