diff --git a/SESSION_LOG_2026_07_19.md b/SESSION_LOG_2026_07_19.md new file mode 100644 index 0000000..e574a22 --- /dev/null +++ b/SESSION_LOG_2026_07_19.md @@ -0,0 +1,30 @@ +# Group invite materialization + +## Goal + +Directly added, verified group members discover the room after sync without requesting a `wire-group:` join code. + +## Change + +- `wire group list` now runs the existing verified-invite intake when identity state exists. +- Intake accepts a direct invite only when the event targets and the signed roster includes the local identity. +- The group integration test proves pull → list materializes the direct invite before any send or tail call. + +## Scope and assumptions + +- Reused the existing creator-signed roster validation, trust pinning, and group persistence paths. +- Kept join codes as the admission path for unpaired/link recipients. +- Did not change daemon behavior, group credentials, trust tiers, or invite consent semantics. + +## Evidence + +- Regression reproduced: `cargo test --test e2e_group group_bidirectional_room_with_introduce_pin -- --nocapture` failed before the code fix because the recipient listed zero groups after pull. +- Focused verification passed: `cargo fmt --check`, `cargo test --test e2e_group -- --nocapture`, and `cargo test --test cli group_list_empty_reports_no_groups -- --nocapture`. +- Full verification passed: `cargo test -q`. +- GitNexus impact: `cmd_group_list` had one direct caller / LOW risk; shared `ingest_group_invites` was HIGH risk due to `send` and `tail` callers, so its validation logic was unchanged. +- Independent review found that automatic listing would otherwise persist a valid roster for an excluded recipient; added recipient and roster-membership gates plus e2e wrong-recipient and excluded-recipient regressions. + +## Artifacts + +- `docs/superpowers/specs/2026-07-19-group-invite-materialization-design.md` — approved narrow design. +- `SESSION_LOG_2026_07_19.md` — implementation and verification record. diff --git a/docs/superpowers/specs/2026-07-19-group-invite-materialization-design.md b/docs/superpowers/specs/2026-07-19-group-invite-materialization-design.md new file mode 100644 index 0000000..ba55f27 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-group-invite-materialization-design.md @@ -0,0 +1,21 @@ +# Group invite materialization + +## Goal + +A verified peer added directly to a group sees that group after sync without requesting a `wire-group:` code. + +## Scope + +- Run existing verified-invite intake before `wire group list`. +- Materialize only an invite addressed to, and whose signed roster includes, the local identity. +- Cover the creator-adds-member, recipient-pulls, recipient-lists path in the group integration test. +- Preserve code-based joins for link-style, unpaired admission. + +## Non-goals + +- No change to group signatures, trust tiers, relay credentials, or daemon behavior. +- No new consent or pending-invite model. + +## Verification + +The integration test must prove that a recipient can list a directly added group after pulling its signed invite, before sending or tailing a group message. diff --git a/src/cli/group.rs b/src/cli/group.rs index 9cbf2df..42ac55d 100644 --- a/src/cli/group.rs +++ b/src/cli/group.rs @@ -150,7 +150,8 @@ fn ingest_group_invites() -> Result<()> { if !inbox.exists() { return Ok(()); } - let (self_did, ..) = group_self()?; + let (self_did, self_handle, ..) = group_self()?; + let expected_recipient = format!("did:wire:{self_handle}"); let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}})); // group_id -> highest-epoch verified roster seen in the inbox. let mut best: std::collections::HashMap = @@ -169,6 +170,9 @@ fn ingest_group_invites() -> Result<()> { if event.get("type").and_then(Value::as_str) != Some("group_invite") { continue; } + if event.get("to").and_then(Value::as_str) != Some(expected_recipient.as_str()) { + continue; + } // Event-level: the invite must be from a pinned peer (the creator) // with a valid signature. if verify_message_v31(&event, &trust_now).is_err() { @@ -207,6 +211,12 @@ fn ingest_group_invites() -> Result<()> { if !group.verify(&creator_key) { continue; } + // A valid creator signature alone is insufficient: the invite must + // both target this identity and name it in the roster before we + // persist its read/write room credential. + if !group.contains_did(&self_did) { + continue; + } match best.get(&group.id) { Some(prev) if prev.epoch >= group.epoch => {} _ => { @@ -765,6 +775,12 @@ pub(crate) fn cmd_group_join(code: &str, as_json: bool) -> Result<()> { } pub(crate) fn cmd_group_list(as_json: bool) -> Result<()> { + // A direct add delivers a signed roster through the regular inbox. Ingest + // it before listing so the recipient can discover and use the room without + // asking the creator for a separate join code. + if config::is_initialized()? { + ingest_group_invites()?; + } let groups = crate::group::list_groups()?; if as_json { let arr: Vec = groups diff --git a/tests/cli.rs b/tests/cli.rs index db9312f..14b09c4 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1369,6 +1369,26 @@ fn group_list_empty_reports_no_groups() { // First tests/cli.rs coverage for the group family: empty-state list, // both human and --json shapes (e2e_group.rs covers the live flows). let home = fresh_home(); + let out = run(&home, &["group", "list"]); + assert!( + out.status.success(), + "uninitialized group list failed: {out:?}" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("no groups yet"), + "unexpected uninitialized output: {stdout}" + ); + + let out = run(&home, &["group", "list", "--json"]); + assert!( + out.status.success(), + "uninitialized group list --json failed: {out:?}" + ); + let v: serde_json::Value = + serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()).unwrap(); + assert_eq!(v["groups"], serde_json::json!([])); + let _ = run(&home, &["init", "--offline"]); let out = run(&home, &["group", "list"]); assert!(out.status.success(), "group list failed: {out:?}"); diff --git a/tests/e2e_group.rs b/tests/e2e_group.rs index be466ce..121f7c5 100644 --- a/tests/e2e_group.rs +++ b/tests/e2e_group.rs @@ -340,8 +340,104 @@ async fn group_bidirectional_room_with_introduce_pin() { assert_eq!(pull["rejected"].as_array().unwrap().len(), 0); } - // ---- 7. members post to the shared room (ingest materializes the roster - // + introduce-pins the other members on the creator's vouch) ---- + // ---- 7. listing materializes the verified direct invite. Members should + // not need a separate join code after the creator adds them. ---- + for m in [&bob, &carol] { + let member_list: Value = + serde_json::from_slice(&wire(m, &["group", "list", "--json"]).stdout).unwrap(); + let groups = member_list["groups"].as_array().unwrap(); + assert_eq!( + groups.len(), + 1, + "member should list the direct invite: {member_list}" + ); + assert_eq!(groups[0]["id"].as_str(), Some(gid.as_str())); + assert_eq!( + groups[0]["members"].as_array().unwrap().len(), + 3, + "member should materialize the full roster from the invite" + ); + } + + // A verified creator's signed roster must still target and name the + // recipient. Neither a wrong-recipient invite nor a roster that omits eve + // may grant verified outsider eve the room credential. + let eve = fresh_dir("eve"); + assert!( + wire(&eve, &["init", "--relay", &relay_url]) + .status + .success() + ); + drive_pairing(&alice, &eve, &relay_url); + let alice_h = read_handle(&alice); + let eve_card: Value = + serde_json::from_slice(&std::fs::read(eve.join("config/wire/agent-card.json")).unwrap()) + .unwrap(); + let eve_h = eve_card["handle"].as_str().unwrap(); + let eve_did = eve_card["did"].as_str().unwrap(); + let alice_card: Value = + serde_json::from_slice(&std::fs::read(alice.join("config/wire/agent-card.json")).unwrap()) + .unwrap(); + let alice_did = alice_card["did"].as_str().unwrap(); + let alice_pk = wire::signing::b64decode( + alice_card["verify_keys"] + .as_object() + .unwrap() + .values() + .next() + .unwrap()["key"] + .as_str() + .unwrap(), + ) + .unwrap(); + let alice_seed = std::fs::read(alice.join("config/wire/private.key")).unwrap(); + let group_path = alice.join(format!("config/wire/groups/{gid}.json")); + let group: wire::group::Group = + serde_json::from_slice(&std::fs::read(group_path).unwrap()).unwrap(); + let mut roster_including_eve = group.clone(); + roster_including_eve + .add_member( + eve_h.to_string(), + eve_did.to_string(), + wire::group::GroupTier::Member, + ) + .unwrap(); + roster_including_eve.sign(&alice_seed).unwrap(); + let sign_invite = |to: String, roster: &wire::group::Group| { + wire::signing::sign_message_v31( + &serde_json::json!({ + "schema_version": wire::signing::EVENT_SCHEMA_VERSION, + "timestamp": "2026-07-19T00:00:00Z", + "from": alice_did, + "to": to, + "type": "group_invite", + "kind": 1000, + "body": roster, + }), + &alice_seed, + &alice_pk, + &alice_h, + ) + .unwrap() + }; + let eve_inbox = eve.join("state/wire/inbox"); + std::fs::create_dir_all(&eve_inbox).unwrap(); + let wrong_recipient = sign_invite(format!("did:wire:{bob_h}"), &roster_including_eve); + let excluded_recipient = sign_invite(format!("did:wire:{eve_h}"), &group); + std::fs::write( + eve_inbox.join(format!("{alice_h}.jsonl")), + format!("{wrong_recipient}\n{excluded_recipient}\n"), + ) + .unwrap(); + let eve_list: Value = + serde_json::from_slice(&wire(&eve, &["group", "list", "--json"]).stdout).unwrap(); + assert_eq!( + eve_list["groups"], + serde_json::json!([]), + "a verified outsider must not materialize a direct invite: {eve_list}" + ); + + // ---- 8. members post to the shared room using the materialized roster. ---- assert!( wire(&bob, &["group", "send", &gid, "hi from bob"]) .status @@ -369,7 +465,7 @@ async fn group_bidirectional_room_with_introduce_pin() { .success() ); - // ---- 8. everyone tails the same room and sees ALL messages, verified ---- + // ---- 9. everyone tails the same room and sees ALL messages, verified ---- // The cross-member reads are the bidirectional proof: bob reads carol's // message (and vice-versa) verified=true via the introduce-pinned key — // neither ever paired with the other.