initial public relay build repo
ci / build (push) Successful in 2m3s
ci / image (push) Successful in 1m4s

This commit is contained in:
2026-07-24 13:11:06 -04:00
commit 294616642f
24 changed files with 2957 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
data
relay
nostermd
.env
.env.*
!.env.example
.DS_Store
+36
View File
@@ -0,0 +1,36 @@
# Nosterm relay deployment configuration.
# Copy to `.env` and adjust.
# Stable 64-char hex identity that signs group metadata/rosters.
# REQUIRED when RELAY_ENV=production (the relay refuses to start without it).
# In dev it is auto-generated and persisted to the data volume.
# Generate one with: openssl rand -hex 32
RELAY_SECRET_KEY=
# Set to "production" to require a stable RELAY_SECRET_KEY (fail-fast if unset).
# Leave empty for development (auto-generates + persists a key).
RELAY_ENV=
# NIP-11 relay metadata.
RELAY_NAME=Nosterm Home Relay
RELAY_DESCRIPTION=
RELAY_CONTACT=relay@nosterm.com
# Optional message-of-the-day: shown in the client's relay "server window" on
# connect (IRC-style MOTD). Multi-line supported with \n. Empty = falls back to
# the built-in "nerdworks.io" ascii banner.
RELAY_MOTD=
# Retention (chat messages only; membership/metadata are never pruned).
# 0 = unlimited.
RELAY_RETENTION_DAYS=0
RELAY_RETENTION_MAX_MESSAGES=0
RELAY_RETENTION_INTERVAL_MINUTES=60
# Optional federation with peer relays. Empty = disabled.
# Format: "<wss-url>[|<mode>:<channels>]" entries separated by ';'.
# mode = mirror (bidirectional) | ingest (read-only). Default mirror.
# channels = comma list of group ids, or '*' for all. Default '*'.
# Example:
# wss://a.example|mirror:general,dev;wss://b.example|ingest:*
RELAY_FEDERATION_PEERS=
+11
View File
@@ -0,0 +1,11 @@
<!-- Describe what this PR changes and why. -->
## Summary
## Checklist
- [ ] `gofmt -l .` prints nothing
- [ ] `go vet ./...` passes
- [ ] `go test ./...` passes
- [ ] `go build ./cmd/nostermd` succeeds
- [ ] No secrets committed
+112
View File
@@ -0,0 +1,112 @@
# ci/cd for the public nostermd relay.
#
# - every push/pr: gofmt check, vet, test, build.
# - push to main / a v* tag: also build the image and push it to ecr public
# (anonymous pulls) so anyone can `docker pull` it.
#
# this is the public contribution repo — it builds and ships only the relay
# image to the public registry. it never touches private ecr or redeploys any
# environment; promoting an image to a live relay is handled out of band.
#
# gitea 1.21 actions on a self-hosted (ec2) runner. aws auth uses the runner's
# ambient iam role (no stored creds): `aws ecr-public get-login-password` mints
# a short-lived token for `docker login`. the runner role needs ecr public push
# perms (ecr-public:GetAuthorizationToken, sts:GetServiceBearerToken,
# BatchCheckLayerAvailability, Put*/Upload*/Complete*).
name: ci
env:
# ecr public always authenticates via the us-east-1 endpoint.
AWS_REGION: us-east-1
# public mirror for anonymous pulls. k3k1z1x5 is the account's ecr public
# registry alias; the repo name (nostermd) is provisioned out of band.
ECR_PUBLIC_REGISTRY: public.ecr.aws/k3k1z1x5
ECR_PUBLIC_REPO: nostermd
on:
push:
branches: [main]
tags: ['v*']
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Verify formatting (gofmt)
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: Vet
run: go vet ./...
# no -race: go-nostr's unsafe json serializer trips the detector during
# event signing (upstream lib issue, not a relay race). see README.md#tests.
- name: Test
run: go test ./...
- name: Build
run: go build -trimpath -o /dev/null ./cmd/nostermd
image:
needs: build
# publish only from the default branch and version tags, never from prs.
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install AWS CLI
# runner image ships without aws; install v2 if it's missing.
run: |
if ! command -v aws >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y -qq curl unzip
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
/tmp/aws/install
fi
aws --version
- name: Log in to Amazon ECR Public
# ecr public auth is a separate endpoint (always us-east-1) with its own
# login user (AWS). this is push auth; anonymous pulls need no login.
run: |
aws ecr-public get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin public.ecr.aws
- name: Build and push image
shell: bash
run: |
set -eo pipefail
IMAGE="$ECR_PUBLIC_REGISTRY/$ECR_PUBLIC_REPO"
# tag by commit sha for traceability.
docker build -t "$IMAGE:${{ gitea.sha }}" .
docker push "$IMAGE:${{ gitea.sha }}"
# move :latest only on main.
if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then
docker tag "$IMAGE:${{ gitea.sha }}" "$IMAGE:latest"
docker push "$IMAGE:latest"
fi
# on a v* tag, also push that version tag.
case "${{ gitea.ref }}" in
refs/tags/v*)
VERSION="${{ gitea.ref_name }}"
docker tag "$IMAGE:${{ gitea.sha }}" "$IMAGE:$VERSION"
docker push "$IMAGE:$VERSION"
;;
esac
+5
View File
@@ -0,0 +1,5 @@
# local, untracked claude instructions
CLAUDE.local.md
# local relay runtime state (boltdb store + generated key)
data/
+46
View File
@@ -0,0 +1,46 @@
# Contributing to nostermd
Thanks for helping improve the Nosterm home relay. This repo is the public home
for relay development — the Go source and its container image. It builds and
ships the relay image to ECR Public; it does not deploy to any live relay.
## Workflow
1. Fork the repo (or branch, if you have push access).
2. Create a topic branch off `main`.
3. Make your change, keeping commits focused.
4. Open a pull request against `main`.
Every push and pull request runs CI: gofmt check, vet, tests, and a build. Get
these green before requesting review:
```bash
gofmt -l . # must print nothing
go vet ./...
go test ./... # run without -race — see README.md#tests
go build ./cmd/nostermd
```
`gofmt -w .` auto-fixes formatting.
## What happens on merge
Merging to `main` builds the container image and pushes it to ECR Public
(`public.ecr.aws/k3k1z1x5/nostermd:latest`), so anyone can pull the latest relay
anonymously. Tagged releases (`v*`) publish a matching versioned image. This
repo does not deploy to any live environment — promotion is handled separately.
## Guidelines
- Match the surrounding code style; gofmt is the source of truth.
- Keep pull requests scoped to one change; smaller is easier to review.
- Add or update tests when you change behavior. Tests live beside the package
they cover.
- Never commit secrets. `RELAY_SECRET_KEY` is a per-deployment identity, not a
repo value.
## Reporting issues
Open an issue with steps to reproduce, what you expected, and what happened. For
security-sensitive reports, please disclose privately rather than in a public
issue.
+33
View File
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
# --- Build stage -----------------------------------------------------------
FROM golang:1.25-alpine AS build
WORKDIR /src
# Cache modules independently of source.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO disabled → a fully static binary (boltdb is pure Go), so it runs on scratch.
# The command lives in ./cmd/nostermd; internal/* holds the relay packages.
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/nostermd ./cmd/nostermd
# Stage an empty data dir we can copy in with the correct nonroot ownership.
RUN mkdir -p /out/data
# --- Runtime stage ---------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
# Binary + a data dir pre-owned by the distroless nonroot uid (65532), so the
# unprivileged process can write the boltdb file to the mounted volume.
COPY --from=build --chown=65532:65532 /out/nostermd /nostermd
COPY --from=build --chown=65532:65532 /out/data /data
# Persisted event storage (mount a volume here in production).
VOLUME ["/data"]
ENV RELAY_DATA_DIR=/data \
RELAY_ADDR=:3334
USER 65532:65532
EXPOSE 3334
ENTRYPOINT ["/nostermd"]
+211
View File
@@ -0,0 +1,211 @@
# 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 (3900039002). 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, 3900039002)
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:
```
<wss-url>[|<mode>:<channels>]
```
- **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'`.
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"log"
"os"
"path/filepath"
"strconv"
"fiatjaf.com/nostr"
)
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// envIntOr parses a non-negative integer env var, falling back on empty/invalid.
func envIntOr(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
log.Printf("invalid %s=%q; using %d", key, v, fallback)
}
return fallback
}
// loadOrGenerateKey resolves the relay's signing identity, which signs the group
// metadata/admins/members events. A STABLE identity is required so those signed
// lists remain valid across restarts and re-deploys.
//
// - RELAY_SECRET_KEY set → use it (production path).
// - RELAY_ENV=production, unset → fatal. A fresh key each boot silently
// corrupts signed group rosters, so we refuse to start.
// - otherwise (dev) → persist a generated key under the data dir
// and reuse it, so even dev keeps a stable identity across restarts.
func loadOrGenerateKey(dataDir string) nostr.SecretKey {
if hex := os.Getenv("RELAY_SECRET_KEY"); hex != "" {
sk, err := nostr.SecretKeyFromHex(hex)
if err != nil {
log.Fatalf("invalid RELAY_SECRET_KEY: %v", err)
}
return sk
}
if isProduction() {
log.Fatal("RELAY_SECRET_KEY is required when RELAY_ENV=production " +
"(a fresh key each boot would corrupt signed group rosters). " +
"Generate one with: openssl rand -hex 32")
}
// Dev convenience: persist a generated key so restarts keep one identity.
keyPath := filepath.Join(dataDir, "relay.key")
if data, err := os.ReadFile(keyPath); err == nil {
if sk, err := nostr.SecretKeyFromHex(string(data)); err == nil {
log.Printf("using persisted dev relay identity from %s", keyPath)
return sk
}
log.Printf("ignoring unreadable %s; generating a new dev key", keyPath)
}
sk := nostr.Generate()
if err := os.WriteFile(keyPath, []byte(sk.Hex()), 0o600); err != nil {
log.Printf("could not persist dev relay key (%v); identity is ephemeral this run", err)
} else {
log.Printf("generated and persisted a dev relay identity at %s (set RELAY_SECRET_KEY for production)", keyPath)
}
return sk
}
func isProduction() bool {
return os.Getenv("RELAY_ENV") == "production"
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"os"
"path/filepath"
"testing"
"fiatjaf.com/nostr"
)
func TestDevKeyPersistsAcrossCalls(t *testing.T) {
t.Setenv("RELAY_SECRET_KEY", "")
t.Setenv("RELAY_ENV", "")
dir := t.TempDir()
first := loadOrGenerateKey(dir)
// The key file should now exist and a second call must return the same key.
if _, err := os.Stat(filepath.Join(dir, "relay.key")); err != nil {
t.Fatalf("dev key not persisted: %v", err)
}
second := loadOrGenerateKey(dir)
if first != second {
t.Fatal("dev identity should be stable across restarts")
}
}
func TestExplicitKeyIsUsed(t *testing.T) {
sk := nostr.Generate()
t.Setenv("RELAY_SECRET_KEY", sk.Hex())
t.Setenv("RELAY_ENV", "production") // explicit key wins even in production
got := loadOrGenerateKey(t.TempDir())
if got != sk {
t.Fatal("explicit RELAY_SECRET_KEY should be used verbatim")
}
}
// Note: the production-without-key path calls log.Fatal (os.Exit), which can't
// be exercised in-process without a subprocess harness; its behavior is simple
// and covered by manual verification + the isProduction() guard below.
func TestIsProduction(t *testing.T) {
t.Setenv("RELAY_ENV", "production")
if !isProduction() {
t.Fatal("RELAY_ENV=production should be detected")
}
t.Setenv("RELAY_ENV", "")
if isProduction() {
t.Fatal("unset RELAY_ENV should not be production")
}
}
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"context"
"testing"
"time"
"fiatjaf.com/nostr"
)
// TestFederationIngest is an end-to-end test: relay B hosts an open channel;
// relay A ingests it. A message posted to B by a member appears on A even though
// the author is not a member of A — the chat-only, membership-bypass contract.
func TestFederationIngest(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
const groupID = "general"
// Relay B: a normal relay hosting the channel.
bURL := startTestRelay(t, "")
// Relay A: ingests channel "general" from B (read-only).
aURL := startTestRelay(t, bURL+"|ingest:"+groupID)
// A subscriber on relay A watches the channel.
aReader, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{})
if err != nil {
t.Fatalf("connect reader to A: %v", err)
}
sub, err := aReader.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage},
Tags: nostr.TagMap{"h": []string{groupID}},
}, nostr.SubscriptionOptions{})
if err != nil {
t.Fatalf("subscribe on A: %v", err)
}
defer sub.Unsub()
// Alice joins + posts on relay B.
aliceSK := nostr.Generate()
bWriter, err := nostr.RelayConnect(ctx, bURL, nostr.RelayOptions{})
if err != nil {
t.Fatalf("connect alice to B: %v", err)
}
join := nostr.Event{Kind: nostr.KindSimpleGroupJoinRequest, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", groupID}}}
mustSign(t, aliceSK, &join)
if err := bWriter.Publish(ctx, join); err != nil {
t.Fatalf("alice join on B: %v", err)
}
msg := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Content: "hello from B", Tags: nostr.Tags{{"h", groupID}}}
mustSign(t, aliceSK, &msg)
if err := bWriter.Publish(ctx, msg); err != nil {
t.Fatalf("alice post on B: %v", err)
}
// The message should arrive on relay A via federation.
select {
case got := <-sub.Events:
if got.ID != msg.ID {
t.Fatalf("got event %s, want %s", got.ID.Hex(), msg.ID.Hex())
}
if got.Content != "hello from B" {
t.Errorf("content = %q", got.Content)
}
case <-ctx.Done():
t.Fatal("timed out waiting for federated message on relay A")
}
}
// TestFederationMirrorNoDuplicate proves the loop guard end-to-end: A mirrors B,
// so a post on A is forwarded to B, which would echo it back to A. The echo is a
// store duplicate and is dropped, not re-delivered — the message arrives on A's
// subscription exactly once.
func TestFederationMirrorNoDuplicate(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
const groupID = "general"
// B must start first (A needs B's URL); then A starts mirroring B. We assert
// the guard on the A→B→(echo)→A path.
bURL := startTestRelay(t, "")
aURL := startTestRelay(t, bURL+"|mirror:"+groupID)
// Reader on A counts how many times the event is delivered.
aReader, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{})
if err != nil {
t.Fatalf("connect reader to A: %v", err)
}
sub, err := aReader.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage},
Tags: nostr.TagMap{"h": []string{groupID}},
}, nostr.SubscriptionOptions{})
if err != nil {
t.Fatalf("subscribe on A: %v", err)
}
defer sub.Unsub()
// Alice joins + posts on A. A forwards to B (mirror); B could echo back to A.
aliceSK := nostr.Generate()
aWriter, err := nostr.RelayConnect(ctx, aURL, nostr.RelayOptions{})
if err != nil {
t.Fatalf("connect alice to A: %v", err)
}
join := nostr.Event{Kind: nostr.KindSimpleGroupJoinRequest, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", groupID}}}
mustSign(t, aliceSK, &join)
if err := aWriter.Publish(ctx, join); err != nil {
t.Fatalf("alice join on A: %v", err)
}
msg := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Content: "once only", Tags: nostr.Tags{{"h", groupID}}}
mustSign(t, aliceSK, &msg)
if err := aWriter.Publish(ctx, msg); err != nil {
t.Fatalf("alice post on A: %v", err)
}
// Count deliveries of our message id for a short window; must be exactly 1.
deadline := time.After(3 * time.Second)
count := 0
for {
select {
case got := <-sub.Events:
if got.ID == msg.ID {
count++
}
case <-deadline:
if count != 1 {
t.Fatalf("expected message delivered exactly once, got %d", count)
}
return
case <-ctx.Done():
t.Fatal("context canceled")
}
}
}
+95
View File
@@ -0,0 +1,95 @@
// Command nostermd is a minimal NIP-29 managed-group relay built on khatru,
// plus the Nosterm capability handshake (a `features` array in its NIP-11 doc).
//
// It is intentionally small: it enforces group membership for chat writes,
// auto-creates open channels on first join, and advertises itself as a
// "nostermd" so a Nosterm client can feature-gate its UI. The relay is
// assembled from focused internal packages (group, federation, retention,
// relayinfo); this file is the composition root that wires them to env config
// and the network.
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"time"
"fiatjaf.com/nostr/eventstore/boltdb"
"github.com/nosterm/relay/internal/federation"
"github.com/nosterm/relay/internal/retention"
)
// defaultMOTD is the built-in message-of-the-day: a "nerdworks.io" ascii banner
// (figlet, standard font) the client shows when RELAY_MOTD is unset.
const defaultMOTD = " _ _ _\n" +
" _ __ ___ _ __ __| |_ _____ _ __| | _____ (_) ___\n" +
"| '_ \\ / _ \\ '__/ _` \\ \\ /\\ / / _ \\| '__| |/ / __| | |/ _ \\\n" +
"| | | | __/ | | (_| |\\ V V / (_) | | | <\\__ \\_| | (_) |\n" +
"|_| |_|\\___|_| \\__,_| \\_/\\_/ \\___/|_| |_|\\_\\___(_)_|\\___/\n"
func main() {
addr := envOr("RELAY_ADDR", ":3334")
dataDir := envOr("RELAY_DATA_DIR", "./data")
// Embedded, pure-Go storage (no cgo → clean static Docker builds).
if err := os.MkdirAll(dataDir, 0o755); err != nil {
log.Fatalf("cannot create data dir: %v", err)
}
// Relay identity: signs the 39000-series group metadata/roster events. A
// stable key is required so those signatures survive restarts (see below).
sk := loadOrGenerateKey(dataDir)
store := &boltdb.BoltBackend{Path: filepath.Join(dataDir, "events.bolt")}
if err := store.Init(); err != nil {
log.Fatalf("cannot open eventstore: %v", err)
}
// Parse optional federation config up front so warnings surface at boot.
fedCfg, fedWarnings := federation.ParseConfig(os.Getenv("RELAY_FEDERATION_PEERS"))
for _, w := range fedWarnings {
log.Printf("federation config: %s", w)
}
app := buildRelay(relayConfig{
secretKey: sk,
store: store,
name: envOr("RELAY_NAME", "Nosterm Home Relay"),
description: envOr("RELAY_DESCRIPTION", "A Nosterm NIP-29 managed-group relay."),
// NIP-11 contact: a NIP-05-style identifier / address for the relay operator.
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,
})
// Reconstruct membership/metadata from persisted events so groups survive
// restarts (without this, every restart silently drops all group state).
if replayed := app.groups.Rebuild(store); replayed > 0 {
log.Printf("replayed %d group management event(s) from storage", replayed)
}
// 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,
MaxMessages: envIntOr("RELAY_RETENTION_MAX_MESSAGES", 0),
Interval: time.Duration(envIntOr("RELAY_RETENTION_INTERVAL_MINUTES", 60)) * time.Minute,
})
defer stopRetention()
// Start federation last, once the relay pipeline is fully wired.
if app.federator != nil {
app.federator.Start()
defer app.federator.Stop()
}
log.Printf("nostermd pubkey: %s", app.pubKey.Hex())
log.Printf("listening on %s (%s; %s)", addr, app.groups.Summary(), fedCfg.Summary())
if err := http.ListenAndServe(addr, app.handler); err != nil {
log.Fatal(err)
}
}
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"context"
"encoding/json"
"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
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
}
// 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,
}
}
// 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)
}
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"context"
"net/http/httptest"
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
"github.com/nosterm/relay/internal/federation"
)
// startTestRelay spins up a fully-wired relay backed by an in-memory store,
// federating per peersSpec (empty = no federation). It returns the relay's ws://
// URL. The federator, if any, is started and cleaned up automatically. This is
// the integration seam: it exercises exactly the wiring buildRelay produces for
// main(), over a real in-process khatru websocket server.
func startTestRelay(t *testing.T, peersSpec string) string {
t.Helper()
store := &slicestore.SliceStore{}
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
fedCfg, _ := federation.ParseConfig(peersSpec)
app := buildRelay(relayConfig{
secretKey: nostr.Generate(),
store: store,
name: "test-relay",
fedCfg: fedCfg,
})
srv := httptest.NewServer(app.handler)
t.Cleanup(srv.Close)
if app.federator != nil {
app.federator.Start()
t.Cleanup(app.federator.Stop)
}
return "ws" + srv.URL[len("http"):]
}
func mustSign(t *testing.T, sk nostr.SecretKey, evt *nostr.Event) {
t.Helper()
if err := evt.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
}
func hpk(sk nostr.SecretKey) nostr.PubKey { return nostr.GetPublicKey(sk) }
func TestGroupJoinSendReceive(t *testing.T) {
url := startTestRelay(t, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Two independent identities: alice writes, bob reads.
aliceSK := nostr.Generate()
bobSK := nostr.Generate()
const groupID = "general"
// Bob connects and subscribes to the group's chat messages first.
bob, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
t.Fatalf("bob connect: %v", err)
}
sub, err := bob.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage},
Tags: nostr.TagMap{"h": []string{groupID}},
}, nostr.SubscriptionOptions{})
if err != nil {
t.Fatalf("bob subscribe: %v", err)
}
// Alice connects, joins the group, then sends a chat message.
alice, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
t.Fatalf("alice connect: %v", err)
}
join := nostr.Event{
Kind: nostr.KindSimpleGroupJoinRequest,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{{"h", groupID}},
}
mustSign(t, aliceSK, &join)
if err := alice.Publish(ctx, join); err != nil {
t.Fatalf("alice join publish: %v", err)
}
msg := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
CreatedAt: nostr.Now(),
Content: "hello nosterm relay",
Tags: nostr.Tags{{"h", groupID}},
}
mustSign(t, aliceSK, &msg)
if err := alice.Publish(ctx, msg); err != nil {
t.Fatalf("alice message publish: %v", err)
}
// Bob must receive alice's message via the subscription (cross-user).
select {
case got := <-sub.Events:
if got.Content != "hello nosterm relay" {
t.Fatalf("unexpected content: %q", got.Content)
}
if got.PubKey != nostr.GetPublicKey(aliceSK) {
t.Fatalf("unexpected author")
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the group message")
}
_ = bobSK // bob reads without needing to sign in this open-group MVP
}
// End-to-end over a real websocket: an admin and two members interact, and the
// relay rejects a non-admin's moderation attempt on the wire.
//
// NOTE: run the suite without -race. go-nostr's unsafe-based JSON serializer
// trips the race detector's checkptr under this test's concurrent signing; it
// is an upstream library issue, not a data race in the relay. The relay's own
// group-state locking is race-clean — verify with:
//
// go test ./internal/group/ -race
func TestMultiUserAdminOverWebsocket(t *testing.T) {
url := startTestRelay(t, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
admin := nostr.Generate()
alice := nostr.Generate()
const g = "ops"
adminConn, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
t.Fatalf("admin connect: %v", err)
}
aliceConn, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
t.Fatalf("alice connect: %v", err)
}
publish := func(conn *nostr.Relay, sk nostr.SecretKey, kind nostr.Kind, tags nostr.Tags) error {
evt := nostr.Event{Kind: kind, CreatedAt: nostr.Now(), Tags: tags}
mustSign(t, sk, &evt)
return conn.Publish(ctx, evt)
}
// Admin creates a closed group.
if err := publish(adminConn, admin, nostr.KindSimpleGroupCreateGroup, nostr.Tags{{"h", g}, {"closed"}}); err != nil {
t.Fatalf("create closed group: %v", err)
}
// Alice (not yet a member) cannot post — the relay rejects it on the wire.
if err := publish(aliceConn, alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}}); err == nil {
t.Fatal("expected the relay to reject a non-member's post to a closed group")
}
// Alice cannot moderate (add herself) — she is not an admin.
if err := publish(aliceConn, alice, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}}); err == nil {
t.Fatal("expected the relay to reject a non-admin moderation event")
}
// The admin admits alice.
if err := publish(adminConn, admin, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}}); err != nil {
t.Fatalf("admin add user: %v", err)
}
// Alice can now post, and the admin — subscribed — receives it.
sub, err := adminConn.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupChatMessage},
Tags: nostr.TagMap{"h": []string{g}},
}, nostr.SubscriptionOptions{})
if err != nil {
t.Fatalf("admin subscribe: %v", err)
}
if err := publish(aliceConn, alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}}); err != nil {
t.Fatalf("member post after admission should succeed: %v", err)
}
select {
case got := <-sub.Events:
if got.PubKey != hpk(alice) {
t.Fatal("admin received a message from an unexpected author")
}
case <-time.After(5 * time.Second):
t.Fatal("admin did not receive the admitted member's message")
}
}
+44
View File
@@ -0,0 +1,44 @@
# Standalone Nosterm home relay deployment.
#
# Runs ONLY the relay: a minimal NIP-29 managed-group Nostr relay with the
# Nosterm capability handshake. It listens on port 3334 and persists its BoltDB
# event store to a named volume so rooms/messages survive restarts.
#
# cp .env.example .env # then set RELAY_SECRET_KEY (see .env.example)
# docker compose up -d --build
services:
relay:
build:
context: .
image: nostermd:latest
environment:
# Stable 64-char hex identity that signs group metadata/rosters. REQUIRED
# when RELAY_ENV=production (the relay refuses to start without it, since a
# fresh key each boot corrupts signed rosters). In dev it is persisted to
# the data volume automatically. Generate with: openssl rand -hex 32
RELAY_SECRET_KEY: ${RELAY_SECRET_KEY:-}
RELAY_ENV: ${RELAY_ENV:-}
RELAY_NAME: ${RELAY_NAME:-Nosterm Home Relay}
RELAY_DESCRIPTION: ${RELAY_DESCRIPTION:-}
RELAY_CONTACT: ${RELAY_CONTACT:-relay@nosterm.com}
RELAY_MOTD: ${RELAY_MOTD:-}
# Retention: prune old CHAT messages so the store doesn't grow unbounded.
# 0 = unlimited. Group membership/metadata is never pruned.
RELAY_RETENTION_DAYS: ${RELAY_RETENTION_DAYS:-0}
RELAY_RETENTION_MAX_MESSAGES: ${RELAY_RETENTION_MAX_MESSAGES:-0}
RELAY_RETENTION_INTERVAL_MINUTES: ${RELAY_RETENTION_INTERVAL_MINUTES:-60}
# Optional federation: mirror/ingest NIP-29 CHAT with peer relays. Empty =
# disabled. Format: "url|mode:channels" entries separated by ';', where
# mode is mirror (bidirectional) or ingest (read-only) and channels is a
# comma list or '*'. e.g.
# wss://peer-a.example|mirror:general,dev;wss://peer-b.example|ingest:*
RELAY_FEDERATION_PEERS: ${RELAY_FEDERATION_PEERS:-}
volumes:
- relay-data:/data
ports:
- '3334:3334'
restart: unless-stopped
volumes:
relay-data:
+39
View File
@@ -0,0 +1,39 @@
module github.com/nosterm/relay
go 1.25.11
require fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e
require (
fiatjaf.com/lib v0.3.7 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/templexxx/cpu v0.0.1 // indirect
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.59.0 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.35.0 // indirect
)
+92
View File
@@ -0,0 +1,92 @@
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e h1:/vgoytiH4qQ28O/oX1hj656X69CnTy1pGj/17HFjH+A=
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/PowerDNS/lmdb-go v1.9.3 h1:AUMY2pZT8WRpkEv39I9Id3MuoHd+NZbTVpNhruVkPTg=
github.com/PowerDNS/lmdb-go v1.9.3/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY=
github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg=
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+406
View File
@@ -0,0 +1,406 @@
// Package federation lets the relay mirror NIP-29 chat with peer relays, so a
// channel can live on more than one relay and survive any single one going down.
// It is OPTIONAL and chat-only by design: only kind-9/10 messages cross the
// boundary. Membership, admins, bans and metadata stay local to each relay (each
// operator moderates their own instance) — this sidesteps cross-relay moderation
// conflicts entirely.
//
// Two per-channel modes:
//
// - mirror bidirectional. Chat posted on either relay is forwarded to the
// other, so the channel is fully replicated.
// - ingest read-only. This relay pulls chat FROM the peer but never pushes
// back (announcement feeds, syndication, public-room mirrors).
//
// Loop prevention is structural rather than tag-based: an incoming federated
// event is injected through the relay's normal add pipeline, whose eventstore
// rejects duplicates (ErrDupEvent) BEFORE the egress hook runs. So an event that
// has already been seen is never re-forwarded, and A↔B↔A cycles die on the
// second sight. No custom "seen" bookkeeping to get wrong.
package federation
import (
"context"
"log"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"github.com/nosterm/relay/internal/group"
)
// Mode is a peer's federation direction.
type Mode int
const (
// ModeMirror is bidirectional federation.
ModeMirror Mode = iota
// ModeIngest is read-only (pull-only) federation.
ModeIngest
)
func (m Mode) String() string {
if m == ModeIngest {
return "ingest"
}
return "mirror"
}
// Peer is one configured peer relay and the channels federated with it.
type Peer struct {
URL string
Mode Mode
Channels map[string]bool // group ids; empty map with AllChans=true means "all"
AllChans bool
}
// wants reports whether events for the given group id federate with this peer.
func (p *Peer) wants(groupID string) bool {
if p.AllChans {
return true
}
return p.Channels[groupID]
}
// Config is the parsed set of peers.
type Config struct {
Peers []Peer
}
// Enabled reports whether any peer is configured.
func (c Config) Enabled() bool {
return len(c.Peers) > 0
}
// FederatesChannel reports whether any peer federates the given group id. Used
// by group evaluation to accept federated chat regardless of local membership
// (a federated channel is effectively open on every participating relay).
func (c Config) FederatesChannel(id string) bool {
for i := range c.Peers {
if c.Peers[i].wants(id) {
return true
}
}
return false
}
// ParseConfig parses the RELAY_FEDERATION_PEERS specification.
//
// Grammar (peers separated by ';', ignoring surrounding whitespace):
//
// <url>|<mode>:<channels>
//
// where <mode> is "mirror" or "ingest" and <channels> is a comma-separated list
// of group ids, or "*" for every channel. The "|<mode>:<channels>" suffix is
// optional and defaults to "mirror:*". Blank entries are skipped.
//
// Examples:
//
// wss://a.example
// wss://a.example|mirror:general,dev
// wss://a.example|mirror:general;wss://b.example|ingest:announcements
// wss://a.example|ingest:*
func ParseConfig(spec string) (Config, []string) {
var cfg Config
var warnings []string
for _, raw := range strings.Split(spec, ";") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
urlPart := entry
modePart := ""
if i := strings.Index(entry, "|"); i >= 0 {
urlPart = strings.TrimSpace(entry[:i])
modePart = strings.TrimSpace(entry[i+1:])
}
// Require an explicit ws/wss scheme on the RAW input: NormalizeURL would
// silently rewrite http→ws and bare hosts→wss, masking config mistakes.
lower := strings.ToLower(urlPart)
if !(strings.HasPrefix(lower, "ws://") || strings.HasPrefix(lower, "wss://")) {
warnings = append(warnings, "ignoring peer with non-ws(s) URL: "+urlPart)
continue
}
url := nostr.NormalizeURL(urlPart)
if url == "" {
warnings = append(warnings, "ignoring peer with invalid URL: "+urlPart)
continue
}
peer := Peer{
URL: url,
Mode: ModeMirror,
Channels: map[string]bool{},
AllChans: true, // default: all channels
}
if modePart != "" {
modeName := modePart
chanList := "*"
if j := strings.Index(modePart, ":"); j >= 0 {
modeName = strings.TrimSpace(modePart[:j])
chanList = strings.TrimSpace(modePart[j+1:])
}
switch strings.ToLower(modeName) {
case "mirror", "":
peer.Mode = ModeMirror
case "ingest", "pull", "read", "readonly":
peer.Mode = ModeIngest
default:
warnings = append(warnings, "unknown federation mode "+modeName+" for "+url+"; using mirror")
peer.Mode = ModeMirror
}
if chanList != "" && chanList != "*" {
peer.AllChans = false
for _, c := range strings.Split(chanList, ",") {
id := strings.TrimSpace(strings.TrimPrefix(c, "#"))
if id != "" {
peer.Channels[id] = true
}
}
// A mode with an explicit but empty channel list federates nothing.
if len(peer.Channels) == 0 {
warnings = append(warnings, "peer "+url+" lists no valid channels; skipping")
continue
}
}
}
cfg.Peers = append(cfg.Peers, peer)
}
return cfg, warnings
}
// EventInjector accepts a federated event into the local relay pipeline. It
// returns whether the event was newly stored (false for duplicates/rejects), so
// tests can assert loop prevention without a live relay. In production this is
// backed by khatru's Relay.AddEvent.
type EventInjector func(ctx context.Context, evt nostr.Event) (stored bool)
// Federator owns the live peer connections and the forwarding logic.
type Federator struct {
cfg Config
inject EventInjector
dialCtx context.Context
cancel context.CancelFunc
mu sync.Mutex
connected map[string]*nostr.Relay // url -> live client connection
wg sync.WaitGroup
}
// federationKinds are the only kinds we ever subscribe to / forward: chat only.
var federationKinds = []nostr.Kind{
nostr.KindSimpleGroupChatMessage, // 9
nostr.KindSimpleGroupThreadedReply, // 10
}
// NewFederator creates a federator for the given config. inject feeds received
// peer events into the local relay pipeline.
func NewFederator(cfg Config, inject EventInjector) *Federator {
ctx, cancel := context.WithCancel(context.Background())
return &Federator{
cfg: cfg,
inject: inject,
dialCtx: ctx,
cancel: cancel,
connected: map[string]*nostr.Relay{},
}
}
// Start dials every peer and begins pulling their chat. Non-blocking: each peer
// runs in its own goroutine that reconnects with backoff until Stop is called.
func (f *Federator) Start() {
for i := range f.cfg.Peers {
peer := f.cfg.Peers[i]
f.wg.Add(1)
go func() {
defer f.wg.Done()
f.runPeer(peer)
}()
}
log.Printf("federation: started with %d peer(s)", len(f.cfg.Peers))
}
// Stop tears down all peer connections and waits for their goroutines to exit.
func (f *Federator) Stop() {
f.cancel()
f.mu.Lock()
for _, r := range f.connected {
_ = r.Close()
}
f.mu.Unlock()
f.wg.Wait()
}
// runPeer maintains one peer connection, reconnecting with capped backoff. Each
// (re)connection opens a fresh subscription for the federated chat kinds and
// injects received events into the local pipeline (dedup handles loops).
func (f *Federator) runPeer(peer Peer) {
backoff := time.Second
const maxBackoff = 30 * time.Second
for f.dialCtx.Err() == nil {
if err := f.connectAndPull(peer); err != nil {
if f.dialCtx.Err() != nil {
return
}
log.Printf("federation: peer %s (%s) error: %v; retrying in %s",
peer.URL, peer.Mode, err, backoff)
select {
case <-f.dialCtx.Done():
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
// Clean subscription end (peer closed / context canceled) — reset backoff
// and try to re-establish unless we're shutting down.
backoff = time.Second
}
}
// connectAndPull opens a connection + subscription and blocks pumping events
// until the subscription ends or the connection drops. Returns nil on a clean
// end (so the caller re-establishes), or an error to trigger backoff.
func (f *Federator) connectAndPull(peer Peer) error {
relay, err := nostr.RelayConnect(f.dialCtx, peer.URL, nostr.RelayOptions{})
if err != nil {
return err
}
f.mu.Lock()
f.connected[peer.URL] = relay
f.mu.Unlock()
defer func() {
f.mu.Lock()
delete(f.connected, peer.URL)
f.mu.Unlock()
_ = relay.Close()
}()
filter := nostr.Filter{Kinds: federationKinds}
// Scope the pull to the configured channels when not "all", so we don't drag
// down chat for channels this peer isn't federating.
if !peer.AllChans {
ids := make([]string, 0, len(peer.Channels))
for id := range peer.Channels {
ids = append(ids, id)
}
filter.Tags = nostr.TagMap{"h": ids}
}
sub, err := relay.Subscribe(f.dialCtx, filter, nostr.SubscriptionOptions{Label: "federation"})
if err != nil {
return err
}
defer sub.Unsub()
log.Printf("federation: pulling %s from %s", peer.Mode, peer.URL)
for {
select {
case <-f.dialCtx.Done():
return nil
case <-sub.EndOfStoredEvents:
// Historical backfill done; keep receiving live events.
case reason := <-sub.ClosedReason:
log.Printf("federation: peer %s closed subscription: %s", peer.URL, reason)
return nil
case evt, ok := <-sub.Events:
if !ok {
return nil
}
f.receive(peer, evt)
}
}
}
// receive validates and injects an event pulled from a peer. Signature is
// re-verified defensively (never trust a peer to have done so), the channel
// scope is re-checked, and the event is fed through the local pipeline where
// the eventstore dedups it — that dedup is what prevents forwarding loops.
func (f *Federator) receive(peer Peer, evt nostr.Event) {
id := group.GroupIDFromEvent(evt)
if id == "" || !peer.wants(id) {
return
}
if !evt.VerifySignature() {
log.Printf("federation: dropped event %s from %s (bad signature)", evt.ID.Hex(), peer.URL)
return
}
f.inject(f.dialCtx, evt)
}
// Forward pushes a locally-saved chat event out to every peer that federates its
// channel in a writable (mirror) mode. Ingest peers are pull-only and are
// skipped. Called from the relay's OnEventSaved hook for chat kinds; a duplicate
// re-injected from a peer never reaches here because AddEvent stops on dedup
// before OnEventSaved fires.
func (f *Federator) Forward(evt nostr.Event) {
id := group.GroupIDFromEvent(evt)
if id == "" {
return
}
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.cfg.Peers {
peer := f.cfg.Peers[i]
if peer.Mode != ModeMirror || !peer.wants(id) {
continue
}
relay := f.connected[peer.URL]
if relay == nil || !relay.IsConnected() {
continue // reconnect logic will backfill via subscription
}
// Publish in the background so a slow peer can't block the save path.
f.wg.Add(1)
go func(r *nostr.Relay, url string) {
defer f.wg.Done()
ctx, cancel := context.WithTimeout(f.dialCtx, 10*time.Second)
defer cancel()
if err := r.Publish(ctx, evt); err != nil {
log.Printf("federation: publish to %s failed: %v", url, err)
}
}(relay, peer.URL)
}
}
// Summary is a short human-readable description for logging/inspection.
func (c Config) Summary() string {
if !c.Enabled() {
return "federation disabled"
}
var b strings.Builder
b.WriteString("federation: ")
for i, p := range c.Peers {
if i > 0 {
b.WriteString(", ")
}
scope := "*"
if !p.AllChans {
ids := make([]string, 0, len(p.Channels))
for id := range p.Channels {
ids = append(ids, id)
}
scope = strings.Join(ids, "/")
}
b.WriteString(p.URL)
b.WriteString("(")
b.WriteString(p.Mode.String())
b.WriteString(":")
b.WriteString(scope)
b.WriteString(")")
}
return b.String()
}
+215
View File
@@ -0,0 +1,215 @@
package federation
import (
"context"
"sync"
"testing"
"fiatjaf.com/nostr"
)
func TestParseConfig(t *testing.T) {
t.Run("empty spec disables federation", func(t *testing.T) {
cfg, warnings := ParseConfig("")
if cfg.Enabled() {
t.Fatal("expected federation disabled for empty spec")
}
if len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
})
t.Run("bare url defaults to mirror all channels", func(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example")
if len(cfg.Peers) != 1 {
t.Fatalf("expected 1 peer, got %d", len(cfg.Peers))
}
p := cfg.Peers[0]
if p.Mode != ModeMirror {
t.Errorf("expected mirror mode, got %s", p.Mode)
}
if !p.AllChans {
t.Error("expected AllChans=true for bare url")
}
if !p.wants("anything") {
t.Error("bare url should federate every channel")
}
})
t.Run("explicit mode and channels", func(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example|ingest:general,dev")
p := cfg.Peers[0]
if p.Mode != ModeIngest {
t.Errorf("expected ingest, got %s", p.Mode)
}
if p.AllChans {
t.Error("expected scoped channels, got AllChans")
}
if !p.wants("general") || !p.wants("dev") {
t.Error("should federate listed channels")
}
if p.wants("secret") {
t.Error("should NOT federate unlisted channel")
}
})
t.Run("multiple peers separated by semicolons", func(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example|mirror:general; wss://b.example|ingest:*")
if len(cfg.Peers) != 2 {
t.Fatalf("expected 2 peers, got %d", len(cfg.Peers))
}
if cfg.Peers[0].Mode != ModeMirror || cfg.Peers[1].Mode != ModeIngest {
t.Error("modes parsed incorrectly")
}
if !cfg.Peers[1].AllChans {
t.Error("ingest:* should mean all channels")
}
})
t.Run("channel names tolerate a leading #", func(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example|mirror:#general")
if !cfg.Peers[0].wants("general") {
t.Error("should strip leading # from channel names")
}
})
t.Run("invalid url is skipped with a warning", func(t *testing.T) {
cfg, warnings := ParseConfig("http://not-websocket.example")
if cfg.Enabled() {
t.Error("non-ws url should be skipped")
}
if len(warnings) == 0 {
t.Error("expected a warning for invalid url")
}
})
t.Run("explicit but empty channel list is skipped", func(t *testing.T) {
cfg, warnings := ParseConfig("wss://a.example|mirror:,")
if cfg.Enabled() {
t.Error("empty channel list should skip the peer")
}
if len(warnings) == 0 {
t.Error("expected a warning for empty channel list")
}
})
t.Run("unknown mode falls back to mirror with a warning", func(t *testing.T) {
cfg, warnings := ParseConfig("wss://a.example|bogus:general")
if cfg.Peers[0].Mode != ModeMirror {
t.Error("unknown mode should fall back to mirror")
}
if len(warnings) == 0 {
t.Error("expected a warning for unknown mode")
}
})
}
func TestFederatesChannel(t *testing.T) {
cfg, _ := ParseConfig("wss://a.example|mirror:general;wss://b.example|ingest:dev")
if !cfg.FederatesChannel("general") {
t.Error("general federates via peer a")
}
if !cfg.FederatesChannel("dev") {
t.Error("dev federates via peer b")
}
if cfg.FederatesChannel("private") {
t.Error("private federates with nobody")
}
}
// TestReceiveLoopPrevention proves the loop guard: an event injected twice is
// only stored (and thus only forwardable) once, because the injector reports the
// second sight as not-newly-stored. This mirrors AddEvent's ErrDupEvent path.
func TestReceiveLoopPrevention(t *testing.T) {
seen := map[nostr.ID]bool{}
var mu sync.Mutex
injectCount := 0
storedCount := 0
inject := func(_ context.Context, evt nostr.Event) bool {
mu.Lock()
defer mu.Unlock()
injectCount++
if seen[evt.ID] {
return false // duplicate: not newly stored (the loop breaker)
}
seen[evt.ID] = true
storedCount++
return true
}
cfg, _ := ParseConfig("wss://peer.example|mirror:general")
f := NewFederator(cfg, inject)
peer := cfg.Peers[0]
sk := nostr.Generate()
evt := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
CreatedAt: nostr.Now(),
Content: "hello federation",
Tags: nostr.Tags{{"h", "general"}},
}
if err := evt.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
// Same event received twice (e.g. A→B then echoed B→A→B).
f.receive(peer, evt)
f.receive(peer, evt)
if injectCount != 2 {
t.Errorf("expected 2 inject attempts, got %d", injectCount)
}
if storedCount != 1 {
t.Errorf("loop guard failed: event stored %d times, want 1", storedCount)
}
}
// TestReceiveDropsBadSignature ensures a peer can't inject forged events.
func TestReceiveDropsBadSignature(t *testing.T) {
injected := false
inject := func(_ context.Context, _ nostr.Event) bool {
injected = true
return true
}
cfg, _ := ParseConfig("wss://peer.example|mirror:general")
f := NewFederator(cfg, inject)
// Unsigned (invalid) event tagged for a federated channel.
evt := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
CreatedAt: nostr.Now(),
Content: "forged",
Tags: nostr.Tags{{"h", "general"}},
}
f.receive(cfg.Peers[0], evt)
if injected {
t.Error("event with an invalid signature must not be injected")
}
}
// TestReceiveIgnoresUnfederatedChannel ensures scope is enforced on ingress.
func TestReceiveIgnoresUnfederatedChannel(t *testing.T) {
injected := false
inject := func(_ context.Context, _ nostr.Event) bool {
injected = true
return true
}
cfg, _ := ParseConfig("wss://peer.example|mirror:general")
f := NewFederator(cfg, inject)
sk := nostr.Generate()
evt := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
CreatedAt: nostr.Now(),
Content: "off-topic",
Tags: nostr.Tags{{"h", "other-channel"}},
}
if err := evt.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
f.receive(cfg.Peers[0], evt)
if injected {
t.Error("chat for an unfederated channel must not be injected")
}
}
+431
View File
@@ -0,0 +1,431 @@
// Package group implements the relay's in-memory NIP-29 managed-group state: the
// authority for channel membership, admin rights, and metadata.
//
// It is a deliberately small subset of NIP-29 (the managed-group MVP):
//
// kind 9007 create group
// kind 9021 join request -> adds a member (open groups)
// kind 9022 leave request -> removes a member
// kind 9000 put user (admin) -> adds a member
// kind 9001 remove user -> removes a member
// kind 9002 edit metadata -> admin sets name/about
// kind 9/10 chat / reply -> allowed for members (or anyone if open)
//
// Metadata (39000), admins (39001) and members (39002) are (re)published as
// signed addressable events whenever the group changes. State is rebuilt from
// the event store on boot so it survives restarts.
package group
import (
"cmp"
"context"
"fmt"
"slices"
"sync"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
)
// State is the relay's in-memory NIP-29 managed-group state. It is the authority
// for membership and metadata and is safe for concurrent use.
type State struct {
mu sync.RWMutex
groups map[string]*group // keyed by group id (the `h` tag value)
sk nostr.SecretKey // relay identity, signs 39000-39002 events
pk nostr.PubKey
// IsFederated, when set, reports whether a channel is federated with a peer.
// Chat on a federated channel is accepted regardless of local membership,
// because the author is authorized on the peer relay that federates it. Nil
// (federation disabled) means "no channel is federated".
IsFederated func(groupID string) bool
}
type group struct {
id string
name string
about string
creator nostr.PubKey
members map[nostr.PubKey]bool
admins map[nostr.PubKey]bool
// open groups auto-accept join requests and allow anyone to post (public
// channels). Closed groups are members-only: a join request does NOT grant
// membership — an admin must add the user (kind 9000) — and only members may
// post. A group is created closed when its 9007 event carries a `closed` tag.
open bool
}
// NewState returns an empty group state that signs its addressable list events
// (39000-39002) with the given relay identity.
func NewState(sk nostr.SecretKey) *State {
return &State{
groups: make(map[string]*group),
sk: sk,
pk: nostr.GetPublicKey(sk),
}
}
// hasTag reports whether the event has a tag with the given name. Unlike
// Tags.Find (which ignores value-less tags), this matches flag tags such as
// ["closed"] that carry no value.
func hasTag(evt nostr.Event, name string) bool {
for _, t := range evt.Tags {
if len(t) >= 1 && t[0] == name {
return true
}
}
return false
}
// managementKinds are the NIP-29 events that mutate group state and therefore
// must be replayed on boot to reconstruct membership.
var managementKinds = []nostr.Kind{
nostr.KindSimpleGroupCreateGroup,
nostr.KindSimpleGroupJoinRequest,
nostr.KindSimpleGroupLeaveRequest,
nostr.KindSimpleGroupPutUser,
nostr.KindSimpleGroupRemoveUser,
}
// Rebuild reconstructs in-memory group state from the persisted event store so
// membership and metadata survive relay restarts. Events are replayed in
// chronological (oldest-first) order so the final state is correct; Evaluate is
// reused so replay and live handling can never diverge. Returns the number of
// management events replayed.
func (gs *State) Rebuild(store eventstore.Store) int {
var events []nostr.Event
for evt := range store.QueryEvents(nostr.Filter{Kinds: managementKinds}, 100_000) {
events = append(events, evt)
}
// Apply oldest-first. On a created_at tie, a group-create (9007) must come
// first so later same-second events (joins/puts) find the group — otherwise
// membership can be lost across a restart when events share a timestamp.
slices.SortFunc(events, func(a, b nostr.Event) int {
if a.CreatedAt != b.CreatedAt {
return cmp.Compare(a.CreatedAt, b.CreatedAt)
}
aCreate := a.Kind == nostr.KindSimpleGroupCreateGroup
bCreate := b.Kind == nostr.KindSimpleGroupCreateGroup
if aCreate != bCreate {
if aCreate {
return -1
}
return 1
}
return 0
})
for _, evt := range events {
gs.Evaluate(evt)
}
return len(events)
}
// GroupIDFromEvent returns the `h` tag value, or "" if absent.
func GroupIDFromEvent(evt nostr.Event) string {
t := evt.Tags.Find("h")
if len(t) >= 2 {
return t[1]
}
return ""
}
// firstTagValue returns the value of the first tag with the given name, or "".
func firstTagValue(evt nostr.Event, name string) string {
t := evt.Tags.Find(name)
if len(t) >= 2 {
return t[1]
}
return ""
}
// ensureGroup returns the group, creating one on first reference (channel
// auto-creation). New groups default to open unless created closed. `open` is
// only applied at creation time — it never downgrades an existing group.
func (gs *State) ensureGroup(id string, creator nostr.PubKey, open bool) *group {
g, ok := gs.groups[id]
if !ok {
g = &group{
id: id,
name: id,
creator: creator,
members: map[nostr.PubKey]bool{},
admins: map[nostr.PubKey]bool{creator: true},
open: open,
}
g.members[creator] = true
gs.groups[id] = g
}
return g
}
// Evaluate applies a management/chat event to group state and reports whether
// the relay should reject it. Called from khatru's OnEvent hook.
func (gs *State) Evaluate(evt nostr.Event) (reject bool, msg string) {
id := GroupIDFromEvent(evt)
switch evt.Kind {
case nostr.KindSimpleGroupCreateGroup: // 9007
if id == "" {
return true, "missing group id (h tag)"
}
// A `closed` tag makes the group members-only (admin-approved joins).
open := !hasTag(evt, "closed")
gs.mu.Lock()
gs.ensureGroup(id, evt.PubKey, open)
gs.mu.Unlock()
return false, ""
case nostr.KindSimpleGroupJoinRequest: // 9021
if id == "" {
return true, "missing group id (h tag)"
}
gs.mu.Lock()
g := gs.ensureGroup(id, evt.PubKey, true)
// Open groups auto-join. Closed groups accept the request event but do
// NOT grant membership — an admin must add the user (kind 9000).
if g.open {
g.members[evt.PubKey] = true
}
gs.mu.Unlock()
return false, ""
case nostr.KindSimpleGroupLeaveRequest: // 9022
gs.mu.Lock()
if g := gs.groups[id]; g != nil {
delete(g.members, evt.PubKey)
}
gs.mu.Unlock()
return false, ""
case nostr.KindSimpleGroupPutUser: // 9000 (admin adds user)
if !gs.IsAdmin(id, evt.PubKey) {
return true, "only admins can add users"
}
for _, pk := range taggedPubkeys(evt) {
gs.setMember(id, pk, true)
}
return false, ""
case nostr.KindSimpleGroupRemoveUser: // 9001 (admin removes user)
if !gs.IsAdmin(id, evt.PubKey) {
return true, "only admins can remove users"
}
for _, pk := range taggedPubkeys(evt) {
gs.setMember(id, pk, false)
}
return false, ""
case nostr.KindSimpleGroupEditMetadata: // 9002 (admin sets name/about)
if id == "" {
return true, "missing group id (h tag)"
}
if !gs.IsAdmin(id, evt.PubKey) {
return true, "only admins can edit group metadata"
}
gs.mu.Lock()
if g := gs.groups[id]; g != nil {
if name := firstTagValue(evt, "name"); name != "" {
g.name = name
}
// `about` is the room topic; allow clearing it with an empty value.
if t := evt.Tags.Find("about"); len(t) >= 2 {
g.about = t[1]
}
}
gs.mu.Unlock()
return false, ""
case nostr.KindSimpleGroupChatMessage, nostr.KindSimpleGroupThreadedReply: // 9 / 10
if id == "" {
return true, "chat message missing group id (h tag)"
}
gs.mu.RLock()
g := gs.groups[id]
gs.mu.RUnlock()
if g == nil {
// A channel we don't know locally may still be one we federate — accept
// so peer chat can land (the group is created lazily on federated write).
if gs.IsFederated != nil && gs.IsFederated(id) {
return false, ""
}
return true, "unknown group; send a join request (kind 9021) first"
}
// Federated channels behave as open across all participating relays: the
// author is a member on the peer that authorized the post, so don't gate
// federated chat on local membership.
if !g.open && !gs.IsMember(id, evt.PubKey) {
if gs.IsFederated != nil && gs.IsFederated(id) {
return false, ""
}
return true, "you are not a member of this group"
}
return false, ""
}
// Non-group events are handled by the relay's normal rules.
return false, ""
}
// IsMember reports whether pk is a member of the group.
func (gs *State) IsMember(id string, pk nostr.PubKey) bool {
gs.mu.RLock()
defer gs.mu.RUnlock()
g := gs.groups[id]
return g != nil && g.members[pk]
}
// IsAdmin reports whether pk is an admin of the group.
func (gs *State) IsAdmin(id string, pk nostr.PubKey) bool {
gs.mu.RLock()
defer gs.mu.RUnlock()
g := gs.groups[id]
return g != nil && g.admins[pk]
}
func (gs *State) setMember(id string, pk nostr.PubKey, member bool) {
gs.mu.Lock()
defer gs.mu.Unlock()
g := gs.groups[id]
if g == nil {
return
}
if member {
g.members[pk] = true
} else {
delete(g.members, pk)
}
}
// taggedPubkeys extracts valid `p` tag pubkeys from an event.
func taggedPubkeys(evt nostr.Event) []nostr.PubKey {
var out []nostr.PubKey
for t := range evt.Tags.FindAll("p") {
if len(t) >= 2 {
if pk, err := nostr.PubKeyFromHex(t[1]); err == nil {
out = append(out, pk)
}
}
}
return out
}
// MetadataEvent builds and signs the relay's kind-39000 metadata event for a
// group, so clients can discover the group's name/about via NIP-29.
func (gs *State) MetadataEvent(ctx context.Context, id string) (nostr.Event, bool) {
gs.mu.RLock()
g := gs.groups[id]
gs.mu.RUnlock()
if g == nil {
return nostr.Event{}, false
}
evt := nostr.Event{
Kind: nostr.KindSimpleGroupMetadata,
CreatedAt: nostr.Now(),
Content: "",
Tags: nostr.Tags{
{"d", id},
{"name", g.name},
{"about", g.about},
},
}
if g.open {
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
}
if err := evt.Sign(gs.sk); err != nil {
return nostr.Event{}, false
}
return evt, true
}
// AdminsEvent builds and signs the relay's kind-39001 admins list for a group.
// Each admin is a `p` tag with an "admin" role, per NIP-29.
func (gs *State) AdminsEvent(id string) (nostr.Event, bool) {
gs.mu.RLock()
g := gs.groups[id]
var admins []nostr.PubKey
if g != nil {
for pk := range g.admins {
admins = append(admins, pk)
}
}
gs.mu.RUnlock()
if g == nil {
return nostr.Event{}, false
}
evt := nostr.Event{
Kind: nostr.KindSimpleGroupAdmins,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{{"d", id}},
}
for _, pk := range admins {
evt.Tags = append(evt.Tags, nostr.Tag{"p", pk.Hex(), "admin"})
}
if err := evt.Sign(gs.sk); err != nil {
return nostr.Event{}, false
}
return evt, true
}
// MembersEvent builds and signs the relay's kind-39002 members list for a group.
func (gs *State) MembersEvent(id string) (nostr.Event, bool) {
gs.mu.RLock()
g := gs.groups[id]
var members []nostr.PubKey
if g != nil {
for pk := range g.members {
members = append(members, pk)
}
}
gs.mu.RUnlock()
if g == nil {
return nostr.Event{}, false
}
evt := nostr.Event{
Kind: nostr.KindSimpleGroupMembers,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{{"d", id}},
}
for _, pk := range members {
evt.Tags = append(evt.Tags, nostr.Tag{"p", pk.Hex()})
}
if err := evt.Sign(gs.sk); err != nil {
return nostr.Event{}, false
}
return evt, true
}
// EnsureFederatedGroup makes sure a group exists locally so that chat federated
// from a peer relay has somewhere to land. Federated chat is chat-only: the
// author is a member on the PEER (which already authorized the post), not
// necessarily here, so we must not gate it on local membership. We create the
// channel open on first sight of federated chat if it doesn't exist yet; we
// never alter membership or admin state from federated events.
func (gs *State) EnsureFederatedGroup(id string) {
if id == "" {
return
}
gs.mu.Lock()
if _, ok := gs.groups[id]; !ok {
gs.groups[id] = &group{
id: id,
name: id,
members: map[nostr.PubKey]bool{},
admins: map[nostr.PubKey]bool{},
open: true,
}
}
gs.mu.Unlock()
}
// Summary is a short human-readable description for logging/inspection.
func (gs *State) Summary() string {
gs.mu.RLock()
defer gs.mu.RUnlock()
return fmt.Sprintf("%d group(s) tracked", len(gs.groups))
}
+291
View File
@@ -0,0 +1,291 @@
package group
import (
"context"
"testing"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
)
func mustSign(t *testing.T, sk nostr.SecretKey, evt *nostr.Event) {
t.Helper()
if err := evt.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
}
func hpk(sk nostr.SecretKey) nostr.PubKey { return nostr.GetPublicKey(sk) }
// evalOK applies an event and fails if the relay rejects it.
func evalOK(t *testing.T, gs *State, evt nostr.Event) {
t.Helper()
if reject, msg := gs.Evaluate(evt); reject {
t.Fatalf("kind %d unexpectedly rejected: %s", evt.Kind, msg)
}
}
// evalRejected applies an event and fails if it is allowed.
func evalRejected(t *testing.T, gs *State, evt nostr.Event) string {
t.Helper()
reject, msg := gs.Evaluate(evt)
if !reject {
t.Fatalf("kind %d unexpectedly allowed", evt.Kind)
}
return msg
}
func TestChatRejectedWithoutGroup(t *testing.T) {
gs := NewState(nostr.Generate())
// A chat message to a group that was never created/joined is rejected.
evt := nostr.Event{
Kind: nostr.KindSimpleGroupChatMessage,
Tags: nostr.Tags{{"h", "nonexistent"}},
}
reject, msg := gs.Evaluate(evt)
if !reject {
t.Fatalf("expected rejection for chat to unknown group, got allow (%q)", msg)
}
}
func TestChatMissingHTagRejected(t *testing.T) {
gs := NewState(nostr.Generate())
evt := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage}
reject, _ := gs.Evaluate(evt)
if !reject {
t.Fatal("expected rejection for chat with no h tag")
}
}
func TestJoinThenChatAllowed(t *testing.T) {
gs := NewState(nostr.Generate())
alice := nostr.Generate()
alicePK := nostr.GetPublicKey(alice)
join := nostr.Event{PubKey: alicePK, Kind: nostr.KindSimpleGroupJoinRequest, Tags: nostr.Tags{{"h", "g1"}}}
if reject, msg := gs.Evaluate(join); reject {
t.Fatalf("join rejected: %s", msg)
}
chat := nostr.Event{PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", "g1"}}}
if reject, msg := gs.Evaluate(chat); reject {
t.Fatalf("chat after join rejected: %s", msg)
}
if !gs.IsMember("g1", alicePK) {
t.Fatal("alice should be a member after joining")
}
}
// The creator of a group is its administrator; a plain joiner is not, and only
// the admin may add or remove users.
func TestAdminModerationInClosedGroup(t *testing.T) {
gs := NewState(nostr.Generate())
admin := nostr.Generate() // creates the group → admin + member
alice := nostr.Generate() // will be admitted by the admin
bob := nostr.Generate() // will try to moderate without permission
mallory := nostr.Generate()
const g = "staff"
adminPK, alicePK, bobPK, malloryPK := hpk(admin), hpk(alice), hpk(bob), hpk(mallory)
// Admin creates a CLOSED group.
evalOK(t, gs, nostr.Event{
PubKey: adminPK, Kind: nostr.KindSimpleGroupCreateGroup,
Tags: nostr.Tags{{"h", g}, {"closed"}},
})
if !gs.IsAdmin(g, adminPK) {
t.Fatal("creator should be an admin")
}
if !gs.IsMember(g, adminPK) {
t.Fatal("creator should be a member")
}
// A join request to a closed group is accepted as an event but does NOT
// grant membership — it awaits admin approval.
evalOK(t, gs, nostr.Event{
PubKey: alicePK, Kind: nostr.KindSimpleGroupJoinRequest, Tags: nostr.Tags{{"h", g}},
})
if gs.IsMember(g, alicePK) {
t.Fatal("closed group: join request alone must not grant membership")
}
// A non-member/non-admin cannot post to the closed group.
evalRejected(t, gs, nostr.Event{
PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}},
})
// A non-admin (bob) cannot add users.
msg := evalRejected(t, gs, nostr.Event{
PubKey: bobPK, Kind: nostr.KindSimpleGroupPutUser,
Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}},
})
if msg == "" {
t.Fatal("expected a rejection reason for non-admin add")
}
// The admin admits alice (kind 9000).
evalOK(t, gs, nostr.Event{
PubKey: adminPK, Kind: nostr.KindSimpleGroupPutUser,
Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}},
})
if !gs.IsMember(g, alicePK) {
t.Fatal("admin add (9000) should grant alice membership")
}
// Now alice, a member, can post.
evalOK(t, gs, nostr.Event{
PubKey: alicePK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}},
})
// The admin can add multiple users in one event.
evalOK(t, gs, nostr.Event{
PubKey: adminPK, Kind: nostr.KindSimpleGroupPutUser,
Tags: nostr.Tags{{"h", g}, {"p", bobPK.Hex()}, {"p", malloryPK.Hex()}},
})
if !gs.IsMember(g, bobPK) || !gs.IsMember(g, malloryPK) {
t.Fatal("admin should add multiple tagged users")
}
// The admin removes mallory (kind 9001); she can no longer post.
evalOK(t, gs, nostr.Event{
PubKey: adminPK, Kind: nostr.KindSimpleGroupRemoveUser,
Tags: nostr.Tags{{"h", g}, {"p", malloryPK.Hex()}},
})
if gs.IsMember(g, malloryPK) {
t.Fatal("admin remove (9001) should revoke mallory's membership")
}
evalRejected(t, gs, nostr.Event{
PubKey: malloryPK, Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}},
})
// A non-admin (bob) cannot remove users either.
evalRejected(t, gs, nostr.Event{
PubKey: bobPK, Kind: nostr.KindSimpleGroupRemoveUser,
Tags: nostr.Tags{{"h", g}, {"p", alicePK.Hex()}},
})
if !gs.IsMember(g, alicePK) {
t.Fatal("a rejected removal must not change membership")
}
}
// Open groups let anyone post without admin approval (public channels), which
// contrasts with the closed-group moderation above.
func TestOpenGroupAllowsNonMemberPosts(t *testing.T) {
gs := NewState(nostr.Generate())
admin := nostr.Generate()
stranger := nostr.Generate()
const g = "lobby"
evalOK(t, gs, nostr.Event{
PubKey: hpk(admin), Kind: nostr.KindSimpleGroupCreateGroup, Tags: nostr.Tags{{"h", g}},
})
// A stranger who never joined can still post to an open group.
evalOK(t, gs, nostr.Event{
PubKey: hpk(stranger), Kind: nostr.KindSimpleGroupChatMessage, Tags: nostr.Tags{{"h", g}},
})
}
// Group membership and metadata must survive a relay restart: state is rebuilt
// from the persisted event store rather than lost. This reproduces the bug
// where an in-memory-only State silently dropped all groups on restart.
func TestStateSurvivesRebuild(t *testing.T) {
store := &slicestore.SliceStore{}
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
admin := nostr.Generate()
alice := nostr.Generate()
const g = "persisted"
// Simulate the live path: Evaluate accepts the event, then it is stored
// (khatru stores accepted events; we do it explicitly here).
apply := func(gs *State, evt nostr.Event) {
if reject, msg := gs.Evaluate(evt); reject {
t.Fatalf("kind %d rejected: %s", evt.Kind, msg)
}
if err := store.SaveEvent(evt); err != nil {
t.Fatalf("save: %v", err)
}
}
signed := func(sk nostr.SecretKey, kind nostr.Kind, tags nostr.Tags) nostr.Event {
e := nostr.Event{Kind: kind, CreatedAt: nostr.Now(), Tags: tags}
mustSign(t, sk, &e)
return e
}
// Build state in the "first boot": admin creates a closed group and admits alice.
first := NewState(nostr.Generate())
apply(first, signed(admin, nostr.KindSimpleGroupCreateGroup, nostr.Tags{{"h", g}, {"closed"}}))
apply(first, signed(admin, nostr.KindSimpleGroupPutUser, nostr.Tags{{"h", g}, {"p", hpk(alice).Hex()}}))
if !first.IsMember(g, hpk(alice)) || !first.IsAdmin(g, hpk(admin)) {
t.Fatal("precondition: alice member + admin admin should hold before restart")
}
// "Restart": a fresh State that knows nothing until it replays the store.
second := NewState(nostr.Generate())
if second.IsMember(g, hpk(alice)) {
t.Fatal("fresh state should be empty before rebuild")
}
replayed := second.Rebuild(store)
if replayed != 2 {
t.Fatalf("expected 2 management events replayed, got %d", replayed)
}
// Membership, admin rights, and closed-ness must be restored.
if !second.IsMember(g, hpk(admin)) {
t.Fatal("admin membership lost across rebuild")
}
if !second.IsMember(g, hpk(alice)) {
t.Fatal("alice membership lost across rebuild")
}
if !second.IsAdmin(g, hpk(admin)) {
t.Fatal("admin rights lost across rebuild")
}
// Closed-group enforcement must still apply after rebuild: a non-member is rejected.
stranger := nostr.Generate()
if reject, _ := second.Evaluate(signed(stranger, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}})); !reject {
t.Fatal("closed-group enforcement lost across rebuild: stranger post should be rejected")
}
// A restored member can still post.
if reject, msg := second.Evaluate(signed(alice, nostr.KindSimpleGroupChatMessage, nostr.Tags{{"h", g}})); reject {
t.Fatalf("restored member alice should be able to post: %s", msg)
}
}
// Only admins can edit group metadata (topic), and the edit is applied to state.
func TestEditMetadataAdminOnly(t *testing.T) {
gs := NewState(nostr.Generate())
admin := nostr.Generate()
stranger := nostr.Generate()
const g = "topictest"
// Admin creates the group.
evalOK(t, gs, nostr.Event{
PubKey: hpk(admin), Kind: nostr.KindSimpleGroupCreateGroup, Tags: nostr.Tags{{"h", g}},
})
// A non-admin cannot set the topic.
evalRejected(t, gs, nostr.Event{
PubKey: hpk(stranger), Kind: nostr.KindSimpleGroupEditMetadata,
Tags: nostr.Tags{{"h", g}, {"about", "hostile takeover"}},
})
// The admin sets name + topic, and state reflects it.
evalOK(t, gs, nostr.Event{
PubKey: hpk(admin), Kind: nostr.KindSimpleGroupEditMetadata,
Tags: nostr.Tags{{"h", g}, {"name", "Topic Test"}, {"about", "the topic"}},
})
meta, ok := gs.MetadataEvent(context.Background(), g)
if !ok {
t.Fatal("expected metadata event")
}
if firstTagValue(meta, "name") != "Topic Test" {
t.Fatalf("name not applied: %q", firstTagValue(meta, "name"))
}
if firstTagValue(meta, "about") != "the topic" {
t.Fatalf("about (topic) not applied: %q", firstTagValue(meta, "about"))
}
}
+42
View File
@@ -0,0 +1,42 @@
// Package relayinfo implements the Nosterm capability handshake.
//
// A standard NIP-11 relay information document describes supported NIPs. A
// "nostermd" additionally advertises a `features` array so that a Nosterm
// client can enable/disable UI capabilities and gracefully fall back when
// connected to a plain Nostr relay that omits it.
//
// The upstream nip11.RelayInformationDocument has no `features` field and its
// custom JSON marshaller drops unknown keys, so the relay serves NIP-11 itself
// and merges these fields into the JSON object.
package relayinfo
const (
// SoftwareName is advertised as the NIP-11 `software` value.
SoftwareName = "nostermd"
// SoftwareVersion is advertised as the NIP-11 `version` value.
SoftwareVersion = "0.1.0"
)
// baseFeatures advertised by every relay build. Presence/typing/moderation/etc.
// are declared here as they are implemented; the baseline relay supports managed
// NIP-29 channels only. Runtime-dependent features (e.g. federation) are added
// in Extras based on configuration.
var baseFeatures = []string{
"channels", // NIP-29 managed group channels
}
// Extras returns the extra top-level keys merged into the NIP-11 document JSON.
// Keeping this in one place makes the handshake easy to extend. `federated`
// reflects whether this relay has any federation peer configured, so a client
// can surface that a channel here may be mirrored elsewhere.
func Extras(federated bool) map[string]any {
features := append([]string{}, baseFeatures...)
if federated {
features = append(features, "federation") // chat-only relay↔relay mirroring
}
return map[string]any{
"software": SoftwareName,
"version": SoftwareVersion,
"features": features,
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package retention prunes old CHAT messages (kinds 9/10) so the event store
// does not grow without bound. Group-management and addressable metadata events
// (9007/9000/9001/9021/9022, 39000-39002) are NEVER pruned — they define
// membership and must survive to be replayed on boot.
package retention
import (
"log"
"slices"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
)
// Config controls what the retention sweeper deletes.
type Config struct {
// MaxAge: chat messages older than this are deleted. Zero disables age pruning.
MaxAge time.Duration
// MaxMessages: keep at most this many chat messages (newest kept). Zero
// disables count pruning.
MaxMessages int
// Interval between sweeps.
Interval time.Duration
}
// chatKinds are the only kinds eligible for retention pruning.
var chatKinds = []nostr.Kind{
nostr.KindSimpleGroupChatMessage,
nostr.KindSimpleGroupThreadedReply,
}
// Sweep runs a single retention pass and returns the number of events deleted.
// `now` is injected so the logic is deterministically testable.
func Sweep(store eventstore.Store, cfg Config, now time.Time) int {
// Collect all chat events (newest-first from the store).
var chats []nostr.Event
for evt := range store.QueryEvents(nostr.Filter{Kinds: chatKinds}, 1_000_000) {
chats = append(chats, evt)
}
// Sort newest-first so index >= MaxMessages are the surplus oldest ones.
slices.SortFunc(chats, nostr.CompareEventReverse)
cutoff := nostr.Timestamp(0)
if cfg.MaxAge > 0 {
cutoff = nostr.Timestamp(now.Add(-cfg.MaxAge).Unix())
}
deleted := 0
for i, evt := range chats {
tooOld := cfg.MaxAge > 0 && evt.CreatedAt < cutoff
surplus := cfg.MaxMessages > 0 && i >= cfg.MaxMessages
if tooOld || surplus {
if err := store.DeleteEvent(evt.ID); err == nil {
deleted++
}
}
}
return deleted
}
// Start launches a background sweeper. It is a no-op (returns a no-op stop) when
// neither limit is configured. The returned stop function halts it.
func Start(store eventstore.Store, cfg Config) (stop func()) {
if cfg.MaxAge == 0 && cfg.MaxMessages == 0 {
log.Println("retention disabled (no RELAY_RETENTION_* limits set)")
return func() {}
}
if cfg.Interval == 0 {
cfg.Interval = time.Hour
}
done := make(chan struct{})
go func() {
// Sweep once at startup, then on the interval.
if n := Sweep(store, cfg, time.Now()); n > 0 {
log.Printf("retention: pruned %d old chat message(s)", n)
}
ticker := time.NewTicker(cfg.Interval)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if n := Sweep(store, cfg, time.Now()); n > 0 {
log.Printf("retention: pruned %d old chat message(s)", n)
}
}
}
}()
log.Printf("retention enabled (maxAge=%s maxMessages=%d interval=%s)",
cfg.MaxAge, cfg.MaxMessages, cfg.Interval)
return func() { close(done) }
}
+116
View File
@@ -0,0 +1,116 @@
package retention
import (
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
)
func mkStore(t *testing.T) *slicestore.SliceStore {
t.Helper()
s := &slicestore.SliceStore{}
if err := s.Init(); err != nil {
t.Fatalf("init: %v", err)
}
return s
}
func save(t *testing.T, s *slicestore.SliceStore, sk nostr.SecretKey, kind nostr.Kind, createdAt nostr.Timestamp) nostr.ID {
t.Helper()
e := nostr.Event{Kind: kind, CreatedAt: createdAt, Tags: nostr.Tags{{"h", "g"}}}
if kind == nostr.KindSimpleGroupChatMessage {
e.Content = "msg"
}
if err := e.Sign(sk); err != nil {
t.Fatalf("sign: %v", err)
}
if err := s.SaveEvent(e); err != nil {
t.Fatalf("save: %v", err)
}
return e.ID
}
func count(s *slicestore.SliceStore, kinds ...nostr.Kind) int {
n := 0
for range s.QueryEvents(nostr.Filter{Kinds: kinds}, 1_000_000) {
n++
}
return n
}
func TestRetentionPrunesOldChatByAge(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
old := nostr.Timestamp(now.Add(-48 * time.Hour).Unix())
fresh := nostr.Timestamp(now.Add(-1 * time.Hour).Unix())
save(t, s, sk, nostr.KindSimpleGroupChatMessage, old)
save(t, s, sk, nostr.KindSimpleGroupChatMessage, fresh)
deleted := Sweep(s, Config{MaxAge: 24 * time.Hour}, now)
if deleted != 1 {
t.Fatalf("expected 1 pruned, got %d", deleted)
}
if c := count(s, nostr.KindSimpleGroupChatMessage); c != 1 {
t.Fatalf("expected 1 chat remaining, got %d", c)
}
}
func TestRetentionPrunesSurplusByCount(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
for i := 0; i < 5; i++ {
save(t, s, sk, nostr.KindSimpleGroupChatMessage, nostr.Timestamp(1_700_000_000+int64(i)))
}
deleted := Sweep(s, Config{MaxMessages: 2}, now)
if deleted != 3 {
t.Fatalf("expected 3 pruned, got %d", deleted)
}
if c := count(s, nostr.KindSimpleGroupChatMessage); c != 2 {
t.Fatalf("expected 2 chats remaining, got %d", c)
}
}
func TestRetentionNeverPrunesManagementEvents(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
ancient := nostr.Timestamp(now.Add(-1000 * time.Hour).Unix())
// Very old management + metadata events must be preserved.
save(t, s, sk, nostr.KindSimpleGroupCreateGroup, ancient)
save(t, s, sk, nostr.KindSimpleGroupPutUser, ancient)
save(t, s, sk, nostr.KindSimpleGroupJoinRequest, ancient)
save(t, s, sk, nostr.KindSimpleGroupMetadata, ancient)
save(t, s, sk, nostr.KindSimpleGroupAdmins, ancient)
save(t, s, sk, nostr.KindSimpleGroupMembers, ancient)
// An old chat that SHOULD be pruned.
save(t, s, sk, nostr.KindSimpleGroupChatMessage, ancient)
deleted := Sweep(s, Config{MaxAge: time.Hour, MaxMessages: 1}, now)
if deleted != 1 {
t.Fatalf("expected only the 1 chat pruned, got %d", deleted)
}
// All 6 management/metadata events survive.
mgmt := count(s,
nostr.KindSimpleGroupCreateGroup, nostr.KindSimpleGroupPutUser,
nostr.KindSimpleGroupJoinRequest, nostr.KindSimpleGroupMetadata,
nostr.KindSimpleGroupAdmins, nostr.KindSimpleGroupMembers)
if mgmt != 6 {
t.Fatalf("management events must never be pruned; have %d/6", mgmt)
}
}
func TestRetentionDisabledIsNoop(t *testing.T) {
s := mkStore(t)
sk := nostr.Generate()
now := time.Unix(1_700_000_000, 0)
save(t, s, sk, nostr.KindSimpleGroupChatMessage, nostr.Timestamp(1))
if d := Sweep(s, Config{}, now); d != 0 {
t.Fatalf("no limits should prune nothing, got %d", d)
}
}