Initial commit: Standard Nostr relay with NIP-86 management API
Some checks failed
Build and Push Docker Image / build (push) Failing after 7s

Features:
- NIP-86 management API with NIP-98 authentication
- Dual mode operation: blacklist (default) or allowlist mode
- BoltDB storage for events and management data
- Negentropy sync support
- Docker deployment ready
This commit is contained in:
@s.roertgen 2026-02-12 13:18:31 +01:00
commit f623e7c969
11 changed files with 1120 additions and 0 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
.git
.env
data/
*.db
*.md
!README.md

18
.env.example Normal file
View file

@ -0,0 +1,18 @@
# Relay operator pubkey (hex format) - becomes the primary admin
PUBKEY=
# Additional admin pubkeys (comma-separated hex format)
ADMIN_PUBKEYS=
# Relay info (NIP-11)
NAME=Standard Relay
DESCRIPTION=A standard Nostr relay with NIP-86 management API
ICON=
# Server configuration
PORT=3334
DB_PATH=./data/relay.db
# Allowlist mode (optional)
# When enabled, only pubkeys explicitly added to the allowlist can publish events
# ALLOWLIST_MODE=true

View file

@ -0,0 +1,51 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
REGISTRY: git.edufeed.org
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: docker-builder
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Forgejo Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Binaries
standard-86-relay
relay
# Data
data/
*.db
# Environment
.env
# IDE
.idea/
.vscode/

32
Dockerfile Normal file
View file

@ -0,0 +1,32 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/standard-86-relay .
# Start a new stage from scratch
FROM alpine:latest
WORKDIR /root/
# Copy the binary from the builder stage
COPY --from=builder /app/standard-86-relay .
# Create data directory
RUN mkdir -p /data
# Expose port 3334
EXPOSE 3334
# Command to run the executable
CMD ["./standard-86-relay"]

123
README.md Normal file
View file

