Skip to content
This repository was archived by the owner on Apr 20, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,17 @@ What this library does is basically:
- it acts on moderation events and on join-request events received and modify the group state;
- it generates group metadata events (39000, 39001, 39002, 39003) events on the fly (these are not stored) and returns them to whoever queries them;
- on startup it loads all the moderation events (9000, 9001, etc) from the database and rebuilds the group state from that (so if you want to modify the group state permanently you must publish one of these events to the relay — but of course you can also monkey-patch the map of groups in memory like an animal if you want);

== Subgroups

Implements the NIP-29 subgroups section. A group declares a parent by including a `parent` tag on its `kind:39000`; the relay stamps the tag on behalf of an admin via a `kind:9002` `edit-metadata` with `["parent", "<parent-d>"]`. Clients build the tree locally from `kind:39000` events.

- Cycles (including self-reference) are rejected.
- A declared parent that does not exist on the relay is accepted; clients treat the subgroup as a root.
- Deleting a parent with `kind:9008` promotes every remaining child to root automatically — the relay broadcasts an updated `kind:39000` for each child without the `parent` tag.
- Membership is independent per subgroup. Being a member of the parent grants no access to the child; children are joined with the normal `kind:9021` → `kind:9000` flow.
- Detach to root: `["parent"]` or `["parent", ""]`.
- *Admin attestation*: the emitted `parent` tag carries the authoring admin's pubkey as a third element, e.g. `["parent", "tech", "<admin-pk>"]`. On detach the attester is cleared.
- *Bilateral declaration*: a parent group admin may publish `["child", "<id>", "<order>", "<flags>"]` tags and/or a `["closed-children"]` flag on the parent's `kind:39000` via `kind:9002`; the relay stores and re-emits them. `<order>` and `<flags>` are optional.
- *Unsetting bilateral state*: a bare `["child"]` tag (no id) clears the entire acceptance list; `["open-children"]` clears the `closed-children` flag. These mirror the existing `open`/`closed` and `public`/`private` pairs.
- Unknown tags on subgroup-related events are ignored rather than rejected (per spec).
40 changes: 39 additions & 1 deletion event_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ func (s *State) RestrictInvalidModerationActions(ctx context.Context, event *nos
return true, "groups cannot be private"
}

if egs, ok := action.(EditMetadata); ok && egs.ParentValue != nil && *egs.ParentValue != "" {
if s.WouldCreateCycle(group.Address.ID, *egs.ParentValue) {
return true, "declared parent would create a cycle in the subgroup hierarchy"
}
}

group.mu.RLock()
defer group.mu.RUnlock()
roles, _ := group.Members[event.PubKey]
Expand Down Expand Up @@ -189,11 +195,27 @@ func (s *State) ApplyModerationAction(ctx context.Context, event *nostr.Event) {
group = s.GetGroupFromEvent(event)
}

// apply the moderation action
// apply the moderation action under the child's own lock
group.mu.Lock()
action.Apply(&group.Group)
// child entries and closed-children live on the wrapper Group; apply them
// here under the same lock as the rest of the metadata edit.
if em, ok := action.(EditMetadata); ok {
if em.ChildEntriesValue != nil {
group.ChildEntries = *em.ChildEntriesValue
}
if em.ClosedChildrenValue != nil {
group.ClosedChildren = *em.ClosedChildrenValue
}
}
group.mu.Unlock()

// parent relationship involves other groups, so Reparent takes its own
// locks in isolation and must run with group.mu released
if em, ok := action.(EditMetadata); ok && em.ParentValue != nil {
s.Reparent(group, *em.ParentValue, event.PubKey)
}

