// Package group implements the relay's in-memory NIP-29 managed-group state: the // authority for channel membership, admin rights, and metadata. // // It is a deliberately small subset of NIP-29 (the managed-group MVP): // // kind 9007 create group // kind 9021 join request -> adds a member (open groups) // kind 9022 leave request -> removes a member // kind 9000 put user (admin) -> adds a member // kind 9001 remove user -> removes a member // kind 9002 edit metadata -> admin sets name/about // kind 9/10 chat / reply -> allowed for members (or anyone if open) // // Metadata (39000), admins (39001) and members (39002) are (re)published as // signed addressable events whenever the group changes. State is rebuilt from // the event store on boot so it survives restarts. package group import ( "cmp" "context" "fmt" "slices" "sync" "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore" ) // State is the relay's in-memory NIP-29 managed-group state. It is the authority // for membership and metadata and is safe for concurrent use. type State struct { mu sync.RWMutex groups map[string]*group // keyed by group id (the `h` tag value) sk nostr.SecretKey // relay identity, signs 39000-39002 events pk nostr.PubKey // IsFederated, when set, reports whether a channel is federated with a peer. // Chat on a federated channel is accepted regardless of local membership, // because the author is authorized on the peer relay that federates it. Nil // (federation disabled) means "no channel is federated". IsFederated func(groupID string) bool } type group struct { id string name string about string creator nostr.PubKey members map[nostr.PubKey]bool admins map[nostr.PubKey]bool // open groups auto-accept join requests and allow anyone to post (public // channels). Closed groups are members-only: a join request does NOT grant // membership — an admin must add the user (kind 9000) — and only members may // post. A group is created closed when its 9007 event carries a `closed` tag. open bool } // NewState returns an empty group state that signs its addressable list events // (39000-39002) with the given relay identity. func NewState(sk nostr.SecretKey) *State { return &State{ groups: make(map[string]*group), sk: sk, pk: nostr.GetPublicKey(sk), } } // hasTag reports whether the event has a tag with the given name. Unlike // Tags.Find (which ignores value-less tags), this matches flag tags such as // ["closed"] that carry no value. func hasTag(evt nostr.Event, name string) bool { for _, t := range evt.Tags { if len(t) >= 1 && t[0] == name { return true } } return false } // managementKinds are the NIP-29 events that mutate group state and therefore // must be replayed on boot to reconstruct membership. var managementKinds = []nostr.Kind{ nostr.KindSimpleGroupCreateGroup, nostr.KindSimpleGroupJoinRequest, nostr.KindSimpleGroupLeaveRequest, nostr.KindSimpleGroupPutUser, nostr.KindSimpleGroupRemoveUser, } // Rebuild reconstructs in-memory group state from the persisted event store so // membership and metadata survive relay restarts. Events are replayed in // chronological (oldest-first) order so the final state is correct; Evaluate is // reused so replay and live handling can never diverge. Returns the number of // management events replayed. func (gs *State) Rebuild(store eventstore.Store) int { var events []nostr.Event for evt := range store.QueryEvents(nostr.Filter{Kinds: managementKinds}, 100_000) { events = append(events, evt) } // Apply oldest-first. On a created_at tie, a group-create (9007) must come // first so later same-second events (joins/puts) find the group — otherwise // membership can be lost across a restart when events share a timestamp. slices.SortFunc(events, func(a, b nostr.Event) int { if a.CreatedAt != b.CreatedAt { return cmp.Compare(a.CreatedAt, b.CreatedAt) } aCreate := a.Kind == nostr.KindSimpleGroupCreateGroup bCreate := b.Kind == nostr.KindSimpleGroupCreateGroup if aCreate != bCreate { if aCreate { return -1 } return 1 } return 0 }) for _, evt := range events { gs.Evaluate(evt) } return len(events) } // GroupIDFromEvent returns the `h` tag value, or "" if absent. func GroupIDFromEvent(evt nostr.Event) string { t := evt.Tags.Find("h") if len(t) >= 2 { return t[1] } return "" } // firstTagValue returns the value of the first tag with the given name, or "". func firstTagValue(evt nostr.Event, name string) string { t := evt.Tags.Find(name) if len(t) >= 2 { return t[1] } return "" } // ensureGroup returns the group, creating one on first reference (channel // auto-creation). New groups default to open unless created closed. `open` is // only applied at creation time — it never downgrades an existing group. func (gs *State) ensureGroup(id string, creator nostr.PubKey, open bool) *group { g, ok := gs.groups[id] if !ok { g = &group{ id: id, name: id, creator: creator, members: map[nostr.PubKey]bool{}, admins: map[nostr.PubKey]bool{creator: true}, open: open, } g.members[creator] = true gs.groups[id] = g } return g } // Evaluate applies a management/chat event to group state and reports whether // the relay should reject it. Called from khatru's OnEvent hook. func (gs *State) Evaluate(evt nostr.Event) (reject bool, msg string) { id := GroupIDFromEvent(evt) switch evt.Kind { case nostr.KindSimpleGroupCreateGroup: // 9007 if id == "" { return true, "missing group id (h tag)" } // A `closed` tag makes the group members-only (admin-approved joins). open := !hasTag(evt, "closed") gs.mu.Lock() gs.ensureGroup(id, evt.PubKey, open) gs.mu.Unlock() return false, "" case nostr.KindSimpleGroupJoinRequest: // 9021 if id == "" { return true, "missing group id (h tag)" } gs.mu.Lock() g := gs.ensureGroup(id, evt.PubKey, true) // Open groups auto-join. Closed groups accept the request event but do // NOT grant membership — an admin must add the user (kind 9000). if g.open { g.members[evt.PubKey] = true } gs.mu.Unlock() return false, "" case nostr.KindSimpleGroupLeaveRequest: // 9022 gs.mu.Lock() if g := gs.groups[id]; g != nil { delete(g.members, evt.PubKey) } gs.mu.Unlock() return false, "" case nostr.KindSimpleGroupPutUser: // 9000 (admin adds user) if !gs.IsAdmin(id, evt.PubKey) { return true, "only admins can add users" } for _, pk := range taggedPubkeys(evt) { gs.setMember(id, pk, true) } return false, "" case nostr.KindSimpleGroupRemoveUser: // 9001 (admin removes user) if !gs.IsAdmin(id, evt.PubKey) { return true, "only admins can remove users" } for _, pk := range taggedPubkeys(evt) { gs.setMember(id, pk, false) } return false, "" case nostr.KindSimpleGroupEditMetadata: // 9002 (admin sets name/about) if id == "" { return true, "missing group id (h tag)" } if !gs.IsAdmin(id, evt.PubKey) { return true, "only admins can edit group metadata" } gs.mu.Lock() if g := gs.groups[id]; g != nil { if name := firstTagValue(evt, "name"); name != "" { g.name = name } // `about` is the room topic; allow clearing it with an empty value. if t := evt.Tags.Find("about"); len(t) >= 2 { g.about = t[1] } } gs.mu.Unlock() return false, "" case nostr.KindSimpleGroupChatMessage, nostr.KindSimpleGroupThreadedReply: // 9 / 10 if id == "" { return true, "chat message missing group id (h tag)" } gs.mu.RLock() g := gs.groups[id] gs.mu.RUnlock() if g == nil { // A channel we don't know locally may still be one we federate — accept // so peer chat can land (the group is created lazily on federated write). if gs.IsFederated != nil && gs.IsFederated(id) { return false, "" } return true, "unknown group; send a join request (kind 9021) first" } // Federated channels behave as open across all participating relays: the // author is a member on the peer that authorized the post, so don't gate // federated chat on local membership. if !g.open && !gs.IsMember(id, evt.PubKey) { if gs.IsFederated != nil && gs.IsFederated(id) { return false, "" } return true, "you are not a member of this group" } return false, "" } // Non-group events are handled by the relay's normal rules. return false, "" } // Has reports whether a group with the given id is tracked. func (gs *State) Has(id string) bool { gs.mu.RLock() defer gs.mu.RUnlock() _, ok := gs.groups[id] return ok } // IsMember reports whether pk is a member of the group. func (gs *State) IsMember(id string, pk nostr.PubKey) bool { gs.mu.RLock() defer gs.mu.RUnlock() g := gs.groups[id] return g != nil && g.members[pk] } // IsAdmin reports whether pk is an admin of the group. func (gs *State) IsAdmin(id string, pk nostr.PubKey) bool { gs.mu.RLock() defer gs.mu.RUnlock() g := gs.groups[id] return g != nil && g.admins[pk] } func (gs *State) setMember(id string, pk nostr.PubKey, member bool) { gs.mu.Lock() defer gs.mu.Unlock() g := gs.groups[id] if g == nil { return } if member { g.members[pk] = true } else { delete(g.members, pk) } } // taggedPubkeys extracts valid `p` tag pubkeys from an event. func taggedPubkeys(evt nostr.Event) []nostr.PubKey { var out []nostr.PubKey for t := range evt.Tags.FindAll("p") { if len(t) >= 2 { if pk, err := nostr.PubKeyFromHex(t[1]); err == nil { out = append(out, pk) } } } return out } // MetadataEvent builds and signs the relay's kind-39000 metadata event for a // group, so clients can discover the group's name/about via NIP-29. func (gs *State) MetadataEvent(ctx context.Context, id string) (nostr.Event, bool) { gs.mu.RLock() g := gs.groups[id] gs.mu.RUnlock() if g == nil { return nostr.Event{}, false } evt := nostr.Event{ Kind: nostr.KindSimpleGroupMetadata, CreatedAt: nostr.Now(), Content: "", Tags: nostr.Tags{ {"d", id}, {"name", g.name}, {"about", g.about}, }, } if g.open { evt.Tags = append(evt.Tags, nostr.Tag{"open"}) } else { evt.Tags = append(evt.Tags, nostr.Tag{"closed"}) } if err := evt.Sign(gs.sk); err != nil { return nostr.Event{}, false } return evt, true } // AdminsEvent builds and signs the relay's kind-39001 admins list for a group. // Each admin is a `p` tag with an "admin" role, per NIP-29. func (gs *State) AdminsEvent(id string) (nostr.Event, bool) { gs.mu.RLock() g := gs.groups[id] var admins []nostr.PubKey if g != nil { for pk := range g.admins { admins = append(admins, pk) } } gs.mu.RUnlock() if g == nil { return nostr.Event{}, false } evt := nostr.Event{ Kind: nostr.KindSimpleGroupAdmins, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"d", id}}, } for _, pk := range admins { evt.Tags = append(evt.Tags, nostr.Tag{"p", pk.Hex(), "admin"}) } if err := evt.Sign(gs.sk); err != nil { return nostr.Event{}, false } return evt, true } // MembersEvent builds and signs the relay's kind-39002 members list for a group. func (gs *State) MembersEvent(id string) (nostr.Event, bool) { gs.mu.RLock() g := gs.groups[id] var members []nostr.PubKey if g != nil { for pk := range g.members { members = append(members, pk) } } gs.mu.RUnlock() if g == nil { return nostr.Event{}, false } evt := nostr.Event{ Kind: nostr.KindSimpleGroupMembers, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"d", id}}, } for _, pk := range members { evt.Tags = append(evt.Tags, nostr.Tag{"p", pk.Hex()}) } if err := evt.Sign(gs.sk); err != nil { return nostr.Event{}, false } return evt, true } // EnsureFederatedGroup makes sure a group exists locally so that chat federated // from a peer relay has somewhere to land. Federated chat is chat-only: the // author is a member on the PEER (which already authorized the post), not // necessarily here, so we must not gate it on local membership. We create the // channel open on first sight of federated chat if it doesn't exist yet; we // never alter membership or admin state from federated events. func (gs *State) EnsureFederatedGroup(id string) { if id == "" { return } gs.mu.Lock() if _, ok := gs.groups[id]; !ok { gs.groups[id] = &group{ id: id, name: id, members: map[nostr.PubKey]bool{}, admins: map[nostr.PubKey]bool{}, open: true, } } gs.mu.Unlock() } // Summary is a short human-readable description for logging/inspection. func (gs *State) Summary() string { gs.mu.RLock() defer gs.mu.RUnlock() return fmt.Sprintf("%d group(s) tracked", len(gs.groups)) }