Files
nostermd-relay/cmd/nostermd/relay.go
T
Robert Goodall 8e9235a820
ci / build (pull_request) Successful in 23s
ci / image (pull_request) Skipped
seed default channel(s) on boot
fresh relay seeds configured open channels (RELAY_DEFAULT_CHANNELS,
default general) so a client always has a room. relay-signed 9007
through the normal pipeline; idempotent; never reopens a closed group.

also adds mission/roadmap and separation rationale to the readme.
2026-07-25 08:11:39 -04:00

215 lines
7.8 KiB
Go

package main
import (
"context"
"encoding/json"
"log"
"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
// defaultChannels are group ids seeded as open channels on boot so a fresh
// relay is never empty — a client always has at least one room to join.
defaultChannels []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
secretKey nostr.SecretKey
defaultChannels []string
}
// 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,
secretKey: cfg.secretKey,
defaultChannels: cfg.defaultChannels,
}
}
// SeedDefaultChannels creates any configured default channel that doesn't
// already exist, so a fresh relay always has at least one room to join. Each
// missing channel is created by the relay identity via a signed kind-9007
// create-group event pushed through the normal add pipeline (OnEvent → store →
// OnEventSaved), so it persists, replays on restart, and publishes its 39000/
// 39001/39002 lists exactly like a client-created group. Existing channels are
// left untouched. Call after Rebuild so it only seeds what's genuinely missing.
func (app *relayApp) SeedDefaultChannels(ctx context.Context) {
for _, id := range app.defaultChannels {
if id == "" || app.groups.Has(id) {
continue
}
evt := nostr.Event{
Kind: nostr.KindSimpleGroupCreateGroup,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{{"h", id}},
}
if err := evt.Sign(app.secretKey); err != nil {
log.Printf("could not sign default-channel create for %q: %v", id, err)
continue
}
if _, err := app.relay.AddEvent(ctx, evt); err != nil {
log.Printf("could not seed default channel %q: %v", id, err)
continue
}
log.Printf("seeded default channel %q", id)
}
}
// 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)
}