// if it's a delete event we have to actually delete stuff from the database here
if event.Kind == nostr.KindSimpleGroupDeleteEvent {
for _, tag := range event.Tags {
Expand Down Expand Up @@ -222,6 +244,22 @@ func (s *State) ApplyModerationAction(ctx context.Context, event *nostr.Event) {
}
}
} else if event.Kind == nostr.KindSimpleGroupDeleteGroup {
// detach from parent's children set
if group.Parent != "" {
if parent, _ := s.Groups.Load(group.Parent); parent != nil {
parent.mu.Lock()
delete(parent.Children, group.Address.ID)
parent.mu.Unlock()
}
}
// per spec, when a parent is deleted its remaining children automatically
// become roots — clear the link and broadcast the updated kind:39000.
promoted := s.promoteChildrenToRoots(group)
for _, child := range promoted {
evt := child.ToMetadataEvent()
evt.Sign(s.secretKey)
s.Relay.BroadcastEvent(evt)
}
// when the group was deleted we just remove it
s.Groups.Delete(group.Address.ID)
}
Expand Down
2 changes: 1 addition & 1 deletion examples/basic-khatru/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func main() {
policies.RestrictToSpecifiedKinds(true,
9, 10, 11, 12, 1111,
30023, 31922, 31923, 9802,
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007,
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009,
9021, 9022,
),
policies.PreventTimestampsInThePast(60*time.Second),
Expand Down
2 changes: 1 addition & 1 deletion examples/basic-relayer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func main() {
}
}
}
if !slices.Contains([]int{9, 10, 11, 12, 30023, 31922, 31923, 9802, 9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9021}, ev.Kind) {
if !slices.Contains([]int{9, 10, 11, 12, 30023, 31922, 31923, 9802, 9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9021, 9022}, ev.Kind) {
return true, fmt.Sprintf("received event kind %d not allowed", ev.Kind)
}
if nostr.Now()-ev.CreatedAt > 60 {
Expand Down
95 changes: 94 additions & 1 deletion groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package relay29

import (
"context"
"sort"
"sync"
"sync/atomic"

Expand All @@ -13,10 +14,24 @@ type Group struct {
nip29.Group
mu sync.RWMutex

Parent string
ParentAttester string
Children map[string]struct{}
ChildEntries []ChildEntry
ClosedChildren bool

last50 []string
last50index atomic.Int32
}

// ChildEntry is an entry of a parent group's bilateral acceptance list, carried
// as ["child", "<id>", "<order>", "<flags>...] on kind:39000.
type ChildEntry struct {
ID string
Order string
Flags []string
}

// NewGroup creates a new group from scratch (but doesn't store it in the groups map)
func (s *State) NewGroup(id string, creator string) *Group {
group := &Group{
Expand All @@ -28,7 +43,8 @@ func (s *State) NewGroup(id string, creator string) *Group {
Roles: s.defaultRoles,
Members: make(map[string][]*nip29.Role, 12),
},
last50: make([]string, 50),
Children: make(map[string]struct{}),
last50: make([]string, 50),
}

group.Members[creator] = []*nip29.Role{s.groupCreatorDefaultRole}
Expand Down Expand Up @@ -88,6 +104,83 @@ func (s *State) loadGroupsFromDB(ctx context.Context) error {
s.Groups.Store(group.Address.ID, group)
}

// second pass: replay every kind:9002 in GLOBAL chronological order to
// rebuild subgroup state (parent link, attester pubkey, child entries,
// closed-children flag) — those fields live on the wrapper Group and are
// therefore not touched by the first-pass Action.Apply. A single global
// order is required so cycle-check sees the same state the runtime did
// when it accepted each event.
metadataEvents, err := s.DB.QueryEvents(ctx, nostr.Filter{Kinds: []int{nostr.KindSimpleGroupEditMetadata}})
if err != nil {
return err
}
allEvents := make([]*nostr.Event, 0)
for evt := range metadataEvents {
if evt.Tags.GetFirst([]string{"h", ""}) == nil {
continue
}
allEvents = append(allEvents, evt)
}
sort.Slice(allEvents, func(i, j int) bool { return allEvents[i].CreatedAt < allEvents[j].CreatedAt })
for _, evt := range allEvents {
id := (*evt.Tags.GetFirst([]string{"h", ""}))[1]
group, _ := s.Groups.Load(id)
if group == nil {
continue
}
if pt := evt.Tags.GetFirst([]string{"parent"}); pt != nil {
parentId := ""
if len(*pt) >= 2 {
parentId = (*pt)[1]
}
// skip parent updates that would form a cycle against the state
// already rebuilt — a kind:9002 rejected at runtime (e.g. the
// loser of a concurrent-reparent race) may still be in the DB,
// and replaying it blindly would corrupt the in-memory tree.
// Other fields on the same event (child entries, closed-children)
// are still applied below.
if parentId == "" || !s.WouldCreateCycle(id, parentId) {
// detach from previous parent
if group.Parent != "" && group.Parent != parentId {
if old, _ := s.Groups.Load(group.Parent); old != nil {
delete(old.Children, id)
}
}
group.Parent = parentId
if parentId != "" {
group.ParentAttester = evt.PubKey
if parent, _ := s.Groups.Load(parentId); parent != nil {
parent.Children[id] = struct{}{}
}
} else {
group.ParentAttester = ""
}
}
}
if childTags := evt.Tags.GetAll([]string{"child"}); len(childTags) > 0 {
entries := make([]ChildEntry, 0, len(childTags))
for _, tag := range childTags {
if len(tag) < 2 || tag[1] == "" {
continue
}
entry := ChildEntry{ID: tag[1]}
if len(tag) >= 3 {
entry.Order = tag[2]
}
if len(tag) >= 4 {
entry.Flags = append([]string(nil), tag[3:]...)
}
entries = append(entries, entry)
}
group.ChildEntries = entries
}
if evt.Tags.GetFirst([]string{"closed-children"}) != nil {
group.ClosedChildren = true
} else if evt.Tags.GetFirst([]string{"open-children"}) != nil {
group.ClosedChildren = false
}
}

return nil
}

Expand Down
Loading