26 KiB
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 |
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.
previousmust 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'sOK falsemessage 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), andKind::SimpleGroups(10009).Kind::ChatMessageis kind 9. 9010and39005are 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 nonip29.rs, and noGroupId, metadata type, parser or builder exists. Everything inprotocol.rsbelow 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'sUniversalSignerworks here (NIP-46 included), as it does incrates/chat/src/room.rs.client.database().query/save_event/delete.Nip19Coordinate { coordinate, relays }withToBech32/FromBech32fornaddr.
- NIP-11 is not exposed.
Relayin this SDK has no document accessor, so the relay self pubkey is taken from the author of the group's39000event; a group whose relay publishes no39000therefore 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)
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: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](fromGroupId::h_tag);previousis added only when we have at least one eligible event, and its values are the first 8 characters of the hex ids. GroupReference::parsesplits?invite=before bech32 decoding; a missing relay hint is an error (there is nowhere to send the join request).GroupList::parseacceptsgrouptags with 1–3 values (id, relay, name).Membership::from_eventstakes the newest9000/9001whoseptags nameme; on an exact tie it prefers9001(removal).PendingandRefusedare not things the relay folds into an event: the join path sets them from the9021send and from the relay's refusal message.GroupMetadata::supportsdistinguishes "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)andkey.id().state_filter(). The id isgrp-<8 hex of the group key hash>(NIP-01 caps ids at 64 chars; the real key lives inroutes). The relay is added and connected first (add_relay(url).and_connect()), likecommunity::sync::connect_relays. On open, also issue a one-shotkey.id().membership_filter(me)fetch so a restart learns9000/9001immediately; 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::Messageand routesRelayMessage::Event/EndOfStoredEvents/Closedby subscription id throughroutes. Forh-tagged events it saves the envelope first, then sendsSignal::Event(key, event); state events sendSignal::State(key, event);ClosedsendsSignal::Refused(key, message). The consumer callsGroup::ingest, which is the only place entity state changes. - Envelope cache. Same shape as
set_rumor/get_rumorincrates/chat:ApplicationSpecificData(30078, addressable) withd= event id (dedup on replay),r="<relay>|<id>"(per-fork history),k= kind, content = the raw event JSON, signed by a crate-localLOCAL_KEYS. Everyh-tagged event the standing subscription delivers is enveloped — any kind, because thepreviouswindow is literally "the last 50 events seen" (NIP-29 timeline references) — while thed-tagged state events (39000–39003) are not. Reading isFilter::new().custom_tag(LOWERCASE_R, key), parsed and sorted bycreated_at, then partitioned: the render set (1,7,9,11,1111) becomes messages,9000/9001fold membership, the rest only feed the window. This is what makes the timeline fork-safe and restart-safe without teaching the database about relays. Groupstate.GroupKey,metadata: Option<GroupMetadata>,metadata_event: Option<Event>(whatGroupMetadata::naddris built from),members,admins,roles,membership: Membership(including the localPending/Refusedaccess states),messages: Vec<Message>for the render set (reusingchat::Message, which already parses any event into display fields), an id index, aTimelineWindowforprevious,loading,refused. It emitsGroupEvent::{Incoming(Event), Updated, Refused(String), Eose}— the panel subscribes to these the wayChatPanelsubscribes toRoomEvent.load(). Subscribe to our own10009(ReqTarget::auto, limit 1), then read the newest one from the database intoself.list, and add one group per entry. A newer10009arriving 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, thenclient.send_event(&event).to([relay]). Kind 9 is the default message kind; whenmetadata.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 intomessages, emitGroupEvent::Incoming. OnOK false: return the relay's message (it already carriesinvalid:/restricted:/duplicate:prefixes) and let the panel show it. Reactions go through the same path withkey.id().reaction(..). - Join.
GroupReference::parse→ error if no relay hint → add/connect the relay → fetch39000(limit 1) to confirm the group and learn the self pubkey;GroupMetadata::parseit; a hidden group may refuse this — proceed without metadata rather than failing → create the entity, fetch state + membership, subscribe → sendkey.id().join_request(code, ..)— thehtag plus thecodetag when an invite code was parsed → markMembership::Pending { since }andself.list.upsert(..), so the group stays listed while it waits. The relay either emits9000(member), refuses the9021withOK false(setRefused { 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 withrestricted:/auth-required:in aCLOSED(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(..); onOK, forget the group's envelopes (client.database().delete(filter)byrtag), drop the entity andself.list.remove(key), then publish. The relay also emits9001, which the (now gone) subscription would have seen. - Publish
10009. Signself.list.to_builder()and publish withto_nip65(). The registry'sGroupListis the one loaded at start, joinupserts an entry and leaveremoves 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
- Moderation writes:
9000put-user (roles),9001remove-user,9002edit-metadata,9005delete-event,9009create-invite; gate the UI on the caller appearing in39001, let the relay be the enforcer. - Group creation (
9007) where the relay supports it, and relay browsing (fetch_events(ReqTarget::single(relay, [Filter::new().kind(Kind::GroupMetadata).limit(..)]))). - Pins:
9010write,39005read (Kind::Custom); subgroups: parseparent/childand lay groups out as a tree. - Range backfill: page the timeline with
untilfor "load more". - LiveKit AV (
livekittag,39004, the/.well-known/nip29/livekittoken endpoint with a NIP-9827235authorization event) and fork/migration detection through the admins'10009.
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) |
| Send / react | Group::send_message / send_reaction |
| Members, admins, roles, flags | Group::members/admins/roles/metadata |
| Membership badge (member, pending, refused, removed) | Group::membership() |
| Copy an invite link | GroupMetadata::naddr(relay, invite) |
| Leave | GroupsRegistry::leave |
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.5 (aliased as gpui) and gpui-base 0.7.0, which crates/ui
styles; the only GPUI APIs the backend uses are the ones chat and community
already use — Entity/WeakEntity, Context::spawn, background_spawn,
EventEmitter, Subscription — and the panel's components (ListState,
MessageRow, Avatar, Button, Scrollbar) already exist in the app.
Tests
protocol.rsunit tests:GroupReference::parse(naddr,?invite=, garbage, missing relay hint, an naddr whose kind is not39000);GroupMetadata::parseflags andsupportswithsupported_kindsabsent vs empty; admins with roles; members; roles;GroupListround trip (parse→upsert→to_builder);TimelineWindow::previous_tag(excludes self, caps at 3, 8-char prefixes, any-kind window,Nonewhen empty);Membership::from_events(9000vs9001, newest wins, tie prefers removal;Pending/Refusedare exercised through the join path);GroupId::messagecarrieshand the replyetags;GroupMetadata::naddrround trip with and without an invite code.- Cache round trip with
nostr-memoryas a dev-dependency (the patterncrates/communityalready uses): save two envelopes (a kind 9 and a kind9000), read them back sorted, assert the partition — the kind 9 renders, the9000only folds membership — re-save one and confirm no duplicate,deleteclears the group. - Routing: subscription id ↔
GroupKeymapping survives a resubscribe. - One integration test against the SDK's
LocalRelay(nostr-sdkdev-dependency with thelocal-relayfeature): seed a39000and a kind 9 with anhtag, runGroup::connect, assert metadata and the timeline land. Timeouts in GPUI-driven tests usecx.background_executor().timer(..), neversmol::Timer, perAGENTS.md.
Decisions
- Crate.
crates/nip29(the body above is written for it). - Timeline kinds. Render
1,9,11,1111and7; every other kind is still cached and feeds thepreviouswindow, per the NIP's timeline references rule. - Default message kind. Kind 9; the composer is disabled when
supported_kindsis present and lacks it. - Pending joins. Pending (and refused) groups are listed in the sidebar, with an access state on the row.
10009merge. Union-only; removals made by another client are not detected and no diffing is done.- Leave and the cache. Leaving wipes the group's envelopes.
- 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. - References.
naddronly (?invite=accepted);id@relayis not. A group whose relay publishes no39000cannot be referenced or joined in v1.