Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions SESSION_LOG_2026_07_19.md
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.

Copy link
Copy Markdown

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_invites validation was unchanged, but this PR adds recipient-DID and roster-membership gates there. Record those validation changes explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SESSION_LOG_2026_07_19.md` at line 24, Update the impact record for
ingest_group_invites to explicitly document the added recipient-DID and
roster-membership validation gates, replacing the claim that its validation
logic was unchanged; retain the existing risk and caller information.

- 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.
18 changes: 17 additions & 1 deletion src/cli/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, crate::group::Group> =
Expand All @@ -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() {
Expand Down Expand Up @@ -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 => {}
_ => {
Expand Down Expand Up @@ -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<Value> = groups
Expand Down
20 changes: 20 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
Expand Down
102 changes: 99 additions & 3 deletions tests/e2e_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 eve_did for the excluded-roster event.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e_group.rs` around lines 425 - 426, Update the test fixtures in the
relevant E2E test to use full recipient DIDs: construct the wrong-recipient
event with Bob’s full DID and construct the excluded-roster event with the
existing eve_did value. Preserve the distinct scenarios so recipient filtering
does not mask the roster-exclusion check.

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
Expand Down Expand Up @@ -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.
Expand Down