Files
coop/docs/nip29-backend-plan.md
T

39 KiB
Raw Blame History

NIP-29 groups backend

Coop has two relay-backed backends today: crates/chat (NIP-17 gift-wrap DMs, rendered by crates/chat_ui) and crates/community (Concord). This document is a plan, not a description of shipped code; nothing below is wired up yet. It covers the backend only — reading, writing, joining and leaving relay-based groups as defined by NIP-29 — and ends with the hooks a UI will use later.

The spec in backend terms

A group is a (relay, id) pair. The relay enforces membership and signs the group's state; there is no cross-relay identity, no encryption, and no portable "create". The same id on two relays is two groups (a fork), which the plan models as two entities.

Concern Wire Backend rule
Group id random string, relay-chosen h tag on everything a user sends; d tag on relay state events
Timeline any event kind + h v1 renders kinds 1, 9, 11, 1111 and 7; every other kind is cached for the previous window but not rendered, except the membership kinds below
Timeline refs previous tag, first 8 hex chars of event ids built from the last 50 events seen on that relay (any kind — NIP-29 timeline references), excluding our own; send up to 3
Relay state 39000 metadata, 39001 admins, 39002 members, 39003 roles, 39004 LiveKit participants relay-signed, addressable by d; read, never written by the client
Membership 9021 join, 9022 leave (user-signed); relay answers with 9000 put-user / 9001 remove-user membership = latest 9000/9001 naming our pubkey; no event means not a member, and Pending/Refused are tracked locally for private groups
Own group list 10009 (NIP-51 simple groups) ["group", id, relay, name?] plus ["r", relay]; replaceable, published to NIP-65 relays
Reference naddr1… of kind 39000 with the relay self pubkey and a relay hint, optionally ?invite=<code> the self pubkey is the author of the fetched 39000; no 39000 means no canonical naddr

Two behaviours that shape the code:

  • Late publication is rejected. Relays drop messages with old timestamps, so there is no offline send queue in v1: a send either goes now or fails visibly.
  • previous must reference events the relay already has. Relays may enforce at least 3 references; a group we have little history for may have its sends rejected until the backlog loads. The relay's OK false message is surfaced as-is.

Not in v1

Moderation writes (9000–9010), group creation (9007, relay-dependent), pinned events (39005/9010), subgroups (parent/child — parsed but inert), LiveKit audio/video (livekit tag, 39004, the token endpoint), fork/migration detection through admins' 10009, and relay browsing ({"kinds":[39000]} to list a relay's public groups). All are additive; none change the model above.

What the local rust-nostr checkout gives us

Cross-checked against the pinned checkout (~/.cargo/git/checkouts/nostr-619b808bb247a9ed/01f1950):

  • Kinds exist for every NIP-29 event in v1: Kind::GroupPutUser (9000), GroupRemoveUser (9001), GroupEditMetadata (9002), GroupDeleteEvent (9005), GroupCreateGroup (9007), GroupDeleteGroup (9008), GroupCreateInvite (9009), GroupJoinRequest (9021), GroupLeaveRequest (9022), GroupMetadata (39000), GroupAdmins (39001), GroupMembers (39002), GroupRoles (39003), GroupLivekitParticipants (39004), and Kind::SimpleGroups (10009). Kind::ChatMessage is kind 9.
  • 9010 and 39005 are missing from this revision (Kind::Custom(9010), Kind::Custom(39005) if they are ever needed — they are not, in v1).
  • There is no NIP-29 module. nostr/src/nips/ has no nip29.rs, and no GroupId, metadata type, parser or builder exists. Everything in protocol.rs below is ours; it is thin — tags and filters, no crypto.
  • The APIs to lean on are all present and already used in the app:
    • Tag::custom, Tag::event, Tag::identifier, Tag::public_key, Tag::content, Tag::as_slice.
    • Filter::custom_tag(SingleLetterTag::LOWERCASE_H, id), .identifier(id), .author, .kinds, .limit, .since.
    • ReqTarget::single(url, filters) for a group-scoped REQ, client.subscribe(..).with_id(..), client.fetch_events(..), client.send_event(&event).to([url]).
    • EventBuilder::new(kind, content).tags(tags).finalize_async(&signer) — the app's UniversalSigner works here (NIP-46 included), as it does in crates/chat/src/room.rs.
    • client.database().query/save_event/delete.
    • Nip19Coordinate { coordinate, relays } with ToBech32/FromBech32 for naddr.
  • NIP-11 is not exposed. Relay in this SDK has no document accessor, so the relay self pubkey is taken from the author of the group's 39000 event; a group whose relay publishes no 39000 therefore has no canonical naddr and cannot be shared or joined in v1.

