-
Notifications
You must be signed in to change notification settings - Fork 2
Fix group invite discovery after direct add #373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Comment on lines
+425
to
+426
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use full DIDs so the roster gate is tested independently. Lines 425-426 use bare DIDs. After recipient matching is corrected, the Eve-targeted fixture fails recipient filtering first, so this test no longer proves that a signed roster excluding Eve is rejected. Use Bob’s full DID for the wrong-recipient event and 🤖 Prompt for AI Agents |
||
| 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. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the impact record.
Line 24 says
ingest_group_invitesvalidation was unchanged, but this PR adds recipient-DID and roster-membership gates there. Record those validation changes explicitly.🤖 Prompt for AI Agents