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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue