diff --git a/README.adoc b/README.adoc index dcec84a..50f1ee7 100644 --- a/README.adoc +++ b/README.adoc @@ -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", ""]`. 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", ""]`. On detach the attester is cleared. +- *Bilateral declaration*: a parent group admin may publish `["child", "", "", ""]` tags and/or a `["closed-children"]` flag on the parent's `kind:39000` via `kind:9002`; the relay stores and re-emits them. `` and `` 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). diff --git a/event_policy.go b/event_policy.go index 73f131a..fd437ff 100644 --- a/event_policy.go +++ b/event_policy.go @@ -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] @@ -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 { @@ -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) } diff --git a/examples/basic-khatru/main.go b/examples/basic-khatru/main.go index 43fb2b9..94d592d 100644 --- a/examples/basic-khatru/main.go +++ b/examples/basic-khatru/main.go @@ -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), diff --git a/examples/basic-relayer/main.go b/examples/basic-relayer/main.go index b96159d..c900e88 100644 --- a/examples/basic-relayer/main.go +++ b/examples/basic-relayer/main.go @@ -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 { diff --git a/groups.go b/groups.go index 1b969b5..9792a0b 100644 --- a/groups.go +++ b/groups.go @@ -2,6 +2,7 @@ package relay29 import ( "context" + "sort" "sync" "sync/atomic" @@ -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", "", "", "...] 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{ @@ -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} @@ -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 } diff --git a/khatru29/subgroups_test.go b/khatru29/subgroups_test.go new file mode 100644 index 0000000..971ef76 --- /dev/null +++ b/khatru29/subgroups_test.go @@ -0,0 +1,760 @@ +package khatru29 + +import ( + "context" + "fmt" + "net" + "net/http" + "slices" + "sync/atomic" + "testing" + "time" + + "github.com/fiatjaf/eventstore/slicestore" + "github.com/fiatjaf/relay29" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip29" + "github.com/stretchr/testify/require" +) + +// tickTimestamp returns a strictly monotonic timestamp (seconds) so successive +// replaceable events (kind:39000) never collide on (kind,pubkey,d,content,tags) +// and get deduped by the client. +var tsCounter atomic.Int64 + +func tickTimestamp() nostr.Timestamp { + return nostr.Timestamp(nostr.Now()) + nostr.Timestamp(tsCounter.Add(1)) +} + +// startSubgroupRelay spins up a throwaway khatru29 relay on an ephemeral port +// and returns its ws:// URL plus a shutdown function. It drops the +// "moderation-events-must-be-recent" policy so tests can control timestamps. +func startSubgroupRelay(t *testing.T) (string, func()) { + t.Helper() + db := &slicestore.SliceStore{} + db.Init() + url, shutdown, _ := startSubgroupRelayWithDB(t, db, nostr.GeneratePrivateKey()) + return url, shutdown +} + +// startSubgroupRelayWithDB starts a relay backed by the given DB + relay secret +// key, so callers can simulate a restart by reusing the same DB. +func startSubgroupRelayWithDB(t *testing.T, db *slicestore.SliceStore, relaySk string) (string, func(), *relay29.State) { + t.Helper() + relay, state := Init(relay29.Options{ + Domain: "localhost", + DB: db, + SecretKey: relaySk, + DefaultRoles: []*nip29.Role{ceo, secretary}, + GroupCreatorDefaultRole: ceo, + }) + + state.AllowAction = func(ctx context.Context, group nip29.Group, role *nip29.Role, action relay29.Action) bool { + return role == ceo + } + + relay.RejectEvent = slices.DeleteFunc(relay.RejectEvent, func(f func(ctx context.Context, event *nostr.Event) (reject bool, msg string)) bool { + return fmt.Sprintf("%v", []any{f}) == fmt.Sprintf("%v", []any{state.RequireModerationEventsToBeRecent}) + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server := &http.Server{Handler: relay} + go server.Serve(ln) + + url := "ws://" + ln.Addr().String() + return url, func() { server.Shutdown(context.Background()) }, state +} + +// waitForMetadata drains a subscription until it sees a kind:39000 for the +// given group id matching `match`, or fails the test after 2s. +func waitForMetadata(t *testing.T, sub *nostr.Subscription, groupId string, match func(*nostr.Event) bool) *nostr.Event { + t.Helper() + deadline := time.After(2 * time.Second) + for { + select { + case evt := <-sub.Events: + if evt == nil { + continue + } + if evt.Tags.GetD() != groupId { + continue + } + if match(evt) { + return evt + } + case <-deadline: + t.Fatalf("timed out waiting for kind:39000 of %q", groupId) + return nil + } + } +} + +func createGroup(t *testing.T, ctx context.Context, r *nostr.Relay, sk, id string) { + t.Helper() + e := nostr.Event{CreatedAt: tickTimestamp(), Kind: 9007, Tags: nostr.Tags{{"h", id}}} + require.NoError(t, e.Sign(sk)) + require.NoError(t, r.Publish(ctx, e), "create-group %q", id) +} + +func editParent(t *testing.T, ctx context.Context, r *nostr.Relay, sk, id string, parent ...string) error { + t.Helper() + tag := nostr.Tag{"parent"} + tag = append(tag, parent...) + e := nostr.Event{CreatedAt: tickTimestamp(), Kind: 9002, Tags: nostr.Tags{{"h", id}, tag}} + require.NoError(t, e.Sign(sk)) + return r.Publish(ctx, e) +} + +func TestSubgroupAttachAndDetach(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "tech") + createGroup(t, ctx, r, sk, "nostr") + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"nostr"}}}}) + require.NoError(t, err) + + // attach nostr under tech + require.NoError(t, editParent(t, ctx, r, sk, "nostr", "tech")) + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 2 && (*pt)[1] == "tech" + }) + + // detach via ["parent"] (no value) + require.NoError(t, editParent(t, ctx, r, sk, "nostr")) + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + return evt.Tags.GetFirst([]string{"parent"}) == nil + }) + + // attach again, then detach via ["parent", ""] + require.NoError(t, editParent(t, ctx, r, sk, "nostr", "tech")) + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 2 && (*pt)[1] == "tech" + }) + require.NoError(t, editParent(t, ctx, r, sk, "nostr", "")) + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + return evt.Tags.GetFirst([]string{"parent"}) == nil + }) +} + +func TestSubgroupReparent(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + for _, id := range []string{"tech", "social", "nostr"} { + createGroup(t, ctx, r, sk, id) + } + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"nostr"}}}}) + require.NoError(t, err) + + require.NoError(t, editParent(t, ctx, r, sk, "nostr", "tech")) + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 2 && (*pt)[1] == "tech" + }) + + require.NoError(t, editParent(t, ctx, r, sk, "nostr", "social")) + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 2 && (*pt)[1] == "social" + }) +} + +func TestSubgroupCycleRejected(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + // build chain: a -> b -> c (c is root, b under c, a under b) + for _, id := range []string{"a", "b", "c"} { + createGroup(t, ctx, r, sk, id) + } + require.NoError(t, editParent(t, ctx, r, sk, "b", "c")) + require.NoError(t, editParent(t, ctx, r, sk, "a", "b")) + + // self-reference + require.Error(t, editParent(t, ctx, r, sk, "a", "a"), "self-parent must be rejected") + + // multi-hop: try to put c under a (would form c->a->b->c) + require.Error(t, editParent(t, ctx, r, sk, "c", "a"), "multi-hop cycle must be rejected") +} + +func TestSubgroupMissingParentAccepted(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "orphan") + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"orphan"}}}}) + require.NoError(t, err) + + // per spec: a declared parent that does not exist is accepted; the + // subgroup is simply treated as a root by clients building the tree. + require.NoError(t, editParent(t, ctx, r, sk, "orphan", "ghost")) + + waitForMetadata(t, sub, "orphan", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 2 && (*pt)[1] == "ghost" + }) +} + +func TestSubgroupDeletePromotesChildrenToRoots(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + for _, id := range []string{"parent", "child1", "child2"} { + createGroup(t, ctx, r, sk, id) + } + require.NoError(t, editParent(t, ctx, r, sk, "child1", "parent")) + require.NoError(t, editParent(t, ctx, r, sk, "child2", "parent")) + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"child1", "child2"}}}}) + require.NoError(t, err) + + // delete parent + del := nostr.Event{CreatedAt: tickTimestamp(), Kind: 9008, Tags: nostr.Tags{{"h", "parent"}}} + require.NoError(t, del.Sign(sk)) + require.NoError(t, r.Publish(ctx, del)) + + // both children should now broadcast kind:39000 without a parent tag + promoted := map[string]bool{} + deadline := time.After(2 * time.Second) + for len(promoted) < 2 { + select { + case evt := <-sub.Events: + if evt == nil { + continue + } + d := evt.Tags.GetD() + if d != "child1" && d != "child2" { + continue + } + if evt.Tags.GetFirst([]string{"parent"}) == nil { + promoted[d] = true + } + case <-deadline: + t.Fatalf("only saw %v promoted to root", promoted) + } + } +} + +func TestSubgroupParentAttestation(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + adminPk, _ := nostr.GetPublicKey(sk) + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "tech") + createGroup(t, ctx, r, sk, "nostr") + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"nostr"}}}}) + require.NoError(t, err) + + // attach → third element on parent tag should be the authoring admin's pubkey + require.NoError(t, editParent(t, ctx, r, sk, "nostr", "tech")) + evt := waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 3 && (*pt)[1] == "tech" + }) + pt := evt.Tags.GetFirst([]string{"parent"}) + require.Equal(t, adminPk, (*pt)[2], "attester pubkey must match event.PubKey") + + // detach → attester must be cleared (tag gone entirely) + require.NoError(t, editParent(t, ctx, r, sk, "nostr")) + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + return evt.Tags.GetFirst([]string{"parent"}) == nil + }) +} + +func TestSubgroupChildEntriesAndClosedChildren(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "tech") + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"tech"}}}}) + require.NoError(t, err) + + // parent admin declares a bilateral acceptance list with order + flags, + // plus the closed-children flag. + e := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{ + {"h", "tech"}, + {"child", "nostr", "a", "suggested"}, + {"child", "music", "b"}, + {"closed-children"}, + }, + } + require.NoError(t, e.Sign(sk)) + require.NoError(t, r.Publish(ctx, e)) + + evt := waitForMetadata(t, sub, "tech", func(evt *nostr.Event) bool { + return evt.Tags.GetFirst([]string{"closed-children"}) != nil + }) + + children := evt.Tags.GetAll([]string{"child"}) + require.Len(t, children, 2) + require.Equal(t, "nostr", children[0][1]) + require.Equal(t, "a", children[0][2]) + require.Equal(t, "suggested", children[0][3]) + require.Equal(t, "music", children[1][1]) + require.Equal(t, "b", children[1][2]) + require.NotNil(t, evt.Tags.GetFirst([]string{"closed-children"})) +} + +func TestSubgroupReparentUpdatesAttester(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + + // two distinct admins. both are given the ceo role on the child so + // either can reparent it. + sk1 := nostr.GeneratePrivateKey() + pk1, _ := nostr.GetPublicKey(sk1) + sk2 := nostr.GeneratePrivateKey() + pk2, _ := nostr.GetPublicKey(sk2) + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk1, "tech") + createGroup(t, ctx, r, sk1, "social") + createGroup(t, ctx, r, sk1, "nostr") + + // promote pk2 to ceo of nostr so it can reparent + put := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9000, + Tags: nostr.Tags{{"h", "nostr"}, {"p", pk2, "ceo"}}, + } + require.NoError(t, put.Sign(sk1)) + require.NoError(t, r.Publish(ctx, put)) + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"nostr"}}}}) + require.NoError(t, err) + + // sk1 attaches nostr under tech → attester = pk1 + require.NoError(t, editParent(t, ctx, r, sk1, "nostr", "tech")) + evt := waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 3 && (*pt)[1] == "tech" + }) + require.Equal(t, pk1, (*evt.Tags.GetFirst([]string{"parent"}))[2]) + + // sk2 reparents nostr under social → attester updates to pk2 + require.NoError(t, editParent(t, ctx, r, sk2, "nostr", "social")) + evt = waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 2 && (*pt)[1] == "social" + }) + require.Equal(t, pk2, (*evt.Tags.GetFirst([]string{"parent"}))[2]) +} + +func TestSubgroupIndependentMembership(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + member := nostr.GeneratePrivateKey() + memberPk, _ := nostr.GetPublicKey(member) + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "parent") + createGroup(t, ctx, r, sk, "child") + require.NoError(t, editParent(t, ctx, r, sk, "child", "parent")) + + // add `member` only to `parent` + inv := nostr.Event{CreatedAt: tickTimestamp(), Kind: 9000, Tags: nostr.Tags{{"h", "parent"}, {"p", memberPk}}} + require.NoError(t, inv.Sign(sk)) + require.NoError(t, r.Publish(ctx, inv)) + + // parent membership allows writing in parent + writeToParent := nostr.Event{CreatedAt: tickTimestamp(), Kind: 9, Content: "hi", Tags: nostr.Tags{{"h", "parent"}}} + require.NoError(t, writeToParent.Sign(member)) + require.NoError(t, r.Publish(ctx, writeToParent), "member of parent must write in parent") + + // but NOT in child (independent membership) + writeToChild := nostr.Event{CreatedAt: tickTimestamp(), Kind: 9, Content: "hi", Tags: nostr.Tags{{"h", "child"}}} + require.NoError(t, writeToChild.Sign(member)) + require.Error(t, r.Publish(ctx, writeToChild), "parent membership must not grant child write access") +} + +// TestSubgroupOpenChildrenUnsetsFlag verifies that ["open-children"] clears +// a previously-set closed-children flag — the symmetric counterpart, following +// the same pattern used for public/private and open/closed. +func TestSubgroupOpenChildrenUnsetsFlag(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "tech") + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"tech"}}}}) + require.NoError(t, err) + + // set closed-children + e1 := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{{"h", "tech"}, {"closed-children"}}, + } + require.NoError(t, e1.Sign(sk)) + require.NoError(t, r.Publish(ctx, e1)) + waitForMetadata(t, sub, "tech", func(evt *nostr.Event) bool { + return evt.Tags.GetFirst([]string{"closed-children"}) != nil + }) + + // unset via open-children + e2 := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{{"h", "tech"}, {"open-children"}}, + } + require.NoError(t, e2.Sign(sk)) + require.NoError(t, r.Publish(ctx, e2), "open-children must be accepted") + waitForMetadata(t, sub, "tech", func(evt *nostr.Event) bool { + // accept the event where closed-children is gone and open-children is + // not re-emitted (it's not a state we advertise, just a signal). + return evt.Tags.GetFirst([]string{"closed-children"}) == nil + }) +} + +// TestSubgroupClearChildList verifies that sending a bare ["child"] tag (no +// id) on a kind:9002 clears the bilateral acceptance list. +func TestSubgroupClearChildList(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "tech") + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"tech"}}}}) + require.NoError(t, err) + + // set a child list + e1 := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{{"h", "tech"}, {"child", "a"}, {"child", "b"}}, + } + require.NoError(t, e1.Sign(sk)) + require.NoError(t, r.Publish(ctx, e1)) + waitForMetadata(t, sub, "tech", func(evt *nostr.Event) bool { + return len(evt.Tags.GetAll([]string{"child"})) == 2 + }) + + // clear via a bare ["child"] sentinel + e2 := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{{"h", "tech"}, {"child"}}, + } + require.NoError(t, e2.Sign(sk)) + require.NoError(t, r.Publish(ctx, e2), "bare child tag must be accepted") + waitForMetadata(t, sub, "tech", func(evt *nostr.Event) bool { + return len(evt.Tags.GetAll([]string{"child"})) == 0 + }) +} + +// TestSubgroupPersistenceAcrossRestart exercises the loadGroupsFromDB second +// pass: after publishing subgroup-related kind:9002 events, shut the relay +// down, start a new one backed by the same DB, and verify every subgroup +// field (parent, attester, child list, closed-children) was rebuilt. +func TestSubgroupPersistenceAcrossRestart(t *testing.T) { + ctx := context.Background() + db := &slicestore.SliceStore{} + db.Init() + relaySk := nostr.GeneratePrivateKey() + adminSk := nostr.GeneratePrivateKey() + adminPk, _ := nostr.GetPublicKey(adminSk) + + url, shutdown, _ := startSubgroupRelayWithDB(t, db, relaySk) + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + for _, id := range []string{"tech", "nostr"} { + createGroup(t, ctx, r, adminSk, id) + } + require.NoError(t, editParent(t, ctx, r, adminSk, "nostr", "tech")) + + // declare bilateral children + closed-children on the parent + e := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{ + {"h", "tech"}, + {"child", "nostr", "a"}, + {"closed-children"}, + }, + } + require.NoError(t, e.Sign(adminSk)) + require.NoError(t, r.Publish(ctx, e)) + + // sanity-check the state before restart + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"tech", "nostr"}}}}) + require.NoError(t, err) + waitForMetadata(t, sub, "tech", func(evt *nostr.Event) bool { + return evt.Tags.GetFirst([]string{"closed-children"}) != nil + }) + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 3 && (*pt)[1] == "tech" && (*pt)[2] == adminPk + }) + r.Close() + shutdown() + + // restart on the same DB + url2, shutdown2, _ := startSubgroupRelayWithDB(t, db, relaySk) + defer shutdown2() + + r2, err := nostr.RelayConnect(ctx, url2) + require.NoError(t, err) + + sub2, err := r2.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"tech", "nostr"}}}}) + require.NoError(t, err) + + // drain both events (the query handler emits them in the order filter.Tags["d"] + // is iterated, which on a multi-d subscription can interleave responses with + // pre-read events); collect until we've seen both groups. + var techEvt, nostrEvt *nostr.Event + deadline := time.After(2 * time.Second) + for techEvt == nil || nostrEvt == nil { + select { + case evt := <-sub2.Events: + if evt == nil { + continue + } + switch evt.Tags.GetD() { + case "tech": + if techEvt == nil { + techEvt = evt + } + case "nostr": + if nostrEvt == nil { + nostrEvt = evt + } + } + case <-deadline: + t.Fatalf("timed out — techEvt=%v nostrEvt=%v", techEvt != nil, nostrEvt != nil) + } + } + + // parent link + attester pubkey survived + pt := nostrEvt.Tags.GetFirst([]string{"parent"}) + require.NotNil(t, pt) + require.GreaterOrEqual(t, len(*pt), 3) + require.Equal(t, "tech", (*pt)[1]) + require.Equal(t, adminPk, (*pt)[2]) + + // child list + closed-children flag survived + require.NotNil(t, techEvt.Tags.GetFirst([]string{"closed-children"})) + children := techEvt.Tags.GetAll([]string{"child"}) + require.Len(t, children, 1) + require.Equal(t, "nostr", children[0][1]) + require.Equal(t, "a", children[0][2]) +} + +// TestSubgroupReplayRejectsCycles guards against a rare pre-save/post-save +// race in which two concurrent reparents both pass the pre-save cycle check, +// are both stored in the DB, but only one wins the reparent mutex at runtime +// (the loser is refused in memory but its kind:9002 persists). On restart the +// second-pass replay must skip the cycle-inducing event instead of blindly +// reapplying it. +// +// We simulate this by seeding the DB directly with a chain a<-b<-c plus a +// rogue kind:9002 that would reparent c under a (forming c->a->b->c), then +// boot a relay on it and verify the in-memory tree is acyclic. +func TestSubgroupReplayRejectsCycles(t *testing.T) { + ctx := context.Background() + db := &slicestore.SliceStore{} + db.Init() + relaySk := nostr.GeneratePrivateKey() + adminSk := nostr.GeneratePrivateKey() + adminPk, _ := nostr.GetPublicKey(adminSk) + + mk := func(kind int, tags nostr.Tags) *nostr.Event { + e := &nostr.Event{CreatedAt: tickTimestamp(), Kind: kind, Tags: tags, PubKey: adminPk} + require.NoError(t, e.Sign(adminSk)) + return e + } + + // seed: create-group a, b, c + for _, id := range []string{"a", "b", "c"} { + require.NoError(t, db.SaveEvent(ctx, mk(9007, nostr.Tags{{"h", id}}))) + } + // b -> c + require.NoError(t, db.SaveEvent(ctx, mk(9002, nostr.Tags{{"h", "b"}, {"parent", "c"}}))) + // a -> b + require.NoError(t, db.SaveEvent(ctx, mk(9002, nostr.Tags{{"h", "a"}, {"parent", "b"}}))) + // rogue: c -> a (cycle c->a->b->c). This is what would survive a + // concurrent-reparent race. + require.NoError(t, db.SaveEvent(ctx, mk(9002, nostr.Tags{{"h", "c"}, {"parent", "a"}}))) + + url, shutdown, state := startSubgroupRelayWithDB(t, db, relaySk) + defer shutdown() + + // in-memory state must be acyclic: c stays root (rogue skipped) + a, _ := state.Groups.Load("a") + b, _ := state.Groups.Load("b") + c, _ := state.Groups.Load("c") + require.NotNil(t, a) + require.NotNil(t, b) + require.NotNil(t, c) + require.Equal(t, "b", a.Parent, "a's parent survives") + require.Equal(t, "c", b.Parent, "b's parent survives") + require.Equal(t, "", c.Parent, "cycle-inducing parent must be skipped") + + // and the emitted kind:39000 for c must not carry a parent tag + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"c"}}}}) + require.NoError(t, err) + waitForMetadata(t, sub, "c", func(evt *nostr.Event) bool { + return evt.Tags.GetFirst([]string{"parent"}) == nil + }) +} + +// TestSubgroupChildListReplacement verifies that a new kind:9002 carrying +// `child` tags replaces the parent's entire acceptance list rather than +// appending. +func TestSubgroupChildListReplacement(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "tech") + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"tech"}}}}) + require.NoError(t, err) + + // first: two children + e1 := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{{"h", "tech"}, {"child", "a"}, {"child", "b"}}, + } + require.NoError(t, e1.Sign(sk)) + require.NoError(t, r.Publish(ctx, e1)) + waitForMetadata(t, sub, "tech", func(evt *nostr.Event) bool { + return len(evt.Tags.GetAll([]string{"child"})) == 2 + }) + + // second: only one child — list should be replaced, not appended + e2 := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{{"h", "tech"}, {"child", "c"}}, + } + require.NoError(t, e2.Sign(sk)) + require.NoError(t, r.Publish(ctx, e2)) + + evt := waitForMetadata(t, sub, "tech", func(evt *nostr.Event) bool { + children := evt.Tags.GetAll([]string{"child"}) + return len(children) == 1 && children[0][1] == "c" + }) + require.Len(t, evt.Tags.GetAll([]string{"child"}), 1, "child list must be replaced, not accumulated") +} + +// TestSubgroupUnknownTagsIgnored verifies the spec "Relays SHOULD ignore +// unknown tags rather than reject the event": a kind:9002 carrying both a +// valid parent tag and an unknown tag is accepted and the parent is applied. +func TestSubgroupUnknownTagsIgnored(t *testing.T) { + url, shutdown := startSubgroupRelay(t) + defer shutdown() + + ctx := context.Background() + sk := nostr.GeneratePrivateKey() + + r, err := nostr.RelayConnect(ctx, url) + require.NoError(t, err) + + createGroup(t, ctx, r, sk, "tech") + createGroup(t, ctx, r, sk, "nostr") + + sub, err := r.Subscribe(ctx, nostr.Filters{{Kinds: []int{39000}, Tags: nostr.TagMap{"d": []string{"nostr"}}}}) + require.NoError(t, err) + + e := nostr.Event{ + CreatedAt: tickTimestamp(), + Kind: 9002, + Tags: nostr.Tags{ + {"h", "nostr"}, + {"parent", "tech"}, + {"totally-made-up-future-tag", "x", "y"}, + }, + } + require.NoError(t, e.Sign(sk)) + require.NoError(t, r.Publish(ctx, e), "unknown tags must not cause rejection") + + waitForMetadata(t, sub, "nostr", func(evt *nostr.Event) bool { + pt := evt.Tags.GetFirst([]string{"parent"}) + return pt != nil && len(*pt) >= 2 && (*pt)[1] == "tech" + }) +} diff --git a/moderation_actions.go b/moderation_actions.go index 0407b79..82b5654 100644 --- a/moderation_actions.go +++ b/moderation_actions.go @@ -96,6 +96,42 @@ var moderationActionFactories = map[int]func(*nostr.Event) (Action, error){ ok = true } + if t := evt.Tags.GetFirst([]string{"parent"}); t != nil { + var parent string + if len(*t) >= 2 { + parent = (*t)[1] + } + edit.ParentValue = &parent + ok = true + } + + 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) + } + edit.ChildEntriesValue = &entries + ok = true + } + + if t := evt.Tags.GetFirst([]string{"closed-children"}); t != nil { + edit.ClosedChildrenValue = &y + ok = true + } else if t := evt.Tags.GetFirst([]string{"open-children"}); t != nil { + edit.ClosedChildrenValue = &n + ok = true + } + if ok { return edit, nil } @@ -170,12 +206,15 @@ func (a RemoveUser) Apply(group *nip29.Group) { } type EditMetadata struct { - NameValue *string - PictureValue *string - AboutValue *string - PrivateValue *bool - ClosedValue *bool - When nostr.Timestamp + NameValue *string + PictureValue *string + AboutValue *string + PrivateValue *bool + ClosedValue *bool + ParentValue *string // nil = unchanged; "" = detach to root; "" = attach + ChildEntriesValue *[]ChildEntry // nil = unchanged; non-nil = replace list + ClosedChildrenValue *bool // nil = unchanged; true = set flag + When nostr.Timestamp } func (_ EditMetadata) Name() string { return "edit-metadata" } diff --git a/state.go b/state.go index f09b5df..dec5beb 100644 --- a/state.go +++ b/state.go @@ -3,6 +3,7 @@ package relay29 import ( "context" "fmt" + "sync" "github.com/fiatjaf/eventstore" "github.com/fiatjaf/set" @@ -29,6 +30,11 @@ type State struct { defaultRoles []*nip29.Role groupCreatorDefaultRole *nip29.Role + // reparentMu serializes cycle-check + reparent so two concurrent + // EditMetadata events cannot both pass the cycle check against stale + // state and then form a cycle when both are applied. + reparentMu sync.Mutex + AllowAction func(ctx context.Context, group nip29.Group, role *nip29.Role, action Action) bool } diff --git a/subgroups.go b/subgroups.go new file mode 100644 index 0000000..a6662e7 --- /dev/null +++ b/subgroups.go @@ -0,0 +1,139 @@ +package relay29 + +import ( + "github.com/nbd-wtf/go-nostr" +) + +// ToMetadataEvent shadows the embedded nip29.Group.ToMetadataEvent so the +// kind:39000 carries the subgroup-related tags: `parent` (with optional third +// element = admin attester pubkey), `child` entries, and `closed-children`. +func (g *Group) ToMetadataEvent() *nostr.Event { + g.mu.RLock() + defer g.mu.RUnlock() + evt := g.Group.ToMetadataEvent() + if g.Parent != "" { + tag := nostr.Tag{"parent", g.Parent} + if g.ParentAttester != "" { + tag = append(tag, g.ParentAttester) + } + evt.Tags = append(evt.Tags, tag) + } + for _, c := range g.ChildEntries { + tag := nostr.Tag{"child", c.ID} + if c.Order != "" || len(c.Flags) > 0 { + tag = append(tag, c.Order) + } + if len(c.Flags) > 0 { + tag = append(tag, c.Flags...) + } + evt.Tags = append(evt.Tags, tag) + } + if g.ClosedChildren { + evt.Tags = append(evt.Tags, nostr.Tag{"closed-children"}) + } + return evt +} + +// WouldCreateCycle reports whether making `childId` a subgroup of `parentId` +// would form a cycle. Walks the parent chain upward from parentId; if it +// reaches childId, it's a cycle. +func (s *State) WouldCreateCycle(childId, parentId string) bool { + if childId == parentId { + return true + } + visited := make(map[string]struct{}) + current := parentId + for current != "" { + if _, seen := visited[current]; seen { + return true + } + visited[current] = struct{}{} + if current == childId { + return true + } + g, _ := s.Groups.Load(current) + if g == nil { + return false + } + current = g.Parent + } + return false +} + +// Reparent updates the parent/children relationship of a group. +// newParent == "" means detach to root (attester is cleared too). attester is +// the pubkey of the admin whose kind:9002 authored the relationship; the relay +// copies it into the emitted kind:39000 as the third element of the `parent` +// tag (spec: Parent consent / Admin attestation). +// +// Locking: the caller MUST NOT hold group.mu. Reparent takes s.reparentMu for +// the duration, then acquires each involved group's mu in isolation (never +// nested), which rules out cross-parent deadlocks. +func (s *State) Reparent(group *Group, newParent, attester string) (oldParent string, changed bool) { + s.reparentMu.Lock() + defer s.reparentMu.Unlock() + + if newParent != "" && s.WouldCreateCycle(group.Address.ID, newParent) { + return group.Parent, false + } + + group.mu.Lock() + oldParent = group.Parent + oldAttester := group.ParentAttester + if oldParent == newParent && (newParent == "" || oldAttester == attester) { + group.mu.Unlock() + return oldParent, false + } + group.Parent = newParent + if newParent == "" { + group.ParentAttester = "" + } else { + group.ParentAttester = attester + } + groupId := group.Address.ID + group.mu.Unlock() + + if oldParent != "" && oldParent != newParent { + if old, _ := s.Groups.Load(oldParent); old != nil { + old.mu.Lock() + delete(old.Children, groupId) + old.mu.Unlock() + } + } + if newParent != "" && oldParent != newParent { + if np, _ := s.Groups.Load(newParent); np != nil { + np.mu.Lock() + np.Children[groupId] = struct{}{} + np.mu.Unlock() + } + } + return oldParent, true +} + +// promoteChildrenToRoots clears the Parent link on every direct child of +// `group`, broadcasts an updated kind:39000 for each (now without a parent +// tag), and returns them. Used when `group` itself is about to be deleted — per +// spec, "when a parent is deleted, its remaining children automatically become +// roots." +func (s *State) promoteChildrenToRoots(group *Group) []*Group { + group.mu.RLock() + childIds := make([]string, 0, len(group.Children)) + for id := range group.Children { + childIds = append(childIds, id) + } + group.mu.RUnlock() + + promoted := make([]*Group, 0, len(childIds)) + for _, id := range childIds { + child, _ := s.Groups.Load(id) + if child == nil { + continue + } + child.mu.Lock() + child.Parent = "" + child.ParentAttester = "" + child.mu.Unlock() + promoted = append(promoted, child) + } + return promoted +}