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
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"log"
"os"
"path/filepath"
"strconv"
"fiatjaf.com/nostr"
)
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// envIntOr parses a non-negative integer env var, falling back on empty/invalid.
func envIntOr(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
log.Printf("invalid %s=%q; using %d", key, v, fallback)
}
return fallback
}
// loadOrGenerateKey resolves the relay's signing identity, which signs the group
// metadata/admins/members events. A STABLE identity is required so those signed
// lists remain valid across restarts and re-deploys.
//
// - RELAY_SECRET_KEY set → use it (production path).
// - RELAY_ENV=production, unset → fatal. A fresh key each boot silently
// corrupts signed group rosters, so we refuse to start.
// - otherwise (dev) → persist a generated key under the data dir
// and reuse it, so even dev keeps a stable identity across restarts.
func loadOrGenerateKey(dataDir string) nostr.SecretKey {
if hex := os.Getenv("RELAY_SECRET_KEY"); hex != "" {
sk, err := nostr.SecretKeyFromHex(hex)
if err != nil {
log.Fatalf("invalid RELAY_SECRET_KEY: %v", err)
}
return sk
}
if isProduction() {
log.Fatal("RELAY_SECRET_KEY is required when RELAY_ENV=production " +
"(a fresh key each boot would corrupt signed group rosters). " +
"Generate one with: openssl rand -hex 32")
}
// Dev convenience: persist a generated key so restarts keep one identity.
keyPath := filepath.Join(dataDir, "relay.key")
if data, err := os.ReadFile(keyPath); err == nil {
if sk, err := nostr.SecretKeyFromHex(string(data)); err == nil {
log.Printf("using persisted dev relay identity from %s", keyPath)
return sk
}
log.Printf("ignoring unreadable %s; generating a new dev key", keyPath)
}
sk := nostr.Generate()
if err := os.WriteFile(keyPath, []byte(sk.Hex()), 0o600); err != nil {
log.Printf("could not persist dev relay key (%v); identity is ephemeral this run", err)
} else {
log.Printf("generated and persisted a dev relay identity at %s (set RELAY_SECRET_KEY for production)", keyPath)
}
return sk
}
func isProduction() bool {
return os.Getenv("RELAY_ENV") == "production"
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"os"
"path/filepath"
"testing"
"fiatjaf.com/nostr"
)
func TestDevKeyPersistsAcrossCalls(t *testing.T) {
t.Setenv("RELAY_SECRET_KEY", "")
t.Setenv("RELAY_ENV", "")
dir := t.TempDir()
first := loadOrGenerateKey(dir)
// The key file should now exist and a second call must return the same key.
if _, err := os.Stat(filepath.Join(dir, "relay.key")); err != nil {
t.Fatalf("dev key not persisted: %v", err)
}
second := loadOrGenerateKey(dir)
if first != second {
t.Fatal("dev identity should be stable across restarts")
}
}
func TestExplicitKeyIsUsed(t *testing.T) {
sk := nostr.Generate()
t.Setenv("RELAY_SECRET_KEY", sk.Hex())
t.Setenv("RELAY_ENV", "production") // explicit key wins even in production
got := loadOrGenerateKey(t.TempDir())
if got != sk {
t.Fatal("explicit RELAY_SECRET_KEY should be used verbatim")
}
}
// Note: the production-without-key path calls log.Fatal (os.Exit), which can't
// be exercised in-process without a subprocess harness; its behavior is simple
// and covered by manual verification + the isProduction() guard below.
func TestIsProduction(t *testing.T) {
t.Setenv("RELAY_ENV", "production")
if !isProduction() {
t.Fatal("RELAY_ENV=production should be detected")
}
t.Setenv("RELAY_ENV", "")
if isProduction() {
t.Fatal("unset RELAY_ENV should not be production")
}
}
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"context"
"testing"
"time"
"fiatjaf.com/nostr"
)
// TestFederationIngest is an end-to-end test: relay B hosts an open channel;
// relay A ingests it. A message posted to B by a member appears on A even though
// the author is not a member of A — the chat-only, membership-bypass contract.
func TestFederationIngest(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
const groupID = "general"
// Relay B: a normal relay hosting the channel.
bURL := startTestRelay(t, "")
// Relay A: ingests channel "general" from B (read-only).
aURL := startTestRelay(t, bURL+"|ingest:"+groupID)
// A subscriber on relay A watches the channel.
aReader, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{})
if err != nil {
t.Fatalf("connect reader to A: %v", err)
}
sub, err := aReader.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage},
Tags: nostr.TagMap{"h": []string{groupID}},
}, nostr.SubscriptionOptions{})
if err != nil {
t.Fatalf("subscribe on A: %v", err)
}
defer sub.Unsub()
// Alice joins + posts on relay B.
aliceSK := nostr.Generate()
bWriter, err := nostr.RelayConnect(ctx, bURL, nostr.RelayOptions{})
if err != nil {
t.Fatalf("connect alice to B: %v", err)
}
join := nostr.Event{Kind: nostr.KindSimpleGroupJoinRequest, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", groupID}}}
mustSign(t, aliceSK, &join)
if err := bWriter.Publish(ctx, join); err != nil {
t.Fatalf("alice join on B: %v", err)
}
msg := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Content: "hello from B", Tags: nostr.Tags{{"h", groupID}}}
mustSign(t, aliceSK, &msg)
if err := bWriter.Publish(ctx, msg); err != nil {
t.Fatalf("alice post on B: %v", err)
}
// The message should arrive on relay A via federation.
select {
case got := <-sub.Events:
if got.ID != msg.ID {
t.Fatalf("got event %s, want %s", got.ID.Hex(), msg.ID.Hex())
}
if got.Content != "hello from B" {
t.Errorf("content = %q", got.Content)
}
case <-ctx.Done():
t.Fatal("timed out waiting for federated message on relay A")
}
}
// TestFederationMirrorNoDuplicate proves the loop guard end-to-end: A mirrors B,
// so a post on A is forwarded to B, which would echo it back to A. The echo is a
// store duplicate and is dropped, not re-delivered — the message arrives on A's
// subscription exactly once.
func TestFederationMirrorNoDuplicate(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
const groupID = "general"
// B must start first (A needs B's URL); then A starts mirroring B. We assert
// the guard on the A→B→(echo)→A path.
bURL := startTestRelay(t, "")
aURL := startTestRelay(t, bURL+"|mirror:"+groupID)
// Reader on A counts how many times the event is delivered.
aReader, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{})
if err != nil {
t.Fatalf("connect reader to A: %v", err)
}
sub, err := aReader.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage},
Tags: nostr.TagMap{"h": []string{groupID}},
}, nostr.SubscriptionOptions{})
if err != nil {
t.Fatalf("subscribe on A: %v", err)
}
defer sub.Unsub()
// Alice joins + posts on A. A forwards to B (mirror); B could echo back to A.
aliceSK := nostr.Generate()
aWriter, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{})
if err != nil {
t.Fatalf("connect alice to A: %v", err)
}
join := nostr.Event{Kind: nostr.KindSimpleGroupJoinRequest, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", groupID}}}
mustSign(t, aliceSK, &join)
if err := aWriter.Publish(ctx, join); err != nil {
t.Fatalf("alice join on A: %v", err)
}
msg := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Content: "once only", Tags: nostr.Tags{{"h", groupID}}}
mustSign(t, aliceSK, &msg)
if err := aWriter.Publish(ctx, msg); err != nil {
t.Fatalf("alice post on A: %v", err)
}
// Count deliveries of our message id for a short window; must be exactly 1.
deadline := time.After(3 * time.Second)
count := 0
for {
select {
case got := <-sub.Events:
if got.ID == msg.ID {
count++
}
case <-deadline:
if count != 1 {
t.Fatalf("expected message delivered exactly once, got %d", count)
}
return
case <-ctx.Done():
t.Fatal("context canceled")
}
}
}
+95
View File
@@ -0,0 +1,95 @@
// Command nostermd is a minimal NIP-29 managed-group relay built on khatru,
// plus the Nosterm capability handshake (a `features` array in its NIP-11 doc).
//
// It is intentionally small: it enforces group membership for chat writes,
// auto-creates open channels on first join, and advertises itself as a
// "nostermd" so a Nosterm client can feature-gate its UI. The relay is
// assembled from focused internal packages (group, federation, retention,
// relayinfo); this file is the composition root that wires them to env config
// and the network.
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"time"
"fiatjaf.com/nostr/eventstore/boltdb"
"github.com/nosterm/relay/internal/federation"
"github.com/nosterm/relay/internal/retention"
)
// defaultMOTD is the built-in message-of-the-day: a "nerdworks.io" ascii banner
// (figlet, standard font) the client shows when RELAY_MOTD is unset.
const defaultMOTD = " _ _ _\n" +
" _ __ ___ _ __ __| |_ _____ _ __| | _____ (_) ___\n" +
"| '_ \\ / _ \\ '__/ _` \\ \\ /\\ / / _ \\| '__| |/ / __| | |/ _ \\\n" +
"| | | | __/ | | (_| |\\ V V / (_) | | | <\\__ \\_| | (_) |\n" +
"|_| |_|\\___|_| \\__,_| \\_/\\_/ \\___/|_| |_|\\_\\___(_)_|\\___/\n"
func main() {
addr := envOr("RELAY_ADDR", ":3334")
dataDir := envOr("RELAY_DATA_DIR", "./data")
// Embedded, pure-Go storage (no cgo → clean static Docker builds).
if err := os.MkdirAll(dataDir, 0o755); err != nil {
log.Fatalf("cannot create data dir: %v", err)
}
// Relay identity: signs the 39000-series group metadata/roster events. A
// stable key is required so those signatures survive restarts (see below).
sk := loadOrGenerateKey(dataDir)
store := &boltdb.BoltBackend{Path: filepath.Join(dataDir, "events.bolt")}
if err := store.Init(); err != nil {
log.Fatalf("cannot open eventstore: %v", err)
}
// Parse optional federation config up front so warnings surface at boot.
fedCfg, fedWarnings := federation.ParseConfig(os.Getenv("RELAY_FEDERATION_PEERS"))
for _, w := range fedWarnings {
log.Printf("federation config: %s", w)
}
app := buildRelay(relayConfig{
secretKey: sk,
store: store,
name: envOr("RELAY_NAME", "Nosterm Home Relay"),
description: envOr("RELAY_DESCRIPTION", "A Nosterm NIP-29 managed-group relay."),
// NIP-11 contact: a NIP-05-style identifier / address for the relay operator.
contact: envOr("RELAY_CONTACT", "relay@nosterm.com"),
// Optional message-of-the-day shown in the client's relay server window;
// defaults to the nerdworks.io banner when unset.
motd: envOr("RELAY_MOTD", defaultMOTD),
fedCfg: fedCfg,
})
// Reconstruct membership/metadata from persisted events so groups survive
// restarts (without this, every restart silently drops all group state).
if replayed := app.groups.Rebuild(store); replayed > 0 {
log.Printf("replayed %d group management event(s) from storage", replayed)
}
// Retention: prune old chat messages so the store doesn't grow forever.
stopRetention := retention.Start(store, retention.Config{
MaxAge: time.Duration(envIntOr("RELAY_RETENTION_DAYS", 0)) * 24 * time.Hour,
MaxMessages: envIntOr("RELAY_RETENTION_MAX_MESSAGES", 0),
Interval: time.Duration(envIntOr("RELAY_RETENTION_INTERVAL_MINUTES", 60)) * time.Minute,
})
defer stopRetention()
// Start federation last, once the relay pipeline is fully wired.
if app.federator != nil {
app.federator.Start()
defer app.federator.Stop()
}
log.Printf("nostermd pubkey: %s", app.pubKey.Hex())
log.Printf("listening on %s (%s; %s)", addr, app.groups.Summary(), fedCfg.Summary())
if err := http.ListenAndServe(addr, app.handler); err != nil {
log.Fatal(err)
}
}
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"context"
"encoding/json"
"net/http"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
"fiatjaf.com/nostr/khatru"
"github.com/nosterm/relay/internal/federation"
"github.com/nosterm/relay/internal/group"
"github.com/nosterm/relay/internal/relayinfo"
)
// relayConfig holds everything buildRelay needs to assemble a relay. It is the
// seam between env/flag parsing (main) and the wiring (buildRelay), so tests can
// construct a relay with an in-memory store and explicit config.
type relayConfig struct {
secretKey nostr.SecretKey
store eventstore.Store
name string
description string
contact string
motd string
fedCfg federation.Config
}
// relayApp is a fully-wired relay: the khatru relay, its HTTP handler, the group
// state authority, and (when configured) the federator. main starts/stops these;
// tests assert against them.
type relayApp struct {
relay *khatru.Relay
handler http.Handler
groups *group.State
federator *federation.Federator
pubKey nostr.PubKey
}
// buildRelay assembles the relay from its internal packages and wires khatru's
// hooks: NIP-29 enforcement on ingress, addressable-list (re)publishing on
// group mutations, federation ingress/egress, and NIP-11 serving with the
// Nosterm capability handshake. It does NOT rebuild state, start retention,
// or start federation — the caller owns lifecycle so tests can drive it.
func buildRelay(cfg relayConfig) *relayApp {
pk := nostr.GetPublicKey(cfg.secretKey)
groups := group.NewState(cfg.secretKey)
if cfg.fedCfg.Enabled() {
// Chat on a federated channel is accepted regardless of local membership.
groups.IsFederated = cfg.fedCfg.FederatesChannel
}
relay := khatru.NewRelay()
relay.Info.Name = cfg.name
relay.Info.Description = cfg.description
relay.Info.Contact = cfg.contact
relay.Info.PubKey = &pk
relay.Info.SupportedNIPs = []any{1, 11, 29, 42}
relay.UseEventstore(cfg.store, 500)
// Enforce NIP-29 group rules on every write.
relay.OnEvent = func(ctx context.Context, evt nostr.Event) (reject bool, msg string) {
return groups.Evaluate(evt)
}
// Federator: connects to peers, pulls their chat (injecting it through the
// relay's own add pipeline — whose eventstore dedup breaks forwarding loops),
// and forwards locally-saved chat to mirror peers via the OnEventSaved hook.
var fed *federation.Federator
if cfg.fedCfg.Enabled() {
fed = federation.NewFederator(cfg.fedCfg, func(ctx context.Context, evt nostr.Event) bool {
// Ensure the channel exists locally so federated chat has a home, then
// run it through the normal pipeline (OnEvent → store → OnEventSaved).
// AddEvent returns skipBroadcast=true on a duplicate: that is our loop
// guard — a re-seen event never reaches the egress hook again.
groups.EnsureFederatedGroup(group.GroupIDFromEvent(evt))
skip, err := relay.AddEvent(ctx, evt)
if err != nil || skip {
return false
}
// AddEvent stores but does not broadcast; deliver to local subscribers.
relay.BroadcastEvent(evt)
return true
})
}
// After a group-mutating event is stored, (re)publish the affected NIP-29
// addressable lists (39000 metadata, 39001 admins, 39002 members) so clients
// can discover the channel and track who may moderate it.
publish := func(evt nostr.Event, ok bool) {
if ok {
relay.BroadcastEvent(evt)
_ = cfg.store.SaveEvent(evt)
}
}
relay.OnEventSaved = func(ctx context.Context, evt nostr.Event) {
id := group.GroupIDFromEvent(evt)
if id == "" {
return
}
switch evt.Kind {
case nostr.KindSimpleGroupCreateGroup:
publish(groups.MetadataEvent(ctx, id))
publish(groups.AdminsEvent(id))
publish(groups.MembersEvent(id))
case nostr.KindSimpleGroupEditMetadata:
publish(groups.MetadataEvent(ctx, id))
case nostr.KindSimpleGroupJoinRequest,
nostr.KindSimpleGroupLeaveRequest,
nostr.KindSimpleGroupPutUser,
nostr.KindSimpleGroupRemoveUser:
// Membership (and possibly admin) changed → refresh both lists.
publish(groups.AdminsEvent(id))
publish(groups.MembersEvent(id))
case nostr.KindSimpleGroupChatMessage, nostr.KindSimpleGroupThreadedReply:
// Egress: forward newly-saved chat to mirror peers. A chat event
// re-injected from a peer is a store duplicate and never reaches here
// (AddEvent stops on dedup before OnEventSaved), so this can't loop.
if fed != nil {
fed.Forward(evt)
}
}
}
// Serve NIP-11 ourselves so we can add the nostermd `features` array
// (the upstream document type has no field for it), then dispatch the real
// protocol traffic to khatru's specific handlers. We must NOT call
// relay.ServeHTTP here: khatru's ServeHTTP dispatches through this same mux,
// so routing "/" back into it recurses infinitely (stack overflow on the
// first non-NIP-11 request). Call the concrete handlers directly instead.
mux := relay.Router()
federated := cfg.fedCfg.Enabled()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Header.Get("Accept") == "application/nostr+json":
serveNIP11(w, cfg.name, cfg.description, cfg.contact, cfg.motd, pk, federated)
case r.Header.Get("Upgrade") == "websocket":
relay.HandleWebsocket(w, r)
case r.Header.Get("Content-Type") == "application/nostr+json+rpc":
relay.HandleNIP86(w, r)
default:
http.Error(w, "expected a nostr relay connection (WebSocket) or NIP-11 request", http.StatusUpgradeRequired)
}
})
return &relayApp{
relay: relay,
handler: mux,
groups: groups,
federator: fed,
pubKey: pk,
}
}
// serveNIP11 emits a standard NIP-11 document merged with the Nosterm handshake
// keys (software/version/features), plus an optional `motd` the client shows in
// the relay's server window.
func serveNIP11(w http.ResponseWriter, name, description, contact, motd string, pk nostr.PubKey, federated bool) {
doc := map[string]any{
"name": name,
"description": description,
"contact": contact,
"pubkey": pk.Hex(),
"supported_nips": []int{1, 11, 29, 42},
}
if motd != "" {
doc["motd"] = motd
}
for k, v := range relayinfo.Extras(federated) {
doc[k] = v
}
w.Header().Set("Content-Type", "application/nostr+json")
w.Header().Set("Access-Control-Allow-Origin", "*")
_ = json.NewEncoder(w).Encode(doc)
}
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"context"
"net/http/httptest"
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
"github.com/nosterm/relay/internal/federation"
)
// startTestRelay spins up a fully-wired relay backed by an in-memory store,
// federating per peersSpec (empty = no federation). It returns the relay's ws://
// URL. The federator, if any, is started and cleaned up automatically. This is
// the integration seam: it exercises exactly the wiring buildRelay produces for
// main(), over a real in-process khatru websocket server.
func startTestRelay(t *testing.T, peersSpec string) string {
t.Helper()
store := &slicestore.SliceStore{}
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
fedCfg, _ := federation.ParseConfig(peersSpec)
app := buildRelay(relayConfig{
secretKey: nostr.Generate(),
store: store,
name: "test-relay",
fedCfg: fedCfg,
})
srv := httptest.NewServer(app.handler)
t.Cleanup(srv.Close)
if app.federator != nil {
app.federator.Start()
t.Cleanup(app.federator.Stop)
}
return "ws" + srv.URL[len("http"):]
}
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) }
func TestGroupJoinSendReceive(t *testing.T) {
url := startTestRelay(t, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Two independent identities: alice writes, bob reads.
aliceSK := nostr.Generate()
bobSK := nostr.Generate()
const groupID = "general"
// Bob connects and subscribes to the group's chat messages first.
bob, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
t.Fatalf("bob connect: %v", err)
}
sub, err := bob.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage},
Tags: nostr.TagMap{"h": []string{groupID}},
}, nostr.SubscriptionOptions{})
if err != nil {
t.Fatalf("bob subscribe: %v", err)
}
// Alice connects, joins the group, then sends a chat message.
alice, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
t.Fatalf("alice connect: %v", err)
}
join := nostr.Event{
Kind: nostr.KindSimpleGroupJoinRequest,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{{"h", groupID}},
}
mustSign(t, aliceSK, &join)
if err := alice.Publish(ctx, join); err != nil {
t.Fatalf("alice join publish: %v", err)
}
msg := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
CreatedAt: nostr.Now(),
Content: "hello nosterm relay",
Tags: nostr.Tags{{"h", groupID}},
}
mustSign(t, aliceSK, &msg)
if err := alice.Publish(ctx, msg); err != nil {
t.Fatalf("alice message publish: %v", err)
}
// Bob must receive alice's message via the subscription (cross-user).
select {
case got := <-sub.Events:
if got.Content != "hello nosterm relay" {
t.Fatalf("unexpected content: %q", got.Content)
}
if got.PubKey != nostr.GetPublicKey(aliceSK) {
t.Fatalf("unexpected author")
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the group message")
}
_ = bobSK // bob reads without needing to sign in this open-group MVP
}
// End-to-end over a real websocket: an admin and two members interact, and the
// relay rejects a non-admin's moderation attempt on the wire.
//
// NOTE: run the suite without -race. go-nostr's unsafe-based JSON serializer
// trips the race detector's checkptr under this test's concurrent signing; it
// is an upstream library issue, not a data race in the relay. The relay's own
// group-state locking is race-clean — verify with:
//
// go test ./internal/group/ -race
func TestMultiUserAdminOverWebsocket(t *testing.T) {
url := startTestRelay(t, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
admin := nostr.Generate()
alice := nostr.Generate()
const g = "ops"
adminConn, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
t.Fatalf("admin connect: %v", err)
}
aliceConn, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
t.Fatalf("alice connect: %v", err)
}
publish := func(conn *nostr.Relay, sk nostr.SecretKey, kind nostr.Kind, tags nostr.Tags) error {
evt := nostr.Event{Kind: kind, CreatedAt: nostr.Now(), Tags: tags}
mustSign(t, sk, &evt)
return conn.Publish(ctx, evt)
}
// Admin creates a closed group.
if err := publish(adminConn, admin, nostr.KindSimpleGroupCreateGroup, nostr.Tags{{"h", g}, {"closed"}}); err != nil {
t.Fatalf("create closed group: %v", err)
}
// Alice (not yet a member) cannot post — the relay rejects it on the wire.
if err := publish(aliceConn, alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}}); err == nil {
t.Fatal("expected the relay to reject a non-member's post to a closed group")
}
// Alice cannot moderate (add herself) — she is not an admin.
if err := publish(aliceConn, alice, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}}); err == nil {
t.Fatal("expected the relay to reject a non-admin moderation event")
}
// The admin admits alice.
if err := publish(adminConn, admin, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}}); err != nil {
t.Fatalf("admin add user: %v", err)
}
// Alice can now post, and the admin — subscribed — receives it.
sub, err := adminConn.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage},
Tags: nostr.TagMap{"h": []string{g}},
}, nostr.SubscriptionOptions{})
if err != nil {
t.Fatalf("admin subscribe: %v", err)
}
if err := publish(aliceConn, alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}}); err != nil {
t.Fatalf("member post after admission should succeed: %v", err)
}
select {
case got := <-sub.Events:
if got.PubKey != hpk(alice) {
t.Fatal("admin received a message from an unexpected author")
}
case <-time.After(5 * time.Second):
t.Fatal("admin did not receive the admitted member's message")
}
}