vector-core 0.7.0

Core library for Vector — the single source of truth for all Vector clients, SDKs, and interfaces.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Targeted invites (GROUP_PROTOCOL.md).
//!
//! An invite bundle is the key material a new member needs to join: the server-root
//! key, the granted channels' keys + ids + epochs + names, the relay set (the 
//! bootstrap), the owner attestation, and the Community id/name. It is delivered to the
//! invitee's npub over a NIP-17 gift-wrapped DM (the carrier; see the service/command
//! layer). `accept_invite` reconstructs a member-view Community (keyless — authority is
//! the owner-rooted roster, not a held key).
//!
//! Byte fields are hex strings so the bundle is plain JSON inside the DM rumor.

use crate::event_ext::FinalizeUnsignedWithId;
use serde::{Deserialize, Serialize};

use super::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
use crate::stored_event::event_kind;
use nostr_sdk::prelude::{EventBuilder, Kind, PublicKey, UnsignedEvent};

/// `skip_serializing_if` helper: omit a `u64` field from the JSON when it's the default 0 (e.g. an
/// un-rotated epoch). `serde(default)` restores it on read. Trims the Community List on the wire.
fn is_zero_u64(n: &u64) -> bool {
    *n == 0
}

/// A granted channel inside an invite bundle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InviteChannel {
    pub id: String,
    pub key: String,
    /// Always serialized: v0.4.0 readers have no `serde(default)` here, so an omitted
    /// epoch fails their whole bundle/list parse. Never add `skip_serializing_if`.
    #[serde(default)]
    pub epoch: u64,
    pub name: String,
}

/// Everything a new member needs to join a Community.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommunityInvite {
    pub community_id: String,
    pub name: String,
    pub server_root_key: String,
    /// The server-root's current epoch, so a joiner adopts the right base read clock. `default`
    /// keeps older bundles parseable (they predate rotation, so epoch 0 is correct for them).
    #[serde(default, skip_serializing_if = "is_zero_u64")]
    pub server_root_epoch: u64,
    /// Tolerant on read: a peer that carries no relays/channels may omit the field
    /// entirely, and a required Vec would reject the WHOLE bundle over an absent
    /// empty list. This bundle is also embedded in the v1 Community List, so one
    /// such entry would strand the entire list.
    #[serde(default)]
    pub relays: Vec<String>,
    #[serde(default)]
    pub channels: Vec<InviteChannel>,
    /// Owner attestation (signed event JSON) so the joiner learns + verifies who the owner
    /// is. `serde(default)` keeps older bundles (pre-feature) parseable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner_attestation: Option<String>,
    /// Community icon (encrypted blob ref) so a PARKED private invite can show the logo before the
    /// recipient joins (the card fetches + decrypts it like a public-invite preview). STRIPPED from
    /// Community List blobs (see list.rs) since a rehydrating device folds the icon from the community
    /// metadata; it's only carried in the actual invite bundle. `default` keeps icon-less bundles parseable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub icon: Option<super::CommunityImage>,
}

/// Hard caps on a received bundle. A bundle arrives over an unauthenticated gift wrap
/// from an arbitrary sender, so a hostile one can declare an unbounded channel/relay
/// list to force mass allocation + per-channel DB writes + relay connections. Reject
/// anything past a sane MVP ceiling before allocating.
const MAX_INVITE_CHANNELS: usize = 256;

impl CommunityInvite {
    pub fn to_json(&self) -> Result<String, String> {
        serde_json::to_string(self).map_err(|e| e.to_string())
    }
    pub fn from_json(json: &str) -> Result<Self, String> {
        let mut inv: Self = serde_json::from_str(json).map_err(|e| e.to_string())?;
        // Truncate-on-read: an inbound bundle is unauthenticated, so enforce the relay cap as it
        // enters rather than trusting (or rejecting on) the sender's count.
        inv.relays = super::cap_relays(inv.relays);
        Ok(inv)
    }

    /// Reject a bundle whose channel count exceeds the MVP ceiling (DoS guard for inbound,
    /// attacker-controlled bundles). Relays are capped by truncation (`cap_relays`), not rejection,
    /// so a >5 or legacy bundle degrades to the cap rather than failing the whole join.
    pub fn validate(&self) -> Result<(), String> {
        if self.channels.len() > MAX_INVITE_CHANNELS {
            return Err(format!("invite declares too many channels ({})", self.channels.len()));
        }
        Ok(())
    }
}