What the app already does (and we copy)

The NIP-17 backend is the template. crates/chat has a global ChatRegistry entity, one Room entity per conversation, a background task over client.notifications() that sends signals through a flume channel, and a foreground cx.spawn consumer that is the only place entities change. Local messages are cached as ApplicationSpecificData (30078) events with a d tag for the target id and an r tag for the room key (set_rumor/get_rumor, crates/chat/src/lib.rs). crates/chat_ui renders chat::Message into a ListState, resolving names through PersonRegistry.

crates/community shows the same pattern for relay-scoped state: routing notifications by subscription id (route_of), bringing relays into the pool before use (connect_relays), and treating a CLOSED message as a refusal. Replacing a list is done in crates/workspace/src/panels/contact_list.rs: sign the builder, client.send_event(&event).to_nip65().await — the client saves the event locally even when the publish fails.

NIP-29 reuses all of this and drops what only NIP-17 needs: no encryption, no per-member fan-out, no gift wraps, no SendReport per receiver — one relay, one event, one OK.

Where this lands

A new crate crates/nip29, because NIP-29 is a different transport (plain relay events), a different state authority (relay-signed 39000s) and a different UI later. Making chat::Room polymorphic over gift-wrapped DMs and relay groups would put two protocols in one entity. The alternative — modules inside crates/chat — saves a Cargo.toml but was set aside (see Decisions).

File Owns
crates/nip29/src/protocol.rs Pure NIP-29 types and their impl blocks: GroupId, GroupKey, GroupReference, GroupMetadata, admins/members/roles, GroupList, Membership, TimelineWindow. No I/O.
crates/nip29/src/group.rs Group entity: state, timeline, membership, connect, ingest, send_message, send_reaction, join, leave, GroupEvent.
crates/nip29/src/lib.rs GroupsRegistry global, init, standing subscriptions and the signal pump, the envelope cache, load/publish of the account's 10009.
desktop/src/main.rs, web/src/lib.rs nip29::init(cx) after chat::init(cx).
UI later crates/chat_ui (reuse chat::Message rendering) or a new crates/nip29_ui; sidebar section; a join dialog.

protocol is re-exported at the crate root (pub use protocol::*) so callers write nip29::GroupId, nip29::GroupMetadata. The crate's dependencies are the ones chat already carries: state (client and signer), chat (Message), gpui, nostr-sdk, anyhow, flume, futures, smallvec, log; dev-dependencies nostr-memory and nostr-sdk with the local-relay feature for the tests below.

Data flow

own 10009 (NIP-65 relays)              group relay (one per group)
      │ load / subscribe                     │  REQ #h=<id>  (timeline + 9000/9001)
      ▼                                      │  REQ #d=<id>  (39000..39003)
GroupsRegistry ──ensure relay──► Group entity ◄──ingest── background listener
      │ index: GroupKey                       │              (saves envelope first)
      │                                       │
      └── flume Signal ◄──────────────────────┘
                 ▲
     UI later: GroupPanel (Vec<Message>), JoinDialog (naddr)

A group's subscriptions are the standing grp-<hash> REQ (timeline + state + membership, the timeline replayed up to 100 events) and, on demand, a pg-<hash> REQ that re-asks the same timeline filter with until set to the oldest event hydrated so far. Both are recognised by the same pump and land in the same envelope cache, so paging needs no separate read path.

Phase 0 — crates/nip29/src/protocol.rs (pure, no I/O)

The protocol is expressed as types with impl blocks: a parsed thing is a struct and every operation on it is a method, so call sites read id.message(...), metadata.naddr(...), admins.contains(&pk) instead of handing the pieces to free builder functions.

/// The relay-chosen group id.
pub struct GroupId(String);

impl GroupId {
    pub fn new(id: impl Into<String>) -> Result<Self, Error>;   // trimmed, non-empty
    pub fn as_str(&self) -> &str;
    pub fn h_tag(&self) -> Tag;                                 // ["h", id]

