Merge pull request 'seed default channel(s) on boot' (#1) from feat/default-channels into main
ci / build (push) Successful in 18s
ci / image (push) Successful in 1m6s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-25 12:23:14 +00:00
7 changed files with 220 additions and 15 deletions
+4
View File
@@ -21,6 +21,10 @@ RELAY_CONTACT=relay@nosterm.com
# the built-in "nerdworks.io" ascii banner. # the built-in "nerdworks.io" ascii banner.
RELAY_MOTD= RELAY_MOTD=
# Channels seeded as open groups on boot so a fresh relay is never empty.
# Comma-separated group ids (leading '#' tolerated). Unset = "general".
RELAY_DEFAULT_CHANNELS=general
# Retention (chat messages only; membership/metadata are never pruned). # Retention (chat messages only; membership/metadata are never pruned).
# 0 = unlimited. # 0 = unlimited.
RELAY_RETENTION_DAYS=0 RELAY_RETENTION_DAYS=0
+86 -2
View File
@@ -9,6 +9,84 @@ Nosterm client can feature-gate its UI and fall back gracefully on plain relays.
This is the "home relay" in Nosterm's hybrid model: the client connects to this relay for This is the "home relay" in Nosterm's hybrid model: the client connects to this relay for
its own communities while still connecting to public relays for general Nostr content. its own communities while still connecting to public relays for general Nostr content.
## Mission
nostermd aims to be the premier self-hosted **home relay** for the Nosterm
[Nostr Relay Chat (NRC)](https://git.nerdworks.io/nerdworks/nosterm-client) client — a
complete, standards-based **NIP-29** group-chat backend anyone can run. We believe private
communication is a right, not a feature, so the relay is built to advance **anonymity,
end-to-end encryption, and censorship resistance** by default rather than as opt-in extras.
While we build features specifically to support the Nosterm client — the Nosterm capability
handshake being the first — we are committed to **standards over lock-in**. We are more than
happy to fold our developments into existing NIPs (or propose new ones) wherever the
protocol can absorb them, so that what we build stays interoperable with the wider Nostr
ecosystem rather than fragmenting into a Nosterm-only dialect.
Three principles guide every decision:
- **Small footprint.** A single pure-Go static binary with no cgo, running on a
`scratch`/distroless image. It should stay auditable, fast to start, and cheap to
self-host — no telemetry, no lock-in, no heavyweight dependencies.
- **Easy to deploy, anywhere.** One prebuilt image, configured at runtime, that drops
cleanly onto different **overlay network protocols** (Tor, I2P, Yggdrasil, Nym, and
WireGuard-style meshes) so operators can run it wherever they trust the transport.
- **Privacy as the default.** Anonymity, encryption, and anti-censorship are the baseline
behavior. The relay should make the private path the easy path for the client that talks
to it.
## Roadmap
The roadmap turns the mission into concrete direction. Items are aspirational and
unordered — contributions that move any of them forward are welcome.
- **Full NIP-29 implementation** — grow beyond the MVP subset into complete relay-based
groups: private/closed groups, roles, invites, the 39001/39002 admin/member lists, and
richer moderation so a self-hosted relay is a complete NRC backend.
- **Overlay-network first** — first-class, documented deployment recipes for Tor hidden
services, I2P, and other overlay transports, keeping the same single runtime-configured
image.
- **Encryption everywhere** — support modern encrypted group and direct messaging (e.g.
NIP-17 / gift-wrapped events) end to end, with no plaintext fallbacks that surprise
operators or their users.
- **Anonymity by design** — minimize metadata retained and leaked, support ephemeral and
disposable identities, and avoid any relay behavior that fingerprints or phones home.
- **Censorship resistance** — deepen **federation** so a channel survives any single relay
disappearing and can be distributed across operators, with graceful reconnection and
replay.
- **Client-driven, standards-first** — build the features the Nosterm client needs, but
upstream them: contribute our extensions back into existing NIPs (or propose new ones)
rather than growing a Nosterm-only dialect, keeping the relay useful to any Nostr client.
- **Stay small** — hold the line on binary size, dependency count, and container footprint
as features grow.
## Why a separate repository
The Nosterm client and the nostermd relay ship as two independent repositories — and two
independent container images — on purpose. Nostr is a client-side protocol: the browser
talks directly to relays over WebSocket, so there is no shared backend binding the two
together. Keeping them separate keeps that boundary honest and the architecture **modular**:
- **Mix and match.** The client is just a Nostr client — it works against any relay, not
only this one. The relay is just a Nostr relay — it serves any NIP-29 client, not only
Nosterm. Neither depends on the other's internals, so you can run one without the other.
- **Deploy each where it belongs.** A static SPA served by Caddy and a stateful Go relay
with a BoltDB store have different runtime, scaling, and persistence needs. Separate
images let operators place, scale, and secure each independently — a shared home relay
behind one client, one relay per client, many clients against a public relay, or the
same-origin `/relay` proxy setup.
- **Standards over coupling.** Because the split forces everything across the boundary to
travel as plain Nostr events, it keeps us honest about being **standards-first** (see
Mission) — the relay can never quietly grow a private, client-only channel that a
non-Nosterm client couldn't use.
- **Independent lifecycles.** Each repo has its own issues, releases, and contribution flow.
A relay change ships without rebuilding the client and vice versa, so the two can evolve
(and be audited) at their own pace.
If you want the batteries-included experience, the client's Caddy service can proxy `/relay`
to this relay on a shared Docker network — modular pieces, one deployment. But that's a
composition choice at deploy time, not a coupling baked into the code.
## What it does ## What it does
- Serves NIP-01/11/42 via khatru, with pure-Go [BoltDB](https://github.com/etcd-io/bbolt) - Serves NIP-01/11/42 via khatru, with pure-Go [BoltDB](https://github.com/etcd-io/bbolt)
@@ -25,6 +103,9 @@ its own communities while still connecting to public relays for general Nostr co
| 39000 | group metadata | (re)published + signed by the relay | | 39000 | group metadata | (re)published + signed by the relay |
- Advertises `software: "nostermd"` and `features: ["channels"]` in NIP-11 (adds - Advertises `software: "nostermd"` and `features: ["channels"]` in NIP-11 (adds
`"federation"` when any federation peer is configured). `"federation"` when any federation peer is configured).
- **Seeds a default channel on boot** so a fresh relay is never empty — a client always has
at least one open room (`#general` by default) to join. Configure with
`RELAY_DEFAULT_CHANNELS`.
- **Optional federation** — mirror/ingest chat with peer relays (see below). - **Optional federation** — mirror/ingest chat with peer relays (see below).
> **MVP scope.** Groups are auto-created as **open** on first join so the client flow works > **MVP scope.** Groups are auto-created as **open** on first join so the client flow works
@@ -108,6 +189,7 @@ can proxy `/relay` to this relay on a shared Docker network).
| `RELAY_CONTACT` | `relay@nosterm.com` | NIP-11 contact (NIP-05-style operator address) | | `RELAY_CONTACT` | `relay@nosterm.com` | NIP-11 contact (NIP-05-style operator address) |
| `RELAY_MOTD` | _(nerdworks.io banner)_ | Message-of-the-day shown in the client's relay server window (defaults to the built-in ascii banner) | | `RELAY_MOTD` | _(nerdworks.io banner)_ | Message-of-the-day shown in the client's relay server window (defaults to the built-in ascii banner) |
| `RELAY_ENV` | _(dev)_ | Set to `production` to require a stable key (fail-fast if unset) | | `RELAY_ENV` | _(dev)_ | Set to `production` to require a stable key (fail-fast if unset) |
| `RELAY_DEFAULT_CHANNELS` | `general` | Comma-separated channel ids seeded as open groups on boot (leading `#` ok) |
| `RELAY_SECRET_KEY` | _(dev: persisted)_ | 64-char hex identity that signs group metadata/rosters | | `RELAY_SECRET_KEY` | _(dev: persisted)_ | 64-char hex identity that signs group metadata/rosters |
| `RELAY_RETENTION_DAYS` | `0` | Prune chat messages older than N days (0 = unlimited) | | `RELAY_RETENTION_DAYS` | `0` | Prune chat messages older than N days (0 = unlimited) |
| `RELAY_RETENTION_MAX_MESSAGES` | `0` | Keep at most N chat messages, newest first (0 = unlimited) | | `RELAY_RETENTION_MAX_MESSAGES` | `0` | Keep at most N chat messages, newest first (0 = unlimited) |
@@ -201,8 +283,10 @@ Tests live beside the package they cover:
- `cmd/nostermd` — integration over a real in-process khatru server: **relay-key - `cmd/nostermd` — integration over a real in-process khatru server: **relay-key
handling** (explicit key used verbatim; dev key persisted and stable across restarts), a handling** (explicit key used verbatim; dev key persisted and stable across restarts), a
**cross-user** WebSocket round-trip (one client joins and publishes, another receives via **cross-user** WebSocket round-trip (one client joins and publishes, another receives via
subscription), and two end-to-end **federation** two-relay tests (a message ingested across subscription), **default-channel seeding** (a fresh relay seeds its configured open
relays, and a mirrored post delivered exactly once despite the echo path). channels, is idempotent, and never reopens a pre-existing closed channel), and two
end-to-end **federation** two-relay tests (a message ingested across relays, and a mirrored
post delivered exactly once despite the echo path).
> Run without `-race`: go-nostr's `unsafe`-based JSON serializer trips the race detector's > Run without `-race`: go-nostr's `unsafe`-based JSON serializer trips the race detector's
> `checkptr` during any event signing (an upstream library issue, not a relay data race). The > `checkptr` during any event signing (an upstream library issue, not a relay data race). The
+17
View File
@@ -5,10 +5,27 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"fiatjaf.com/nostr" "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 { func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
+7
View File
@@ -10,6 +10,7 @@
package main package main
import ( import (
"context"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -64,6 +65,8 @@ func main() {
// Optional message-of-the-day shown in the client's relay server window; // Optional message-of-the-day shown in the client's relay server window;
// defaults to the nerdworks.io banner when unset. // defaults to the nerdworks.io banner when unset.
motd: envOr("RELAY_MOTD", defaultMOTD), 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, fedCfg: fedCfg,
}) })
@@ -73,6 +76,10 @@ func main() {
log.Printf("replayed %d group management event(s) from storage", replayed) 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. // Retention: prune old chat messages so the store doesn't grow forever.
stopRetention := retention.Start(store, retention.Config{ stopRetention := retention.Start(store, retention.Config{
MaxAge: time.Duration(envIntOr("RELAY_RETENTION_DAYS", 0)) * 24 * time.Hour, MaxAge: time.Duration(envIntOr("RELAY_RETENTION_DAYS", 0)) * 24 * time.Hour,
+37
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
@@ -24,6 +25,9 @@ type relayConfig struct {
description string description string
contact string contact string
motd 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 fedCfg federation.Config
} }
@@ -36,6 +40,8 @@ type relayApp struct {
groups *group.State groups *group.State
federator *federation.Federator federator *federation.Federator
pubKey nostr.PubKey pubKey nostr.PubKey
secretKey nostr.SecretKey
defaultChannels []string
} }
// buildRelay assembles the relay from its internal packages and wires khatru's // buildRelay assembles the relay from its internal packages and wires khatru's
@@ -151,6 +157,37 @@ func buildRelay(cfg relayConfig) *relayApp {
groups: groups, groups: groups,
federator: fed, federator: fed,
pubKey: pk, 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") 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")
}
}
+8
View File
@@ -267,6 +267,14 @@ func (gs *State) Evaluate(evt nostr.Event) (reject bool, msg string) {
return false, "" return false, ""
} }
// Has reports whether a group with the given id is tracked.
func (gs *State) Has(id string) bool {
gs.mu.RLock()
defer gs.mu.RUnlock()
_, ok := gs.groups[id]
return ok
}
// IsMember reports whether pk is a member of the group. // IsMember reports whether pk is a member of the group.
func (gs *State) IsMember(id string, pk nostr.PubKey) bool { func (gs *State) IsMember(id string, pk nostr.PubKey) bool {
gs.mu.RLock() gs.mu.RLock()