/// Decode a 64-char hex string to 32 bytes, rejecting malformed input (never silently zero-fills —
/// a corrupt invite must error, not fabricate keys). Public input → SIMD-validated decode.
fn hex32(hex: &str) -> Result<[u8; 32], String> {
    crate::simd::hex::hex_to_bytes_32_checked(hex)
        .ok_or_else(|| format!("invalid or wrong-length 64-char hex ({} chars)", hex.len()))
}

/// Build an invite bundle granting ALL of a Community's channels (the MVP grants the
/// full channel set; per-channel/role-scoped grants are a later feature). Only the
/// management *pubkey* is included — never the secret, so an invited member can't
/// write metadata.
pub fn build_invite(community: &Community) -> CommunityInvite {
    CommunityInvite {
        community_id: community.id.to_hex(),
        name: community.name.clone(),
        server_root_key: crate::simd::hex::bytes_to_hex_32(community.server_root_key.as_bytes()),
        server_root_epoch: community.server_root_epoch.0,
        relays: super::cap_relays(community.relays.clone()),
        channels: community
            .channels
            .iter()
            .map(|c| InviteChannel {
                id: c.id.to_hex(),
                key: crate::simd::hex::bytes_to_hex_32(c.key.as_bytes()),
                epoch: c.epoch.0,
                name: c.name.clone(),
            })
            .collect(),
        owner_attestation: community.owner_attestation.clone(),
        icon: community.icon.clone(),
    }
}

/// Reconstruct a **member-view** Community from an invite bundle: full read/post access via the
/// granted channel keys (keyless — write authority is the member's npub roster rank, not a held
/// key).
pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
    invite.validate()?;
    let id = CommunityId(hex32(&invite.community_id)?);
    let server_root_key = ServerRootKey(hex32(&invite.server_root_key)?);

    // Keep the owner attestation ONLY if it verifies against this community's id (keyless — the
    // community_id is the sole binding; a bundle can't smuggle a bogus owner claim).
    let owner_attestation = invite.owner_attestation.as_ref().and_then(|att| {
        super::owner::verify_owner_attestation(att, &invite.community_id)
            .map(|_| att.clone())
    });

    let mut channels = Vec::with_capacity(invite.channels.len());
    for ic in &invite.channels {
        channels.push(Channel {
            id: ChannelId(hex32(&ic.id)?),
            key: ChannelKey(hex32(&ic.key)?),
            epoch: Epoch(ic.epoch),
            name: ic.name.clone(),
            // A fresh joiner starts with no local banlist; it arrives with the metadata fetch.
            banned: Vec::new(),
            protected: Vec::new(), roster: Default::default(),
            // Only the current key is conveyed on join; the archive fills as rekeys are caught up.
            epoch_keys: Vec::new(),
            dissolved: false,
        });
    }

    Ok(Community {
        id,
        server_root_key,
        server_root_epoch: Epoch(invite.server_root_epoch),
        name: invite.name.clone(),
        // Description/banner still arrive with the GroupRoot fold; the icon now rides the bundle so it
        // shows on the parked invite AND instantly on join (the fold refreshes it authoritatively).
        description: None,
        icon: invite.icon.clone(),
        banner: None,
        relays: super::cap_relays(invite.relays.clone()),
        channels,
        owner_attestation,
        // A fresh join starts alive; the first control fold detects + seals if a tombstone is present.
        dissolved: false,
    })
}

/// Build the gift-wrap rumor that carries an invite to an invitee (carrier). The
/// rumor is an unsigned NIP-59 inner event (kind 3304) whose content is the bundle
/// JSON; the caller gift-wraps it to the invitee's npub over NIP-17 (reusing Vector's
/// existing private-DM path). `my_pubkey` is the rumor author — irrelevant to the
/// bundle's trust (the owner attestation inside it anchors authority), it just
/// satisfies NIP-01 serialization.
pub fn build_invite_rumor(
    community: &Community,
    my_pubkey: PublicKey,
    now_secs: u64,
) -> Result<UnsignedEvent, String> {
    let json = build_invite(community).to_json()?;
    Ok(
        EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), json)
            .tag(nostr_sdk::prelude::Tag::expiration(
                nostr_sdk::prelude::Timestamp::from_secs(now_secs + DIRECT_INVITE_EXPIRY_SECS),
            ))
            .finalize_unsigned_with_id(my_pubkey),
    )
}