    pub fn timeline_filter(&self, limit: usize) -> Filter;      // #h = id
    pub fn state_filter(&self) -> Filter;                       // #d = id, kinds 39000..39003
    pub fn membership_filter(&self, me: PublicKey) -> Filter;   // #h = id, #p = me, kinds 9000/9001

    pub fn message(&self, content: String, replies: &[EventId], previous: Option<Tag>) -> EventBuilder; // kind 9
    pub fn reaction(&self, target: EventId, emoji: String, previous: Option<Tag>) -> EventBuilder;      // kind 7 + e
    pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder;                       // 9021
    pub fn leave_request(&self, reason: &str) -> EventBuilder;                                          // 9022
}

/// Entity identity: the same id on another relay is another group (a fork).
pub struct GroupKey { relay: RelayUrl, id: GroupId }

impl GroupKey {
    pub fn new(relay: RelayUrl, id: GroupId) -> Self;
    pub fn relay(&self) -> &RelayUrl;
    pub fn id(&self) -> &GroupId;
    pub fn cache_tag(&self) -> String;   // "<relay>|<id>"
    pub fn uniq_id(&self) -> u64;        // hashed, for panel and element ids
}

/// What a user pastes to join: naddr1…(+"?invite=<code>").
pub struct GroupReference { id: GroupId, relay: RelayUrl, invite: Option<String> }

impl GroupReference {
    pub fn parse(input: &str) -> Result<Self, Error>;
    pub fn id(&self) -> &GroupId;
    pub fn relay(&self) -> &RelayUrl;
    pub fn invite(&self) -> Option<&str>;
    pub fn key(&self) -> GroupKey;
}

impl FromStr for GroupReference { .. }   // so a pasted naddr can use `.parse()` too

/// kind:39000, relay-signed.
pub struct GroupMetadata {
    pub name: Option<String>, pub picture: Option<String>, pub banner: Option<String>,
    pub about: Option<String>, pub private: bool, pub restricted: bool, pub hidden: bool,
    pub closed: bool, pub livekit: bool, pub parent: Option<GroupId>,
    pub supported_kinds: Option<Vec<Kind>>,   // None = unspecified (all); Some(vec![]) = none
}

impl GroupMetadata {
    pub fn parse(event: &Event) -> Result<Self, Error>;
    pub fn name(&self) -> Option<&str>;
    pub fn supports(&self, kind: Kind) -> bool;
    pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result<String, Error>;
    // Coordinate { kind: 39000, public_key: <the event author>, identifier: <the d tag> }
}

/// kind:39001 — each pubkey with its role labels.
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);

impl GroupAdmins {
    pub fn parse(event: &Event) -> Result<Self, Error>;
    pub fn entries(&self) -> &[(PublicKey, Vec<String>)];
    pub fn contains(&self, public_key: &PublicKey) -> bool;
}

/// kind:39002 — may be absent or a subset; absence means "not published".
pub struct GroupMembers(Vec<PublicKey>);

impl GroupMembers {
    pub fn parse(event: &Event) -> Result<Self, Error>;
    pub fn contains(&self, public_key: &PublicKey) -> bool;
    pub fn iter(&self) -> impl Iterator<Item = &PublicKey>;
}

/// kind:39003 — role names are relay-defined.
pub struct GroupRoles(Vec<Role>);
pub struct Role { pub name: String, pub description: Option<String> }

impl GroupRoles {
    pub fn parse(event: &Event) -> Result<Self, Error>;
    pub fn roles(&self) -> &[Role];
}

/// kind:39004 — who is live in the group's AV room; `participant` tags, not `p`.
/// Absence means nothing is known, which is not the same as an empty room.
pub struct GroupParticipants(Vec<PublicKey>);

impl GroupParticipants {
    pub fn parse(event: &Event) -> Result<Self, Error>;
    pub fn contains(&self, public_key: &PublicKey) -> bool;
    pub fn iter(&self) -> impl Iterator<Item = &PublicKey>;
}

/// kind:10009 — the account's own list.
pub struct GroupList(Vec<GroupListEntry>);
pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option<String> }

impl GroupList {
    pub fn parse(event: &Event) -> Result<Self, Error>;
    pub fn entries(&self) -> &[GroupListEntry];
    pub fn upsert(&mut self, entry: GroupListEntry);
    pub fn remove(&mut self, key: &GroupKey);
    pub fn to_builder(&self) -> EventBuilder;   // kind 10009, group + r tags
}

