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) }