seed default channel(s) on boot
ci / build (pull_request) Successful in 23s
ci / image (pull_request) Skipped

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.
This commit is contained in:
Robert Goodall
2026-07-25 08:11:39 -04:00
parent 294616642f
commit 8e9235a820
7 changed files with 220 additions and 15 deletions
+17
View File
@@ -5,10 +5,27 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"fiatjaf.com/nostr"
)
// parseChannels splits a comma-separated channel list into cleaned group ids: a
// leading `#` is tolerated and blanks/dupes are dropped. Order is preserved.
func parseChannels(s string) []string {
var out []string
seen := map[string]bool{}
for _, part := range strings.Split(s, ",") {
id := strings.TrimPrefix(strings.TrimSpace(part), "#")
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
+9 -2
View File
@@ -10,6 +10,7 @@
package main
import (
"context"
"log"
"net/http"
"os"
@@ -63,8 +64,10 @@ func main() {
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,
motd: envOr("RELAY_MOTD", defaultMOTD),
// Seed at least one open channel so a fresh relay is never empty.
defaultChannels: parseChannels(envOr("RELAY_DEFAULT_CHANNELS", "general")),
fedCfg: fedCfg,
})
// Reconstruct membership/metadata from persisted events so groups survive
@@ -73,6 +76,10 @@ func main() {
log.Printf("replayed %d group management event(s) from storage", replayed)
}
// Seed default channel(s) after replay so a fresh relay always has a room to
// join; existing channels are left untouched.
app.SeedDefaultChannels(context.Background())
// 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,
+48 -11
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"log"
"net/http"
"fiatjaf.com/nostr"
@@ -24,18 +25,23 @@ type relayConfig struct {
description string
contact string
motd string
fedCfg federation.Config
// 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
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
@@ -146,11 +152,42 @@ func buildRelay(cfg relayConfig) *relayApp {
})
return &relayApp{
relay: relay,
handler: mux,
groups: groups,
federator: fed,
pubKey: pk,
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)
}
}
+48
View File
@@ -192,3 +192,51 @@ func TestMultiUserAdminOverWebsocket(t *testing.T) {
t.Fatal("admin did not receive the admitted member's message")
}
}
// SeedDefaultChannels creates configured channels that don't exist yet, signed
// by the relay, and is idempotent: seeding an already-present channel is a
// no-op. A seeded open channel accepts chat without an explicit create.
func TestSeedDefaultChannels(t *testing.T) {
store := &slicestore.SliceStore{}
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
app := buildRelay(relayConfig{
secretKey: nostr.Generate(),
store: store,
name: "test-relay",
defaultChannels: []string{"general", "general", ""}, // dupes/blanks ignored
})
ctx := context.Background()
app.SeedDefaultChannels(ctx)
if !app.groups.Has("general") {
t.Fatal("expected default channel 'general' to be seeded")
}
// The seed event is signed by the relay identity.
if !app.groups.IsAdmin("general", app.pubKey) {
t.Fatal("expected the relay to be admin of the seeded channel")
}
// Idempotent: a second seed doesn't error or duplicate.
app.SeedDefaultChannels(ctx)
// Seeding never clobbers an existing channel: pre-create one closed, then a
// seed of the same id must leave it closed.
closed := nostr.Event{Kind: nostr.KindSimpleGroupCreateGroup, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", "ops"}, {"closed"}}}
mustSign(t, app.secretKey, &closed)
if _, err := app.relay.AddEvent(ctx, closed); err != nil {
t.Fatalf("pre-create closed group: %v", err)
}
app.defaultChannels = []string{"ops"}
app.SeedDefaultChannels(ctx)
// A non-member posting to the still-closed 'ops' must be rejected.
stranger := nostr.Generate()
if reject, _ := app.groups.Evaluate(func() nostr.Event {
e := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", "ops"}}}
mustSign(t, stranger, &e)
return e
}()); !reject {
t.Fatal("seed must not reopen a pre-existing closed channel")
}
}