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