Merge pull request 'seed default channel(s) on boot' (#1) from feat/default-channels into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -21,6 +21,10 @@ RELAY_CONTACT=relay@nosterm.com
|
||||
# the built-in "nerdworks.io" ascii banner.
|
||||
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).
|
||||
# 0 = unlimited.
|
||||
RELAY_RETENTION_DAYS=0
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- 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 |
|
||||
- Advertises `software: "nostermd"` and `features: ["channels"]` in NIP-11 (adds
|
||||
`"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).
|
||||
|
||||
> **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_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_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_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) |
|
||||
@@ -201,8 +283,10 @@ Tests live beside the package they cover:
|
||||
- `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
|
||||
**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
|
||||
relays, and a mirrored post delivered exactly once despite the echo path).
|
||||
subscription), **default-channel seeding** (a fresh relay seeds its configured open
|
||||
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
|
||||
> `checkptr` during any event signing (an upstream library issue, not a relay data race). The
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -64,6 +65,8 @@ func main() {
|
||||
// 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),
|
||||
// Seed at least one open channel so a fresh relay is never empty.
|
||||
defaultChannels: parseChannels(envOr("RELAY_DEFAULT_CHANNELS", "general")),
|
||||
fedCfg: fedCfg,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
@@ -24,6 +25,9 @@ type relayConfig struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -36,6 +40,8 @@ type relayApp struct {
|
||||
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
|
||||
@@ -151,6 +157,37 @@ func buildRelay(cfg relayConfig) *relayApp {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,14 @@ func (gs *State) Evaluate(evt nostr.Event) (reject bool, msg string) {
|
||||
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.
|
||||
func (gs *State) IsMember(id string, pk nostr.PubKey) bool {
|
||||
gs.mu.RLock()
|
||||
|
||||
Reference in New Issue
Block a user