docs(relay): packaging (deploy/relay) + operator guide + .env.example

Completes the operator side of the relay integration:

- deploy/relay/: reference docker-compose + config.toml for a locked-down
  nostr-rs-relay wired to the backend's gRPC admission (host networking so the
  loopback admission socket stays private). Clearly marked a template to pin/
  verify against the operator's nostr-rs-relay version.
- docs/operations/RELAY.md: what it is (inbox relay, not the only relay), the
  admission architecture, the three write policies + NIP-13 PoW, bundled vs
  external, verification, and the seam's modularity. Linked from OPERATOR_SETUP.
- .env.example: the RELAY_* block (all off/placeholder).

Backend relay integration (config → seam → policy → gRPC admission →
capabilities → NIP-05 → packaging/docs) is now complete. Client DM stack next.
This commit is contained in:
jwinterm
2026-07-02 07:06:36 -04:00
parent f1872c781c
commit 17bb7bcc20
5 changed files with 205 additions and 0 deletions
+26
View File
@@ -133,6 +133,32 @@ PRICES_FETCH_INTERVAL_SECS=300
# Tips are parked for the conditional-transfer redesign; off until that lands.
FEATURE_TIPS=false
# ── Nostr relay (optional messaging plane; see docs/operations/RELAY.md) ────
# Off by default. When on, the operator runs a relay (the packaged
# nostr-rs-relay under deploy/relay, or an external one) that this backend
# advertises (/capabilities + NIP-05) and write-restricts via a gRPC admission
# service. It is the user's DM INBOX, never the only relay — clients also use the
# public interop relays. Self-hosting concern.
RELAY_ENABLED=false
# bundled (run deploy/relay's nostr-rs-relay) | external (point at your own).
RELAY_MODE=bundled
# Public ws(s):// URL clients connect to (wss required in production).
RELAY_URL=
# Write policy the admission service enforces:
# inbox-outbox — registered npubs publish their own events; anyone may
# deliver a gift-wrapped DM (kind 1059) to a registered user.
# author-allowlist — only registered npubs may publish anything.
# open — accept everything (relay resource caps still apply).
RELAY_WRITE_POLICY=inbox-outbox
# NIP-13 proof-of-work bits required on cross-ecosystem (non-registered-author)
# inbound events; 0 disables the PoW gate.
RELAY_INBOUND_POW_BITS=0
# Loopback address the gRPC event-admission service binds (the relay calls it).
# Keep it loopback; the bundled compose reaches it via the host network.
RELAY_ADMISSION_BIND=127.0.0.1:8090
RELAY_MAX_EVENT_BYTES=65536
RELAY_RETENTION_DAYS=30
# ── Admin surface (see docs; default posture is loopback/Tor) ──────────────
# Admin auth is "Sign in with Smirk": only admin PUBLIC keys are stored here,
# never a seed or private key. The allowlist is mutated CLI/loopback-only in v1.
+50
View File
@@ -0,0 +1,50 @@
# nostr-rs-relay config for a locked-down Smirk messaging relay.
#
# REFERENCE TEMPLATE — the exact key set is version-specific; check it against the
# nostr-rs-relay release you pin. The load-bearing piece is [grpc]
# event_admission_server: it points at the Smirk backend's gRPC admission service,
# which enforces RELAY_WRITE_POLICY + the NIP-13 PoW gate per event. Do NOT also
# set a static [authorization] pubkey_whitelist — that would double-gate and
# break the inbox (gift-wraps from non-registered senders).
[info]
relay_url = "wss://relay.example.org/"
name = "Smirk relay"
description = "Locked-down Nostr relay for a self-hosted Smirk backend (DM inbox)."
# pubkey = "<operator x-only hex pubkey>" # optional contact identity
[database]
# SQLite is simplest for a single-node self-host; switch to postgres to share
# infra. Keep it on a persisted volume (see docker-compose.yml).
engine = "sqlite"
data_directory = "/usr/src/app/db"
[grpc]
# Smirk backend event-admission (nauthz). With host networking the backend's
# loopback RELAY_ADMISSION_BIND is reachable here. This is what enforces the
# write policy — keep it set.
event_admission_server = "http://127.0.0.1:8090"
[network]
address = "0.0.0.0"
port = 8080
# Conservative, self-host-friendly resource ceilings (the anti-abuse floor even
# for the `open` policy). Tune to your box.
[limits]
messages_per_sec = 10
subscriptions_per_min = 60
max_event_bytes = 65536
max_ws_message_bytes = 131072
max_ws_frame_bytes = 131072
broadcast_buffer = 16384
event_persist_buffer = 4096
limit_scrapers = true
[options]
# Reject events dated too far in the future (clock-skew abuse).
reject_future_seconds = 1800
[authorization]
# Write restriction is enforced by the gRPC admission service above, NOT a static
# whitelist. Leave pubkey_whitelist unset.
+35
View File
@@ -0,0 +1,35 @@
# Bundled, locked-down Nostr relay for a self-hosted Smirk backend.
#
# This runs nostr-rs-relay as the messaging-plane relay. WRITE ADMISSION is
# delegated to the Smirk backend's gRPC service (RELAY_ADMISSION_BIND, default
# 127.0.0.1:8090) via nostr-rs-relay's [grpc] event_admission_server hook — so
# the RELAY_WRITE_POLICY (inbox-outbox / author-allowlist / open) + NIP-13 PoW
# are enforced by the backend, not baked into this file.
#
# Networking: the relay uses the HOST network so it can reach the backend's
# loopback admission socket (127.0.0.1:8090) while keeping that socket private.
# host networking is Linux-only; on other hosts, bind RELAY_ADMISSION_BIND to a
# docker-reachable address behind your firewall and drop `network_mode: host`,
# adding `extra_hosts: ["host.docker.internal:host-gateway"]` + a port mapping.
#
# This is a REFERENCE template — verify the config keys against the nostr-rs-relay
# version you pin (https://github.com/scsibug/nostr-rs-relay).
services:
relay:
image: ghcr.io/scsibug/nostr-rs-relay:latest # pin a digest in production
restart: unless-stopped
network_mode: host # reach the backend admission socket on 127.0.0.1
user: "1000:1000"
volumes:
- ./config.toml:/usr/src/app/config.toml:ro
- relay-data:/usr/src/app/db
environment:
RUST_LOG: info
# Modest, self-host-friendly resource ceilings.
deploy:
resources:
limits:
memory: 512M
volumes:
relay-data:
+7
View File
@@ -110,6 +110,13 @@ self-hosting bypass them. All default off or open.
- **Restore policy** — `WALLET_RESTORE_POLICY=create-only|bounded|unlimited` (default `create-only`), with optional proof-of-work pricing on restore depth.
- **Pay-to-register** — `REGISTRATION_REQUIRE_PAYMENT=true` against a BTCPay-compatible processor (`PAYMENT_*`); a new wallet settles an invoice, paid to your own wallet, before it can register.
## 7. Nostr relay (optional)
Run a first-party, locked-down Nostr relay as your users' encrypted-DM inbox
(`RELAY_ENABLED=true` + `RELAY_*`). Off by default; write-restricted via a gRPC
admission service, advertised through `/capabilities` + NIP-05. Full guide:
[RELAY.md](RELAY.md).
## Testing an instance
The `@smirk/smoke-tests` harness drives the API client end-to-end. Point it at an
+87
View File
@@ -0,0 +1,87 @@
# Nostr relay (messaging plane) — operator guide
The relay is **optional**. It gives your users a first-party, **locked-down**
Nostr relay that acts as their **encrypted-DM inbox** — the transport foundation
for messaging (and, later, P2P coordination). It is **off by default**; a Smirk
backend is fully functional without it.
## What it is (and isn't)
- **It is the user's inbox relay**, advertised via `/capabilities` and NIP-05
relay hints, and **write-restricted** by policy (see below).
- **It is NOT the only relay.** For cross-wallet delivery (e.g. to/from Goblin or
any Nostr user), clients also connect to the public interop relays
(`relay.damus.io`, `nos.lol`). A private relay alone would black-hole DMs to
outsiders — so the NIP-05 hints list your relay **first**, then the public set.
- The backend **does not run the relay process** — it *connects to + advertises*
it and enforces the write policy. You run the relay (bundled or external).
## Architecture
```
wallet ──ws──▶ your relay (nostr-rs-relay) ──gRPC EventAdmit──▶ smirk-backend
(RELAY_WRITE_POLICY
+ NIP-13 PoW,
registered-npub DB)
```
`nostr-rs-relay`'s `[grpc] event_admission_server` calls the backend once per
inbound event; the backend runs the pure policy engine (`src/infra/relay/policy.rs`)
against the user DB and returns PERMIT/DENY. This is dynamic (per-event, per-
recipient), which a static pubkey whitelist can't do.
## Write policies (`RELAY_WRITE_POLICY`)
| policy | who may publish |
| --- | --- |
| `inbox-outbox` (default) | registered npubs publish their own events (outbox); **anyone** may deliver a NIP-17 gift-wrap (kind 1059) **addressed to a registered user** (inbox) |
| `author-allowlist` | only registered Smirk npubs may publish anything (no external inbound) |
| `open` | accept everything (the relay's resource caps still apply) |
`RELAY_INBOUND_POW_BITS` (0 = off) requires a NIP-13 proof-of-work on
cross-ecosystem (non-registered-author) inbound events — spam friction without an
author allowlist. Only the `inbox-outbox` external-inbound branch is gated.
## Enable it
1. Set the relay env (see `.env.example` → *Nostr relay*):
```sh
RELAY_ENABLED=true
RELAY_MODE=bundled # or external
RELAY_URL=wss://relay.yourdomain.tld # what clients connect to (wss in prod)
RELAY_WRITE_POLICY=inbox-outbox
RELAY_INBOUND_POW_BITS=0
RELAY_ADMISSION_BIND=127.0.0.1:8090 # loopback gRPC admission
```
The backend fail-closes at startup on a bad relay config, and for any non-`open`
policy it spawns the gRPC admission service on `RELAY_ADMISSION_BIND`.
2. **Bundled** (`RELAY_MODE=bundled`): run the reference `nostr-rs-relay` in
[`deploy/relay/`](../../deploy/relay/):
```sh
# set relay_url + event_admission_server in deploy/relay/config.toml first
docker compose -f deploy/relay/docker-compose.yml up -d
```
It uses host networking so the relay reaches the loopback admission socket
while that socket stays private (Linux; see the compose comments for other
hosts). Front `RELAY_URL` with your TLS terminator.
3. **External** (`RELAY_MODE=external`): point `RELAY_URL` at any relay you run,
and configure *its* `[grpc] event_admission_server` at `RELAY_ADMISSION_BIND`.
## Verify
- `GET /capabilities` → `features.nostr_relay: true` and a `messaging` object
(`relay_url`, `write_policy`, `inbound_pow_bits`, `supported_nips`).
- `GET /.well-known/nostr.json?name=<user>` lists your relay first in `relays`.
- Publish a registered-npub event → accepted; a random-author, non-gift-wrap
event → rejected (`inbox-outbox`); an under-PoW cross-ecosystem event →
rejected (when `RELAY_INBOUND_POW_BITS > 0`).
## Modularity
`nostr-rs-relay` is the first adapter behind the `RelayProvider` seam
(`src/infra/relay/`). The write policy lives in a pure engine; the gRPC transport
is the adapter's concern. A different relay (strfry, or another messaging
protocol) can enforce the same policy over a different mechanism without touching
the policy or the advertising.