initial public relay build repo
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
// 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, ""
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user