Skip to content

Commit 9a24138

Browse files
authored
fix(groups): materialize direct invites from list (#373)
1 parent fc28959 commit 9a24138

5 files changed

Lines changed: 187 additions & 4 deletions

File tree

SESSION_LOG_2026_07_19.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Group invite materialization
2+
3+
## Goal
4+
5+
Directly added, verified group members discover the room after sync without requesting a `wire-group:` join code.
6+
7+
## Change
8+
9+
- `wire group list` now runs the existing verified-invite intake when identity state exists.
10+
- Intake accepts a direct invite only when the event targets and the signed roster includes the local identity.
11+
- The group integration test proves pull → list materializes the direct invite before any send or tail call.
12+
13+
## Scope and assumptions
14+
15+
- Reused the existing creator-signed roster validation, trust pinning, and group persistence paths.
16+
- Kept join codes as the admission path for unpaired/link recipients.
17+
- Did not change daemon behavior, group credentials, trust tiers, or invite consent semantics.
18+
19+
## Evidence
20+
21+
- 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.
22+
- Focused verification passed: `cargo fmt --check`, `cargo test --test e2e_group -- --nocapture`, and `cargo test --test cli group_list_empty_reports_no_groups -- --nocapture`.
23+
- Full verification passed: `cargo test -q`.
24+
- 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.
25+
- 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.
26+
27+
## Artifacts
28+
29+
- `docs/superpowers/specs/2026-07-19-group-invite-materialization-design.md` — approved narrow design.
30+
- `SESSION_LOG_2026_07_19.md` — implementation and verification record.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Group invite materialization
2+
3+
## Goal
4+
5+
A verified peer added directly to a group sees that group after sync without requesting a `wire-group:` code.
6+
7+
## Scope
8+
9+
- Run existing verified-invite intake before `wire group list`.
10+
- Materialize only an invite addressed to, and whose signed roster includes, the local identity.
11+
- Cover the creator-adds-member, recipient-pulls, recipient-lists path in the group integration test.
12+
- Preserve code-based joins for link-style, unpaired admission.
13+
14+
## Non-goals
15+
16+
- No change to group signatures, trust tiers, relay credentials, or daemon behavior.
17+
- No new consent or pending-invite model.
18+
19+
## Verification
20+
21+
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.

src/cli/group.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ fn ingest_group_invites() -> Result<()> {
150150
if !inbox.exists() {
151151
return Ok(());
152152
}
153-
let (self_did, ..) = group_self()?;
153+
let (self_did, self_handle, ..) = group_self()?;
154+
let expected_recipient = format!("did:wire:{self_handle}");
154155
let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
155156
// group_id -> highest-epoch verified roster seen in the inbox.
156157
let mut best: std::collections::HashMap<String, crate::group::Group> =
@@ -169,6 +170,9 @@ fn ingest_group_invites() -> Result<()> {
169170
if event.get("type").and_then(Value::as_str) != Some("group_invite") {
170171
continue;
171172
}
173+
if event.get("to").and_then(Value::as_str) != Some(expected_recipient.as_str()) {
174+
continue;
175+
}
172176
// Event-level: the invite must be from a pinned peer (the creator)
173177
// with a valid signature.
174178
if verify_message_v31(&event, &trust_now).is_err() {
@@ -207,6 +211,12 @@ fn ingest_group_invites() -> Result<()> {
207211
if !group.verify(&creator_key) {
208212
continue;
209213
}
214+
// A valid creator signature alone is insufficient: the invite must
215+
// both target this identity and name it in the roster before we
216+
// persist its read/write room credential.
217+
if !group.contains_did(&self_did) {
218+
continue;
219+
}
210220
match best.get(&group.id) {
211221
Some(prev) if prev.epoch >= group.epoch => {}
212222
_ => {
@@ -765,6 +775,12 @@ pub(crate) fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
765775
}
766776

767777
pub(crate) fn cmd_group_list(as_json: bool) -> Result<()> {
778+
// A direct add delivers a signed roster through the regular inbox. Ingest
779+
// it before listing so the recipient can discover and use the room without
780+
// asking the creator for a separate join code.
781+
if config::is_initialized()? {
782+
ingest_group_invites()?;
783+
}
768784
let groups = crate::group::list_groups()?;
769785
if as_json {
770786
let arr: Vec<Value> = groups

tests/cli.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,26 @@ fn group_list_empty_reports_no_groups() {
13691369
// First tests/cli.rs coverage for the group family: empty-state list,
13701370
// both human and --json shapes (e2e_group.rs covers the live flows).
13711371
let home = fresh_home();
1372+
let out = run(&home, &["group", "list"]);
1373+
assert!(
1374+
out.status.success(),
1375+
"uninitialized group list failed: {out:?}"
1376+
);
1377+
let stdout = String::from_utf8_lossy(&out.stdout);
1378+
assert!(
1379+
stdout.contains("no groups yet"),
1380+
"unexpected uninitialized output: {stdout}"
1381+
);
1382+
1383+
let out = run(&home, &["group", "list", "--json"]);
1384+
assert!(
1385+
out.status.success(),
1386+
"uninitialized group list --json failed: {out:?}"
1387+
);
1388+
let v: serde_json::Value =
1389+
serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()).unwrap();
1390+
assert_eq!(v["groups"], serde_json::json!([]));
1391+
13721392
let _ = run(&home, &["init", "--offline"]);
13731393
let out = run(&home, &["group", "list"]);
13741394
assert!(out.status.success(), "group list failed: {out:?}");

tests/e2e_group.rs

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,104 @@ async fn group_bidirectional_room_with_introduce_pin() {
340340
assert_eq!(pull["rejected"].as_array().unwrap().len(), 0);
341341
}
342342

343-
// ---- 7. members post to the shared room (ingest materializes the roster
344-
// + introduce-pins the other members on the creator's vouch) ----
343+
// ---- 7. listing materializes the verified direct invite. Members should
344+
// not need a separate join code after the creator adds them. ----
345+
for m in [&bob, &carol] {
346+
let member_list: Value =
347+
serde_json::from_slice(&wire(m, &["group", "list", "--json"]).stdout).unwrap();
348+
let groups = member_list["groups"].as_array().unwrap();
349+
assert_eq!(
350+
groups.len(),
351+
1,
352+
"member should list the direct invite: {member_list}"
353+
);
354+
assert_eq!(groups[0]["id"].as_str(), Some(gid.as_str()));
355+
assert_eq!(
356+
groups[0]["members"].as_array().unwrap().len(),
357+
3,
358+
"member should materialize the full roster from the invite"
359+
);
360+
}
361+
362+
// A verified creator's signed roster must still target and name the
363+
// recipient. Neither a wrong-recipient invite nor a roster that omits eve
364+
// may grant verified outsider eve the room credential.
365+
let eve = fresh_dir("eve");
366+
assert!(
367+
wire(&eve, &["init", "--relay", &relay_url])
368+
.status
369+
.success()
370+
);
371+
drive_pairing(&alice, &eve, &relay_url);
372+
let alice_h = read_handle(&alice);
373+
let eve_card: Value =
374+
serde_json::from_slice(&std::fs::read(eve.join("config/wire/agent-card.json")).unwrap())
375+
.unwrap();
376+
let eve_h = eve_card["handle"].as_str().unwrap();
377+
let eve_did = eve_card["did"].as_str().unwrap();
378+
let alice_card: Value =
379+
serde_json::from_slice(&std::fs::read(alice.join("config/wire/agent-card.json")).unwrap())
380+
.unwrap();
381+
let alice_did = alice_card["did"].as_str().unwrap();
382+
let alice_pk = wire::signing::b64decode(
383+
alice_card["verify_keys"]
384+
.as_object()
385+
.unwrap()
386+
.values()
387+
.next()
388+
.unwrap()["key"]
389+
.as_str()
390+
.unwrap(),
391+
)
392+
.unwrap();
393+
let alice_seed = std::fs::read(alice.join("config/wire/private.key")).unwrap();
394+
let group_path = alice.join(format!("config/wire/groups/{gid}.json"));
395+
let group: wire::group::Group =
396+
serde_json::from_slice(&std::fs::read(group_path).unwrap()).unwrap();
397+
let mut roster_including_eve = group.clone();
398+
roster_including_eve
399+
.add_member(
400+
eve_h.to_string(),
401+
eve_did.to_string(),
402+
wire::group::GroupTier::Member,
403+
)
404+
.unwrap();
405+
roster_including_eve.sign(&alice_seed).unwrap();
406+
let sign_invite = |to: String, roster: &wire::group::Group| {
407+
wire::signing::sign_message_v31(
408+
&serde_json::json!({
409+
"schema_version": wire::signing::EVENT_SCHEMA_VERSION,
410+
"timestamp": "2026-07-19T00:00:00Z",
411+
"from": alice_did,
412+
"to": to,
413+
"type": "group_invite",
414+
"kind": 1000,
415+
"body": roster,
416+
}),
417+
&alice_seed,
418+
&alice_pk,
419+
&alice_h,
420+
)
421+
.unwrap()
422+
};
423+
let eve_inbox = eve.join("state/wire/inbox");
424+
std::fs::create_dir_all(&eve_inbox).unwrap();
425+
let wrong_recipient = sign_invite(format!("did:wire:{bob_h}"), &roster_including_eve);
426+
let excluded_recipient = sign_invite(format!("did:wire:{eve_h}"), &group);
427+
std::fs::write(
428+
eve_inbox.join(format!("{alice_h}.jsonl")),
429+
format!("{wrong_recipient}\n{excluded_recipient}\n"),
430+
)
431+
.unwrap();
432+
let eve_list: Value =
433+
serde_json::from_slice(&wire(&eve, &["group", "list", "--json"]).stdout).unwrap();
434+
assert_eq!(
435+
eve_list["groups"],
436+
serde_json::json!([]),
437+
"a verified outsider must not materialize a direct invite: {eve_list}"
438+
);
439+
440+
// ---- 8. members post to the shared room using the materialized roster. ----
345441
assert!(
346442
wire(&bob, &["group", "send", &gid, "hi from bob"])
347443
.status
@@ -369,7 +465,7 @@ async fn group_bidirectional_room_with_introduce_pin() {
369465
.success()
370466
);
371467

372-
// ---- 8. everyone tails the same room and sees ALL messages, verified ----
468+
// ---- 9. everyone tails the same room and sees ALL messages, verified ----
373469
// The cross-member reads are the bidirectional proof: bob reads carol's
374470
// message (and vice-versa) verified=true via the introduce-pinned key —
375471
// neither ever paired with the other.

0 commit comments

Comments
 (0)