Phase 2f: groups as a distinct primitive alongside circles
Introduces **groups** — a new many-way primitive anchored at a public root
post — reusing the existing circle encryption machinery. Circles stay
one-way (admin posts only, as before). Groups are distinguished from
circles by a single field: a non-null `canonical_root_post_id` on the
group-key record.
Type / schema changes:
- `GroupKeyRecord.canonical_root_post_id: Option<PostId>` (serde default).
When `Some`, the record represents a group rooted at that public post;
when `None`, it's a traditional circle.
- `group_keys` table gets a `canonical_root_post_id BLOB` column + an
index `idx_group_keys_root` on it. Migration added for upgraded DBs;
the CREATE TABLE statement carries the column so fresh DBs match.
Wire:
- `GroupKeyDistributePayload` gains an optional `canonical_root_post_id`
field. v0.6.1 peers will deserialize it as absent and continue treating
the record as a circle. All three sender sites (new-circle distribute,
add-member distribute, epoch-rotation distribute) pass the field through.
Storage:
- `create_group_key` / `get_group_key` / `get_group_key_by_circle` write
and read the new column. Added a shared `row_to_group_key` helper so the
two lookup functions don't drift.
- New `get_group_by_canonical_root(root_post_id)` — the inverse lookup
used by posting / retrieval flows.
Node API (new):
- `create_group_from_post(root_post_id, initial_members)` — creates a
backing circle named `group:<6-byte-hex-of-root>`, initializes the
group key with `canonical_root_post_id` set, and invites each initial
member (reusing `add_to_circle`'s wrap+distribute path so members get
the seed on the wire). Returns `(GroupId, circle_name)`.
- `post_to_group(root_post_id, content, attachments)` — any member with
the group seed can call this. Looks up the group by root, routes
through `create_post_with_visibility(Circle(name))` (which already
chooses `GroupEncrypted` when the seed is present), then stores a
`ThreadMeta` row linking the new post back to the canonical root so
retrieval can reconstruct the group.
- `list_group_posts_by_root(root_post_id)` — returns all contributions
via the ThreadMeta parent index. Callers decrypt normally; members see
full content, non-members see encrypted blobs.
Shared plumbing:
- `create_group_key_for_circle` now delegates to a shared
`create_group_key_inner(circle_name, canonical_root)` helper, keeping
one place where the record is constructed and the seed is persisted.
Notes:
- No crypto change: groups use the same `GroupEncrypted` primitive
circles already used. The "admin-only post" restriction on circles was
a UX choice, not a cryptographic limit — groups expose the many-way
path directly by letting any member with the seed call `post_to_group`.
- ThreadMeta is the clustering primitive. It already existed for split
comment threads; groups reuse it so the query pattern
("posts whose parent is root X") stays in one place.
- Frontend UI for groups is deferred — the backend surface is complete
and exercise-able via Tauri/CLI.
Tests: new storage test asserts canonical_root lookup round-trips and
that circles (no root) are invisible to the root lookup. 117 / 117 core
tests pass.
This commit is contained in:
parent
88d5cc9f23
commit
2cb211eb11
5 changed files with 261 additions and 29 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<NodeId>,
|
||||
) -> 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<u8>, 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<Vec<(PostId, Post, PostVisibility)>> {
|
||||
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<PostId>,
|
||||
) -> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -395,6 +395,11 @@ pub struct GroupKeyDistributePayload {
|
|||
pub group_public_key: [u8; 32],
|
||||
pub admin: NodeId,
|
||||
pub member_keys: Vec<GroupMemberKey>,
|
||||
/// 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<PostId>,
|
||||
}
|
||||
|
||||
/// Member requests current group key (bi-stream request)
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
circle_name: String,
|
||||
epoch: i64,
|
||||
gpk: Vec<u8>,
|
||||
admin: Vec<u8>,
|
||||
created_at: i64,
|
||||
canonical_root: Option<Vec<u8>>,
|
||||
) -> anyhow::Result<GroupKeyRecord> {
|
||||
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<Option<GroupKeyRecord>> {
|
||||
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<u8> = row.get(0)?;
|
||||
|
|
@ -2174,20 +2215,13 @@ impl Storage {
|
|||
let gpk: Vec<u8> = row.get(3)?;
|
||||
let admin: Vec<u8> = row.get(4)?;
|
||||
let created_at: i64 = row.get(5)?;
|
||||
Ok((gid, circle_name, epoch, gpk, admin, created_at))
|
||||
let canonical_root: Option<Vec<u8>> = 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<Option<GroupKeyRecord>> {
|
||||
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<u8> = row.get(0)?;
|
||||
|
|
@ -2205,20 +2239,39 @@ impl Storage {
|
|||
let gpk: Vec<u8> = row.get(3)?;
|
||||
let admin: Vec<u8> = row.get(4)?;
|
||||
let created_at: i64 = row.get(5)?;
|
||||
Ok((gid, circle_name, epoch, gpk, admin, created_at))
|
||||
let canonical_root: Option<Vec<u8>> = 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<Option<GroupKeyRecord>> {
|
||||
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<u8> = row.get(0)?;
|
||||
let circle_name: String = row.get(1)?;
|
||||
let epoch: i64 = row.get(2)?;
|
||||
let gpk: Vec<u8> = row.get(3)?;
|
||||
let admin: Vec<u8> = row.get(4)?;
|
||||
let created_at: i64 = row.get(5)?;
|
||||
let canonical_root: Option<Vec<u8>> = 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();
|
||||
|
|
|
|||
|
|
@ -177,7 +177,13 @@ pub struct GroupMemberKey {
|
|||
pub wrapped_group_key: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 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<PostId>,
|
||||
}
|
||||
|
||||
/// Visibility of a post — separate from Post struct so it doesn't affect PostId
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue