# nostermd A minimal **NIP-29 managed-group** relay for [Nosterm](https://www.nosterm.com), built on [`khatru`](https://fiatjaf.com/nostr/khatru) (part of the `fiatjaf.com/nostr` monorepo), plus the **Nosterm capability handshake** — a `features` array in its NIP-11 document so a 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. ## What it does - Serves NIP-01/11/42 via khatru, with pure-Go [BoltDB](https://github.com/etcd-io/bbolt) storage (no cgo → a fully static binary that runs on `scratch`/distroless). - Enforces a focused subset of **NIP-29 managed groups**: | Kind | Event | Behavior | | ----- | ---------------- | ------------------------------------------- | | 9007 | create group | registers a group | | 9021 | join request | adds the sender as a member (auto-creates) | | 9022 | leave request | removes the sender | | 9000 | put user (admin) | admin adds tagged members | | 9001 | remove user | admin removes tagged members | | 9/10 | chat / reply | accepted for members (open groups: anyone) | | 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). - **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 > without an invite system. Membership is authoritative in-memory; richer moderation, > private/closed groups, roles, and the 39001/39002 admin/member lists are future work. ## Project layout Standard Go layout — the command is a thin composition root over focused, independently-testable packages: ``` nostermd/ cmd/nostermd/ # main: env config, key loading, khatru wiring, NIP-11 serving internal/ group/ # NIP-29 managed-group state machine (membership, admin, metadata) federation/ # optional chat mirroring/ingest with peer relays retention/ # background pruning of old chat messages relayinfo/ # Nosterm capability handshake (NIP-11 features array) Dockerfile # static binary → distroless nonroot image docker-compose.yml ``` ## Run it ```bash # Local (Go 1.25+) go run ./cmd/nostermd # listens on :3334, ephemeral identity + ./data # Configurable via env RELAY_ADDR=:3334 \ RELAY_DATA_DIR=./data \ RELAY_NAME="My Relay" \ RELAY_SECRET_KEY=<64-char-hex> \ # stable identity (signs group metadata) go run ./cmd/nostermd # Docker (single image) docker build -t nostermd . docker run -p 3334:3334 -v nostermd-data:/data nostermd # Or pull the prebuilt public image (no auth): docker run -p 3334:3334 -v nostermd-data:/data public.ecr.aws/k3k1z1x5/nostermd:latest ``` Merges to `main` build and publish that image to ECR Public; `v*` tags publish a matching versioned image. This repo builds the image only — it does not deploy. ### Docker Compose (recommended) ```bash cp .env.example .env # For production, generate a stable signing identity and set RELAY_ENV: echo "RELAY_SECRET_KEY=$(openssl rand -hex 32)" >> .env echo "RELAY_ENV=production" >> .env docker compose up -d --build ``` The relay then listens on `ws://localhost:3334` and persists its BoltDB event store to a named volume so rooms/messages survive restarts. Point a Nosterm client at it via `PUBLIC_DEFAULT_RELAYS` — see the [nosterm-client](https://git.nerdworks.io/nerdworks/nosterm-client) repository. ### Production TLS The relay speaks plain WebSocket on `:3334`. For public `wss://` access, put it behind a TLS-terminating reverse proxy (e.g. Caddy, or the [nosterm-client](https://git.nerdworks.io/nerdworks/nosterm-client) Caddy service, which can proxy `/relay` to this relay on a shared Docker network). ## Environment | Variable | Default | Meaning | | --------------------------------- | -------------------- | -------------------------------------------------------------- | | `RELAY_ADDR` | `:3334` | Listen address | | `RELAY_DATA_DIR` | `./data` | BoltDB storage directory | | `RELAY_NAME` | `Nosterm Home Relay` | NIP-11 name | | `RELAY_DESCRIPTION` | _(default text)_ | NIP-11 description | | `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_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) | | `RELAY_RETENTION_INTERVAL_MINUTES`| `60` | How often the retention sweep runs | | `RELAY_FEDERATION_PEERS` | _(empty: disabled)_ | Peer relays to mirror/ingest chat with (see Federation) | ### Relay identity (`RELAY_SECRET_KEY`) The relay's key signs the group metadata/admins/members events (39000–39002). A **stable** identity is required so those signatures stay valid across restarts: - **Production** (`RELAY_ENV=production`): `RELAY_SECRET_KEY` is **required** — the relay refuses to start without it. Generate one with `openssl rand -hex 32`. - **Development**: if unset, a key is generated and **persisted** to `$RELAY_DATA_DIR/relay.key` (mode `0600`) and reused on every restart, so even local dev keeps one identity. ### Retention By default nothing is pruned. Set `RELAY_RETENTION_DAYS` and/or `RELAY_RETENTION_MAX_MESSAGES` to cap growth. Retention **only** deletes chat messages (kinds 9/10); group-management and metadata events (9007/9000/9001/9021/9022, 39000–39002) are never pruned so membership always survives and can be replayed on boot. ## Federation (optional) Federation lets a channel live on more than one relay, so it survives any single relay going down and can be distributed across operators. It is **off by default** and **chat-only** by design: only kind-9/10 messages cross the relay boundary. Membership, admins, bans, and metadata stay **local to each relay** — every operator moderates their own instance, which sidesteps cross-relay moderation conflicts entirely. Configure peers with `RELAY_FEDERATION_PEERS`. Entries are separated by `;`; each is: ``` [|:] ``` - **mode** — `mirror` (bidirectional: chat posted on either relay is forwarded to the other) or `ingest` (read-only: pull chat _from_ the peer, never push back). Defaults to `mirror`. - **channels** — a comma-separated list of group ids, or `*` for all. Defaults to `*`. A leading `#` is tolerated. Examples: ```bash # Mirror two channels with peer A, ingest everything from peer B (read-only): RELAY_FEDERATION_PEERS="wss://a.example|mirror:general,dev;wss://b.example|ingest:*" # Read-only announcement feed pulled from an upstream relay: RELAY_FEDERATION_PEERS="wss://announce.example|ingest:announcements" # Simplest: fully mirror every channel with one peer: RELAY_FEDERATION_PEERS="wss://peer.example" ``` **How it works.** The relay opens a client subscription to each peer for the federated chat kinds and injects received events through its own add pipeline (`OnEvent` → store → `OnEventSaved`), then broadcasts them to local subscribers. Locally-saved chat is forwarded to `mirror` peers from the `OnEventSaved` hook. Incoming events are **re-verified** (a peer can't inject forged messages) and scoped to the configured channels. **Loop prevention is structural, not tag-based.** A re-received event is a duplicate in the eventstore, and `AddEvent` stops on `ErrDupEvent` _before_ the egress hook runs — so an event is never forwarded twice and `A↔B↔A` cycles die on the second sight. There is no "seen" set to tune or get wrong. Peers reconnect with capped backoff; on reconnect the peer replays stored events so nothing missed during an outage is lost (deduped on arrival). **Trust model.** Federate only with relays you trust to authorize their own posters — a federated channel behaves as open on every participating relay (federated chat bypasses _local_ membership because the author is a member on the peer that accepted it). Federation is strictly chat; it never imports another relay's membership, admin, or ban state. ## Tests ```bash go test ./... ``` Tests live beside the package they cover: - `internal/group` — the group state machine: join → chat allowed, chat-before-join rejected, missing `h`-tag rejected, **multi-user administrator moderation** in a closed group (creator is admin; non-admins cannot add/remove users; admin-approved members can post; removed members are blocked; open groups allow anyone), and **state survives a rebuild** from the event store. - `internal/retention` — prunes old/surplus chat by age and count but never management/metadata events. - `internal/federation` — config parsing (modes, channel scoping, invalid-URL/empty-list rejection), ingress signature + scope enforcement, and the loop-prevention contract. - `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). > 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 > relay's own group-state locking is race-clean — verify with the state-machine tests that > don't sign events: > `go test ./internal/group/ -race -run 'TestAdminModerationInClosedGroup|TestOpenGroup'`.