/// How long a Direct Invite stays valid. A bundle hands out live key material for a
/// community that keeps evolving (rotations, renames, bans), so an invite that never
/// expires is both stale key material and clutter in the recipient's list. The sender
/// stamps NIP-40 on the rumor AND (mirrored by the send path) on the outer gift wrap, so
/// relays drop it on schedule; recipients enforce it themselves too, since relay support
/// for NIP-40 is optional.
pub const DIRECT_INVITE_EXPIRY_SECS: u64 = 24 * 60 * 60;

/// Read a NIP-40 `expiration` tag (unix seconds) off an invite rumor's tags. `None` =
/// no expiry declared (a pre-expiry sender), which callers treat as permanent.
pub fn expiration_secs(tags: &nostr_sdk::prelude::Tags) -> Option<u64> {
    tags.iter()
        .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
        .and_then(|t| t.as_slice().get(1))
        .and_then(|v| v.parse::<u64>().ok())
}

/// Parse an inbound rumor as a Community invite. Returns `None` unless the rumor is an
/// invite (kind 3304) carrying a well-formed bundle — a non-invite DM or corrupt
/// content yields `None`, never an error, so the inbound dispatcher can fall through.
pub fn parse_invite_rumor(kind: Kind, content: &str) -> Option<CommunityInvite> {
    if kind != Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE) {
        return None;
    }
    CommunityInvite::from_json(content).ok()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::community::envelope::{open_message, seal_message};
    use nostr_sdk::prelude::Keys;

    #[test]
    fn relay_cap_truncates_dedups_and_holds_at_every_boundary() {
        // 7 entries, 2 of them duplicates → dedup to 6 distinct, truncate to 5.
        let many: Vec<String> = vec![
            "wss://a".into(), "wss://b".into(), "wss://a".into(), "wss://c".into(),
            "wss://d".into(), "wss://e".into(), "wss://f".into(),
        ];
        let capped = super::super::cap_relays(many.clone());
        assert_eq!(capped, vec!["wss://a", "wss://b", "wss://c", "wss://d", "wss://e"]);

        // Mint honors the cap.
        let owner = Community::create("HQ", "general", many.clone());
        assert!(owner.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);

        // A hostile bundle declaring >5 relays parses (truncate-on-read), not rejects,
        // and the accepted member view is capped too.
        let mut bundle = build_invite(&owner);
        bundle.relays = many; // force an over-cap bundle past build_invite
        let json = bundle.to_json().unwrap();
        let parsed = CommunityInvite::from_json(&json).unwrap();
        assert!(parsed.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
        let member = accept_invite(&parsed).unwrap();
        assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
    }

    /// A Direct Invite carries a 24h NIP-40 expiry on the RUMOR. The send path mirrors any
    /// rumor `expiration` tag onto the outer gift wrap, so tagging the rumor is what makes
    /// relays drop the wrap on schedule — and it survives into the recipient's parked row.
    #[test]
    fn invite_rumor_declares_a_24h_expiry() {
        let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
        let author = Keys::generate();
        let now = 1_800_000_000u64;
        let rumor = build_invite_rumor(&owner, author.public_key(), now).unwrap();

        let exp = expiration_secs(&rumor.tags).expect("rumor carries a NIP-40 expiration tag");
        assert_eq!(exp, now + 86_400, "24h after the send time");
        assert_eq!(DIRECT_INVITE_EXPIRY_SECS, 86_400);
    }

    /// `expiration_secs` is the recipient's read of the sender's promise, so it must be
    /// total: absent, malformed, or valueless tags all mean "no deadline declared" rather
    /// than a panic or a bogus 0 deadline (which would void the invite instantly).
    #[test]
    fn expiration_secs_is_total_over_hostile_tags() {
        use nostr_sdk::prelude::{Tag, Timestamp};
        let author = Keys::generate();
        let mk = |tags: Vec<Tag>| {
            EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "{}")
                .tags(tags)
                .finalize_unsigned_with_id(author.public_key())
        };

        assert_eq!(expiration_secs(&mk(vec![]).tags), None, "no tag = no deadline");
        // A non-numeric value must not be read as a deadline.
        assert_eq!(
            expiration_secs(&mk(vec![Tag::custom("expiration", ["soon"])]).tags),
            None,
        );
        // A valueless expiration tag likewise.
        assert_eq!(
            expiration_secs(&mk(vec![Tag::custom("expiration", Vec::<String>::new())]).tags),
            None,
        );
        // A real tag reads back exactly, even alongside other tags.
        let tags = vec![
            Tag::public_key(author.public_key()),
            Tag::expiration(Timestamp::from_secs(1_800_086_400)),
        ];
        assert_eq!(expiration_secs(&mk(tags).tags), Some(1_800_086_400));
    }

    #[test]
    fn epoch_zero_channel_serializes_the_field_explicitly() {
        // v0.4.0 wire contract: InviteChannel.epoch has no serde(default) on shipped
        // clients, so it must appear in the JSON even at 0 — in the invite bundle AND
        // in every CommunityInvite embedded in a Community List blob.
        let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
        let bundle = build_invite(&owner);
        assert_eq!(bundle.channels[0].epoch, 0);
        let json = bundle.to_json().unwrap();
        assert!(json.contains("\"epoch\":0"), "epoch must never be omitted: {json}");
    }

    #[test]
    fn invite_round_trips_to_member_view() {
        let owner = Community::create("HQ", "general", vec!["wss://r1".into(), "wss://r2".into()]);
        let json = build_invite(&owner).to_json().unwrap();
        let invite = CommunityInvite::from_json(&json).unwrap();
        let member = accept_invite(&invite).unwrap();

        // Same Community identity + read material (keyless — the bundle conveys read material only).
        assert_eq!(member.id, owner.id);
        assert_eq!(member.name, "HQ");
        assert_eq!(member.relays, owner.relays);
        assert_eq!(member.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
        assert_eq!(member.channels.len(), 1);
        assert_eq!(member.channels[0].id, owner.channels[0].id);
        assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
        assert_eq!(member.channels[0].name, "general");
    }

    #[test]
    fn invited_member_can_read_owner_messages() {
        // The killer property: the keys conveyed by the invite actually WORK. Owner
        // seals a message under the channel key; the invited member, reconstructed
        // purely from the bundle, opens it and recovers the author.
        let owner = Community::create("HQ", "general", vec![]);
        let owner_author = Keys::generate();
        let chan = &owner.channels[0];
        let sealed =
            seal_message(&owner_author, &chan.key, &chan.id, chan.epoch, "welcome!", 1).unwrap();

        let invite = CommunityInvite::from_json(&build_invite(&owner).to_json().unwrap()).unwrap();
        let member = accept_invite(&invite).unwrap();
        let mc = &member.channels[0];
        let opened = open_message(&sealed, &mc.key, &mc.id, mc.epoch).unwrap();
        assert_eq!(opened.content, "welcome!");
        assert_eq!(opened.author, owner_author.public_key());
    }

    #[test]
    fn malformed_invite_errors() {
        let owner = Community::create("HQ", "general", vec![]);
        let mut invite = build_invite(&owner);
        invite.server_root_key = "zz".into(); // too short + non-hex
        assert!(accept_invite(&invite).is_err());
    }

    #[test]
    fn accept_rejects_a_bundle_exceeding_the_caps() {
        // DoS guard: an attacker-controlled bundle declaring a huge channel list is rejected
        // before any allocation/connection. Relays are CAPPED by truncation (cap_relays) rather
        // than rejected, so a >5 relay bundle still joins — just at ≤5 relays.
        let owner = Community::create("HQ", "general", vec![]);
        let mut over_relays = build_invite(&owner);
        over_relays.relays = (0..100).map(|i| format!("wss://r{i}")).collect();
        let member = accept_invite(&over_relays).expect("over-relay bundle truncates, not rejects");
        assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS, "relays truncated to cap");

        let mut over_channels = build_invite(&owner);
        over_channels.channels = (0..500)
            .map(|i| InviteChannel { id: "aa".repeat(32), key: "bb".repeat(32), epoch: 0, name: format!("c{i}") })
            .collect();
        assert!(accept_invite(&over_channels).is_err(), "too many channels → rejected");
    }

    #[test]
    fn accept_rejects_malformed_community_id() {
        // A corrupt id must error, never silently zero-fill into a fabricated community.
        let owner = Community::create("HQ", "general", vec![]);
        let mut bad = build_invite(&owner);
        bad.community_id = "not-64-hex".into();
        assert!(accept_invite(&bad).is_err());
    }

    #[test]
    fn accept_drops_a_bogus_owner_attestation_but_still_joins() {
        // The bundle can carry an owner_attestation; an unverifiable one is DROPPED (no spoofed crown),
        // but the join still succeeds — graceful, no panic, no false owner.
        let owner = Community::create("HQ", "general", vec![]);
        let mut inv = build_invite(&owner);
        inv.owner_attestation = Some("not even an event".to_string());
        let member = accept_invite(&inv).unwrap();
        assert!(member.owner_attestation.is_none(), "an unverifiable attestation is dropped, not trusted");
    }

    #[test]
    fn accept_an_empty_channel_bundle_is_graceful() {
        let owner = Community::create("HQ", "general", vec![]);
        let mut inv = build_invite(&owner);
        inv.channels.clear();
        let member = accept_invite(&inv).unwrap();
        assert!(member.channels.is_empty(), "a 0-channel bundle accepts without panic");
    }

    #[test]
    fn bundle_carries_read_keys() {
        // The invite conveys the read keys (server-root + channel) a member needs to read/post.
        let owner = Community::create("HQ", "general", vec![]);
        let json = build_invite(&owner).to_json().unwrap();
        assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.server_root_key.as_bytes())));
        assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.channels[0].key.as_bytes())));
    }

    #[test]
    fn invite_rumor_round_trips() {
        let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
        let author = Keys::generate();
        let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();

        assert_eq!(rumor.kind, Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE));
        let parsed = parse_invite_rumor(rumor.kind, &rumor.content).expect("parses");
        let member = accept_invite(&parsed).unwrap();
        assert_eq!(member.id, owner.id);
        assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
    }

    #[test]
    fn invite_rumor_stamps_real_send_time_not_backdated() {
        // The decline-tombstone supersession orders re-invites by the inner rumor's
        // `created_at`. That only works if the rumor carries the true send time; a
        // NIP-59-style backdate here would make a fresh re-invite look older than a
        // decline and get silently dropped (the "can't re-invite after decline" bug).
        let owner = Community::create("HQ", "general", vec![]);
        let author = Keys::generate();
        let now = nostr_sdk::prelude::Timestamp::now().as_secs();
        let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
        let stamped = rumor.created_at.as_secs();
        assert!(
            stamped + 5 >= now && stamped <= now + 5,
            "rumor created_at {stamped} must track now {now}, not be backdated"
        );
    }

    #[test]
    fn parse_invite_rumor_rejects_wrong_kind() {
        // A normal text DM (kind 14) must never parse as an invite, even if its content
        // happened to be valid bundle JSON.
        let owner = Community::create("HQ", "general", vec![]);
        let json = build_invite(&owner).to_json().unwrap();
        assert!(parse_invite_rumor(Kind::Custom(14), &json).is_none());
        // Right kind but garbage content → None, not panic.
        assert!(parse_invite_rumor(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "not json").is_none());
    }

    #[tokio::test]
    async fn full_carrier_chain_invitee_reads_owner_message() {
        // End-to-end (sans the gift-wrap transport, which is src-tauri): owner builds an
        // invite rumor → invitee parses it → accepts → opens a real channel message the
        // owner sealed. Proves the rumor faithfully conveys working keys.
        use crate::community::transport::memory::MemoryRelay;
        use crate::community::send::{fetch_channel_messages, publish_message};

        let owner = Community::create("HQ", "general", vec!["r1".into()]);
        let author = Keys::generate();
        let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();

        // The invitee only ever sees the rumor content.
        let invite = parse_invite_rumor(rumor.kind, &rumor.content).expect("invite");
        let member = accept_invite(&invite).unwrap();

        // Owner posts a message; the member (reconstructed from the bundle) reads it.
        let relay = MemoryRelay::new();
        let owner_author = Keys::generate();
        publish_message(&relay, &owner, &owner.channels[0], &owner_author, "welcome aboard", 1)
            .await
            .unwrap();

        let msgs = fetch_channel_messages(&relay, &member, &member.channels[0]).await.unwrap();
        assert_eq!(msgs.len(), 1);
        assert_eq!(msgs[0].content, "welcome aboard");
        assert_eq!(msgs[0].author, owner_author.public_key());
    }
}