pub enum Membership {
    Unknown,
    Pending { since: Timestamp },          // our 9021 went out; no answer yet
    Member,
    Refused { reason: Option<String> },    // the relay's OK false / CLOSED message
    Removed,
}

impl Membership {
    /// Folds 9000/9001 only; `Pending` and `Refused` are set by the join path.
    pub fn from_events(events: &[Event], me: PublicKey) -> Self;
}

/// The newest-first window (≤50) that `previous` references are drawn from.
pub struct TimelineWindow(Vec<Event>);

impl TimelineWindow {
    pub fn new() -> Self;
    pub fn push(&mut self, event: Event);                       // dedups, caps at 50
    pub fn previous_tag(&self, me: PublicKey) -> Option<Tag>;   // excludes `me`, ≤3 distinct 8-hex
}

Details that must be right:

  • Every user event carries ["h", id] (from GroupId::h_tag); previous is added only when we have at least one eligible event, and its values are the first 8 characters of the hex ids.
  • GroupReference::parse splits ?invite= before bech32 decoding; a missing relay hint is an error (there is nowhere to send the join request).
  • GroupList::parse accepts group tags with 1–3 values (id, relay, name).
  • Membership::from_events takes the newest 9000/9001 whose p tags name me; on an exact tie it prefers 9001 (removal). Pending and Refused are not things the relay folds into an event: the join path sets them from the 9021 send and from the relay's refusal message.
  • GroupMetadata::supports distinguishes "tag absent" from "tag present with no values": the first means all kinds, the second means no text messages.
  • The builders return EventBuilders so the caller decides when to sign; nothing in this file does I/O.

Phase 1 — read path (group.rs + lib.rs)

GroupsRegistry (global entity, mirroring CommunityRegistry):

pub struct GroupsRegistry {
    groups: Vec<Entity<Group>>,
    index: HashMap<GroupKey, Entity<Group>>,
    /// The account's 10009, loaded at start and mutated by join/leave
    list: GroupList,
    /// subscription id → group, rebuilt whenever a group resubscribes
    routes: Arc<RwLock<HashMap<SubscriptionId, GroupKey>>>,
    signal_tx: flume::Sender<Signal>, signal_rx: flume::Receiver<Signal>,
    notification_listener: Option<Task<Result<()>>>,  // cancelled on signer change
    signal_consumer: Option<Task<Result<()>>>,
    tasks: SmallVec<[Task<Result<()>>; 2]>,
    _subscriptions: SmallVec<[Subscription; 2]>,
}

impl GroupsRegistry {
    pub fn global(cx: &App) -> Entity<Self>;
    pub fn groups(&self) -> &[Entity<Group>];
    pub fn group(&self, key: &GroupKey) -> Option<Entity<Group>>;
    pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>);
    pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>);
    fn load(&mut self, cx);           // own 10009 from the database into `self.list`
    fn publish_list(&mut self, cx);   // sign `self.list.to_builder()`, to_nip65, best-effort
}
  • Subscriptions. One standing REQ per group, two filters in one id: key.id().timeline_filter(100) and key.id().state_filter(). The id is grp-<8 hex of the group key hash> (NIP-01 caps ids at 64 chars; the real key lives in routes). The relay is added and connected first (add_relay(url).and_connect()), like community::sync::connect_relays. On open, also issue a one-shot key.id().membership_filter(me) fetch so a restart learns 9000/9001 immediately; the state filter is fetched once (limit 1 per kind) so the sidebar has names before the live REQ settles. A dead relay costs only its own group.
  • Pump. The listener matches ClientNotification::Message and routes RelayMessage::Event/EndOfStoredEvents/Closed by subscription id through routes. For h-tagged events it saves the envelope first, then sends Signal::Event(key, event); state events send Signal::State(key, event); Closed sends Signal::Refused(key, message). The consumer calls Group::ingest, which is the only place entity state changes.
  • Envelope cache. Same shape as set_rumor/get_rumor in crates/chat: ApplicationSpecificData (30078, addressable) with d = event id (dedup on replay), r = "<relay>|<id>" (per-fork history), k = kind, content = the raw event JSON, signed by a crate-local LOCAL_KEYS. Every h-tagged event the standing subscription delivers is enveloped — any kind, because the previous window is literally "the last 50 events seen" (NIP-29 timeline references) — while the d-tagged state events (39000–39003) are not. Reading is Filter::new().custom_tag(LOWERCASE_R, key), parsed and sorted by created_at, then partitioned: the render set (1, 7, 9, 11, 1111) becomes messages, 9000/9001 fold membership, the rest only feed the window. This is what makes the timeline fork-safe and restart-safe without teaching the database about relays.
  • Group state. GroupKey, metadata: Option<GroupMetadata>, metadata_event: Option<Event> (what GroupMetadata::naddr is built from), members, admins, roles, membership: Membership (including the local Pending/Refused access states), messages: Vec<Message> for the render set (reusing chat::Message, which already parses any event into display fields), an id index, a TimelineWindow for previous, loading, refused. It emits GroupEvent::{Incoming(Event), Updated, Refused(String), Eose} — the panel subscribes to these the way ChatPanel subscribes to RoomEvent.
  • load(). Subscribe to our own 10009 (ReqTarget::auto, limit 1), then read the newest one from the database into self.list, and add one group per entry. A newer 10009 arriving from another device is merged (GroupList::upsert) and the new groups are subscribed; removals by another client cannot be detected in a replaceable list and are not guessed at.

