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