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