@ -0,0 +1,123 @@
# Standard-86-Relay
A standard Nostr relay with NIP-86 Management API support built on [khatru](https://github.com/fiatjaf/khatru).
## Features
- **NIP-86 Management API**: Full support for relay management via HTTP/JSON-RPC
- **NIP-98 Authentication**: Secure admin authentication using signed Nostr events
- **BoltDB Storage**: Embedded key-value storage for events and management data
- **Negentropy Sync**: Efficient event synchronization with other relays
- **Dual Mode Operation**: Choose between blacklist (ban) or allowlist (whitelist) modes
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PUBKEY` | Relay operator pubkey (hex format) - becomes the primary admin | Required |
| `ADMIN_PUBKEYS` | Additional admin pubkeys (comma-separated hex) | Optional |
| `NAME` | Relay name (NIP-11) | `Standard Relay` |
| `DESCRIPTION` | Relay description (NIP-11) | Empty |
| `ICON` | Relay icon URL (NIP-11) | Empty |
| `PORT` | HTTP server port | `3334` |
| `DB_PATH` | Path to BoltDB database file | `./data/relay.db` |
| `ALLOWLIST_MODE` | Enable allowlist mode (`true`/`false`) | `false` |
## Operating Modes
### Blacklist Mode (Default)
In blacklist mode, all pubkeys can publish events unless explicitly banned:
- Use `banpubkey` to block a pubkey from publishing
- Use `allowpubkey` to unban a previously banned pubkey
- `listbannedpubkeys` returns all banned pubkeys
### Allowlist Mode
When `ALLOWLIST_MODE=true`, only explicitly allowed pubkeys can publish:
- On startup, operator and admin pubkeys are automatically added to the allowlist
- Use `allowpubkey` to add a pubkey to the allowlist
- Use `listallowedpubkeys` to view all allowed pubkeys
- Pubkeys not in the allowlist receive "pubkey not in allowlist" rejection
## NIP-86 API Methods
All methods require NIP-98 authentication with an admin pubkey.
### Pubkey Management
| Method | Description |
|--------|-------------|
| `banpubkey` | Ban a pubkey from publishing (params: `[pubkey, reason?]`) |
| `allowpubkey` | Add to allowlist AND remove from ban list (params: `[pubkey, reason?]`) |
| `listbannedpubkeys` | List all banned pubkeys with reasons |
| `listallowedpubkeys` | List all allowed pubkeys with reasons |
### Event Management
| Method | Description |
|--------|-------------|
| `banevent` | Ban an event by ID (params: `[id, reason?]`) |
| `allowevent` | Remove event from ban list (params: `[id, reason?]`) |
| `listbannedevents` | List all banned event IDs with reasons |
### Relay Configuration
| Method | Description |
|--------|-------------|
| `changerelayname` | Update relay name (params: `[name]`) |
| `changerelaydescription` | Update relay description (params: `[description]`) |
| `changerelayicon` | Update relay icon URL (params: `[url]`) |
### Admin Management
| Method | Description |
|--------|-------------|
| `grantadmin` | Grant admin access to a pubkey (params: `[pubkey, methods?]`) |
| `revokeadmin` | Revoke admin access (params: `[pubkey, methods?]`) |
## Quick Start
### Using Docker
```bash
docker build -t standard-86-relay .
docker run -d \
-e PUBKEY=your_hex_pubkey \
-e NAME="My Relay" \
-p 3334:3334 \
-v relay-data:/app/data \
standard-86-relay
```
### Using Docker Compose
```bash
cp .env.example .env
# Edit .env with your pubkey and settings
docker compose up -d
```
### From Source
```bash
go build -o relay .
PUBKEY=your_hex_pubkey ./relay
```
## Example API Call
```bash
# Create NIP-98 auth event and call API
curl -X POST http://localhost:3334 \
-H "Content-Type: application/nostr+json+rpc" \
-H "Authorization: Nostr <base64-encoded-signed-event>" \
-d '{"method":"listbannedpubkeys","params":[]}'
```
## Related Projects
- [relay-manager](../relay-manager) - Web UI for managing this relay
- [khatru](https://github.com/fiatjaf/khatru) - The underlying relay framework

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
services:
standard-86-relay:
build:
context: .
restart: on-failure
ports:
- "${PORT:-3334}:${PORT:-3334}"
volumes:
- ./data:/data
environment:
- NAME=${NAME:-Standard Relay}
- DESCRIPTION=${DESCRIPTION:-A standard Nostr relay with NIP-86 management API}
- ICON=${ICON}
- PUBKEY=${PUBKEY}
- ADMIN_PUBKEYS=${ADMIN_PUBKEYS}
- PORT=${PORT:-3334}
- DB_PATH=/data/relay.db

54
go.mod Normal file
View file

@ -0,0 +1,54 @@
module git.edufeed.org/edufeed/standard-86-relay
go 1.25.5
replace fiatjaf.com/nostr => git.edufeed.org/edufeed/nostrlib v0.0.0-20260210102229-b6e185a36e88
require (
fiatjaf.com/nostr v0.0.0
github.com/joho/godotenv v1.5.1
go.etcd.io/bbolt v1.4.2
)
require (
github.com/FastFilter/xorfilter v0.2.1 // 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 v0.24.2 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.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/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elnosh/gonuts v0.4.2 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // 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
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.39.0 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect
)

209
go.sum Normal file
View file

@ -0,0 +1,209 @@
git.edufeed.org/edufeed/nostrlib v0.0.0-20260210102229-b6e185a36e88 h1:KkwsihRPz6UEMyjBqeINew5sDBwlS2jV45Ng8tyRGwg=
git.edufeed.org/edufeed/nostrlib v0.0.0-20260210102229-b6e185a36e88/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I=
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/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
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 v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
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/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
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/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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 v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
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.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
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/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/elnosh/gonuts v0.4.2 h1:/WubPAWGxTE+okJ0WPvmtEzTzpi04RGxiTHAF1FYU+M=
github.com/elnosh/gonuts v0.4.2/go.mod h1:vgZomh4YQk7R3w4ltZc0sHwCmndfHkuX6V4sga/8oNs=
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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
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/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
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.7.0/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/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
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/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
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/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
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.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
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.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=

290
main.go Normal file
View file

@ -0,0 +1,290 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime/debug"
"strings"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/boltdb"
"fiatjaf.com/nostr/khatru"
"fiatjaf.com/nostr/khatru/policies"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip86"
"github.com/joho/godotenv"
)
func main() {
// Load .env file (optional — Docker passes env vars directly)
if err := godotenv.Load(); err != nil {
if !os.IsNotExist(err) {
fmt.Printf("Error loading .env file: %v\n", err)
}
}
// BoltDB backend (event persistence)
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./data/relay.db"
}
if dir := filepath.Dir(dbPath); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
panic(fmt.Sprintf("failed to create data directory %s: %v", dir, err))
}
}
boltDB := boltdb.BoltBackend{Path: dbPath}
if err := boltDB.Init(); err != nil {
panic(err)
}
defer boltDB.Close()
// Management store (bans + admins) — shares the same bbolt database
mgmt := ManagementStore{}
if err := mgmt.Init(boltDB.DB); err != nil {
panic(err)
}
// Create relay
relay := khatru.NewRelay()
// Relay info from environment
relay.Info.Name = getEnvOrDefault("NAME", "Standard Relay")
relay.Info.Description = os.Getenv("DESCRIPTION")
relay.Info.Icon = os.Getenv("ICON")
// Override with persisted values if set
if name := mgmt.GetRelayInfo("name"); name != "" {
relay.Info.Name = name
}
if desc := mgmt.GetRelayInfo("description"); desc != "" {
relay.Info.Description = desc
}
if icon := mgmt.GetRelayInfo("icon"); icon != "" {
relay.Info.Icon = icon
}
// NIP-11: Software identification
relay.Info.Software = "https://git.edufeed.org/edufeed/standard-86-relay"
relay.Info.Version = getVersion()
// NIP-11: Supported NIPs
relay.Info.AddSupportedNIPs([]int{9, 45, 86}) // deletion, count, management
// NIP-11: Limitations
relay.Info.Limitation = &nip11.RelayLimitationDocument{
MaxLimit: 500,
RestrictedWrites: false,
AuthRequired: false,
}
// Parse relay operator pubkey
var operatorPK nostr.PubKey
if pkHex := os.Getenv("PUBKEY"); pkHex != "" {
pk, err := nostr.PubKeyFromHex(pkHex)
if err != nil {
fmt.Printf("Error parsing PUBKEY: %v\n", err)
} else {
operatorPK = pk
relay.Info.PubKey = &pk
// Ensure operator is always admin with full access
mgmt.GrantAdmin(pk, nil)
}
}
// Build admin pubkey set from environment
var adminPubkeys []nostr.PubKey
if adminList := os.Getenv("ADMIN_PUBKEYS"); adminList != "" {
for _, hex := range strings.Split(adminList, ",") {
hex = strings.TrimSpace(hex)
if pk, err := nostr.PubKeyFromHex(hex); err == nil {
mgmt.GrantAdmin(pk, nil) // full access
adminPubkeys = append(adminPubkeys, pk)
} else {
fmt.Printf("Error parsing admin pubkey %q: %v\n", hex, err)
}
}
}
// Configure allowlist mode from environment
if os.Getenv("ALLOWLIST_MODE") == "true" {
mgmt.SetAllowlistMode(true)
fmt.Println("Relay running in ALLOWLIST mode")
// Auto-add operator and admin pubkeys to allowlist
if operatorPK != (nostr.PubKey{}) {
mgmt.AddToAllowlist(operatorPK, "operator")
}
for _, pk := range adminPubkeys {
mgmt.AddToAllowlist(pk, "admin")
}
}
// Use the event store
relay.UseEventstore(&boltDB, 500)
// Enable negentropy sync
relay.Negentropy = true
// Set up policies
relay.OnEvent = policies.SeqEvent(
policies.ValidateKind,
policies.PreventTimestampsInTheFuture(time.Minute*10),
policies.PreventLargeContent(100000),
func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
if mgmt.IsAllowlistMode() {
// Allowlist mode: reject if NOT in allowlist
if !mgmt.IsAllowedPubKey(event.PubKey) {
return true, "pubkey not in allowlist"
}
} else {
// Blacklist mode: reject if banned
if mgmt.IsPubKeyBanned(event.PubKey) {
return true, "pubkey is banned"
}
}
return false, ""
},
func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
if mgmt.IsEventBanned(event.ID) {
return true, "event is banned"
}
return false, ""
},
)
relay.OnRequest = policies.SeqRequest(
policies.NoEmptyFilters,
policies.NoComplexFilters,
)
// NIP-86 Management API
relay.ManagementAPI.OnAPICall = func(ctx context.Context, mp nip86.MethodParams) (reject bool, msg string) {
authed, ok := khatru.GetAuthed(ctx)
if !ok {
return true, "authentication required"
}
// Check if pubkey can call this method
if !mgmt.CanCallMethod(authed, mp.MethodName()) {
return true, "not authorized for this method"
}
return false, ""
}
// Pubkey management
relay.ManagementAPI.BanPubKey = func(ctx context.Context, pubkey nostr.PubKey, reason string) error {
// Remove from allowlist when banning
mgmt.RemoveFromAllowlist(pubkey)
return mgmt.BanPubKey(pubkey, reason)
}
relay.ManagementAPI.ListBannedPubKeys = func(ctx context.Context) ([]nip86.PubKeyReason, error) {
return mgmt.ListBannedPubKeys()
}
relay.ManagementAPI.AllowPubKey = func(ctx context.Context, pubkey nostr.PubKey, reason string) error {
// Always add to allowlist
if err := mgmt.AddToAllowlist(pubkey, reason); err != nil {
return err
}
// Also remove from ban list (unban)
return mgmt.AllowPubKey(pubkey)
}
relay.ManagementAPI.ListAllowedPubKeys = func(ctx context.Context) ([]nip86.PubKeyReason, error) {
return mgmt.ListAllowedPubKeys()
}
// Event management
relay.ManagementAPI.BanEvent = func(ctx context.Context, id nostr.ID, reason string) error {
// Also delete the event from storage
if err := boltDB.DeleteEvent(id); err != nil {
fmt.Printf("Warning: could not delete event %s: %v\n", id, err)
}
return mgmt.BanEvent(id, reason)
}
relay.ManagementAPI.ListBannedEvents = func(ctx context.Context) ([]nip86.IDReason, error) {
return mgmt.ListBannedEvents()
}
relay.ManagementAPI.AllowEvent = func(ctx context.Context, id nostr.ID, reason string) error {
return mgmt.AllowEvent(id)
}
// Relay info management (persisted)
relay.ManagementAPI.ChangeRelayName = func(ctx context.Context, name string) error {
relay.Info.Name = name
return mgmt.SaveRelayInfo("name", name)
}
relay.ManagementAPI.ChangeRelayDescription = func(ctx context.Context, desc string) error {
relay.Info.Description = desc
return mgmt.SaveRelayInfo("description", desc)
}
relay.ManagementAPI.ChangeRelayIcon = func(ctx context.Context, icon string) error {
relay.Info.Icon = icon
return mgmt.SaveRelayInfo("icon", icon)
}
// Admin management
relay.ManagementAPI.GrantAdmin = func(ctx context.Context, pubkey nostr.PubKey, methods []string) error {
// Don't allow granting to yourself or modifying the operator
if pubkey == operatorPK {
return nil // silently ignore
}
return mgmt.GrantAdmin(pubkey, methods)
}
relay.ManagementAPI.RevokeAdmin = func(ctx context.Context, pubkey nostr.PubKey, methods []string) error {
// Don't allow revoking the operator
if pubkey == operatorPK {
return nil // silently ignore
}
return mgmt.RevokeAdmin(pubkey, methods)
}
// Stats
relay.ManagementAPI.Stats = func(ctx context.Context) (nip86.Response, error) {
count, _ := boltDB.CountEvents(nostr.Filter{})
return nip86.Response{
Result: map[string]any{
"event_count": count,
"uptime": time.Since(startTime).String(),
},
}, nil
}
port := os.Getenv("PORT")
if port == "" {
port = "3334"
}
fmt.Printf("Standard relay running on :%s\n", port)
if operatorPK != (nostr.PubKey{}) {
fmt.Printf("Operator pubkey: %s\n", operatorPK.Hex())
}
http.ListenAndServe(":"+port, relay)
}
var startTime = time.Now()
// getVersion returns the git commit hash from build info, or "dev" if unavailable.
func getVersion() string {
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" {
if len(setting.Value) > 7 {
return setting.Value[:7]
}
return setting.Value
}
}
}
return "dev"
}
// getEnvOrDefault returns the environment variable value or a default.
func getEnvOrDefault(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}

306
management.go Normal file
View file

@ -0,0 +1,306 @@
package main
import (
"encoding/json"
"slices"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip86"
"go.etcd.io/bbolt"
)
var (
bucketBannedPubKeys = []byte("banned_pubkeys")
bucketBannedEvents = []byte("banned_events")
bucketAllowedPubKeys = []byte("allowed_pubkeys")
bucketAdmins = []byte("admins")
bucketRelayInfo = []byte("relay_info")
)
const (
relayInfoKeyAllowlistMode = "allowlist_mode"
)
type ManagementStore struct {
DB *bbolt.DB
}
func (m *ManagementStore) Init(db *bbolt.DB) error {
m.DB = db
return db.Update(func(tx *bbolt.Tx) error {
for _, bucket := range [][]byte{bucketBannedPubKeys, bucketBannedEvents, bucketAllowedPubKeys, bucketAdmins, bucketRelayInfo} {
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
return err
}
}
return nil
})
}
type reasonEntry struct {
Reason string `json:"reason,omitempty"`
}
type adminEntry struct {
Methods []string `json:"methods,omitempty"` // empty = full access
}
// BanPubKey adds a pubkey to the ban list.
func (m *ManagementStore) BanPubKey(pubkey nostr.PubKey, reason string) error {
val, _ := json.Marshal(reasonEntry{Reason: reason})
return m.DB.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketBannedPubKeys).Put([]byte(pubkey.Hex()), val)
})
}
// AllowPubKey removes a pubkey from the ban list.
func (m *ManagementStore) AllowPubKey(pubkey nostr.PubKey) error {
return m.DB.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketBannedPubKeys).Delete([]byte(pubkey.Hex()))
})
}
// ListBannedPubKeys returns all banned pubkeys.
func (m *ManagementStore) ListBannedPubKeys() ([]nip86.PubKeyReason, error) {
result := []nip86.PubKeyReason{} // Initialize as empty slice, not nil
err := m.DB.View(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketBannedPubKeys).ForEach(func(k, v []byte) error {
pk, err := nostr.PubKeyFromHex(string(k))
if err != nil {
return nil // skip invalid entries
}
var entry reasonEntry
json.Unmarshal(v, &entry)
result = append(result, nip86.PubKeyReason{PubKey: pk, Reason: entry.Reason})
return nil
})
})
return result, err
}
// IsPubKeyBanned checks if a pubkey is banned.
func (m *ManagementStore) IsPubKeyBanned(pubkey nostr.PubKey) bool {
var banned bool
m.DB.View(func(tx *bbolt.Tx) error {
if tx.Bucket(bucketBannedPubKeys).Get([]byte(pubkey.Hex())) != nil {
banned = true
}
return nil
})
return banned
}
// AddToAllowlist adds a pubkey to the allowlist.
func (m *ManagementStore) AddToAllowlist(pubkey nostr.PubKey, reason string) error {
val, _ := json.Marshal(reasonEntry{Reason: reason})
return m.DB.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketAllowedPubKeys).Put([]byte(pubkey.Hex()), val)
})
}
// RemoveFromAllowlist removes a pubkey from the allowlist.
func (m *ManagementStore) RemoveFromAllowlist(pubkey nostr.PubKey) error {
return m.DB.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketAllowedPubKeys).Delete([]byte(pubkey.Hex()))
})
}
// ListAllowedPubKeys returns all allowed pubkeys.
func (m *ManagementStore) ListAllowedPubKeys() ([]nip86.PubKeyReason, error) {
result := []nip86.PubKeyReason{} // Initialize as empty slice, not nil
err := m.DB.View(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketAllowedPubKeys).ForEach(func(k, v []byte) error {
pk, err := nostr.PubKeyFromHex(string(k))
if err != nil {
return nil // skip invalid entries
}
var entry reasonEntry
json.Unmarshal(v, &entry)
result = append(result, nip86.PubKeyReason{PubKey: pk, Reason: entry.Reason})
return nil
})
})
return result, err
}
// IsAllowedPubKey checks if a pubkey is in the allowlist.
func (m *ManagementStore) IsAllowedPubKey(pubkey nostr.PubKey) bool {
var allowed bool
m.DB.View(func(tx *bbolt.Tx) error {
if tx.Bucket(bucketAllowedPubKeys).Get([]byte(pubkey.Hex())) != nil {
allowed = true
}
return nil
})
return allowed
}
// SetAllowlistMode enables or disables allowlist mode.
func (m *ManagementStore) SetAllowlistMode(enabled bool) error {
value := "false"
if enabled {
value = "true"
}
return m.SaveRelayInfo(relayInfoKeyAllowlistMode, value)
}
// IsAllowlistMode returns true if the relay is in allowlist mode.
func (m *ManagementStore) IsAllowlistMode() bool {
return m.GetRelayInfo(relayInfoKeyAllowlistMode) == "true"
}
// BanEvent adds an event ID to the ban list.
func (m *ManagementStore) BanEvent(id nostr.ID, reason string) error {
val, _ := json.Marshal(reasonEntry{Reason: reason})
return m.DB.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketBannedEvents).Put([]byte(id.Hex()), val)
})
}
// AllowEvent removes an event ID from the ban list.
func (m *ManagementStore) AllowEvent(id nostr.ID) error {
return m.DB.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketBannedEvents).Delete([]byte(id.Hex()))
})
}
// ListBannedEvents returns all banned event IDs.
func (m *ManagementStore) ListBannedEvents() ([]nip86.IDReason, error) {
result := []nip86.IDReason{} // Initialize as empty slice, not nil
err := m.DB.View(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketBannedEvents).ForEach(func(k, v []byte) error {
id, err := nostr.IDFromHex(string(k))
if err != nil {
return nil // skip invalid entries
}
var entry reasonEntry
json.Unmarshal(v, &entry)
result = append(result, nip86.IDReason{ID: id, Reason: entry.Reason})
return nil
})
})
return result, err
}
// IsEventBanned checks if an event is banned.
func (m *ManagementStore) IsEventBanned(id nostr.ID) bool {
var banned bool
m.DB.View(func(tx *bbolt.Tx) error {
if tx.Bucket(bucketBannedEvents).Get([]byte(id.Hex())) != nil {
banned = true
}
return nil
})
return banned
}
// GrantAdmin adds or updates admin permissions for a pubkey.
func (m *ManagementStore) GrantAdmin(pubkey nostr.PubKey, methods []string) error {
return m.DB.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bucketAdmins)
key := []byte(pubkey.Hex())
// Get existing entry
var entry adminEntry
if existing := bucket.Get(key); existing != nil {
json.Unmarshal(existing, &entry)
}
if len(methods) == 0 {
// Grant full access
entry.Methods = nil
} else {
// Add new methods to existing
for _, m := range methods {
if !slices.Contains(entry.Methods, m) {
entry.Methods = append(entry.Methods, m)
}
}
}
val, _ := json.Marshal(entry)
return bucket.Put(key, val)
})
}
// RevokeAdmin removes admin permissions for a pubkey.
func (m *ManagementStore) RevokeAdmin(pubkey nostr.PubKey, methods []string) error {
return m.DB.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bucketAdmins)
key := []byte(pubkey.Hex())
if len(methods) == 0 {
// Revoke all access
return bucket.Delete(key)
}
// Get existing entry
existing := bucket.Get(key)
if existing == nil {
return nil
}
var entry adminEntry
json.Unmarshal(existing, &entry)
// Remove specific methods
entry.Methods = slices.DeleteFunc(entry.Methods, func(m string) bool {
return slices.Contains(methods, m)
})
val, _ := json.Marshal(entry)
return bucket.Put(key, val)
})
}
// IsAdmin checks if a pubkey is an admin.
func (m *ManagementStore) IsAdmin(pubkey nostr.PubKey) bool {
var isAdmin bool
m.DB.View(func(tx *bbolt.Tx) error {
if tx.Bucket(bucketAdmins).Get([]byte(pubkey.Hex())) != nil {
isAdmin = true
}
return nil
})
return isAdmin
}
// CanCallMethod checks if a pubkey can call a specific NIP-86 method.
func (m *ManagementStore) CanCallMethod(pubkey nostr.PubKey, method string) bool {
var canCall bool
m.DB.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(bucketAdmins).Get([]byte(pubkey.Hex()))
if data == nil {
return nil
}
var entry adminEntry
json.Unmarshal(data, &entry)
// Empty methods = full access
if len(entry.Methods) == 0 {
canCall = true
return nil
}
canCall = slices.Contains(entry.Methods, method)
return nil
})
return canCall
}
// SaveRelayInfo stores relay info (name, description, icon) persistently.
func (m *ManagementStore) SaveRelayInfo(key, value string) error {
return m.DB.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bucketRelayInfo).Put([]byte(key), []byte(value))
})
}
// GetRelayInfo retrieves relay info.
func (m *ManagementStore) GetRelayInfo(key string) string {
var value string
m.DB.View(func(tx *bbolt.Tx) error {
if data := tx.Bucket(bucketRelayInfo).Get([]byte(key)); data != nil {
value = string(data)
}
return nil
})
return value
}