Phase 2 — write path

All writes sign with the app signer (finalize_async(&signer)), send to the group's relay only, and treat the relay's OK as the truth.

  • Send a message. Build with key.id().message(content, &replies, window.previous_tag(me)), sign, then client.send_event(&event).to([relay]). Kind 9 is the default message kind; when metadata.supports(Kind::ChatMessage) is false the composer is disabled rather than sending a kind the relay does not take. On acceptance: save the envelope, insert into messages, emit GroupEvent::Incoming. On OK false: return the relay's message (it already carries invalid: / restricted: / duplicate: prefixes) and let the panel show it. Reactions go through the same path with key.id().reaction(..).
  • Join. GroupReference::parse → error if no relay hint → add/connect the relay → fetch 39000 (limit 1) to confirm the group and learn the self pubkey; GroupMetadata::parse it; a hidden group may refuse this — proceed without metadata rather than failing → create the entity, fetch state + membership, subscribe → send key.id().join_request(code, ..) — the h tag plus the code tag when an invite code was parsed → mark Membership::Pending { since } and self.list.upsert(..), so the group stays listed while it waits. The relay either emits 9000 (member), refuses the 9021 with OK false (set Refused { reason } from its message — the NIP says the message explains whether the rejection is final, pending review or payment-required), or refuses the timeline REQ with restricted: / auth-required: in a CLOSED (keep the state, do not raise an error toast). All three are observed on the standing subscription or the send result.
  • Leave. Send key.id().leave_request(..); on OK, forget the group's envelopes (client.database().delete(filter) by r tag), drop the entity and self.list.remove(key), then publish. The relay also emits 9001, which the (now gone) subscription would have seen.
  • Publish 10009. Sign self.list.to_builder() and publish with to_nip65(). The registry's GroupList is the one loaded at start, join upserts an entry and leave removes one, so a rewrite can never drop a group we loaded. The client saves the event locally even when the publish fails, so the list survives offline.

