add invites and community list

This commit is contained in:
2026-09-17 07:49:07 +07:00
parent ea9abae554
commit 79a4dd387d
6 changed files with 1449 additions and 30 deletions
+138 -20
View File
@@ -85,7 +85,8 @@ crates/concord/
src/roles.rs CORD-04 §2–§4: permissions, roles, grants, banlist, delegation fixpoint
src/guestbook.rs CORD-02 §5: join/leave/kick/snapshot, coalesce, complete memberlist
src/chat.rs CORD-03: channel plane — message/edit/delete/reaction builders + message view
src/invite.rs CORD-05: bundle, link, registry, Invite List, Direct Invite
src/invite.rs CORD-05 §1–§3, §6: bundle, link (naddr + fragment), Direct Invite
src/list.rs CORD-02 §8: the Community List — join material, merge, to-self envelope
src/rekey.rs CORD-06: blob codec, continuity, refounding, compaction, dissolution
src/store.rs local persistence + opened-rumor cache + history queries
```
@@ -555,30 +556,142 @@ pub struct ChatMessage {
gpui type, and a protocol crate does not take a UI dependency for a derived field. They
land with the first consumer that renders them.
### 8.5 Invites (`invite.rs`)
### 8.5 Invites (`invite.rs`) — implemented in M6
```rust
pub struct CommunityInvite { community_id, owner, owner_salt, community_root, root_epoch,
control_pk: Option<String>, channels: Vec<ChannelGrant>,
relays: Vec<String>, name: String, icon: Option<ImageRef>,
expires_at: Option<u64>, creator_npub: Option<String>, label: Option<String>,
extra: Map<String, Value> }
impl CommunityInvite { pub fn validate(&self) -> Result<(), InviteError>; // recompute community_id
pub fn expired(&self, now_ms: u64) -> bool; }
pub const KIND_BUNDLE: u16 = 33301;
pub const KIND_DIRECT_INVITE: u16 = 3313;
pub const FRAGMENT_VERSION: u8 = 4; // the dictionary generation
pub const MAX_BUNDLE_CHANNELS: usize = 256;
pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40; // attacker-set, so bound it
pub fn build_bundle(token: &[u8; 16], link_signer: &Keys, invite: &CommunityInvite) -> Result<Event, InviteError>; // 33301, d = ""
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError>; // vsk 9
pub struct ChannelGrant { id: ChannelId, key: Option<String>, epoch: Epoch, name: String, extra }
pub struct CommunityInvite { community_id: CommunityId, owner: PublicKey, owner_salt: String,
community_root: String, root_epoch: Epoch,
control_pk: Option<PublicKey>, channels: Vec<ChannelGrant>,
relays: Vec<String>, name: String, icon: Option<ImageRef>,
expires_at: Option<u64>, creator_npub: Option<PublicKey>,
label: Option<String>, extra }
impl CommunityInvite {
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError>; // bound, truncate, validate
pub fn validate(&self) -> Result<(), InviteError>; // recompute community_id
pub fn expired(&self, now_ms: u64) -> bool;
}
pub enum BundleState { Live(Box<CommunityInvite>), Revoked }
pub fn build_bundle_event(link_signer: &Keys, invite: &CommunityInvite, bundle_key: &[u8; 32])
-> Result<Event, InviteError>;
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError>;
pub fn parse_bundle_event(event: &Event, expected_signer: &PublicKey, bundle_key: &[u8; 32])
-> Result<BundleState, InviteError>;
pub fn stock_relays() -> Vec<String>;
pub fn encode_fragment(token: &[u8; 16], relays: &[String]) -> Result<String, InviteError>;
pub fn decode_fragment(fragment: &str) -> Result<([u8; 16], Vec<String>), InviteError>;
pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError>;
pub fn build_invite_url(base: &str, link_signer, token, relays) -> Result<String, InviteError>;
pub struct ParsedInviteLink { link_signer: PublicKey, token: [u8; 16],
bootstrap_relays: Vec<String>, naddr: String }
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError>;
pub fn encode_fragment(relays: &[RelayUrl], token: &[u8; 16]) -> String; // version byte 4, flags, ≤ 3 relays, base64url
pub fn decode_fragment(fragment: &str) -> Result<(Vec<RelayUrl>, [u8; 16]), InviteError>;
pub fn build_direct_invite(receiver: &PublicKey, invite: &CommunityInvite, signer: &UniversalSigner) -> Task<Result<Event, Error>>; // 3313 rumor → 13 seal → k-tagged 1059
pub fn build_direct_invite(inviter: &Keys, recipient: &PublicKey, invite: &CommunityInvite)
-> Result<Event, InviteError>;
pub fn unwrap_direct_invite(wrap: &Event, recipient: &Keys)
-> Result<(PublicKey, CommunityInvite), InviteError>;
```
The link rides `naddr` (`Nip19Coordinate` for kind 33301, link signer, empty `d`) in the path and the token + bootstrap relays in the fragment. A fragment is never sent to a server. The bundle is decrypted with `invite_bundle_key(token)`, and the joiner must recompute `community_id` from `owner` + `owner_salt`.
- The link is `…/invite/<naddr>#<fragment>`: `Nip19Coordinate` for `(33301, link_signer, "")` in
the path, and `[version][flags][relays?][token:16]` base64url-no-pad in the fragment, which is
never sent to a server. `parse_link` also accepts the domain-agnostic bare `<naddr>#<fragment>`.
- **The fragment's byte layout is frozen and golden-tested** against hand-computed base64url: the
stock set is flag `0x01` with zero relay bytes (and exempt from the 3-relay cap, which applies to
explicit entries), otherwise a count then per-relay dictionary id, `0x00 len host` for a
`wss://`-implied literal, or `0xff len url` verbatim. A version this client will not decode is
fatal in **both** directions, since a legacy link would be decoded against the wrong dictionary.
- The bundle is sealed with `invite_bundle_key(token)` used **directly** as the NIP-44 conversation
key (`ConversationKey::new`, not an ECDH pair) — the one place in the protocol where that is so.
- Trust is the `community_id`, which `validate` recomputes from `owner` + `owner_salt`. The bundle
is attacker-reached input, so it is bounded *before* it is used: an over-count of channels, an
epoch past the ceiling, and a secret that is not 32 bytes of hex are all refused up front.
`expires_at` is deliberately not part of `validate`: a parked invite still renders past expiry,
only joining refuses.
- `channels` and `relays` default to empty rather than being required: a bundle vending no channel
keys omits the field entirely, and a required `Vec` would turn a stale read into a join failure.
A channel `key` is `Option`, because a public channel derives from `community_root` and never
carries one.
- The bundle's `name` is a preview, not authority — the Control fold is — so it is not bounded here.
`relays` **is** truncated to the community cap, because a hostile list is a connect storm.
- `parse_bundle_event` re-checks the author, the empty `d` (an absent `d` is that coordinate, so
absent and empty are both accepted and a non-empty one is fatal), the signature, and the `vsk`
marker before it decrypts anything. `vsk 6` is live, `vsk 9` is the tombstone.
- The Direct Invite is a **standard** NIP-59 giftwrap, built by `nip59::GiftWrapBuilder` and read
by `nip59::UnwrappedGift::from_gift_wrap`, both from nostr: the builder does the ephemeral wrap and
the tweaked timestamps, and the reader verifies the wrap, the seal's signature, and the rumor/seal
author bind, which is exactly the gate set the reference implementation hand-rolls. Everything left
is the rumor kind and the bundle's own validation. The wrap carries `["k","3313"]` so a recipient
can index their invites without decrypting their whole giftwrap inbox, and mirrors `expires_at`
as a NIP-40 tag in seconds.
- The earlier sketch's `signer: &UniversalSigner` is `&Keys`, matching every other builder in the
crate: NIP-46 is one deliberate pass, not a per-milestone patch (see §14.9).
Bounds before allocation: reject a bundle with more than 256 channels, truncate the relay list to 5, refuse an expired one.
**The Invite List (13303) and the Registry (vsk 8) are deferred to M7**, together and for the same
reason: nothing in M6 consumes them. A link's signer is held by its caller, so minting, refreshing
and revoking need no document, and a Registry write whose fold does not exist is dead wire. M7 is
where both become load-bearing — the Registry's aggregate is the Public/Private source of truth and
retiring the last live link is what triggers a Refounding.
### 8.6 Rekeys and refoundings (`rekey.rs`)
### 8.6 The Community List (`list.rs`) — implemented in M6
The member's own memberships, `13302` replaceable, NIP-44-encrypted to self.
```rust
pub const KIND_COMMUNITY_LIST: u16 = 13302;
pub const MAX_MEMBERSHIPS: usize = 50;
pub struct JoinMaterial { community_id: CommunityId, owner: PublicKey, owner_salt: String,
community_root: String, root_epoch: Epoch,
control_pk: Option<PublicKey>, control_root: Option<String>,
channels: Vec<ChannelGrant>, relays: Vec<String>, name: String, extra }
pub struct CommunityListEntry { community_id: CommunityId, seed: JoinMaterial,
current: JoinMaterial, added_at: u64, extra }
pub struct Tombstone { community_id: CommunityId, removed_at: u64, extra }
pub struct CommunityList { entries: Vec<CommunityListEntry>, tombstones: Vec<Tombstone>, extra }
impl CommunityList {
pub fn is_live(&self, community_id: &CommunityId) -> bool;
pub fn fits(&self) -> Result<(), ListError>; // the write gate
}
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial;
pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList;
pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, ListError>;
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError>;
```
- Join material is the bundle's **membership subset**: the link-only fields (icon, expiry, label,
creator) are dropped, and `control_root` is added when the holder is staff, since no bundle carries
it. `join_material` is the one conversion between the two documents.
- The two snapshots solve opposite problems and merge the same way: `seed` keeps the **lower**
`root_epoch` and `current` keeps the **higher**, each anchoring an end of the history so a fresh
device needs no epoch-by-epoch walk.
- An epoch tie breaks on the **lowest canonical bytes of the whole snapshot** — a total order, so
two devices never flap competing republishes. `extra` maps union on the same principle (the lower
canonical value wins a key clash), which keeps the merge order-independent while still preserving
unknown fields.
- `added_at` merges to the newest and `removed_at` likewise, so a re-join legitimately resurrects a
membership while a stale device's republish can never re-add a tombstoned id. **A tombstoned entry
stays in the document** — pruning it would make the merge depend on gossip order — and
`is_live` is what reads the newest of the two timestamps.
- `parse_list_event`'s failure is deliberately meaningful: the caller must read it as "no news" and
merge whatever does arrive, never clobber a populated local list with an absence.
- `fits` is the write gate: over the membership cap or over the NIP-44 plaintext cap, the build is
refused rather than truncating memberships to make it fit. The *join* gate that refuses a 51st
membership is the registry's, and the byte-level cap audit is M8's.
- `13302` is implemented per spec. Vector has retired it for fragmented `33302` (a replaceable kind
holds one event per pubkey, so it cannot shard past the size cap); that remains an interop
follow-up, recorded in §14.1.
### 8.7 Rekeys and refoundings (`rekey.rs`)
```rust
pub enum RekeyScope { Channel(ChannelId), Base }
@@ -815,6 +928,7 @@ Each of these has burned a real implementation, or is a documented cross-client
- **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate.
- **Enforced in M4:** the chat plane's encrypted-seal requirement, at both publish and open; `channel` **and** `epoch` strict-equal to the plane whose key opened the wrap; a retired or unregistered rumor kind rejected on both sides; a target bearing tag that appears twice rejected outright; and a delete from anybody other than the message's own author refused by the caller's gate.
- **Enforced in M5:** a Kick counts only where the roster admits its actor under `KICK` with a strictly higher rank and a resolvable citation; a Snapshot counts only from the epoch's refounder, with no owner fallback; a guestbook entry more than an hour ahead is dropped, and an out-of-range `ms` or a non-verb `3306` entry is malformed, not interpreted; a duplicated `vac` is rejected outright; and a delete is honored from its target's author unconditionally, or from another actor only where `can_delete` admits them.
- **Enforced in M6:** an invite fragment whose version is not this one is refused in either direction, as is one with a bad count or trailing bytes, and its encoding caps bootstrap relays at three; a bundle past the channel cap, past the epoch ceiling, or carrying a secret that is not 32 bytes of hex is refused before it is used, and one whose `owner` + `owner_salt` does not reproduce its `community_id` is refused outright; a bundle event off its coordinate, off its author, or unsigned is refused, and a tombstone at the coordinate reads as revoked; a Direct Invite's wrap, its seal signature and its rumor/seal author bind are all verified before the bundle is even parsed; and a Community List refuses to build past its membership cap or the NIP-44 plaintext cap.
- Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts of the caps the folds already apply.
## 13. Milestones
@@ -827,7 +941,7 @@ Each of these has burned a real implementation, or is a documented cross-client
| M3 | Control fold + roster + metadata/channels | ✅ `cargo test -p concord` (14 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone |
| M4 | Chat plane | ✅ `cargo test -p concord` (19 tests): a second holder folds a message's reactions, its author's edit and its author's delete, and ignores an edit or a delete from anybody else; a comment's root and parent survive the wire; a foreign channel, a replayed epoch, a plaintext seal, a retired kind and a duplicated target are each rejected; and history pages backwards across a rekey in order |
| M5 | Guestbook + member list + moderation | ✅ `cargo test -p concord` (23 tests): a second holder folds joins, leaves, a cited kick and a chunked snapshot into one memberlist in either arrival order, with a ban and a Grant deciding the edges; an uncited, unranked or owner-directed kick and a foreign snapshot are dropped; a future-dated entry, a malformed `ms`, a non-verb `3306`, a duplicated `vac` and a bad snapshot chunk are each refused; and a moderator delete lands only under a citation the roster admits, while a self-delete never asks |
| M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 |
| M6 | Invites + Community List | `cargo test -p concord` (32 tests): the fragment's byte layout is pinned by golden base64url for the stock set, a dictionary mix and a verbatim literal, with a wrong version in either direction, trailing bytes and an over-cap count each fatal; a link round-trips as a full URL and as a bare naddr, and refuses a non-invite; a bundle round-trips while a revocation tombstone reads as revoked, and a wrong token, a squatter's author, a foreign `d`, a forged owner, a malformed secret and an over-cap channel count are each refused; a Direct Invite round-trips to its verified inviter and refuses a stranger's keys and a non-invite rumor; and the Community List keeps the earlier seed and the later current in either merge order, refuses to resurrect a tombstoned id until a newer join outruns it, and rebuilds on a second device with unknown fields intact while refusing an over-cap or oversized list |
| M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused |
| M8 | Pins + disappearing messages + hardening | pins verify from a keyless reader's view; expiry is refused at ingest and purged by the sweep; the audit of §12 is complete with a test per bullet |
@@ -849,11 +963,15 @@ M5 closed at 23 tests, again with no dependency change and `Cargo.lock` untouche
What M5 still defers, and to what: the **guestbook's fetch and ingest path**`coalesce` is fed wraps by its caller, so the plane has no `backfill` twin of `chat::backfill` until §10's sync engine needs one, and no `CommunityState.observed` to persist what it would learn; the **banlist head's timestamp** that `complete_memberlist`'s `banned_at` wants (the fold does not surface it, so an empty map means "every ban is terminal" until the registry plumbs it); and the **`Refound` seed** argument to `complete_memberlist`, which waits for M7 to mint one.
M6 closed at 32 tests, again with no dependency change and `Cargo.lock` untouched — the fragment codec is `data_encoding::BASE64URL_NOPAD` and the Direct Invite is nostr's own `nip59` builder and unwrapper, so nothing new was needed. New: `src/invite.rs` (bundle, link, fragment, Direct Invite) and `src/list.rs` (the Community List). In `stream.rs`, the seal and open primitives were factored out of `seal_content`/`decode_content` as `seal_bytes`/`open_bytes`, so the bundle's raw-token key and the List's to-self envelope share the crate's one NIP-44 shape instead of re-implementing it twice; `edition::TAG_SUBKIND` became public so the invite sub-kind tags do not restate the string.
What M6 still defers, and to what: the **Invite List (13303) and the Registry (`vsk 8`)**, both to M7 and for the same reason — nothing in M6 consumes them, and a Registry write whose fold does not exist is dead wire, while M7's refounding is what reads the Registry's aggregate as the Public/Private source of truth (see §8.5); the **join gate that refuses a 51st membership** (the registry's, since `fits` protects the write rather than the add); and the byte-level cap audit, which is M8's.
**M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol.
## 14. Open questions and risks
1. **Community List kind.** CORD-02 §8 specifies `13302`, replaceable. Vector has retired it in favour of fragmented `33302`, because a replaceable kind holds one event per pubkey and so cannot shard past the NIP-44 size cap. We implement `13302` per spec, enforce the 50-membership cap and pre-publish size check, and treat `33302` as an interop follow-up. Confirm with Armada before writing the multi-device code.
1. **Community List kind.** CORD-02 §8 specifies `13302`, replaceable. Vector has retired it in favour of fragmented `33302`, because a replaceable kind holds one event per pubkey and so cannot shard past the NIP-44 size cap. **M6 implements `13302` per spec**, with the 50-membership cap and the pre-publish size check as the write gate (`list::CommunityList::fits`); `33302` remains an interop follow-up, so a coop member's list is invisible to a Vector device until it lands. Confirm the sharded form with Armada before writing it.
2. **NIP-42 for stream-authored REQs.** Relays that gate kind 1059 by author (for example `ditto-relay`'s `AUTH_KINDS`) need an AUTH event signed by that plane's derived key. `nostr-sdk`'s `Authenticator` is per-client and returns one identity, while a Concord client holds many plane keys — so this cannot be solved by swapping the authenticator. Vector's answer is a dedicated stream-auth responder installed on the client (`community/v2/streamauth`), primed before any relay interaction, which retains the relay's challenge so plane keys registering later can still answer it: a gating relay challenges once per connection and will not re-challenge an authed one, so a responder attached later never gets the chance. Read that module before deciding; the alternative options remain a per-REQ auth hook upstream or documenting the limitation.
3. **`invite_bundle_key` — resolved in M0.** Appendix A.6 was read in full: the raw HKDF output *is* the NIP-44 conversation key, and the derivation is now pinned by a vector.
4. **`pins_locator` has no upstream vector.** Resolved in M0 by minting one from our own implementation and flagging it self-referential in the test.
@@ -861,7 +979,7 @@ What M5 still defers, and to what: the **guestbook's fetch and ingest path** —
6. **Local plaintext state.** §9 records the decision. Revisit only if the local database stops being treated as trusted.
7. **Was a `community_id` ever hashed into a tag?** No — it must never appear on the wire. A lint-style test should assert it appears in no published event other than inside an invite bundle and a direct invite.
8. **The Pin List's message-key disclosure has no public API (M8).** CORD-04 §7 Pins let a keyless reader verify a disclosure, which means revealing one message's NIP-44 keys rather than the plane's conversation key. `nostr`'s `nip44::v2::get_message_keys(conversation_key, nonce)` is a private `fn`, and both public entry points (`encrypt_to_bytes_with_nonce`, `decrypt_to_bytes`) take the whole conversation key — so the expansion has to be reproduced as `hkdf::expand_into(conversation_key, nonce, 76 bytes)` plus ChaCha20 and an HMAC-SHA256, exactly as Vector does, and round-tripped against nostr's own `encrypt` in a test. Read CORD-04 §7 in full at M8 before writing it: the reproduction is only worth it once the exact verification the pin must support is settled, and the alternative is contributing a `pub` message-key accessor upstream (we already track git master, so a patch branch or an upstream PR is viable and strictly better than a reproduction we must keep in sync).
9. **A remote signer is not plumbed.** `ControlWriter::publish` and `stream`'s seal builders all take `&Keys`. NIP-46 is a stated Vector feature (§10's task slots are already cleared on signer change), but making the writers async over a `NostrSigner` is a change to every builder, so it should be one deliberate pass rather than a patch per milestone. Nothing in M4M8 depends on it except the UX of using a remote signer at all.
9. **A remote signer is not plumbed.** `ControlWriter::publish`, `stream`'s seal builders and the invite builders all take `&Keys`. NIP-46 is a stated Vector feature (§10's task slots are already cleared on signer change), but making the writers async over a `NostrSigner` is a change to every builder, so it should be one deliberate pass rather than a patch per milestone. Nothing in M4M8 depends on it except the UX of using a remote signer at all.
10. **The fold is not incremental.** `fold_control` re-parses and re-folds the whole control edition window on every call, and each fold is up to `2 × entities + 8` passes. That is fine at the caps the spec sets (100 roles, 400-odd grants) and it is the simplest thing that is correct, but if the sync engine ends up calling it per event rather than per batch, the candidate maps and their parse belong in a cache keyed by edition id. Measure before optimizing.
## 15. Test strategy