initial public relay build repo
ci / build (push) Successful in 2m3s
ci / image (push) Successful in 1m4s

This commit is contained in:
2026-07-24 13:11:06 -04:00
commit 294616642f
24 changed files with 2957 additions and 0 deletions
+406
View File
@@ -0,0 +1,406 @@
// Package federation lets the relay mirror NIP-29 chat with peer relays, so a
// channel can live on more than one relay and survive any single one going down.
// It is OPTIONAL and chat-only by design: only kind-9/10 messages cross the
// boundary. Membership, admins, bans and metadata stay local to each relay (each
// operator moderates their own instance) — this sidesteps cross-relay moderation
// conflicts entirely.
//
// Two per-channel modes:
//
// - mirror bidirectional. Chat posted on either relay is forwarded to the
// other, so the channel is fully replicated.
// - ingest read-only. This relay pulls chat FROM the peer but never pushes
// back (announcement feeds, syndication, public-room mirrors).
//
// Loop prevention is structural rather than tag-based: an incoming federated
// event is injected through the relay's normal add pipeline, whose eventstore
// rejects duplicates (ErrDupEvent) BEFORE the egress hook runs. So an event that
// has already been seen is never re-forwarded, and A↔B↔A cycles die on the
// second sight. No custom "seen" bookkeeping to get wrong.
package federation
import (
"context"
"log"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"github.com/nosterm/relay/internal/group"
)
// Mode is a peer's federation direction.
type Mode int
const (
// ModeMirror is bidirectional federation.
ModeMirror Mode = iota
// ModeIngest is read-only (pull-only) federation.
ModeIngest
)
func (m Mode) String() string {
if m == ModeIngest {
return "ingest"
}
return "mirror"
}
// Peer is one configured peer relay and the channels federated with it.
type Peer struct {
URL string
Mode Mode
Channels map[string]bool // group ids; empty map with AllChans=true means "all"
AllChans bool
}
// wants reports whether events for the given group id federate with this peer.
func (p *Peer) wants(groupID string) bool {
if p.AllChans {
return true
}
return p.Channels[groupID]
}
// Config is the parsed set of peers.
type Config struct {
Peers []Peer
}
// Enabled reports whether any peer is configured.
func (c Config) Enabled() bool {
return len(c.Peers) > 0
}
// FederatesChannel reports whether any peer federates the given group id. Used
// by group evaluation to accept federated chat regardless of local membership
// (a federated channel is effectively open on every participating relay).
func (c Config) FederatesChannel(id string) bool {
for i := range c.Peers {
if c.Peers[i].wants(id) {
return true
}
}
return false
}
// ParseConfig parses the RELAY_FEDERATION_PEERS specification.
//
// Grammar (peers separated by ';', ignoring surrounding whitespace):
//
// <url>|<mode>:<channels>
//
// where <mode> is "mirror" or "ingest" and <channels> is a comma-separated list
// of group ids, or "*" for every channel. The "|<mode>:<channels>" suffix is
// optional and defaults to "mirror:*". Blank entries are skipped.
//
// Examples:
//
// wss://a.example
// wss://a.example|mirror:general,dev
// wss://a.example|mirror:general;wss://b.example|ingest:announcements
// wss://a.example|ingest:*
func ParseConfig(spec string) (Config, []string) {
var cfg Config
var warnings []string
for _, raw := range strings.Split(spec, ";") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
urlPart := entry
modePart := ""
if i := strings.Index(entry, "|"); i >= 0 {
urlPart = strings.TrimSpace(entry[:i])
modePart = strings.TrimSpace(entry[i+1:])
}
// Require an explicit ws/wss scheme on the RAW input: NormalizeURL would
// silently rewrite http→ws and bare hosts→wss, masking config mistakes.
lower := strings.ToLower(urlPart)
if !(strings.HasPrefix(lower, "ws://") || strings.HasPrefix(lower, "wss://")) {
warnings = append(warnings, "ignoring peer with non-ws(s) URL: "+urlPart)
continue
}
url := nostr.NormalizeURL(urlPart)
if url == "" {
warnings = append(warnings, "ignoring peer with invalid URL: "+urlPart)
continue
}
peer := Peer{
URL: url,
Mode: ModeMirror,
Channels: map[string]bool{},
AllChans: true, // default: all channels
}
if modePart != "" {
modeName := modePart
chanList := "*"
if j := strings.Index(modePart, ":"); j >= 0 {
modeName = strings.TrimSpace(modePart[:j])
chanList = strings.TrimSpace(modePart[j+1:])
}
switch strings.ToLower(modeName) {
case "mirror", "":
peer.Mode = ModeMirror
case "ingest", "pull", "read", "readonly":
peer.Mode = ModeIngest
default:
warnings = append(warnings, "unknown federation mode "+modeName+" for "+url+"; using mirror")
peer.Mode = ModeMirror
}
if chanList != "" && chanList != "*" {
peer.AllChans = false
for _, c := range strings.Split(chanList, ",") {
id := strings.TrimSpace(strings.TrimPrefix(c, "#"))
if id != "" {
peer.Channels[id] = true
}
}
// A mode with an explicit but empty channel list federates nothing.
if len(peer.Channels) == 0 {
warnings = append(warnings, "peer "+url+" lists no valid channels; skipping")
continue
}
}
}
cfg.Peers = append(cfg.Peers, peer)
}
return cfg, warnings
}
// EventInjector accepts a federated event into the local relay pipeline. It
// returns whether the event was newly stored (false for duplicates/rejects), so
// tests can assert loop prevention without a live relay. In production this is
// backed by khatru's Relay.AddEvent.
type EventInjector func(ctx context.Context, evt nostr.Event) (stored bool)
// Federator owns the live peer connections and the forwarding logic.
type Federator struct {
cfg Config
inject EventInjector
dialCtx context.Context
cancel context.CancelFunc
mu sync.Mutex
connected map[string]*nostr.Relay // url -> live client connection
wg sync.WaitGroup
}
// federationKinds are the only kinds we ever subscribe to / forward: chat only.
var federationKinds = []nostr.Kind{
nostr.KindSimpleGroupChatMessage, // 9
nostr.KindSimpleGroupThreadedReply, // 10
}
// NewFederator creates a federator for the given config. inject feeds received
// peer events into the local relay pipeline.
func NewFederator(cfg Config, inject EventInjector) *Federator {
ctx, cancel := context.WithCancel(context.Background())
return &Federator{
cfg: cfg,
inject: inject,
dialCtx: ctx,
cancel: cancel,
connected: map[string]*nostr.Relay{},
}
}
// Start dials every peer and begins pulling their chat. Non-blocking: each peer
// runs in its own goroutine that reconnects with backoff until Stop is called.
func (f *Federator) Start() {
for i := range f.cfg.Peers {
peer := f.cfg.Peers[i]
f.wg.Add(1)
go func() {
defer f.wg.Done()
f.runPeer(peer)
}()
}
log.Printf("federation: started with %d peer(s)", len(f.cfg.Peers))
}
// Stop tears down all peer connections and waits for their goroutines to exit.
func (f *Federator) Stop() {
f.cancel()
f.mu.Lock()
for _, r := range f.connected {
_ = r.Close()
}
f.mu.Unlock()
f.wg.Wait()
}
// runPeer maintains one peer connection, reconnecting with capped backoff. Each
// (re)connection opens a fresh subscription for the federated chat kinds and
// injects received events into the local pipeline (dedup handles loops).
func (f *Federator) runPeer(peer Peer) {
backoff := time.Second
const maxBackoff = 30 * time.Second
for f.dialCtx.Err() == nil {
if err := f.connectAndPull(peer); err != nil {
if f.dialCtx.Err() != nil {
return
}
log.Printf("federation: peer %s (%s) error: %v; retrying in %s",
peer.URL, peer.Mode, err, backoff)
select {
case <-f.dialCtx.Done():
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
// Clean subscription end (peer closed / context canceled) — reset backoff
// and try to re-establish unless we're shutting down.
backoff = time.Second
}
}
// connectAndPull opens a connection + subscription and blocks pumping events
// until the subscription ends or the connection drops. Returns nil on a clean
// end (so the caller re-establishes), or an error to trigger backoff.
func (f *Federator) connectAndPull(peer Peer) error {
relay, err := nostr.RelayConnect(f.dialCtx, peer.URL, nostr.RelayOptions{})
if err != nil {
return err
}
f.mu.Lock()
f.connected[peer.URL] = relay
f.mu.Unlock()
defer func() {
f.mu.Lock()
delete(f.connected, peer.URL)
f.mu.Unlock()
_ = relay.Close()
}()
filter := nostr.Filter{Kinds: federationKinds}
// Scope the pull to the configured channels when not "all", so we don't drag
// down chat for channels this peer isn't federating.
if !peer.AllChans {
ids := make([]string, 0, len(peer.Channels))
for id := range peer.Channels {
ids = append(ids, id)
}
filter.Tags = nostr.TagMap{"h": ids}
}
sub, err := relay.Subscribe(f.dialCtx, filter, nostr.SubscriptionOptions{Label: "federation"})
if err != nil {
return err
}
defer sub.Unsub()
log.Printf("federation: pulling %s from %s", peer.Mode, peer.URL)
for {
select {
case <-f.dialCtx.Done():
return nil
case <-sub.EndOfStoredEvents:
// Historical backfill done; keep receiving live events.
case reason := <-sub.ClosedReason:
log.Printf("federation: peer %s closed subscription: %s", peer.URL, reason)
return nil
case evt, ok := <-sub.Events:
if !ok {
return nil
}
f.receive(peer, evt)
}
}
}
// receive validates and injects an event pulled from a peer. Signature is
// re-verified defensively (never trust a peer to have done so), the channel
// scope is re-checked, and the event is fed through the local pipeline where
// the eventstore dedups it — that dedup is what prevents forwarding loops.
func (f *Federator) receive(peer Peer, evt nostr.Event) {
id := group.GroupIDFromEvent(evt)
if id == "" || !peer.wants(id) {
return
}
if !evt.VerifySignature() {
log.Printf("federation: dropped event %s from %s (bad signature)", evt.ID.Hex(), peer.URL)
return
}
f.inject(f.dialCtx, evt)
}
// Forward pushes a locally-saved chat event out to every peer that federates its
// channel in a writable (mirror) mode. Ingest peers are pull-only and are
// skipped. Called from the relay's OnEventSaved hook for chat kinds; a duplicate
// re-injected from a peer never reaches here because AddEvent stops on dedup
// before OnEventSaved fires.
func (f *Federator) Forward(evt nostr.Event) {
id := group.GroupIDFromEvent(evt)
if id == "" {
return
}
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.cfg.Peers {
peer := f.cfg.Peers[i]
if peer.Mode != ModeMirror || !peer.wants(id) {
continue
}
relay := f.connected[peer.URL]
if relay == nil || !relay.IsConnected() {
continue // reconnect logic will backfill via subscription
}
// Publish in the background so a slow peer can't block the save path.
f.wg.Add(1)
go func(r *nostr.Relay, url string) {
defer f.wg.Done()
ctx, cancel := context.WithTimeout(f.dialCtx, 10*time.Second)
defer cancel()
if err := r.Publish(ctx, evt); err != nil {
log.Printf("federation: publish to %s failed: %v", url, err)
}
}(relay, peer.URL)
}
}
// Summary is a short human-readable description for logging/inspection.
func (c Config) Summary() string {
if !c.Enabled() {
return "federation disabled"
}
var b strings.Builder
b.WriteString("federation: ")
for i, p := range c.Peers {
if i > 0 {
b.WriteString(", ")
}
scope := "*"
if !p.AllChans {
ids := make([]string, 0, len(p.Channels))
for id := range p.Channels {
ids = append(ids, id)
}
scope = strings.Join(ids, "/")
}
b.WriteString(p.URL)
b.WriteString("(")
b.WriteString(p.Mode.String())
b.WriteString(":")
b.WriteString(scope)
b.WriteString(")")
}
return b.String()
}
+215
View File
@@ -0,0 +1,215 @@
package federation
import (
"context"
"sync"
"testing"
"fiatjaf.com/nostr"
)
func TestParseConfig(t *testing.T) {
t.Run("empty spec disables federation", func(t *testing.T) {
cfg, warnings := ParseConfig("")
if cfg.Enabled() {
t.Fatal("expected federation disabled for empty spec")
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
})
t.Run("bare url defaults to mirror all channels", func(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example")
if len(cfg.Peers) != 1 {
t.Fatalf("expected 1 peer, got %d", len(cfg.Peers))
}
p := cfg.Peers[0]
if p.Mode != ModeMirror {
t.Errorf("expected mirror mode, got %s", p.Mode)
}
if !p.AllChans {
t.Error("expected AllChans=true for bare url")
}
if !p.wants("anything") {
t.Error("bare url should federate every channel")
}
})
t.Run("explicit mode and channels", func(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example|ingest:general,dev")
p := cfg.Peers[0]
if p.Mode != ModeIngest {
t.Errorf("expected ingest, got %s", p.Mode)
}
if p.AllChans {
t.Error("expected scoped channels, got AllChans")
}
if !p.wants("general") || !p.wants("dev") {
t.Error("should federate listed channels")
}
if p.wants("secret") {
t.Error("should NOT federate unlisted channel")
}
})
t.Run("multiple peers separated by semicolons", func(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example|mirror:general; wss://b.example|ingest:*")
if len(cfg.Peers) != 2 {
t.Fatalf("expected 2 peers, got %d", len(cfg.Peers))
}
if cfg.Peers[0].Mode != ModeMirror || cfg.Peers[1].Mode != ModeIngest {
t.Error("modes parsed incorrectly")
}
if !cfg.Peers[1].AllChans {
t.Error("ingest:* should mean all channels")
}
})
t.Run("channel names tolerate a leading #", func(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example|mirror:#general")
if !cfg.Peers[0].wants("general") {
t.Error("should strip leading # from channel names")
}
})
t.Run("invalid url is skipped with a warning", func(t *testing.T) {
cfg, warnings := ParseConfig("http://not-websocket.example")
if cfg.Enabled() {
t.Error("non-ws url should be skipped")
}
if len(warnings) == 0 {
t.Error("expected a warning for invalid url")
}
})
t.Run("explicit but empty channel list is skipped", func(t *testing.T) {
cfg, warnings := ParseConfig("wss://a.example|mirror:,")
if cfg.Enabled() {
t.Error("empty channel list should skip the peer")
}
if len(warnings) == 0 {
t.Error("expected a warning for empty channel list")
}
})
t.Run("unknown mode falls back to mirror with a warning", func(t *testing.T) {
cfg, warnings := ParseConfig("wss://a.example|bogus:general")
if cfg.Peers[0].Mode != ModeMirror {
t.Error("unknown mode should fall back to mirror")
}
if len(warnings) == 0 {
t.Error("expected a warning for unknown mode")
}
})
}
func TestFederatesChannel(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example|mirror:general;wss://b.example|ingest:dev")
if !cfg.FederatesChannel("general") {
t.Error("general federates via peer a")
}
if !cfg.FederatesChannel("dev") {
t.Error("dev federates via peer b")
}
if cfg.FederatesChannel("private") {
t.Error("private federates with nobody")
}
}
// TestReceiveLoopPrevention proves the loop guard: an event injected twice is
// only stored (and thus only forwardable) once, because the injector reports the
// second sight as not-newly-stored. This mirrors AddEvent's ErrDupEvent path.
func TestReceiveLoopPrevention(t *testing.T) {
seen := map[nostr.ID]bool{}
var mu sync.Mutex
injectCount := 0
storedCount := 0
inject := func(_ context.Context, evt nostr.Event) bool {
mu.Lock()
defer mu.Unlock()
injectCount++
if seen[evt.ID] {
return false // duplicate: not newly stored (the loop breaker)
}
seen[evt.ID] = true
storedCount++
return true
}
cfg, _ := ParseConfig("wss://peer.example|mirror:general")
f := NewFederator(cfg, inject)
peer := cfg.Peers[0]
sk := nostr.Generate()
evt := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
CreatedAt: nostr.Now(),
Content: "hello federation",
Tags: nostr.Tags{{"h", "general"}},
}
if err := evt.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
// Same event received twice (e.g. A→B then echoed B→A→B).
f.receive(peer, evt)
f.receive(peer, evt)
if injectCount != 2 {
t.Errorf("expected 2 inject attempts, got %d", injectCount)
}
if storedCount != 1 {
t.Errorf("loop guard failed: event stored %d times, want 1", storedCount)
}
}
// TestReceiveDropsBadSignature ensures a peer can't inject forged events.
func TestReceiveDropsBadSignature(t *testing.T) {
injected := false
inject := func(_ context.Context, _ nostr.Event) bool {
injected = true
return true
}
cfg, _ := ParseConfig("wss://peer.example|mirror:general")
f := NewFederator(cfg, inject)
// Unsigned (invalid) event tagged for a federated channel.
evt := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
CreatedAt: nostr.Now(),
Content: "forged",
Tags: nostr.Tags{{"h", "general"}},
}
f.receive(cfg.Peers[0], evt)
if injected {
t.Error("event with an invalid signature must not be injected")
}
}
// TestReceiveIgnoresUnfederatedChannel ensures scope is enforced on ingress.
func TestReceiveIgnoresUnfederatedChannel(t *testing.T) {
injected := false
inject := func(_ context.Context, _ nostr.Event) bool {
injected = true
return true
}
cfg, _ := ParseConfig("wss://peer.example|mirror:general")
f := NewFederator(cfg, inject)
sk := nostr.Generate()
evt := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
CreatedAt: nostr.Now(),
Content: "off-topic",
Tags: nostr.Tags{{"h", "other-channel"}},
}
if err := evt.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
f.receive(cfg.Peers[0], evt)
if injected {
t.Error("chat for an unfederated channel must not be injected")
}
}
+431
View File
@@ -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))
}
+291
View File
@@ -0,0 +1,291 @@
package group
import (
"context"
"testing"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
)
func mustSign(t *testing.T, sk nostr.SecretKey, evt *nostr.Event) {
t.Helper()
if err := evt.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
}
func hpk(sk nostr.SecretKey) nostr.PubKey { return nostr.GetPublicKey(sk) }
// evalOK applies an event and fails if the relay rejects it.
func evalOK(t *testing.T, gs *State, evt nostr.Event) {
t.Helper()
if reject, msg := gs.Evaluate(evt); reject {
t.Fatalf("kind %d unexpectedly rejected: %s", evt.Kind, msg)
}
}
// evalRejected applies an event and fails if it is allowed.
func evalRejected(t *testing.T, gs *State, evt nostr.Event) string {
t.Helper()
reject, msg := gs.Evaluate(evt)
if !reject {
t.Fatalf("kind %d unexpectedly allowed", evt.Kind)
}
return msg
}
func TestChatRejectedWithoutGroup(t *testing.T) {
gs := NewState(nostr.Generate())
// A chat message to a group that was never created/joined is rejected.
evt := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
Tags: nostr.Tags{{"h", "nonexistent"}},
}
reject, msg := gs.Evaluate(evt)
if !reject {
t.Fatalf("expected rejection for chat to unknown group, got allow (%q)", msg)
}
}
func TestChatMissingHTagRejected(t *testing.T) {
gs := NewState(nostr.Generate())
evt := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage}
reject, _ := gs.Evaluate(evt)
if !reject {
t.Fatal("expected rejection for chat with no h tag")
}
}
func TestJoinThenChatAllowed(t *testing.T) {
gs := NewState(nostr.Generate())
alice := nostr.Generate()
alicePK := nostr.GetPublicKey(alice)
join := nostr.Event{PubKey: alicePK, Kind: nostr.KindSimpleGroupJoinRequest, Tags: nostr.Tags{{"h", "g1"}}}
if reject, msg := gs.Evaluate(join); reject {
t.Fatalf("join rejected: %s", msg)
}
chat := nostr.Event{PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", "g1"}}}
if reject, msg := gs.Evaluate(chat); reject {
t.Fatalf("chat after join rejected: %s", msg)
}
if !gs.IsMember("g1", alicePK) {
t.Fatal("alice should be a member after joining")
}
}
// The creator of a group is its administrator; a plain joiner is not, and only
// the admin may add or remove users.
func TestAdminModerationInClosedGroup(t *testing.T) {
gs := NewState(nostr.Generate())
admin := nostr.Generate() // creates the group → admin + member
alice := nostr.Generate() // will be admitted by the admin
bob := nostr.Generate() // will try to moderate without permission
mallory := nostr.Generate()
const g = "staff"
adminPK, alicePK, bobPK, malloryPK := hpk(admin), hpk(alice), hpk(bob), hpk(mallory)
// Admin creates a CLOSED group.
evalOK(t, gs, nostr.Event{
PubKey: adminPK, Kind: nostr.KindSimpleGroupCreateGroup,
Tags: nostr.Tags{{"h", g}, {"closed"}},
})
if !gs.IsAdmin(g, adminPK) {
t.Fatal("creator should be an admin")
}
if !gs.IsMember(g, adminPK) {
t.Fatal("creator should be a member")
}
// A join request to a closed group is accepted as an event but does NOT
// grant membership — it awaits admin approval.
evalOK(t, gs, nostr.Event{
PubKey: alicePK, Kind: nostr.KindSimpleGroupJoinRequest, Tags: nostr.Tags{{"h", g}},
})
if gs.IsMember(g, alicePK) {
t.Fatal("closed group: join request alone must not grant membership")
}
// A non-member/non-admin cannot post to the closed group.
evalRejected(t, gs, nostr.Event{
PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}},
})
// A non-admin (bob) cannot add users.
msg := evalRejected(t, gs, nostr.Event{
PubKey: bobPK, Kind: nostr.KindSimpleGroupPutUser,
Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}},
})
if msg == "" {
t.Fatal("expected a rejection reason for non-admin add")
}
// The admin admits alice (kind 9000).
evalOK(t, gs, nostr.Event{
PubKey: adminPK, Kind: nostr.KindSimpleGroupPutUser,
Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}},
})
if !gs.IsMember(g, alicePK) {
t.Fatal("admin add (9000) should grant alice membership")
}
// Now alice, a member, can post.
evalOK(t, gs, nostr.Event{
PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}},
})
// The admin can add multiple users in one event.
evalOK(t, gs, nostr.Event{
PubKey: adminPK, Kind: nostr.KindSimpleGroupPutUser,
Tags: nostr.Tags{{"h", g}, {"p", bobPK.Hex()}, {"p", malloryPK.Hex()}},
})
if !gs.IsMember(g, bobPK) || !gs.IsMember(g, malloryPK) {
t.Fatal("admin should add multiple tagged users")
}
// The admin removes mallory (kind 9001); she can no longer post.
evalOK(t, gs, nostr.Event{
PubKey: adminPK, Kind: nostr.KindSimpleGroupRemoveUser,
Tags: nostr.Tags{{"h", g}, {"p", malloryPK.Hex()}},
})
if gs.IsMember(g, malloryPK) {
t.Fatal("admin remove (9001) should revoke mallory's membership")
}
evalRejected(t, gs, nostr.Event{
PubKey: malloryPK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}},
})
// A non-admin (bob) cannot remove users either.
evalRejected(t, gs, nostr.Event{
PubKey: bobPK, Kind: nostr.KindSimpleGroupRemoveUser,
Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}},
})
if !gs.IsMember(g, alicePK) {
t.Fatal("a rejected removal must not change membership")
}
}
// Open groups let anyone post without admin approval (public channels), which
// contrasts with the closed-group moderation above.
func TestOpenGroupAllowsNonMemberPosts(t *testing.T) {
gs := NewState(nostr.Generate())
admin := nostr.Generate()
stranger := nostr.Generate()
const g = "lobby"
evalOK(t, gs, nostr.Event{
PubKey: hpk(admin), Kind: nostr.KindSimpleGroupCreateGroup, Tags: nostr.Tags{{"h", g}},
})
// A stranger who never joined can still post to an open group.
evalOK(t, gs, nostr.Event{
PubKey: hpk(stranger), Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}},
})
}
// Group membership and metadata must survive a relay restart: state is rebuilt
// from the persisted event store rather than lost. This reproduces the bug
// where an in-memory-only State silently dropped all groups on restart.
func TestStateSurvivesRebuild(t *testing.T) {
store := &slicestore.SliceStore{}
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
admin := nostr.Generate()
alice := nostr.Generate()
const g = "persisted"
// Simulate the live path: Evaluate accepts the event, then it is stored
// (khatru stores accepted events; we do it explicitly here).
apply := func(gs *State, evt nostr.Event) {
if reject, msg := gs.Evaluate(evt); reject {
t.Fatalf("kind %d rejected: %s", evt.Kind, msg)
}
if err := store.SaveEvent(evt); err != nil {
t.Fatalf("save: %v", err)
}
}
signed := func(sk nostr.SecretKey, kind nostr.Kind, tags nostr.Tags) nostr.Event {
e := nostr.Event{Kind: kind, CreatedAt: nostr.Now(), Tags: tags}
mustSign(t, sk, &e)
return e
}
// Build state in the "first boot": admin creates a closed group and admits alice.
first := NewState(nostr.Generate())
apply(first, signed(admin, nostr.KindSimpleGroupCreateGroup, nostr.Tags{{"h", g}, {"closed"}}))
apply(first, signed(admin, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}}))
if !first.IsMember(g, hpk(alice)) || !first.IsAdmin(g, hpk(admin)) {
t.Fatal("precondition: alice member + admin admin should hold before restart")
}
// "Restart": a fresh State that knows nothing until it replays the store.
second := NewState(nostr.Generate())
if second.IsMember(g, hpk(alice)) {
t.Fatal("fresh state should be empty before rebuild")
}
replayed := second.Rebuild(store)
if replayed != 2 {
t.Fatalf("expected 2 management events replayed, got %d", replayed)
}
// Membership, admin rights, and closed-ness must be restored.
if !second.IsMember(g, hpk(admin)) {
t.Fatal("admin membership lost across rebuild")
}
if !second.IsMember(g, hpk(alice)) {
t.Fatal("alice membership lost across rebuild")
}
if !second.IsAdmin(g, hpk(admin)) {
t.Fatal("admin rights lost across rebuild")
}
// Closed-group enforcement must still apply after rebuild: a non-member is rejected.
stranger := nostr.Generate()
if reject, _ := second.Evaluate(signed(stranger, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}})); !reject {
t.Fatal("closed-group enforcement lost across rebuild: stranger post should be rejected")
}
// A restored member can still post.
if reject, msg := second.Evaluate(signed(alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}})); reject {
t.Fatalf("restored member alice should be able to post: %s", msg)
}
}
// Only admins can edit group metadata (topic), and the edit is applied to state.
func TestEditMetadataAdminOnly(t *testing.T) {
gs := NewState(nostr.Generate())
admin := nostr.Generate()
stranger := nostr.Generate()
const g = "topictest"
// Admin creates the group.
evalOK(t, gs, nostr.Event{
PubKey: hpk(admin), Kind: nostr.KindSimpleGroupCreateGroup, Tags: nostr.Tags{{"h", g}},
})
// A non-admin cannot set the topic.
evalRejected(t, gs, nostr.Event{
PubKey: hpk(stranger), Kind: nostr.KindSimpleGroupEditMetadata,
Tags: nostr.Tags{{"h", g}, {"about", "hostile takeover"}},
})
// The admin sets name + topic, and state reflects it.
evalOK(t, gs, nostr.Event{
PubKey: hpk(admin), Kind: nostr.KindSimpleGroupEditMetadata,
Tags: nostr.Tags{{"h", g}, {"name", "Topic Test"}, {"about", "the topic"}},
})
meta, ok := gs.MetadataEvent(context.Background(), g)
if !ok {
t.Fatal("expected metadata event")
}
if firstTagValue(meta, "name") != "Topic Test" {
t.Fatalf("name not applied: %q", firstTagValue(meta, "name"))
}
if firstTagValue(meta, "about") != "the topic" {
t.Fatalf("about (topic) not applied: %q", firstTagValue(meta, "about"))
}
}
+42
View File
@@ -0,0 +1,42 @@
// Package relayinfo implements the Nosterm capability handshake.
//
// A standard NIP-11 relay information document describes supported NIPs. A
// "nostermd" additionally advertises a `features` array so that a Nosterm
// client can enable/disable UI capabilities and gracefully fall back when
// connected to a plain Nostr relay that omits it.
//
// The upstream nip11.RelayInformationDocument has no `features` field and its
// custom JSON marshaller drops unknown keys, so the relay serves NIP-11 itself
// and merges these fields into the JSON object.
package relayinfo
const (
// SoftwareName is advertised as the NIP-11 `software` value.
SoftwareName = "nostermd"
// SoftwareVersion is advertised as the NIP-11 `version` value.
SoftwareVersion = "0.1.0"
)
// baseFeatures advertised by every relay build. Presence/typing/moderation/etc.
// are declared here as they are implemented; the baseline relay supports managed
// NIP-29 channels only. Runtime-dependent features (e.g. federation) are added
// in Extras based on configuration.
var baseFeatures = []string{
"channels", // NIP-29 managed group channels
}
// Extras returns the extra top-level keys merged into the NIP-11 document JSON.
// Keeping this in one place makes the handshake easy to extend. `federated`
// reflects whether this relay has any federation peer configured, so a client
// can surface that a channel here may be mirrored elsewhere.
func Extras(federated bool) map[string]any {
features := append([]string{}, baseFeatures...)
if federated {
features = append(features, "federation") // chat-only relay↔relay mirroring
}
return map[string]any{
"software": SoftwareName,
"version": SoftwareVersion,
"features": features,
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package retention prunes old CHAT messages (kinds 9/10) so the event store
// does not grow without bound. Group-management and addressable metadata events
// (9007/9000/9001/9021/9022, 39000-39002) are NEVER pruned — they define
// membership and must survive to be replayed on boot.
package retention
import (
"log"
"slices"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
)
// Config controls what the retention sweeper deletes.
type Config struct {
// MaxAge: chat messages older than this are deleted. Zero disables age pruning.
MaxAge time.Duration
// MaxMessages: keep at most this many chat messages (newest kept). Zero
// disables count pruning.
MaxMessages int
// Interval between sweeps.
Interval time.Duration
}
// chatKinds are the only kinds eligible for retention pruning.
var chatKinds = []nostr.Kind{
nostr.KindSimpleGroupChatMessage,
nostr.KindSimpleGroupThreadedReply,
}
// Sweep runs a single retention pass and returns the number of events deleted.
// `now` is injected so the logic is deterministically testable.
func Sweep(store eventstore.Store, cfg Config, now time.Time) int {
// Collect all chat events (newest-first from the store).
var chats []nostr.Event
for evt := range store.QueryEvents(nostr.Filter{Kinds: chatKinds}, 1_000_000) {
chats = append(chats, evt)
}
// Sort newest-first so index >= MaxMessages are the surplus oldest ones.
slices.SortFunc(chats, nostr.CompareEventReverse)
cutoff := nostr.Timestamp(0)
if cfg.MaxAge > 0 {
cutoff = nostr.Timestamp(now.Add(-cfg.MaxAge).Unix())
}
deleted := 0
for i, evt := range chats {
tooOld := cfg.MaxAge > 0 && evt.CreatedAt < cutoff
surplus := cfg.MaxMessages > 0 && i >= cfg.MaxMessages
if tooOld || surplus {
if err := store.DeleteEvent(evt.ID); err == nil {
deleted++
}
}
}
return deleted
}
// Start launches a background sweeper. It is a no-op (returns a no-op stop) when
// neither limit is configured. The returned stop function halts it.
func Start(store eventstore.Store, cfg Config) (stop func()) {
if cfg.MaxAge == 0 && cfg.MaxMessages == 0 {
log.Println("retention disabled (no RELAY_RETENTION_* limits set)")
return func() {}
}
if cfg.Interval == 0 {
cfg.Interval = time.Hour
}
done := make(chan struct{})
go func() {
// Sweep once at startup, then on the interval.
if n := Sweep(store, cfg, time.Now()); n > 0 {
log.Printf("retention: pruned %d old chat message(s)", n)
}
ticker := time.NewTicker(cfg.Interval)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if n := Sweep(store, cfg, time.Now()); n > 0 {
log.Printf("retention: pruned %d old chat message(s)", n)
}
}
}
}()
log.Printf("retention enabled (maxAge=%s maxMessages=%d interval=%s)",
cfg.MaxAge, cfg.MaxMessages, cfg.Interval)
return func() { close(done) }
}
+116
View File
@@ -0,0 +1,116 @@
package retention
import (
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
)
func mkStore(t *testing.T) *slicestore.SliceStore {
t.Helper()
s := &slicestore.SliceStore{}
if err := s.Init(); err != nil {
t.Fatalf("init: %v", err)
}
return s
}
func save(t *testing.T, s *slicestore.SliceStore, sk nostr.SecretKey, kind nostr.Kind, createdAt nostr.Timestamp) nostr.ID {
t.Helper()
e := nostr.Event{Kind: kind, CreatedAt: createdAt, Tags: nostr.Tags{{"h", "g"}}}
if kind == nostr.KindSimpleGroupChatMessage {
e.Content = "msg"
}
if err := e.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
if err := s.SaveEvent(e); err != nil {
t.Fatalf("save: %v", err)
}
return e.ID
}
func count(s *slicestore.SliceStore, kinds ...nostr.Kind) int {
n := 0
for range s.QueryEvents(nostr.Filter{Kinds: kinds}, 1_000_000) {
n++
}
return n
}
func TestRetentionPrunesOldChatByAge(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
old := nostr.Timestamp(now.Add(-48 * time.Hour).Unix())
fresh := nostr.Timestamp(now.Add(-1 * time.Hour).Unix())
save(t, s, sk, nostr.KindSimpleGroupChatMessage, old)
save(t, s, sk, nostr.KindSimpleGroupChatMessage, fresh)
deleted := Sweep(s, Config{MaxAge: 24 * time.Hour}, now)
if deleted != 1 {
t.Fatalf("expected 1 pruned, got %d", deleted)
}
if c := count(s, nostr.KindSimpleGroupChatMessage); c != 1 {
t.Fatalf("expected 1 chat remaining, got %d", c)
}
}
func TestRetentionPrunesSurplusByCount(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
for i := 0; i < 5; i++ {
save(t, s, sk, nostr.KindSimpleGroupChatMessage, nostr.Timestamp(1_700_000_000+int64(i)))
}
deleted := Sweep(s, Config{MaxMessages: 2}, now)
if deleted != 3 {
t.Fatalf("expected 3 pruned, got %d", deleted)
}
if c := count(s, nostr.KindSimpleGroupChatMessage); c != 2 {
t.Fatalf("expected 2 chats remaining, got %d", c)
}
}
func TestRetentionNeverPrunesManagementEvents(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
ancient := nostr.Timestamp(now.Add(-1000 * time.Hour).Unix())
// Very old management + metadata events must be preserved.
save(t, s, sk, nostr.KindSimpleGroupCreateGroup, ancient)
save(t, s, sk, nostr.KindSimpleGroupPutUser, ancient)
save(t, s, sk, nostr.KindSimpleGroupJoinRequest, ancient)
save(t, s, sk, nostr.KindSimpleGroupMetadata, ancient)
save(t, s, sk, nostr.KindSimpleGroupAdmins, ancient)
save(t, s, sk, nostr.KindSimpleGroupMembers, ancient)
// An old chat that SHOULD be pruned.
save(t, s, sk, nostr.KindSimpleGroupChatMessage, ancient)
deleted := Sweep(s, Config{MaxAge: time.Hour, MaxMessages: 1}, now)
if deleted != 1 {
t.Fatalf("expected only the 1 chat pruned, got %d", deleted)
}
// All 6 management/metadata events survive.
mgmt := count(s,
nostr.KindSimpleGroupCreateGroup, nostr.KindSimpleGroupPutUser,
nostr.KindSimpleGroupJoinRequest, nostr.KindSimpleGroupMetadata,
nostr.KindSimpleGroupAdmins, nostr.KindSimpleGroupMembers)
if mgmt != 6 {
t.Fatalf("management events must never be pruned; have %d/6", mgmt)
}
}
func TestRetentionDisabledIsNoop(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
save(t, s, sk, nostr.KindSimpleGroupChatMessage, nostr.Timestamp(1))
if d := Sweep(s, Config{}, now); d != 0 {
t.Fatalf("no limits should prune nothing, got %d", d)
}
}