Phase 3 — later, in rough order

  1. Moderation writes: 9000 put-user (roles), 9001 remove-user, 9002 edit-metadata, 9005 delete-event, 9009 create-invite; gate the UI on the caller appearing in 39001, let the relay be the enforcer. Done as pure writes: builders on GroupId, senders on Group sharing dispatch. edit_metadata takes a GroupMetadata, which is now Default-able (its private id/author origin are Option) so the UI can start an edit from scratch or from Group::metadata(). 9005 is write-only for now: the relay stops serving the event but nothing is removed locally, so the deleted message lingers in the cache and timeline. The UI phase must drop the target envelope (filter on its d = target id and the group's r cache tag) and evict it from messages/index, otherwise only a reload clears it.
  2. Group creation (9007) where the relay supports it, and relay browsing. Done. GroupId::create_group reuses the 9002 metadata tags, so the proposed id (h) and the initial name/flags go in one event; the relay republishes 39000, which the standing subscription picks up. GroupsRegistry::create mirrors join: ensure → optimistic 10009 entry
    • publish_list → publish, and on OK false it forgets the group and emits GroupsEvent::Error(reason) (that variant was previously unused). There is no NIP-11 support check available, so "where the relay supports it" is enforced by the relay refusing the 9007. GroupsRegistry::browse(relay, cx) follows the same pipeline as every other read: it registers a brw-<hash> subscription (an opaque id, like grp-<hash>; the relay is keyed in state, not encoded in the id, because the pump needs a prefix to recognise its own ids and a bare SubscriptionId::generate() would be indistinguishable from the other crates' ids on the shared client). add_relay + subscribe (auto-close on EOSE, 10s cap), and returns immediately. The global pump observes the events, and handle_event/handle_eose/ handle_closed fold each relay-signed 39000 into self.browse, keyed by the subscription id, so no DB scoping is needed. GroupsRegistry::browsed(relay) returns Browse { relay, loading, groups: Vec<GroupCandidate> } for the UI, and GroupsEvent::Updated says when to re-read it. No fetch_events anywhere: the DB cannot be scoped to a relay (a stored event carries no origin), so the subscription's own events are the source. join now delegates to a new join_key(key, invite), which is what a browse result needs (a candidate has a key, not a GroupReference).
  3. Pins and subgroups. Pins done: Pin::{Event, Address} and GroupPins in protocol.rs, with PIN_LIST (39005) and UPDATE_PIN_LIST (9010) as u16 consts used through Kind::Custom. 39005 joins STATE_KINDS, so the standing subscription fetches it, absorb_state folds it into Group::pins(), and Group::update_pins(pins) writes 9010 through the shared dispatch. Subgroups parsed: GroupMetadata gained pub children: Vec<GroupId> (order preserved, parsed from child tags) and metadata_tags re-emits them — 9002 requires it, an edit that drops a child is rejected. parent/children are per-relay, so assembling the tree from Group::metadata() is UI work; there is no backend tree helper.
  4. Range backfill: page the timeline with until for "load more". Done. GroupId::timeline_page(limit, until) is timeline_filter plus .until(..), and Group::load_page sends it as a pg-<hash> subscription (auto-close on EOSE, 10s cap) alongside the standing grp-<hash> one, which keeps running so paging cannot replace the live tail. The read path is the usual one: pg- joins is_own_subscription, the id is registered in routes against the same key, and handle_event therefore caches each page event and reloads the group, so it arrives through the envelope cache and the timeline like any other event. GroupsRegistry::load_more(key, cx) is the entry point: it does nothing while a page is in flight (Group::loading_more) or before anything is cached, and registers the route before Group::load_page sets the flag. handle_eose and a page CLOSED remove the route and call Group::page_finished, which clears the flag and emits GroupEvent::Updated so a panel's spinner can settle. The cursor is Group::oldest(), the oldest h-tagged event ever hydrated, updated by track next to window.push — not read from TimelineWindow, which keeps only the newest 50 events, so a cursor taken from it would never move and would page the same window forever. until is inclusive, so a page repeats the event it starts from; hydrate dedups by id, so that costs one redundant fetch, never a gap. forget also drops and unsubscribes the pg- route (reset already sweeps routes). Two gaps left for the UI phase: a relay that accepts the REQ but never EOSEs leaves loading_more true (the auto-close timeout closes the subscription locally and emits no notification — the same stickiness as Browse::loading), and a group whose next page shares the until second cannot advance the cursor, so it can re-fetch the same page. Neither loops on its own; both need a "load older messages" button bound to Group::loading_more() to be observable at all.
  5. LiveKit AV and fork/migration detection, split in three:
    1. 39004 participants. Done. GroupParticipants joins protocol.rs with the same shape as GroupMembers but reads participant tags (the NIP does not use p here); one malformed pubkey fails the whole event, as in GroupAdmins. The kind joins STATE_KINDS, so the standing subscription already carries it, and absorb_state folds it into Group::participants(). Nothing is written by us — the relay publishes it and updates it as people come and go, so it rides the live tail like any other state event. "Absent" and "empty" are deliberately the same value here: unlike supported_kinds, the NIP gives the empty list no special meaning, so the UI reads "no one is live" either way.
    2. The /.well-known/nip29/livekit/<group-id> token endpoint and the 204 probe at /.well-known/nip29/livekit. Done. The HTTP client is the one GPUI already owns: cx.http_client() returning Arc<dyn HttpClient>, already wired to reqwest_client::ReqwestClient on desktop and to fetch on wasm — the same path state::nip05 uses, which is why no HTTP dependency lands in this crate. Its get() convenience cannot set headers, so the request goes through HttpClient::send with a Builder, an explicit RedirectPolicy::NoFollow (the Authorization header is signed for exactly one URL, so a redirect could only ever fail verification) and a 10s timeout — both are opt-in extensions, and a request without them has none. The header itself is NIP-98 from the SDK: nostr/nip98 is now enabled on the workspace nostr entry and nip29 declares nostr itself, so HttpData::new(url, HttpMethod::GET).to_authorization(&signer) builds Nostr <base64> — the u + method tags that the crate's own verify_auth_header demands, though the NIP's prose mentions only u. GroupKey::livekit_token_url / livekit_probe_url derive the endpoint purely: the relay URL is rewritten to http(s) (keeping host and port), reset to the origin, and the path built with pop_if_empty().push(..) so each segment is escaped — a relay with its own path prefix loses it, since the NIP puts the endpoint at the absolute path. Group::livekit_session and Group::livekit_supported are the two calls, both &App + background_spawn like publish. The session is returned, not stored: tokens are per-join artifacts, not group state, so the caller owns it. Only livekit_supported's unauthenticated GET ignores status beyond 204; the token endpoint's response shape is not specified by the NIP (see Decisions) and no LiveKit client library is involved — the backend stops at the JWT and server URL.
    3. Fork/migration detection. Done. One standing nip29-forks subscription over {authors: <every admin of every group>, kinds: [10009]} on ReqTarget::auto — the admins' own group lists live on their outbox relays, so this works with the group's relay down, and the admin pubkeys come from 39001, which is hydrated from the envelope cache, so it works after a restart too. Each event is folded into forks (author → newest GroupList, guarded on created_at because 10009 is replaceable), then evaluate_forks recomputes every group. Group::elsewhere() is Some when an admin of that group lists the same group id at a different relay; the data cannot distinguish a move from a fork, hence the neutral name. Evaluation is incremental, on each event, rather than batched on EOSE: ReqTarget::auto fans out to several relays, so the first EOSE would otherwise evaluate — and clear — against one relay's partial view. Nothing is auto-joined, since the NIP asks the client to offer; the offer is GroupsRegistry::join_key(elsewhere, None, cx), which is exactly "fetch from the new relay and update our own 10009". Two triggers, because the NIP's "periodically" and its "MUST when the relay is offline" are different things. Reactively, the group observer calls sync_forks(false) on GroupEvent::Updated, subscribing as soon as the first 39001 lands and re-subscribing only when the admin set actually changes — the guard matters, since Updated also fires for metadata, pins, membership and a finished page. Periodically, a background_executor().timer loop started in load pushes Signal::Forks every 15 minutes and sync_forks(true) re-queries regardless: that is the recovery path for a subscription that died or a client that was offline, not the detection path. reset drops the fork state and unsubscribes the REQ; a CLOSED on it is only logged, because the tick retries and there is nothing to repair.

UI later — the hooks this backend leaves

UI action Call
Paste an naddr and join (or retry a refused join) GroupReference::parse → GroupsRegistry::join
Open a group GroupsRegistry::group(&key, cx) → panel subscribes to GroupEvent
Timeline Group::messages() (already chat::Messages)
Load older messages GroupsRegistry::load_more(&key, cx); spinner from Group::loading_more()
Send / react Group::send_message / send_reaction
Members, admins, roles, flags Group::members/admins/roles/metadata
Who is in the AV room Group::participants()
Join AV (get a LiveKit session) Group::livekit_session(cx) → await → LivekitSession { token, server_url }
Is AV available here Group::livekit_supported(cx) after seeing metadata().livekit
The group may have moved or been forked Group::elsewhere() → offer GroupsRegistry::join_key(elsewhere, None, cx)
Membership badge (member, pending, refused, removed) Group::membership()
Copy an invite link GroupMetadata::naddr(relay, invite)
Leave GroupsRegistry::leave
Create a group GroupMetadata { ..Default::default() } → GroupsRegistry::create(key, metadata, cx); failure surfaces as GroupsEvent::Error
Browse a relay's groups GroupsRegistry::browse(relay, cx) → re-read GroupsRegistry::browsed(relay) on GroupsEvent::Updated; join a result with GroupsRegistry::join_key(candidate.key, None, cx)
Moderate (put/remove user, edit metadata, delete event, invite) Group::put_user/remove_user/edit_metadata/delete_event/create_invite
Pins Group::pins() to read, Group::update_pins(pins) to replace the list
Subgroup tree Group::metadata() → parent/children, assembled per relay

Where it lands: the sidebar gains a section reading GroupsRegistry::groups() (the same shape as its Communities section) with pending and refused groups listed alongside members; a new panel renders Vec<Message> with the existing ListState + ui::message::MessageRow and PersonRegistry for authors. The message data is already shared (chat::Message), so only ChatPanel's row-rendering methods need lifting into ui (or copying) rather than the type. A join dialog accepts a pasted naddr (with or without ?invite=). System notifications can hang off GroupEvent::Incoming when the window is inactive.

Nothing new is needed from GPUI to support this backend. The local checkouts are gpui-pre 0.3.7 (aliased as gpui) and gpui-base 0.7.0, which crates/ui styles; the GPUI APIs the backend uses are the ones chat and community already use — Entity/WeakEntity, Context::spawn, background_spawn, EventEmitter, Subscription — plus two that state already uses: App::http_client() for the AV token fetch and App::background_executor().timer(..) for the fork tick. The panel's components (ListState, MessageRow, Avatar, Button, Scrollbar) already exist in the app.

Tests

  • protocol.rs unit tests: GroupReference::parse (naddr, ?invite=, garbage, missing relay hint, an naddr whose kind is not 39000); GroupMetadata::parse flags and supports with supported_kinds absent vs empty; admins with roles; members; roles; GroupList round trip (parse → upsert → to_builder); TimelineWindow::previous_tag (excludes self, caps at 3, 8-char prefixes, any-kind window, None when empty); Membership::from_events (9000 vs 9001, newest wins, tie prefers removal; Pending/Refused are exercised through the join path); GroupId::message carries h and the reply e tags; GroupMetadata::naddr round trip with and without an invite code.
  • Cache round trip with nostr-memory as a dev-dependency (the pattern crates/community already uses): save two envelopes (a kind 9 and a kind 9000), read them back sorted, assert the partition — the kind 9 renders, the 9000 only folds membership — re-save one and confirm no duplicate, delete clears the group.
  • Routing: subscription id ↔ GroupKey mapping survives a resubscribe.
  • One integration test against the SDK's LocalRelay (nostr-sdk dev-dependency with the local-relay feature): seed a 39000 and a kind 9 with an h tag, run Group::connect, assert metadata and the timeline land. Timeouts in GPUI-driven tests use cx.background_executor().timer(..), never smol::Timer, per AGENTS.md.

Decisions

  1. Crate. crates/nip29 (the body above is written for it).
  2. Timeline kinds. Render 1, 9, 11, 1111 and 7; every other kind is still cached and feeds the previous window, per the NIP's timeline references rule.
  3. Default message kind. Kind 9; the composer is disabled when supported_kinds is present and lacks it.
  4. Pending joins. Pending (and refused) groups are listed in the sidebar, with an access state on the row.
  5. 10009 merge. Union-only; removals made by another client are not detected and no diffing is done.
  6. Leave and the cache. Leaving wipes the group's envelopes.
  7. Restricted/private groups. A visible access state is required: Membership::{Unknown, Pending, Member, Refused, Removed} with the relay's refusal reason shown and a retry, while the client's NIP-42 authenticator handles the reads.
  8. References. naddr only (?invite= accepted); id@relay is not. A group whose relay publishes no 39000 cannot be referenced or joined in v1.
  9. LiveKit response shape. The NIP says the token endpoint returns "the LiveKit JWT and the URL of the LiveKit server" but names no fields, so LivekitSession::parse reads token (or jwt) and url (or server_url) and fails loudly on anything else. This is a guess, not a spec, and it is the one part of the AV path that a real relay can contradict; it is worth re-checking against whichever relay is targeted before the UI lands. Likewise livekit_supported treats "not 204" as "no AV" rather than an error, because it only feeds a button.
  10. Monitor. Not adopted. It is only reachable through ClientBuilder::monitor(Monitor::new(n)), so nothing can read per-relay status until crates/state opts in, and its StatusChanged stream does not distinguish a permanent outage from a reconnect — so it would need filtering before it could mean anything. The only thing it buys here is running the admins' 10009 check the instant a group's relay drops, rather than at the next 15-minute tick. The NIP frames that as MUST, but the substance is already covered: the admins come from the cached 39001, so the check runs with the relay gone, just later. If that latency ever matters, the hook is sync_forks(true, cx) driven by a StatusChanged consumer — one place.