first commit

This commit is contained in:
2026-04-29 02:35:00 +00:00
commit 2cb17df4c5
90 changed files with 7321 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.gitignore
.github
.data
.env
bin
*.test
*.out
README.md
SPEC.md
deploy
.golangci.yml
Dockerfile
.dockerignore

43
.env.example Normal file
View File

@@ -0,0 +1,43 @@
# --- Core ---
DOMAIN=azzamo.net
PORT=8080
ADMIN_API_KEY=change-me-to-a-long-random-string
FRONTEND_URL=https://azzamo.net/nip05
# --- Database ---
DATABASE_PATH=.data/nip05.db
# --- Lightning (LNbits) ---
LIGHTNING_ENABLED=true
LNBITS_URL=https://lnbits.azzamo.net
LNBITS_INVOICE_KEY=your-lnbits-invoice-read-key
PRICE_YEARLY_SATS=1000
PRICE_LIFETIME_SATS=10000
INVOICE_EXPIRY_MINUTES=30
# --- Nostr ---
RELAYS=wss://relay.azzamo.net,wss://nostr.azzamo.net,wss://wot.azzamo.net
USERNAME_SYNC_ENABLED=true
SYNC_INTERVAL_MINUTES=15
# --- DMs ---
DM_ENABLED=true
DM_NSEC=nsec1...
DM_KIND=1059
MESSAGES_FILE=messages.yaml
# --- Expiry & grace ---
EXPIRY_REMINDER_DAYS=7
USERNAME_GRACE_DAYS=30
EXPIRY_CRON_HOUR_UTC=9
# --- Webhook (optional) ---
WEBHOOK_URL=
WEBHOOK_SECRET=
WEBHOOK_TIMEOUT_SECONDS=10
WEBHOOK_MAX_RETRIES=5
# --- Operational ---
LOG_LEVEL=info
RATE_LIMIT_PER_MIN=30
RESERVED_USERNAMES=admin,root,support,help,abuse,postmaster,_

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.data/
.env
/messages.yaml
bin/
*.test
*.out
.DS_Store
.claude/

57
.golangci.yml Normal file
View File

@@ -0,0 +1,57 @@
run:
timeout: 3m
tests: true
linters:
disable-all: true
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- misspell
- bodyclose
- errorlint
- gofmt
- goimports
- revive
- unconvert
- gocritic
- gosec
linters-settings:
errcheck:
check-type-assertions: true
check-blank: true
govet:
enable-all: true
disable:
- fieldalignment
- shadow
revive:
rules:
- name: var-naming
- name: package-comments
disabled: true
- name: exported
disabled: true
gosec:
excludes:
- G104 # we audit-log errors instead of failing requests
- G404 # math/rand is fine for non-crypto paths
- G115 # cast safety; manual review
issues:
exclude-rules:
- path: _test\.go
linters:
- gosec
- errcheck
- gocritic
- path: cmd/nip05api/main\.go
linters:
- gocritic
max-issues-per-linter: 0
max-same-issues: 0

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS build
WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS="-trimpath"
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN go build -ldflags="-s -w -X main.version=${VERSION}" \
-o /out/nip05api ./cmd/nip05api
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/nip05api /app/nip05api
COPY messages.example.yaml /app/messages.yaml
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/nip05api"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Noderunners
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

41
Makefile Normal file
View File

@@ -0,0 +1,41 @@
.PHONY: build run test test-race lint clean docker tidy install-systemd
GO ?= go
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
build:
CGO_ENABLED=0 $(GO) build -ldflags "$(LDFLAGS)" -o bin/nip05api ./cmd/nip05api
run:
$(GO) run ./cmd/nip05api
test:
$(GO) test ./... -count=1
test-race:
$(GO) test ./... -race -count=1
lint:
golangci-lint run
tidy:
$(GO) mod tidy
clean:
rm -rf bin/ .data/
docker:
docker build --build-arg VERSION=$(VERSION) -t nip05api:$(VERSION) -t nip05api:latest .
# Install onto a Linux host running systemd. Run as root.
# Assumes the binary has already been built into ./bin/nip05api.
install-systemd:
id nip05 >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin -d /opt/nip05api nip05
install -d -m 0755 -o nip05 -g nip05 /opt/nip05api/.data
install -d -m 0755 -o nip05 -g nip05 /opt/nip05api/bin
install -m 0755 -o nip05 -g nip05 bin/nip05api /opt/nip05api/bin/nip05api
install -m 0644 -o nip05 -g nip05 messages.example.yaml /opt/nip05api/messages.yaml
install -m 0644 -o root -g root deploy/nip05api.service /etc/systemd/system/nip05api.service
systemctl daemon-reload
@echo "Installed. Now: copy your .env to /opt/nip05api/.env, then 'systemctl enable --now nip05api'."

203
README.md Normal file
View File

@@ -0,0 +1,203 @@
# nip05api
A single-domain NIP-05 identity service. Lightning-paid registration through
LNbits. Lifecycle DMs over Nostr. Optional outbound webhook on payment events.
Swagger docs. One binary, one SQLite file, one systemd unit.
See [SPEC.md](SPEC.md) for the full design specification.
## Features
- NIP-05 lookup at `/.well-known/nostr.json` for active subscribers
- Yearly or lifetime subscriptions, BOLT11 invoices via LNbits
- Renewal detection by pubkey: same user, same username, extended expiry
- Configurable expiry-reminder DMs and grace-period username reservation
- Profile sync: pulls `kind:0` from configured relays and updates non-pinned usernames
- HMAC-signed webhook outbox with exponential retry
- NIP-04 or NIP-17 (gift-wrapped) DM delivery
- Pure-Go SQLite, no CGO; static binary under 20 MB
- Embedded migrations applied on boot
- Embedded OpenAPI spec served at `/openapi.json`, Swagger UI at `/docs`
## Quick start
```bash
git clone https://github.com/noderunners/nip05api.git
cd nip05api
cp .env.example .env
cp messages.example.yaml messages.yaml
# Edit .env: DOMAIN, ADMIN_API_KEY, LNBITS_*, DM_NSEC, RELAYS
make build
./bin/nip05api
```
The binary creates `.data/nip05.db`, applies migrations, and starts listening
on `PORT` (default `8080`). Hit `http://localhost:8080/healthz` to confirm.
## Configuration
All config is environment-driven. See [.env.example](.env.example) for the full
list. Required keys:
| Key | Notes |
|---|---|
| `DOMAIN` | the apex domain users will be `name@domain` on |
| `ADMIN_API_KEY` | 24+ chars; sent as `X-API-Key` on `/v1/admin/*` |
| `LNBITS_URL`, `LNBITS_INVOICE_KEY` | required when `LIGHTNING_ENABLED=true` |
| `RELAYS` | comma-separated `wss://...`, required when DM or sync enabled |
| `DM_NSEC` | required when `DM_ENABLED=true` |
Subsystems toggle independently: `LIGHTNING_ENABLED`, `DM_ENABLED`,
`USERNAME_SYNC_ENABLED`, plus webhook is enabled by setting `WEBHOOK_URL`.
## API surface
Eleven endpoints, full schema at `/openapi.json` and rendered at `/docs`:
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/.well-known/nostr.json` | — | NIP-05 lookup |
| GET | `/healthz` | — | DB ping + version |
| GET | `/v1/pricing` | — | yearly/lifetime sat pricing |
| GET | `/v1/users/{pubkey}` | — | lookup by hex or npub |
| GET | `/v1/usernames/{name}/available` | — | availability check |
| POST | `/v1/invoices` | — | create payment invoice |
| GET | `/v1/invoices/{hash}` | — | invoice status |
| POST | `/v1/admin/users` | api key | manual add |
| GET | `/v1/admin/users` | api key | list / search |
| PUT | `/v1/admin/users/{pubkey}` | api key | pin username |
| DELETE | `/v1/admin/users/{pubkey}` | api key | hard delete |
| POST | `/v1/admin/users/{pubkey}/extend` | api key | extend or upgrade |
## Deployment
### systemd (recommended for VMs)
```bash
make build
sudo make install-systemd
sudo cp .env /opt/nip05api/.env
sudo systemctl enable --now nip05api
journalctl -u nip05api -f
```
The unit is hardened (no-new-privileges, ProtectSystem=strict, capability-bound
to nothing) and runs as a dedicated `nip05` user. See
[deploy/nip05api.service](deploy/nip05api.service).
### Docker
```bash
docker build -t nip05api:latest .
docker compose up -d
```
The image is multi-stage and ships from `gcr.io/distroless/static-debian12:nonroot`.
Compose binds the data directory and `messages.yaml` from the host so updates
to copy don't require a rebuild.
### Reverse proxy
A reverse proxy terminates TLS and forwards `/.well-known/nostr.json`, `/v1/*`,
`/healthz`, `/openapi.json`, and `/docs` to `127.0.0.1:8080`.
Sample configs:
- nginx → [deploy/nginx.conf](deploy/nginx.conf)
- Caddy → [deploy/Caddyfile](deploy/Caddyfile)
The proxy must forward `X-Forwarded-For` or `X-Real-IP`; the API uses these
for rate limiting and access logs.
## Operations
### Database
SQLite WAL-mode at `DATABASE_PATH` (default `.data/nip05.db`). Backups while
the service is running:
```bash
sqlite3 .data/nip05.db ".backup .data/backup-$(date +%F).db"
```
WAL files (`-wal`, `-shm`) are checkpointed on graceful shutdown. To rotate:
stop the service, copy `nip05.db`, `nip05.db-wal`, `nip05.db-shm` together,
restart.
### Migrations
Embedded as `.sql` files under `internal/db/migrations/`. They run inside a
transaction on every boot and record their version in `schema_migrations`.
Applying twice is a no-op. To add a migration: drop a new
`NNNN_description.sql` next to the others; rebuild.
### Audit log
Admin actions, payment confirmations, expiries, and grace-period purges all
write to `audit_log`. Cleanup worker prunes entries older than 180 days
hourly.
### Outbox cleanup
Delivered webhook and DM rows are pruned after 7 days; dead (gave up after
all retries) after 30 days. Tunable in `cmd/nip05api/cleanup.go`.
### Crash safety
- Migrations: transactional, idempotent.
- Payment confirmation: idempotent. The first confirmation captures
`target_expires_at` on the invoice row; subsequent retries apply the same
absolute value, so a crash mid-confirm cannot double-extend a renewal.
- Webhook delivery: outbox + retry schedule (`30s, 2m, 10m, 1h, 6h`),
HMAC-signed if `WEBHOOK_SECRET` is set.
- DM delivery: outbox + retry schedule (`1m, 5m, 30m, 2h, 12h`).
- Expiry pass: each phase (reminders → expirations → grace cleanup) updates
idempotent flags, so partial completion is safe to resume.
- Workers all watch a single root context; SIGINT/SIGTERM closes them in
order with a 30s HTTP grace window.
### Observability
- Logs: structured JSON to stdout via `slog`; ship to journald or your
logging stack of choice. `request_id` is propagated through middleware.
- Secrets that never appear in logs: `ADMIN_API_KEY`, `LNBITS_INVOICE_KEY`,
`WEBHOOK_SECRET`, `DM_NSEC`, BOLT11 strings, DM plaintext.
- Health: `/healthz` returns 200 with version + db status, 503 if DB unreachable.
## Development
```bash
make tidy # go mod tidy
make build # static binary in bin/
make test # short tests
make test-race # all tests with -race
make lint # golangci-lint, see .golangci.yml
make docker # build container image
```
Module layout:
```
cmd/nip05api/ entry point + cleanup worker
internal/audit/ append-only audit log
internal/config/ env loader + validation
internal/db/ SQLite + embedded migrations
internal/dm/ outbox + NIP-04/NIP-17 builder + worker
internal/expiry/ daily reminders / expirations / grace cleanup
internal/http/ chi router, middleware, handlers, OpenAPI docs
internal/invoice/ LNbits client + service + repo
internal/log/ slog setup
internal/messages/ YAML template loader + renderer
internal/nostr/ keys, relay pool, profile fetch, publish
internal/payments/ poll LNbits → confirm → dispatch DM/webhook/audit
internal/sync/ periodic kind:0 username sync
internal/user/ repo + service
internal/webhook/ outbox + HMAC signer + worker
```
Each Go file is kept under ~200 lines. The dependency graph points downward:
handlers depend on services, services on repos, repos on `db`. Nothing in
`internal/` depends on `internal/http/`.
## License
See [LICENSE](LICENSE) (MIT).

59
cmd/nip05api/cleanup.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"context"
"log/slog"
"time"
"github.com/noderunners/nip05api/internal/db"
)
// cleanupWorker prunes old delivered/dead outbox rows hourly so the DB stays
// small. Delivered: 7 days. Dead: 30 days (kept longer for forensic value).
type cleanupWorker struct {
db *db.DB
}
func newCleanupWorker(d *db.DB) *cleanupWorker { return &cleanupWorker{db: d} }
func (c *cleanupWorker) Run(ctx context.Context) {
t := time.NewTicker(1 * time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.prune(ctx)
}
}
}
func (c *cleanupWorker) prune(ctx context.Context) {
queries := []struct {
label string
query string
args []any
}{
{"webhook delivered", `DELETE FROM webhook_outbox WHERE status = 'delivered' AND created_at < ?`,
[]any{time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339)}},
{"webhook dead", `DELETE FROM webhook_outbox WHERE status = 'dead' AND created_at < ?`,
[]any{time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339)}},
{"dm delivered", `DELETE FROM dm_outbox WHERE status = 'delivered' AND created_at < ?`,
[]any{time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339)}},
{"dm dead", `DELETE FROM dm_outbox WHERE status = 'dead' AND created_at < ?`,
[]any{time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339)}},
{"audit", `DELETE FROM audit_log WHERE created_at < ?`,
[]any{time.Now().UTC().Add(-180 * 24 * time.Hour).Format(time.RFC3339)}},
}
for _, q := range queries {
res, err := c.db.ExecContext(ctx, q.query, q.args...)
if err != nil {
slog.Warn("cleanup", "label", q.label, "err", err)
continue
}
if n, _ := res.RowsAffected(); n > 0 {
slog.Info("cleanup", "label", q.label, "rows", n)
}
}
}

175
cmd/nip05api/main.go Normal file
View File

@@ -0,0 +1,175 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"sync"
"syscall"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/config"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/expiry"
httpapi "github.com/noderunners/nip05api/internal/http"
"github.com/noderunners/nip05api/internal/invoice"
applog "github.com/noderunners/nip05api/internal/log"
"github.com/noderunners/nip05api/internal/messages"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/payments"
syncw "github.com/noderunners/nip05api/internal/sync"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
// version is overridden at build time via -ldflags "-X main.version=..."
var version = "dev"
func main() {
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
applog.Setup("info")
return err
}
applog.Setup(cfg.LogLevel)
slog.Info("starting",
"version", version,
"domain", cfg.Domain,
"port", cfg.Port,
"lightning", cfg.Lightning.Enabled,
"dm", cfg.DM.Enabled,
"sync", cfg.Nostr.UsernameSyncEnabled,
"webhook", cfg.Webhook.URL != "",
)
database, err := db.Open(cfg.DatabasePath)
if err != nil {
return err
}
defer database.Close()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := database.Migrate(ctx); err != nil {
return err
}
tmpls, err := messages.Load(cfg.DM.MessagesFile)
if err != nil {
return err
}
userRepo := user.NewRepo(database)
userSvc := user.NewService(userRepo, cfg.ReservedUsernames)
auditLogger := audit.New(database)
pool := nostr.NewPool(cfg.Nostr.Relays)
defer pool.Close()
hookRepo := webhook.NewRepo(database)
hookSvc := webhook.NewService(hookRepo, cfg.Domain, cfg.Webhook.URL != "")
hookWorker := webhook.NewWorker(hookRepo, cfg.Webhook.URL, cfg.Webhook.Secret,
cfg.Webhook.TimeoutSecs, cfg.Webhook.MaxRetries)
dmRepo := dm.NewRepo(database)
dmSvc := dm.NewService(dmRepo, tmpls, cfg.DM.Enabled)
var dmWorker *dm.Worker
if cfg.DM.Enabled {
dmWorker, err = dm.NewWorker(dmRepo, pool, cfg.DM.Nsec, cfg.DM.Kind)
if err != nil {
return err
}
}
var invSvc *invoice.Service
var lnClient *invoice.LNbitsClient
if cfg.Lightning.Enabled {
lnClient = invoice.NewLNbits(cfg.Lightning.LNbitsURL, cfg.Lightning.LNbitsInvoiceKey)
invRepo := invoice.NewRepo(database)
invSvc = invoice.NewService(invRepo, userSvc, lnClient,
invoice.Pricing{
YearlySats: cfg.Lightning.PriceYearlySats,
LifetimeSats: cfg.Lightning.PriceLifetimeSats,
ExpiryMins: cfg.Lightning.InvoiceExpiryMins,
}, cfg.Domain)
}
payWorker := payments.NewWorker(invSvc, userSvc, lnClient, dmSvc, hookSvc, auditLogger,
cfg.Domain, cfg.FrontendURL, cfg.Lightning.Enabled && invSvc != nil)
syncWorker := syncw.NewWorker(userSvc, pool, cfg.Nostr.SyncIntervalMins,
cfg.Nostr.UsernameSyncEnabled, cfg.Domain, cfg.ReservedUsernames)
expiryWorker := expiry.NewWorker(userSvc, dmSvc, hookSvc, auditLogger,
cfg.Domain, cfg.FrontendURL, cfg.Expiry.GraceDays,
cfg.Expiry.ReminderDays, cfg.Expiry.CronHourUTC)
cleanupWorker := newCleanupWorker(database)
srv := httpapi.NewServer(httpapi.Deps{
Cfg: cfg,
DB: database,
Users: userSvc,
Invoices: invSvc,
DMs: dmSvc,
Hooks: hookSvc,
Audit: auditLogger,
Version: version,
})
var wg sync.WaitGroup
startWorker(&wg, ctx, "webhook", hookWorker.Run)
if dmWorker != nil {
startWorker(&wg, ctx, "dm", dmWorker.Run)
}
startWorker(&wg, ctx, "payments", payWorker.Run)
startWorker(&wg, ctx, "sync", syncWorker.Run)
startWorker(&wg, ctx, "expiry", expiryWorker.Run)
startWorker(&wg, ctx, "cleanup", cleanupWorker.Run)
serverErr := make(chan error, 1)
go func() {
slog.Info("http listening", "addr", cfg.Addr())
if err := srv.ListenAndServe(); err != nil && err.Error() != "http: Server closed" {
serverErr <- err
}
close(serverErr)
}()
select {
case <-ctx.Done():
slog.Info("shutdown signal received")
case err := <-serverErr:
if err != nil {
cancel()
wg.Wait()
return err
}
}
if err := httpapi.Shutdown(context.Background(), srv); err != nil {
slog.Error("server shutdown", "err", err)
}
wg.Wait()
slog.Info("shutdown complete")
return nil
}
func startWorker(wg *sync.WaitGroup, ctx context.Context, name string, run func(context.Context)) {
wg.Add(1)
go func() {
defer wg.Done()
defer slog.Info("worker stopped", "name", name)
slog.Info("worker started", "name", name)
run(ctx)
}()
}

34
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,34 @@
# Caddy alternative to nginx. Replace example.com with your DOMAIN.
#
# Caddy auto-issues TLS via Let's Encrypt by default.
example.com {
encode gzip zstd
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
# Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
request_body {
max_size 1MB
}
@nostrjson path /.well-known/nostr.json
handle @nostrjson {
reverse_proxy 127.0.0.1:8080
header Cache-Control "public, max-age=60"
header Access-Control-Allow-Origin "*"
}
@api path /v1/* /healthz /openapi.json /docs /docs/*
handle @api {
reverse_proxy 127.0.0.1:8080
}
handle {
respond "Not found" 404
}
}

62
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,62 @@
# Example nginx config for nip05api.
# Replace example.com with your DOMAIN.
upstream nip05api {
server 127.0.0.1:8080;
keepalive 16;
}
# HTTP → HTTPS redirect (assumes certbot or equivalent has terminated TLS).
server {
listen 80;
listen [::]:80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# HSTS — opt in once you're confident HTTPS is permanent.
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 1m;
# NIP-05 well-known endpoint MUST be served on the apex domain.
location = /.well-known/nostr.json {
proxy_pass http://nip05api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=60" always;
}
location ~ ^/(v1/|healthz|version|openapi.json|docs) {
proxy_pass http://nip05api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 30s;
}
}

45
deploy/nip05api.service Normal file
View File

@@ -0,0 +1,45 @@
[Unit]
Description=NIP-05 API
Documentation=https://github.com/noderunners/nip05api
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nip05
Group=nip05
WorkingDirectory=/opt/nip05api
EnvironmentFile=/opt/nip05api/.env
ExecStart=/opt/nip05api/bin/nip05api
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadWritePaths=/opt/nip05api/.data
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
CapabilityBoundingSet=
AmbientCapabilities=
# Limits
LimitNOFILE=65536
TimeoutStopSec=45
# Logging to stdout/stderr → journald
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
nip05api:
build:
context: .
args:
VERSION: ${VERSION:-dev}
image: nip05api:latest
container_name: nip05api
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
env_file:
- .env
volumes:
- ./.data:/app/.data
- ./messages.yaml:/app/messages.yaml:ro
# Healthcheck: distroless has no shell, so probe externally.
# Recommended: Caddy/nginx upstream probe or your monitoring system.
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL

54
go.mod Normal file
View File

@@ -0,0 +1,54 @@
module github.com/noderunners/nip05api
go 1.24.1
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/httprate v0.14.1
github.com/joho/godotenv v1.5.1
github.com/nbd-wtf/go-nostr v0.51.12
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.34.1
)
require (
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // 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/bytedance/sonic v1.13.1 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

228
go.sum Normal file
View File

@@ -0,0 +1,228 @@
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/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
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/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/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g=
github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
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/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/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/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/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/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
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/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/nbd-wtf/go-nostr v0.51.12 h1:MRQcrShiW/cHhnYSVDQ4SIEc7DlYV7U7gg/l4H4gbbE=
github.com/nbd-wtf/go-nostr v0.51.12/go.mod h1:IF30/Cm4AS90wd1GjsFJbBqq7oD1txo+2YUFYXqK3Nc=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
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/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
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.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
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=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

44
internal/audit/audit.go Normal file
View File

@@ -0,0 +1,44 @@
package audit
import (
"context"
"encoding/json"
"log/slog"
"github.com/noderunners/nip05api/internal/db"
)
const (
ActorAdmin = "admin"
ActorSystem = "system"
ActionUserAdded = "user.added"
ActionUserUsernameChanged = "user.username_changed"
ActionUserExtended = "user.extended"
ActionUserDeleted = "user.deleted"
ActionPaymentConfirmed = "payment.confirmed"
ActionUserExpired = "user.expired"
ActionUserGracePurged = "user.grace_purged"
)
type Logger struct{ db *db.DB }
func New(d *db.DB) *Logger { return &Logger{db: d} }
func (l *Logger) Log(ctx context.Context, action, actor, pubkey string, details map[string]any) {
if l == nil || l.db == nil {
return
}
var detailsJSON string
if details != nil {
b, err := json.Marshal(details)
if err == nil {
detailsJSON = string(b)
}
}
if _, err := l.db.ExecContext(ctx,
`INSERT INTO audit_log (action, actor, pubkey, details) VALUES (?, ?, ?, ?)`,
action, actor, pubkey, detailsJSON); err != nil {
slog.Warn("audit log insert failed", "action", action, "err", err)
}
}

171
internal/config/config.go Normal file
View File

@@ -0,0 +1,171 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type LightningConfig struct {
Enabled bool
LNbitsURL string
LNbitsInvoiceKey string
PriceYearlySats int64
PriceLifetimeSats int64
InvoiceExpiryMins int
}
type NostrConfig struct {
Relays []string
UsernameSyncEnabled bool
SyncIntervalMins int
}
type DMConfig struct {
Enabled bool
Nsec string
Kind int
MessagesFile string
}
type ExpiryConfig struct {
ReminderDays []int
GraceDays int
CronHourUTC int
}
type WebhookConfig struct {
URL string
Secret string
TimeoutSecs int
MaxRetries int
}
type Config struct {
Domain string
Port int
AdminAPIKey string
FrontendURL string
DatabasePath string
Lightning LightningConfig
Nostr NostrConfig
DM DMConfig
Expiry ExpiryConfig
Webhook WebhookConfig
LogLevel string
RateLimitPerMin int
ReservedUsernames []string
}
func Load() (*Config, error) {
_ = godotenv.Load()
c := &Config{
Domain: env("DOMAIN", ""),
Port: envInt("PORT", 8080),
AdminAPIKey: env("ADMIN_API_KEY", ""),
FrontendURL: env("FRONTEND_URL", ""),
DatabasePath: env("DATABASE_PATH", ".data/nip05.db"),
Lightning: LightningConfig{
Enabled: envBool("LIGHTNING_ENABLED", true),
LNbitsURL: env("LNBITS_URL", ""),
LNbitsInvoiceKey: env("LNBITS_INVOICE_KEY", ""),
PriceYearlySats: int64(envInt("PRICE_YEARLY_SATS", 1000)),
PriceLifetimeSats: int64(envInt("PRICE_LIFETIME_SATS", 10000)),
InvoiceExpiryMins: envInt("INVOICE_EXPIRY_MINUTES", 30),
},
Nostr: NostrConfig{
Relays: csv(env("RELAYS", "")),
UsernameSyncEnabled: envBool("USERNAME_SYNC_ENABLED", true),
SyncIntervalMins: envInt("SYNC_INTERVAL_MINUTES", 15),
},
DM: DMConfig{
Enabled: envBool("DM_ENABLED", true),
Nsec: env("DM_NSEC", ""),
Kind: envInt("DM_KIND", 1059),
MessagesFile: env("MESSAGES_FILE", "messages.yaml"),
},
Expiry: ExpiryConfig{
ReminderDays: csvInt(env("EXPIRY_REMINDER_DAYS", "7")),
GraceDays: envInt("USERNAME_GRACE_DAYS", 30),
CronHourUTC: envInt("EXPIRY_CRON_HOUR_UTC", 9),
},
Webhook: WebhookConfig{
URL: env("WEBHOOK_URL", ""),
Secret: env("WEBHOOK_SECRET", ""),
TimeoutSecs: envInt("WEBHOOK_TIMEOUT_SECONDS", 10),
MaxRetries: envInt("WEBHOOK_MAX_RETRIES", 5),
},
LogLevel: env("LOG_LEVEL", "info"),
RateLimitPerMin: envInt("RATE_LIMIT_PER_MIN", 30),
ReservedUsernames: csv(env("RESERVED_USERNAMES", "")),
}
if err := Validate(c); err != nil {
return nil, err
}
return c, nil
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v, ok := os.LookupEnv(key); ok && v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envBool(key string, def bool) bool {
if v, ok := os.LookupEnv(key); ok && v != "" {
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return def
}
func csv(v string) []string {
if v == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func csvInt(v string) []int {
parts := csv(v)
out := make([]int, 0, len(parts))
for _, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
continue
}
out = append(out, n)
}
return out
}
func (c *Config) Addr() string { return fmt.Sprintf(":%d", c.Port) }

View File

@@ -0,0 +1,78 @@
package config
import (
"errors"
"fmt"
"strings"
)
func Validate(c *Config) error {
var problems []string
if c.Domain == "" {
problems = append(problems, "DOMAIN is required")
}
if c.Port <= 0 || c.Port > 65535 {
problems = append(problems, "PORT must be 1-65535")
}
if c.AdminAPIKey == "" || c.AdminAPIKey == "change-me-to-a-long-random-string" {
problems = append(problems, "ADMIN_API_KEY must be set to a non-default value")
} else if len(c.AdminAPIKey) < 24 {
problems = append(problems, "ADMIN_API_KEY must be at least 24 characters")
}
if c.DatabasePath == "" {
problems = append(problems, "DATABASE_PATH is required")
}
if c.Lightning.Enabled {
if c.Lightning.LNbitsURL == "" {
problems = append(problems, "LNBITS_URL is required when LIGHTNING_ENABLED=true")
} else if !strings.HasPrefix(c.Lightning.LNbitsURL, "http://") && !strings.HasPrefix(c.Lightning.LNbitsURL, "https://") {
problems = append(problems, "LNBITS_URL must start with http:// or https://")
}
if c.Lightning.LNbitsInvoiceKey == "" {
problems = append(problems, "LNBITS_INVOICE_KEY is required when LIGHTNING_ENABLED=true")
}
if c.Lightning.PriceYearlySats <= 0 {
problems = append(problems, "PRICE_YEARLY_SATS must be > 0")
}
if c.Lightning.PriceLifetimeSats <= 0 {
problems = append(problems, "PRICE_LIFETIME_SATS must be > 0")
}
if c.Lightning.InvoiceExpiryMins <= 0 {
problems = append(problems, "INVOICE_EXPIRY_MINUTES must be > 0")
}
}
if c.DM.Enabled {
if c.DM.Nsec == "" {
problems = append(problems, "DM_NSEC is required when DM_ENABLED=true")
}
if c.DM.Kind != 4 && c.DM.Kind != 1059 {
problems = append(problems, "DM_KIND must be 4 or 1059")
}
}
if (c.Nostr.UsernameSyncEnabled || c.DM.Enabled) && len(c.Nostr.Relays) == 0 {
problems = append(problems, "RELAYS is required when sync or DM is enabled")
}
for _, r := range c.Nostr.Relays {
if !strings.HasPrefix(r, "ws://") && !strings.HasPrefix(r, "wss://") {
problems = append(problems, "RELAYS entry must be ws:// or wss://, got "+r)
}
}
if c.Webhook.URL != "" {
if !strings.HasPrefix(c.Webhook.URL, "http://") && !strings.HasPrefix(c.Webhook.URL, "https://") {
problems = append(problems, "WEBHOOK_URL must start with http:// or https://")
}
}
if c.Expiry.GraceDays < 0 {
problems = append(problems, "USERNAME_GRACE_DAYS must be >= 0")
}
if c.Expiry.CronHourUTC < 0 || c.Expiry.CronHourUTC > 23 {
problems = append(problems, "EXPIRY_CRON_HOUR_UTC must be 0-23")
}
if len(problems) > 0 {
return fmt.Errorf("invalid config:\n - %s", strings.Join(problems, "\n - "))
}
return nil
}
var ErrInvalidConfig = errors.New("invalid config")

View File

@@ -0,0 +1,32 @@
package config
import "testing"
func TestValidate_Defaults(t *testing.T) {
c := &Config{
Domain: "azzamo.net",
Port: 8080,
AdminAPIKey: "long-secret-key-for-tests",
DatabasePath: ".data/nip05.db",
}
if err := Validate(c); err != nil {
t.Fatalf("expected ok, got %v", err)
}
}
func TestValidate_MissingDomain(t *testing.T) {
c := &Config{Port: 8080, AdminAPIKey: "long", DatabasePath: ".data/nip05.db"}
if err := Validate(c); err == nil {
t.Fatal("expected error")
}
}
func TestValidate_LightningRequiresLNbits(t *testing.T) {
c := &Config{
Domain: "azzamo.net", Port: 8080, AdminAPIKey: "long", DatabasePath: ".data/nip05.db",
Lightning: LightningConfig{Enabled: true},
}
if err := Validate(c); err == nil {
t.Fatal("expected error for missing LNBITS_URL")
}
}

38
internal/db/db.go Normal file
View File

@@ -0,0 +1,38 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type DB struct {
*sql.DB
}
func Open(path string) (*DB, error) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
}
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)", path)
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
sqlDB.SetMaxOpenConns(1)
if err := sqlDB.Ping(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return &DB{DB: sqlDB}, nil
}
func (d *DB) Ping(ctx context.Context) error { return d.DB.PingContext(ctx) }
func (d *DB) Close() error { return d.DB.Close() }

32
internal/db/db_test.go Normal file
View File

@@ -0,0 +1,32 @@
package db
import (
"context"
"path/filepath"
"testing"
)
func TestOpenAndMigrate(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
d, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
if err := d.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
// Idempotent.
if err := d.Migrate(context.Background()); err != nil {
t.Fatalf("migrate twice: %v", err)
}
row := d.QueryRow(`SELECT COUNT(*) FROM schema_migrations`)
var n int
if err := row.Scan(&n); err != nil {
t.Fatalf("scan: %v", err)
}
if n < 1 {
t.Fatalf("expected migrations applied, got %d", n)
}
}

114
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,114 @@
package db
import (
"context"
"embed"
"fmt"
"io/fs"
"log/slog"
"sort"
"strconv"
"strings"
"time"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type migration struct {
version int
name string
sql string
}
func (d *DB) Migrate(ctx context.Context) error {
if _, err := d.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
migs, err := loadMigrations()
if err != nil {
return err
}
applied, err := d.appliedVersions(ctx)
if err != nil {
return err
}
for _, m := range migs {
if applied[m.version] {
continue
}
slog.Info("applying migration", "version", m.version, "name", m.name)
if err := d.applyOne(ctx, m); err != nil {
return fmt.Errorf("apply %d %s: %w", m.version, m.name, err)
}
}
return nil
}
func (d *DB) applyOne(ctx context.Context, m migration) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`,
m.version, time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
return tx.Commit()
}
func (d *DB) appliedVersions(ctx context.Context) (map[int]bool, error) {
rows, err := d.QueryContext(ctx, `SELECT version FROM schema_migrations`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]bool{}
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
return nil, err
}
out[v] = true
}
return out, rows.Err()
}
func loadMigrations() ([]migration, error) {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read migrations: %w", err)
}
out := make([]migration, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
parts := strings.SplitN(e.Name(), "_", 2)
if len(parts) != 2 {
continue
}
v, err := strconv.Atoi(parts[0])
if err != nil {
continue
}
b, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, err
}
out = append(out, migration{version: v, name: e.Name(), sql: string(b)})
}
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
return out, nil
}

View File

@@ -0,0 +1,73 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pubkey TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
subscription_type TEXT NOT NULL,
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
manual_username INTEGER NOT NULL DEFAULT 0,
last_synced_at TEXT,
expiring_reminder_sent_at TEXT,
deactivated_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_active ON users(is_active);
CREATE INDEX idx_users_expires ON users(expires_at);
CREATE INDEX idx_users_deactivated ON users(deactivated_at);
CREATE TABLE pending_invoices (
payment_hash TEXT PRIMARY KEY,
payment_request TEXT NOT NULL,
username TEXT NOT NULL,
pubkey TEXT NOT NULL,
subscription_type TEXT NOT NULL,
years INTEGER NOT NULL DEFAULT 1,
amount_sats INTEGER NOT NULL,
expires_at TEXT NOT NULL,
paid INTEGER NOT NULL DEFAULT 0,
is_renewal INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_pending_unpaid ON pending_invoices(paid, expires_at);
CREATE INDEX idx_pending_username ON pending_invoices(username, paid);
CREATE TABLE webhook_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TEXT,
next_attempt_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_webhook_outbox_pending ON webhook_outbox(status, next_attempt_at);
CREATE TABLE dm_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
pubkey TEXT NOT NULL,
content TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TEXT,
next_attempt_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_dm_outbox_pending ON dm_outbox(status, next_attempt_at);
CREATE INDEX idx_dm_outbox_pubkey ON dm_outbox(pubkey, event_type);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
actor TEXT NOT NULL,
pubkey TEXT,
details TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,9 @@
-- target_expires_at captures the expiry value computed at first confirmation
-- attempt. Subsequent attempts (e.g. after a crash mid-confirm) read this
-- value back so user mutation stays idempotent.
ALTER TABLE pending_invoices ADD COLUMN target_expires_at TEXT;
CREATE INDEX idx_audit_pubkey ON audit_log(pubkey);
CREATE INDEX idx_audit_created ON audit_log(created_at);
CREATE INDEX idx_webhook_outbox_status ON webhook_outbox(status, created_at);
CREATE INDEX idx_dm_outbox_status ON dm_outbox(status, created_at);

87
internal/dm/encrypt.go Normal file
View File

@@ -0,0 +1,87 @@
package dm
import (
"context"
"errors"
"fmt"
gn "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/keyer"
"github.com/nbd-wtf/go-nostr/nip04"
"github.com/nbd-wtf/go-nostr/nip59"
)
var ErrUnsupportedKind = errors.New("unsupported DM kind")
// BuildEvent builds the kind-4 or kind-1059 event(s) for a DM.
// For kind 1059 it returns the gift wrap addressed to the recipient.
// For kind 4 it returns a single signed event.
func BuildEvent(kind int, senderSk, recipientHex, content string) ([]gn.Event, error) {
switch kind {
case 4:
return buildNip04(senderSk, recipientHex, content)
case 1059:
return buildNip17(senderSk, recipientHex, content)
default:
return nil, fmt.Errorf("%w: %d", ErrUnsupportedKind, kind)
}
}
func buildNip04(sk, recipient, content string) ([]gn.Event, error) {
shared, err := nip04.ComputeSharedSecret(recipient, sk)
if err != nil {
return nil, fmt.Errorf("nip04 shared: %w", err)
}
enc, err := nip04.Encrypt(content, shared)
if err != nil {
return nil, fmt.Errorf("nip04 encrypt: %w", err)
}
pk, err := gn.GetPublicKey(sk)
if err != nil {
return nil, err
}
ev := gn.Event{
PubKey: pk,
CreatedAt: gn.Now(),
Kind: 4,
Tags: gn.Tags{{"p", recipient}},
Content: enc,
}
if err := ev.Sign(sk); err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
return []gn.Event{ev}, nil
}
func buildNip17(sk, recipient, content string) ([]gn.Event, error) {
ks, err := keyer.NewPlainKeySigner(sk)
if err != nil {
return nil, fmt.Errorf("keyer: %w", err)
}
ctx := context.Background()
ourPubkey, err := ks.GetPublicKey(ctx)
if err != nil {
return nil, err
}
rumor := gn.Event{
Kind: gn.KindDirectMessage,
Content: content,
Tags: gn.Tags{{"p", recipient}},
CreatedAt: gn.Now(),
PubKey: ourPubkey,
}
rumor.ID = rumor.GetID()
wrap, err := nip59.GiftWrap(
rumor,
recipient,
func(s string) (string, error) { return ks.Encrypt(ctx, s, recipient) },
func(e *gn.Event) error { return ks.SignEvent(ctx, e) },
nil,
)
if err != nil {
return nil, fmt.Errorf("nip17 giftwrap: %w", err)
}
return []gn.Event{wrap}, nil
}

33
internal/dm/model.go Normal file
View File

@@ -0,0 +1,33 @@
package dm
import "time"
type EventType string
const (
EventWelcome EventType = "welcome"
EventExpiringSoon EventType = "expiring_soon"
EventExpired EventType = "expired"
EventExtended EventType = "extended"
)
type Status string
const (
StatusPending Status = "pending"
StatusDelivered Status = "delivered"
StatusDead Status = "dead"
)
type OutboxItem struct {
ID int64
EventType EventType
Pubkey string
Content string
Attempts int
LastAttemptAt *time.Time
NextAttemptAt time.Time
Status Status
LastError string
CreatedAt time.Time
}

86
internal/dm/repo.go Normal file
View File

@@ -0,0 +1,86 @@
package dm
import (
"context"
"database/sql"
"time"
"github.com/noderunners/nip05api/internal/db"
)
type Repo struct{ db *db.DB }
func NewRepo(d *db.DB) *Repo { return &Repo{db: d} }
func (r *Repo) Insert(ctx context.Context, eventType EventType, pubkey, content string) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO dm_outbox
(event_type, pubkey, content, next_attempt_at) VALUES (?, ?, ?, ?)`,
string(eventType), pubkey, content, time.Now().UTC().Format(time.RFC3339))
return err
}
func (r *Repo) Claim(ctx context.Context, limit int) ([]*OutboxItem, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, event_type, pubkey, content, attempts,
last_attempt_at, next_attempt_at, status, COALESCE(last_error, ''), created_at
FROM dm_outbox
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY next_attempt_at ASC LIMIT ?`,
time.Now().UTC().Format(time.RFC3339), limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*OutboxItem{}
for rows.Next() {
var it OutboxItem
var et, status string
var lastAttempt, nextAttempt, created sql.NullString
if err := rows.Scan(&it.ID, &et, &it.Pubkey, &it.Content, &it.Attempts,
&lastAttempt, &nextAttempt, &status, &it.LastError, &created); err != nil {
return nil, err
}
it.EventType = EventType(et)
it.Status = Status(status)
if lastAttempt.Valid {
if t, err := time.Parse(time.RFC3339, lastAttempt.String); err == nil {
it.LastAttemptAt = &t
}
}
if nextAttempt.Valid {
if t, err := time.Parse(time.RFC3339, nextAttempt.String); err == nil {
it.NextAttemptAt = t
}
}
if created.Valid {
if t, err := time.Parse(time.RFC3339, created.String); err == nil {
it.CreatedAt = t
} else if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
it.CreatedAt = t
}
}
out = append(out, &it)
}
return out, rows.Err()
}
func (r *Repo) MarkDelivered(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET status = 'delivered',
last_attempt_at = ?, last_error = '' WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), id)
return err
}
func (r *Repo) MarkRetry(ctx context.Context, id int64, attempts int, nextAt time.Time, errMsg string) error {
_, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET attempts = ?,
last_attempt_at = ?, next_attempt_at = ?, last_error = ? WHERE id = ?`,
attempts, time.Now().UTC().Format(time.RFC3339),
nextAt.UTC().Format(time.RFC3339), errMsg, id)
return err
}
func (r *Repo) MarkDead(ctx context.Context, id int64, errMsg string) error {
_, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET status = 'dead',
last_attempt_at = ?, last_error = ? WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), errMsg, id)
return err
}

35
internal/dm/service.go Normal file
View File

@@ -0,0 +1,35 @@
package dm
import (
"context"
"github.com/noderunners/nip05api/internal/messages"
)
type Service struct {
repo *Repo
templates *messages.Templates
enabled bool
}
func NewService(repo *Repo, t *messages.Templates, enabled bool) *Service {
return &Service{repo: repo, templates: t, enabled: enabled}
}
func (s *Service) Enabled() bool { return s.enabled }
// Send renders the template for the given event and enqueues a DM. Empty rendered
// content short-circuits and returns nil.
func (s *Service) Send(ctx context.Context, event EventType, pubkey string, vars map[string]string) error {
if !s.enabled {
return nil
}
content, err := s.templates.Render(string(event), vars)
if err != nil {
return err
}
if content == "" {
return nil
}
return s.repo.Insert(ctx, event, pubkey, content)
}

109
internal/dm/worker.go Normal file
View File

@@ -0,0 +1,109 @@
package dm
import (
"context"
"log/slog"
"sync"
"time"
"github.com/noderunners/nip05api/internal/nostr"
)
var retrySchedule = []time.Duration{
1 * time.Minute,
5 * time.Minute,
30 * time.Minute,
2 * time.Hour,
12 * time.Hour,
}
type Worker struct {
repo *Repo
pool *nostr.Pool
senderSk string
kind int
maxRetries int
}
func NewWorker(repo *Repo, pool *nostr.Pool, nsec string, kind int) (*Worker, error) {
sk, err := nostr.NsecToHex(nsec)
if err != nil {
return nil, err
}
return &Worker{
repo: repo,
pool: pool,
senderSk: sk,
kind: kind,
maxRetries: len(retrySchedule),
}, nil
}
func (w *Worker) Run(ctx context.Context) {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tick(ctx)
}
}
}
func (w *Worker) tick(ctx context.Context) {
items, err := w.repo.Claim(ctx, 5)
if err != nil {
slog.Error("dm claim", "err", err)
return
}
if len(items) == 0 {
return
}
var wg sync.WaitGroup
for _, it := range items {
wg.Add(1)
go func(it *OutboxItem) {
defer wg.Done()
w.deliver(ctx, it)
}(it)
}
wg.Wait()
}
func (w *Worker) deliver(ctx context.Context, it *OutboxItem) {
pubCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
events, err := BuildEvent(w.kind, w.senderSk, it.Pubkey, it.Content)
if err != nil {
w.handleErr(ctx, it, err)
return
}
for _, ev := range events {
ev := ev
if err := nostr.Publish(pubCtx, w.pool, &ev); err != nil {
w.handleErr(ctx, it, err)
return
}
}
_ = w.repo.MarkDelivered(ctx, it.ID)
slog.Info("dm delivered", "event", it.EventType, "pubkey", it.Pubkey, "id", it.ID)
}
func (w *Worker) handleErr(ctx context.Context, it *OutboxItem, err error) {
attempts := it.Attempts + 1
if attempts >= w.maxRetries {
_ = w.repo.MarkDead(ctx, it.ID, err.Error())
slog.Error("dm dead", "id", it.ID, "pubkey", it.Pubkey, "err", err)
return
}
idx := attempts - 1
if idx >= len(retrySchedule) {
idx = len(retrySchedule) - 1
}
next := time.Now().UTC().Add(retrySchedule[idx])
_ = w.repo.MarkRetry(ctx, it.ID, attempts, next, err.Error())
slog.Warn("dm retry", "id", it.ID, "attempts", attempts, "err", err)
}

175
internal/expiry/worker.go Normal file
View File

@@ -0,0 +1,175 @@
package expiry
import (
"context"
"log/slog"
"strconv"
"time"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
type Worker struct {
users *user.Service
dms *dm.Service
hooks *webhook.Service
audit *audit.Logger
domain string
frontend string
graceDays int
reminderDays []int
cronHourUTC int
clock func() time.Time
}
func NewWorker(users *user.Service, dms *dm.Service, hooks *webhook.Service, aud *audit.Logger,
domain, frontend string, graceDays int, reminderDays []int, cronHourUTC int) *Worker {
if len(reminderDays) == 0 {
reminderDays = []int{7}
}
return &Worker{
users: users,
dms: dms,
hooks: hooks,
audit: aud,
domain: domain,
frontend: frontend,
graceDays: graceDays,
reminderDays: reminderDays,
cronHourUTC: cronHourUTC,
clock: func() time.Time { return time.Now().UTC() },
}
}
func (w *Worker) WithClock(c func() time.Time) *Worker { w.clock = c; return w }
func (w *Worker) Run(ctx context.Context) {
w.RunOnce(ctx)
for {
next := w.nextRun()
timer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
w.RunOnce(ctx)
}
}
}
func (w *Worker) nextRun() time.Time {
now := w.clock()
target := time.Date(now.Year(), now.Month(), now.Day(), w.cronHourUTC, 0, 0, 0, time.UTC)
if !target.After(now) {
target = target.Add(24 * time.Hour)
}
return target
}
func (w *Worker) RunOnce(ctx context.Context) {
now := w.clock()
w.processReminders(ctx, now)
w.processExpirations(ctx, now)
w.processGraceCleanup(ctx, now)
}
func (w *Worker) processReminders(ctx context.Context, now time.Time) {
for _, days := range w.reminderDays {
users, err := w.users.Repo().ListPendingReminders(ctx, days, now)
if err != nil {
slog.Error("expiry reminders list", "err", err)
continue
}
for _, u := range users {
vars := buildVars(u, w.domain, w.frontend, days)
if err := w.dms.Send(ctx, dm.EventExpiringSoon, u.Pubkey, vars); err != nil {
slog.Error("expiry reminder dm", "pubkey", u.Pubkey, "err", err)
continue
}
ts := now
u.ExpiringReminderSentAt = &ts
if err := w.users.Repo().Update(ctx, u); err != nil {
slog.Error("expiry reminder update", "pubkey", u.Pubkey, "err", err)
}
}
}
}
func (w *Worker) processExpirations(ctx context.Context, now time.Time) {
users, err := w.users.Repo().ListExpired(ctx, now)
if err != nil {
slog.Error("expiry list", "err", err)
return
}
for _, u := range users {
ts := now
u.IsActive = false
u.DeactivatedAt = &ts
if err := w.users.Repo().Update(ctx, u); err != nil {
slog.Error("expiry deactivate", "pubkey", u.Pubkey, "err", err)
continue
}
vars := buildVars(u, w.domain, w.frontend, 0)
vars["grace_days"] = strconv.Itoa(w.graceDays)
_ = w.dms.Send(ctx, dm.EventExpired, u.Pubkey, vars)
_ = w.hooks.Enqueue(ctx, webhook.EventUserRemoved, hookData(u, "expired"))
w.audit.Log(ctx, audit.ActionUserExpired, audit.ActorSystem, u.Pubkey, map[string]any{
"username": u.Username,
})
}
}
func (w *Worker) processGraceCleanup(ctx context.Context, now time.Time) {
cutoff := now.Add(-time.Duration(w.graceDays) * 24 * time.Hour)
users, err := w.users.Repo().ListGraceExpired(ctx, cutoff)
if err != nil {
slog.Error("grace list", "err", err)
return
}
for _, u := range users {
if err := w.users.Repo().Delete(ctx, u.Pubkey); err != nil {
slog.Error("grace delete", "pubkey", u.Pubkey, "err", err)
continue
}
_ = w.hooks.Enqueue(ctx, webhook.EventUserRemoved, hookData(u, "grace_cleanup"))
w.audit.Log(ctx, audit.ActionUserGracePurged, audit.ActorSystem, u.Pubkey, map[string]any{
"username": u.Username,
})
}
}
func buildVars(u *user.User, domain, frontend string, daysRemaining int) map[string]string {
expires := "lifetime"
if u.ExpiresAt != nil {
expires = u.ExpiresAt.Format("2006-01-02")
}
return map[string]string{
"username": u.Username,
"npub": nostr.HexToNpub(u.Pubkey),
"pubkey": u.Pubkey,
"domain": domain,
"expires_at": expires,
"days_remaining": strconv.Itoa(daysRemaining),
"frontend_url": frontend,
"subscription_type": string(u.SubscriptionType),
}
}
func hookData(u *user.User, reason string) map[string]any {
d := map[string]any{
"pubkey": u.Pubkey,
"npub": nostr.HexToNpub(u.Pubkey),
"username": u.Username,
"subscription_type": string(u.SubscriptionType),
"reason": reason,
}
if u.ExpiresAt != nil {
d["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
}
return d
}

View File

@@ -0,0 +1,204 @@
package expiry
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/messages"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
func newTestStack(t *testing.T) (*db.DB, *user.Service, *dm.Service, *webhook.Service, *audit.Logger) {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := d.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
tmpls, _ := messages.Load("/nonexistent.yaml")
users := user.NewService(user.NewRepo(d), nil)
dms := dm.NewService(dm.NewRepo(d), tmpls, true)
hooks := webhook.NewService(webhook.NewRepo(d), "test.local", true)
return d, users, dms, hooks, audit.New(d)
}
func TestExpiry_Reminder(t *testing.T) {
d, users, dms, hooks, aud := newTestStack(t)
now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
expires := now.Add(7 * 24 * time.Hour)
u := &user.User{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
ExpiresAt: &expires,
IsActive: true,
}
if err := users.Repo().Insert(context.Background(), u); err != nil {
t.Fatal(err)
}
w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local",
30, []int{7}, 9).WithClock(func() time.Time { return now })
w.RunOnce(context.Background())
var dmCount int
if err := d.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expiring_soon'`).Scan(&dmCount); err != nil {
t.Fatal(err)
}
if dmCount != 1 {
t.Errorf("expected 1 expiring_soon DM, got %d", dmCount)
}
// Reminder flag should be set, so a re-run does not double-send.
w.RunOnce(context.Background())
if err := d.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expiring_soon'`).Scan(&dmCount); err != nil {
t.Fatal(err)
}
if dmCount != 1 {
t.Errorf("re-run should not double-fire, got %d", dmCount)
}
}
func TestExpiry_Expiration(t *testing.T) {
d, users, dms, hooks, aud := newTestStack(t)
now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
expired := now.Add(-1 * time.Hour)
u := &user.User{
Pubkey: testHex,
Username: "bob",
SubscriptionType: user.SubYearly,
ExpiresAt: &expired,
IsActive: true,
}
if err := users.Repo().Insert(context.Background(), u); err != nil {
t.Fatal(err)
}
w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local",
30, []int{7}, 9).WithClock(func() time.Time { return now })
w.RunOnce(context.Background())
got, err := users.Repo().GetByPubkey(context.Background(), testHex)
if err != nil {
t.Fatal(err)
}
if got.IsActive {
t.Error("expected user deactivated")
}
if got.DeactivatedAt == nil {
t.Error("expected deactivated_at set")
}
var dmCount int
if err := d.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expired'`).Scan(&dmCount); err != nil {
t.Fatal(err)
}
if dmCount != 1 {
t.Errorf("expected 1 expired DM, got %d", dmCount)
}
var hookCount int
if err := d.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM webhook_outbox WHERE event_type = 'user.removed'`).Scan(&hookCount); err != nil {
t.Fatal(err)
}
if hookCount != 1 {
t.Errorf("expected 1 user.removed webhook, got %d", hookCount)
}
}
func TestExpiry_GraceCleanup(t *testing.T) {
d, users, dms, hooks, aud := newTestStack(t)
now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
deactivated := now.Add(-31 * 24 * time.Hour)
expired := now.Add(-32 * 24 * time.Hour)
u := &user.User{
Pubkey: testHex,
Username: "charlie",
SubscriptionType: user.SubYearly,
ExpiresAt: &expired,
IsActive: false,
DeactivatedAt: &deactivated,
}
if err := users.Repo().Insert(context.Background(), u); err != nil {
t.Fatal(err)
}
// Insert sets is_active=1 by default; force it inactive.
if err := users.Repo().Update(context.Background(), u); err != nil {
t.Fatal(err)
}
w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local",
30, []int{7}, 9).WithClock(func() time.Time { return now })
w.RunOnce(context.Background())
// User should be hard-deleted.
_, err := users.Repo().GetByPubkey(context.Background(), testHex)
if err == nil {
t.Error("expected user deleted after grace cleanup")
}
// Audit log should record the purge.
var auditCount int
if err := d.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM audit_log WHERE action = 'user.grace_purged'`).Scan(&auditCount); err != nil {
t.Fatal(err)
}
if auditCount != 1 {
t.Errorf("expected 1 grace_purged audit, got %d", auditCount)
}
}
func TestExpiry_LifetimeUserNotAffected(t *testing.T) {
d, users, dms, hooks, aud := newTestStack(t)
now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
u := &user.User{
Pubkey: testHex,
Username: "lifetime",
SubscriptionType: user.SubLifetime,
IsActive: true,
}
if err := users.Repo().Insert(context.Background(), u); err != nil {
t.Fatal(err)
}
w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local",
30, []int{7}, 9).WithClock(func() time.Time { return now })
w.RunOnce(context.Background())
got, err := users.Repo().GetByPubkey(context.Background(), testHex)
if err != nil {
t.Fatal(err)
}
if !got.IsActive {
t.Error("lifetime user should not be deactivated")
}
var dmCount int
if err := d.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM dm_outbox`).Scan(&dmCount); err != nil {
t.Fatal(err)
}
if dmCount != 0 {
t.Errorf("lifetime user should receive no DM, got %d", dmCount)
}
}

View File

@@ -0,0 +1,93 @@
package docs
import (
_ "embed"
"encoding/json"
"net/http"
"gopkg.in/yaml.v3"
)
//go:embed openapi.yaml
var openapiYAML []byte
var openapiJSON []byte
func init() {
var raw any
if err := yaml.Unmarshal(openapiYAML, &raw); err != nil {
panic(err)
}
clean := convertMaps(raw)
b, err := json.Marshal(clean)
if err != nil {
panic(err)
}
openapiJSON = b
}
// convertMaps recursively converts map[interface{}]interface{} to map[string]interface{}.
func convertMaps(in any) any {
switch v := in.(type) {
case map[any]any:
m := make(map[string]any, len(v))
for k, val := range v {
m[toString(k)] = convertMaps(val)
}
return m
case map[string]any:
m := make(map[string]any, len(v))
for k, val := range v {
m[k] = convertMaps(val)
}
return m
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = convertMaps(item)
}
return out
default:
return v
}
}
func toString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func ServeJSON(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write(openapiJSON)
}
const swaggerHTML = `<!DOCTYPE html>
<html>
<head>
<title>NIP-05 API</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: "/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
});
};
</script>
</body>
</html>`
func ServeUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(swaggerHTML))
}

View File

@@ -0,0 +1,297 @@
openapi: 3.1.0
info:
title: NIP-05 API
description: Single-domain NIP-05 identity service with Lightning-paid registration.
version: 1.0.0
servers:
- url: /
tags:
- name: Public
description: Anonymous, infrastructure-level endpoints. Cacheable, no auth.
- name: User
description: Anonymous flows for end users — lookup, availability, payment.
- name: Admin
description: Privileged operations. Require an `X-API-Key` header.
components:
securitySchemes:
AdminAPIKey:
type: apiKey
in: header
name: X-API-Key
schemas:
Error:
type: object
properties:
error: { type: string }
detail: { type: string }
Pricing:
type: object
properties:
yearly_sats: { type: integer }
lifetime_sats: { type: integer }
lightning_enabled: { type: boolean }
Invoice:
type: object
properties:
payment_hash: { type: string }
payment_request: { type: string }
amount_sats: { type: integer }
expires_at: { type: string, format: date-time }
username: { type: string }
is_renewal: { type: boolean }
InvoiceStatus:
type: object
properties:
payment_hash: { type: string }
status: { type: string, enum: [pending, paid, expired] }
username: { type: string }
User:
type: object
properties:
pubkey: { type: string }
npub: { type: string }
username: { type: string }
subscription_type: { type: string, enum: [yearly, lifetime] }
is_active: { type: boolean }
expires_at: { type: string, format: date-time, nullable: true }
deactivated_at: { type: string, format: date-time, nullable: true }
UserLookup:
type: object
properties:
pubkey: { type: string }
npub: { type: string }
is_whitelisted: { type: boolean }
username: { type: string }
expires_at: { type: string, format: date-time, nullable: true }
in_grace: { type: boolean }
reserved_username: { type: string }
expired_at: { type: string, format: date-time }
paths:
/.well-known/nostr.json:
get:
tags: [Public]
summary: NIP-05 lookup
parameters:
- in: query
name: name
schema: { type: string }
responses:
'200':
description: NIP-05 names map
content:
application/json:
schema:
type: object
properties:
names:
type: object
additionalProperties: { type: string }
relays:
type: object
additionalProperties:
type: array
items: { type: string }
/healthz:
get:
tags: [Public]
summary: Health check
responses:
'200': { description: OK }
'503': { description: Down }
/v1/pricing:
get:
tags: [Public]
summary: Pricing info
responses:
'200':
description: Pricing
content:
application/json:
schema: { $ref: '#/components/schemas/Pricing' }
/v1/invoices:
post:
tags: [User]
summary: Create payment invoice
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [pubkey]
properties:
pubkey: { type: string, description: "Hex pubkey or npub" }
username:
type: string
description: "Optional. Auto-generated from the pubkey if omitted."
subscription_type:
type: string
enum: [yearly, lifetime]
description: "Optional. Defaults to lifetime."
years:
type: integer
minimum: 1
description: "Optional. Defaults to 1 when subscription_type is yearly; ignored for lifetime."
responses:
'200':
description: Invoice
content:
application/json:
schema: { $ref: '#/components/schemas/Invoice' }
'400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
'403': { description: Forbidden — user already has lifetime access, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
'409': { description: Conflict — username unavailable or pending invoice already exists, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
'503': { description: Lightning unavailable, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
/v1/invoices/{payment_hash}:
get:
tags: [User]
summary: Invoice status
parameters:
- in: path
name: payment_hash
required: true
schema: { type: string }
responses:
'200':
description: Status
content:
application/json:
schema: { $ref: '#/components/schemas/InvoiceStatus' }
'404': { description: Not found }
/v1/users/{pubkey}:
get:
tags: [User]
summary: Lookup user by pubkey (npub or hex)
parameters:
- in: path
name: pubkey
required: true
schema: { type: string }
responses:
'200':
description: User lookup
content:
application/json:
schema: { $ref: '#/components/schemas/UserLookup' }
'404': { description: Never registered }
/v1/usernames/{name}/available:
get:
tags: [User]
summary: Username availability
parameters:
- in: path
name: name
required: true
schema: { type: string }
responses:
'200':
description: Availability
content:
application/json:
schema:
type: object
properties:
username: { type: string }
available: { type: boolean }
/v1/admin/users:
post:
tags: [Admin]
summary: Add user (admin)
security: [{ AdminAPIKey: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [pubkey, username, subscription_type]
properties:
pubkey: { type: string }
username: { type: string }
subscription_type: { type: string, enum: [yearly, lifetime] }
years: { type: integer }
responses:
'201':
description: Created
content: { application/json: { schema: { $ref: '#/components/schemas/User' } } }
'401': { description: Unauthorized }
get:
tags: [Admin]
summary: List users (admin)
security: [{ AdminAPIKey: [] }]
parameters:
- in: query
name: active
schema: { type: boolean }
- in: query
name: q
schema: { type: string }
responses:
'200':
description: User list
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/User' }
'401': { description: Unauthorized }
/v1/admin/users/{pubkey}:
put:
tags: [Admin]
summary: Update username (admin)
security: [{ AdminAPIKey: [] }]
parameters:
- in: path
name: pubkey
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username]
properties:
username: { type: string }
responses:
'200': { description: Updated }
'401': { description: Unauthorized }
'404': { description: Not found }
'409': { description: Conflict }
delete:
tags: [Admin]
summary: Delete user (admin)
security: [{ AdminAPIKey: [] }]
parameters:
- in: path
name: pubkey
required: true
schema: { type: string }
responses:
'200': { description: Deleted }
'401': { description: Unauthorized }
'404': { description: Not found }
/v1/admin/users/{pubkey}/extend:
post:
tags: [Admin]
summary: Extend subscription (admin)
security: [{ AdminAPIKey: [] }]
parameters:
- in: path
name: pubkey
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
years: { type: integer }
subscription_type: { type: string, enum: [yearly, lifetime] }
responses:
'200': { description: Extended }
'401': { description: Unauthorized }
'404': { description: Not found }

View File

@@ -0,0 +1,80 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
type AdminExtend struct {
Users *user.Service
DMs *dm.Service
Hooks *webhook.Service
Audit *audit.Logger
Domain string
Frontend string
}
type extendReq struct {
Years int `json:"years"`
SubscriptionType string `json:"subscription_type"`
}
func (h *AdminExtend) Handle(w http.ResponseWriter, r *http.Request) {
hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey"))
if err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
return
}
var body extendReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON")
return
}
u, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk)
if errors.Is(err, user.ErrUserNotFound) {
WriteError(w, http.StatusNotFound, "NotFound", "user not found")
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
sub := u.SubscriptionType
if body.SubscriptionType != "" {
s := user.SubscriptionType(body.SubscriptionType)
if !s.Valid() {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type")
return
}
sub = s
}
years := body.Years
if sub == user.SubYearly && years <= 0 {
years = 1
}
if err := h.Users.Renew(r.Context(), u, sub, years); err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
vars := dmVars(u, h.Domain, h.Frontend)
_ = h.DMs.Send(r.Context(), dm.EventExtended, u.Pubkey, vars)
_ = h.Hooks.Enqueue(r.Context(), webhook.EventUserExtended, hookData(u, h.Domain))
h.Audit.Log(r.Context(), audit.ActionUserExtended, audit.ActorAdmin, u.Pubkey, map[string]any{
"subscription_type": string(sub),
"years": years,
})
WriteJSON(w, http.StatusOK, userResponse(u))
}

View File

@@ -0,0 +1,57 @@
package handlers
import (
"time"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
)
func userResponse(u *user.User) map[string]any {
resp := map[string]any{
"pubkey": u.Pubkey,
"npub": nostr.HexToNpub(u.Pubkey),
"username": u.Username,
"subscription_type": string(u.SubscriptionType),
"is_active": u.IsActive,
"manual_username": u.ManualUsername,
"created_at": u.CreatedAt.UTC().Format(time.RFC3339),
}
if u.ExpiresAt != nil {
resp["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
}
if u.DeactivatedAt != nil {
resp["deactivated_at"] = u.DeactivatedAt.UTC().Format(time.RFC3339)
}
return resp
}
func dmVars(u *user.User, domain, frontend string) map[string]string {
expires := "lifetime"
if u.ExpiresAt != nil {
expires = u.ExpiresAt.Format("2006-01-02")
}
return map[string]string{
"username": u.Username,
"npub": nostr.HexToNpub(u.Pubkey),
"pubkey": u.Pubkey,
"domain": domain,
"expires_at": expires,
"days_remaining": "",
"frontend_url": frontend,
"subscription_type": string(u.SubscriptionType),
}
}
func hookData(u *user.User, domain string) map[string]any {
d := map[string]any{
"pubkey": u.Pubkey,
"npub": nostr.HexToNpub(u.Pubkey),
"username": u.Username,
"subscription_type": string(u.SubscriptionType),
}
if u.ExpiresAt != nil {
d["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
}
return d
}

View File

@@ -0,0 +1,166 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
type AdminUsers struct {
Users *user.Service
DMs *dm.Service
Hooks *webhook.Service
Audit *audit.Logger
Domain string
Frontend string
}
type adminAddReq struct {
Pubkey string `json:"pubkey"`
Username string `json:"username"`
SubscriptionType string `json:"subscription_type"`
Years int `json:"years"`
}
func (h *AdminUsers) Add(w http.ResponseWriter, r *http.Request) {
var body adminAddReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON")
return
}
hexpk, err := nostr.NormalizePubkey(body.Pubkey)
if err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
return
}
sub := user.SubscriptionType(body.SubscriptionType)
if !sub.Valid() {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type")
return
}
years := body.Years
if years <= 0 {
years = 1
}
if existing, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk); err == nil && existing != nil {
WriteError(w, http.StatusConflict, "Conflict", "user already exists")
return
}
if existing, err := h.Users.Repo().GetByUsername(r.Context(), user.NormalizeUsername(body.Username)); err == nil && existing != nil {
WriteError(w, http.StatusConflict, "Conflict", "username taken")
return
}
u, err := h.Users.CreateOrActivate(r.Context(), hexpk, body.Username, sub, years, true)
if err != nil {
if errors.Is(err, user.ErrInvalidUsername) {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username")
return
}
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
vars := dmVars(u, h.Domain, h.Frontend)
_ = h.DMs.Send(r.Context(), dm.EventWelcome, u.Pubkey, vars)
_ = h.Hooks.Enqueue(r.Context(), webhook.EventUserAdded, hookData(u, h.Domain))
h.Audit.Log(r.Context(), audit.ActionUserAdded, audit.ActorAdmin, u.Pubkey, map[string]any{
"username": u.Username,
"subscription_type": string(u.SubscriptionType),
"years": years,
})
WriteJSON(w, http.StatusCreated, userResponse(u))
}
func (h *AdminUsers) List(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
activeOnly := q.Get("active") == "true"
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 {
limit = 100
}
users, err := h.Users.Repo().List(r.Context(), user.ListFilter{
ActiveOnly: activeOnly,
Search: q.Get("q"),
Limit: limit,
})
if err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, userResponse(u))
}
WriteJSON(w, http.StatusOK, out)
}
func (h *AdminUsers) Update(w http.ResponseWriter, r *http.Request) {
hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey"))
if err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
return
}
var body struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON")
return
}
u, err := h.Users.SetUsername(r.Context(), hexpk, body.Username)
if err != nil {
switch {
case errors.Is(err, user.ErrUserNotFound):
WriteError(w, http.StatusNotFound, "NotFound", "user not found")
case errors.Is(err, user.ErrInvalidUsername):
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username")
case errors.Is(err, user.ErrUsernameTaken):
WriteError(w, http.StatusConflict, "Conflict", "username taken")
default:
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
}
return
}
h.Audit.Log(r.Context(), audit.ActionUserUsernameChanged, audit.ActorAdmin, u.Pubkey, map[string]any{
"username": u.Username,
})
WriteJSON(w, http.StatusOK, userResponse(u))
}
func (h *AdminUsers) Delete(w http.ResponseWriter, r *http.Request) {
hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey"))
if err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
return
}
u, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk)
if errors.Is(err, user.ErrUserNotFound) {
WriteError(w, http.StatusNotFound, "NotFound", "user not found")
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
if err := h.Users.Delete(r.Context(), hexpk); err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
_ = h.Hooks.Enqueue(r.Context(), webhook.EventUserRemoved, hookData(u, h.Domain))
h.Audit.Log(r.Context(), audit.ActionUserDeleted, audit.ActorAdmin, u.Pubkey, map[string]any{
"username": u.Username,
})
WriteJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}

View File

@@ -0,0 +1,26 @@
package handlers
import (
"net/http"
"github.com/noderunners/nip05api/internal/db"
)
type Health struct {
DB *db.DB
Version string
}
func (h *Health) Handle(w http.ResponseWriter, r *http.Request) {
if err := h.DB.Ping(r.Context()); err != nil {
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "down",
"version": h.Version,
})
return
}
WriteJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"version": h.Version,
})
}

View File

@@ -0,0 +1,109 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
)
type Invoices struct {
Service *invoice.Service
LightningEnabled bool
}
type createInvoiceReq struct {
Username string `json:"username"`
Pubkey string `json:"pubkey"`
SubscriptionType string `json:"subscription_type"`
Years int `json:"years"`
}
func (h *Invoices) Create(w http.ResponseWriter, r *http.Request) {
if !h.LightningEnabled {
WriteError(w, http.StatusServiceUnavailable, "LightningDisabled", "lightning payments are disabled")
return
}
var body createInvoiceReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON")
return
}
hexpk, err := nostr.NormalizePubkey(body.Pubkey)
if err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
return
}
subStr := strings.TrimSpace(body.SubscriptionType)
if subStr == "" {
subStr = string(user.SubLifetime)
}
sub := user.SubscriptionType(subStr)
if !sub.Valid() {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type")
return
}
years := body.Years
if sub == user.SubYearly && years <= 0 {
years = 1
}
p, err := h.Service.Create(r.Context(), invoice.CreateRequest{
Username: body.Username,
Pubkey: hexpk,
SubscriptionType: sub,
Years: years,
})
if err != nil {
switch {
case errors.Is(err, invoice.ErrLifetimeAccess):
WriteError(w, http.StatusForbidden, "User already has lifetime access", "")
case errors.Is(err, invoice.ErrPendingInvoiceExists):
WriteError(w, http.StatusConflict, "Conflict", err.Error())
case errors.Is(err, invoice.ErrUsernameTaken),
errors.Is(err, user.ErrUsernameTaken):
WriteError(w, http.StatusConflict, "Conflict", "username unavailable")
case errors.Is(err, invoice.ErrUsernameMismatch):
WriteError(w, http.StatusConflict, "Conflict", err.Error())
case errors.Is(err, user.ErrInvalidUsername),
errors.Is(err, invoice.ErrInvalidYears):
WriteError(w, http.StatusBadRequest, "ValidationError", err.Error())
case errors.Is(err, invoice.ErrLNbits):
WriteError(w, http.StatusServiceUnavailable, "LightningError", err.Error())
default:
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
}
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"payment_hash": p.PaymentHash,
"payment_request": p.PaymentRequest,
"amount_sats": p.AmountSats,
"expires_at": p.ExpiresAt.UTC().Format(time.RFC3339),
"username": p.Username,
"is_renewal": p.IsRenewal,
})
}
func (h *Invoices) Get(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "payment_hash")
p, err := h.Service.Repo().Get(r.Context(), hash)
if errors.Is(err, invoice.ErrInvoiceNotFound) {
WriteError(w, http.StatusNotFound, "NotFound", "invoice not found")
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"payment_hash": p.PaymentHash,
"status": string(p.Status()),
"username": p.Username,
})
}

View File

@@ -0,0 +1,41 @@
package handlers
import (
"net/http"
"github.com/noderunners/nip05api/internal/user"
)
type NostrJSON struct {
Users *user.Service
Relays []string
}
func (h *NostrJSON) Handle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=60")
names, err := h.Users.Repo().ActiveByName(r.Context())
if err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
if q := r.URL.Query().Get("name"); q != "" {
filtered := map[string]string{}
if pk, ok := names[q]; ok {
filtered[q] = pk
}
names = filtered
}
relays := map[string][]string{}
if len(h.Relays) > 0 {
for _, pk := range names {
relays[pk] = h.Relays
}
}
WriteJSON(w, http.StatusOK, map[string]any{
"names": names,
"relays": relays,
})
}

View File

@@ -0,0 +1,17 @@
package handlers
import "net/http"
type Pricing struct {
YearlySats int64
LifetimeSats int64
LightningEnabled bool
}
func (h *Pricing) Handle(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"yearly_sats": h.YearlySats,
"lifetime_sats": h.LifetimeSats,
"lightning_enabled": h.LightningEnabled,
})
}

View File

@@ -0,0 +1,19 @@
package handlers
import (
"encoding/json"
"net/http"
)
func WriteJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if body == nil {
return
}
_ = json.NewEncoder(w).Encode(body)
}
func WriteError(w http.ResponseWriter, code int, kind, detail string) {
WriteJSON(w, code, map[string]string{"error": kind, "detail": detail})
}

View File

@@ -0,0 +1,40 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/noderunners/nip05api/internal/user"
)
type Usernames struct{ Users *user.Service }
func (h *Usernames) Available(w http.ResponseWriter, r *http.Request) {
name := user.NormalizeUsername(chi.URLParam(r, "name"))
if err := user.ValidateUsername(name, h.Users.Reserved()); err != nil {
WriteJSON(w, http.StatusOK, map[string]any{
"username": name,
"available": false,
"reason": "invalid_or_reserved",
})
return
}
avail, err := h.Users.IsAvailable(r.Context(), name)
if err != nil {
if errors.Is(err, user.ErrInvalidUsername) {
WriteJSON(w, http.StatusOK, map[string]any{
"username": name,
"available": false,
"reason": "invalid",
})
return
}
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"username": name,
"available": avail,
})
}

View File

@@ -0,0 +1,63 @@
package handlers
import (
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
)
type Users struct {
Users *user.Service
GraceDays int
}
func (h *Users) Get(w http.ResponseWriter, r *http.Request) {
hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey"))
if err != nil {
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
return
}
u, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk)
if errors.Is(err, user.ErrUserNotFound) {
WriteError(w, http.StatusNotFound, "NotFound", "user not registered")
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
resp := map[string]any{
"pubkey": u.Pubkey,
"npub": nostr.HexToNpub(u.Pubkey),
}
if u.IsActive {
resp["is_whitelisted"] = true
resp["username"] = u.Username
if u.ExpiresAt != nil {
resp["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
} else {
resp["expires_at"] = nil
}
resp["subscription_type"] = string(u.SubscriptionType)
} else {
resp["is_whitelisted"] = false
if u.ExpiresAt != nil {
resp["expired_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
}
if u.DeactivatedAt != nil {
cutoff := u.DeactivatedAt.Add(time.Duration(h.GraceDays) * 24 * time.Hour)
if time.Now().UTC().Before(cutoff) {
resp["in_grace"] = true
resp["reserved_username"] = u.Username
} else {
resp["in_grace"] = false
}
}
}
WriteJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1,25 @@
package middleware
import (
"crypto/subtle"
"encoding/json"
"net/http"
)
func AdminAuth(apiKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
provided := r.Header.Get("X-API-Key")
if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(apiKey)) != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "Unauthorized",
"detail": "missing or invalid X-API-Key",
})
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,19 @@
package middleware
import "net/http"
// BodyLimit caps request body size. Returns 413 if exceeded.
func BodyLimit(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > maxBytes {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_, _ = w.Write([]byte(`{"error":"PayloadTooLarge","detail":"request body exceeds limit"}`))
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,18 @@
package middleware
import "net/http"
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
h.Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,49 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"time"
applog "github.com/noderunners/nip05api/internal/log"
)
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func newRequestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = newRequestID()
}
w.Header().Set("X-Request-ID", id)
ctx := applog.WithRequestID(r.Context(), id)
rec := &statusRecorder{ResponseWriter: w, status: 200}
start := time.Now()
next.ServeHTTP(rec, r.WithContext(ctx))
slog.Info("http",
"request_id", id,
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}

View File

@@ -0,0 +1,27 @@
package middleware
import (
"net/http"
"strings"
"time"
"github.com/go-chi/httprate"
)
// RateLimit returns a middleware that limits requests per minute by IP.
// Admin routes are skipped.
func RateLimit(perMin int) func(http.Handler) http.Handler {
if perMin <= 0 {
return func(next http.Handler) http.Handler { return next }
}
limiter := httprate.LimitByIP(perMin, time.Minute)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v1/admin/") {
next.ServeHTTP(w, r)
return
}
limiter(next).ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net"
"net/http"
"strings"
)
// RealIP rewrites RemoteAddr from common reverse-proxy headers so downstream
// rate limiters and loggers see the original client IP. Trusted unconditionally;
// terminate this header at your proxy.
func RealIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := clientIP(r); ip != "" {
r.RemoteAddr = ip + ":0"
}
next.ServeHTTP(w, r)
})
}
func clientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
return parsed.String()
}
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first := strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])
if parsed := net.ParseIP(first); parsed != nil {
return parsed.String()
}
}
return ""
}

View File

@@ -0,0 +1,42 @@
package middleware
import (
"encoding/json"
"log/slog"
"net/http"
"runtime/debug"
)
// Recoverer turns panics into 500 JSON responses without leaking the stack to
// clients. The full stack is logged at error level.
func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rv := recover(); rv != nil {
slog.Error("panic recovered",
"path", r.URL.Path,
"method", r.Method,
"err", rv,
"stack", string(debug.Stack()),
)
if !headerWritten(w) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "InternalError",
"detail": "internal server error",
})
}
}
}()
next.ServeHTTP(w, r)
})
}
// headerWritten is best-effort; if the response is hijacked we skip writing.
func headerWritten(w http.ResponseWriter) bool {
if rw, ok := w.(interface{ Status() int }); ok {
return rw.Status() != 0
}
return false
}

99
internal/http/server.go Normal file
View File

@@ -0,0 +1,99 @@
package http
import (
"context"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/config"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/http/docs"
"github.com/noderunners/nip05api/internal/http/handlers"
"github.com/noderunners/nip05api/internal/http/middleware"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
type Deps struct {
Cfg *config.Config
DB *db.DB
Users *user.Service
Invoices *invoice.Service
DMs *dm.Service
Hooks *webhook.Service
Audit *audit.Logger
Version string
}
func NewServer(d Deps) *http.Server {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
r.Use(middleware.Logging)
r.Use(middleware.CORS)
r.Use(middleware.BodyLimit(1 << 20)) // 1 MiB max request body
r.Use(middleware.RateLimit(d.Cfg.RateLimitPerMin))
health := &handlers.Health{DB: d.DB, Version: d.Version}
nostrJSON := &handlers.NostrJSON{Users: d.Users, Relays: d.Cfg.Nostr.Relays}
pricing := &handlers.Pricing{
YearlySats: d.Cfg.Lightning.PriceYearlySats,
LifetimeSats: d.Cfg.Lightning.PriceLifetimeSats,
LightningEnabled: d.Cfg.Lightning.Enabled,
}
users := &handlers.Users{Users: d.Users, GraceDays: d.Cfg.Expiry.GraceDays}
usernames := &handlers.Usernames{Users: d.Users}
invoices := &handlers.Invoices{Service: d.Invoices, LightningEnabled: d.Cfg.Lightning.Enabled}
adminUsers := &handlers.AdminUsers{
Users: d.Users, DMs: d.DMs, Hooks: d.Hooks, Audit: d.Audit,
Domain: d.Cfg.Domain, Frontend: d.Cfg.FrontendURL,
}
adminExtend := &handlers.AdminExtend{
Users: d.Users, DMs: d.DMs, Hooks: d.Hooks, Audit: d.Audit,
Domain: d.Cfg.Domain, Frontend: d.Cfg.FrontendURL,
}
r.Get("/healthz", health.Handle)
r.Get("/.well-known/nostr.json", nostrJSON.Handle)
r.Get("/openapi.json", docs.ServeJSON)
r.Get("/docs", docs.ServeUI)
r.Get("/docs/", docs.ServeUI)
r.Route("/v1", func(r chi.Router) {
r.Get("/pricing", pricing.Handle)
r.Get("/users/{pubkey}", users.Get)
r.Get("/usernames/{name}/available", usernames.Available)
if d.Invoices != nil {
r.Post("/invoices", invoices.Create)
r.Get("/invoices/{payment_hash}", invoices.Get)
}
r.Route("/admin", func(r chi.Router) {
r.Use(middleware.AdminAuth(d.Cfg.AdminAPIKey))
r.Post("/users", adminUsers.Add)
r.Get("/users", adminUsers.List)
r.Put("/users/{pubkey}", adminUsers.Update)
r.Delete("/users/{pubkey}", adminUsers.Delete)
r.Post("/users/{pubkey}/extend", adminExtend.Handle)
})
})
return &http.Server{
Addr: d.Cfg.Addr(),
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
func Shutdown(ctx context.Context, srv *http.Server) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}

View File

@@ -0,0 +1,357 @@
package http_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/config"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/dm"
httpapi "github.com/noderunners/nip05api/internal/http"
"github.com/noderunners/nip05api/internal/messages"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
const testKey = "test-admin-key-twenty-five-chars"
type fixture struct {
srv *httptest.Server
db *db.DB
}
func newFixture(t *testing.T) *fixture {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
d, err := db.Open(dbPath)
if err != nil {
t.Fatal(err)
}
if err := d.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
Domain: "test.local",
Port: 0,
AdminAPIKey: testKey,
FrontendURL: "https://test.local/nip05",
Lightning: config.LightningConfig{Enabled: false},
Expiry: config.ExpiryConfig{GraceDays: 30},
ReservedUsernames: []string{"admin", "root"},
RateLimitPerMin: 0, // disabled in tests
}
tmpls, _ := messages.Load("/nonexistent.yaml")
users := user.NewService(user.NewRepo(d), cfg.ReservedUsernames)
dms := dm.NewService(dm.NewRepo(d), tmpls, false)
hooks := webhook.NewService(webhook.NewRepo(d), cfg.Domain, false)
srv := httptest.NewServer(httpapi.NewServer(httpapi.Deps{
Cfg: cfg, DB: d, Users: users, DMs: dms, Hooks: hooks,
Audit: audit.New(d), Version: "test",
}).Handler)
t.Cleanup(func() {
srv.Close()
_ = d.Close()
})
return &fixture{srv: srv, db: d}
}
func (f *fixture) get(t *testing.T, path string) (*http.Response, []byte) {
t.Helper()
resp, err := http.Get(f.srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body := readAll(t, resp)
return resp, body
}
func (f *fixture) admin(t *testing.T, method, path string, payload any) (*http.Response, []byte) {
t.Helper()
var body []byte
if payload != nil {
var err error
body, err = json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
}
req, _ := http.NewRequest(method, f.srv.URL+path, bytes.NewReader(body))
req.Header.Set("X-API-Key", testKey)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
return resp, readAll(t, resp)
}
func readAll(t *testing.T, resp *http.Response) []byte {
t.Helper()
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(resp.Body); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestHealthz(t *testing.T) {
f := newFixture(t)
resp, body := f.get(t, "/healthz")
if resp.StatusCode != 200 {
t.Fatalf("status %d: %s", resp.StatusCode, body)
}
var got map[string]string
_ = json.Unmarshal(body, &got)
if got["status"] != "ok" || got["version"] != "test" {
t.Errorf("body: %s", body)
}
}
func TestPricing(t *testing.T) {
f := newFixture(t)
resp, body := f.get(t, "/v1/pricing")
if resp.StatusCode != 200 {
t.Fatalf("status %d: %s", resp.StatusCode, body)
}
var got map[string]any
_ = json.Unmarshal(body, &got)
if _, ok := got["yearly_sats"]; !ok {
t.Errorf("missing yearly_sats: %s", body)
}
}
func TestNostrJSON_EmptyAndPopulated(t *testing.T) {
f := newFixture(t)
resp, body := f.get(t, "/.well-known/nostr.json")
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
var got map[string]any
_ = json.Unmarshal(body, &got)
if got["names"] == nil {
t.Errorf("missing names key: %s", body)
}
f.admin(t, "POST", "/v1/admin/users", map[string]any{
"pubkey": testHex, "username": "alice",
"subscription_type": "yearly", "years": 1,
})
_, body = f.get(t, "/.well-known/nostr.json")
var populated struct {
Names map[string]string `json:"names"`
}
if err := json.Unmarshal(body, &populated); err != nil {
t.Fatal(err)
}
if populated.Names["alice"] != testHex {
t.Errorf("alice not in nostr.json: %s", body)
}
}
func TestUsernameAvailability(t *testing.T) {
f := newFixture(t)
resp, body := f.get(t, "/v1/usernames/alice/available")
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
var got map[string]any
_ = json.Unmarshal(body, &got)
if got["available"] != true {
t.Errorf("expected available=true: %s", body)
}
// Reserved name.
_, body = f.get(t, "/v1/usernames/admin/available")
_ = json.Unmarshal(body, &got)
if got["available"] != false {
t.Errorf("admin should be reserved: %s", body)
}
// Invalid format.
_, body = f.get(t, "/v1/usernames/-bad/available")
_ = json.Unmarshal(body, &got)
if got["available"] != false {
t.Errorf("-bad should be invalid: %s", body)
}
}
func TestAdminAuthGate(t *testing.T) {
f := newFixture(t)
resp, _ := f.get(t, "/v1/admin/users")
if resp.StatusCode != 401 {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
resp, _ = f.admin(t, "GET", "/v1/admin/users", nil)
if resp.StatusCode != 200 {
t.Fatalf("expected 200 with key, got %d", resp.StatusCode)
}
}
func TestAdminLifecycle(t *testing.T) {
f := newFixture(t)
// Add.
resp, body := f.admin(t, "POST", "/v1/admin/users", map[string]any{
"pubkey": testHex, "username": "alice",
"subscription_type": "yearly", "years": 1,
})
if resp.StatusCode != 201 {
t.Fatalf("add status %d: %s", resp.StatusCode, body)
}
// Lookup hex.
resp, body = f.get(t, "/v1/users/"+testHex)
if resp.StatusCode != 200 {
t.Fatalf("lookup status %d", resp.StatusCode)
}
var got map[string]any
_ = json.Unmarshal(body, &got)
if got["is_whitelisted"] != true || got["username"] != "alice" {
t.Errorf("unexpected lookup body: %s", body)
}
// Lookup npub form.
npub := "npub1p6xyr67d2k5d3kewp2xr5juutehh4zuup50z7wjtt3kharu6pvwqjh7065"
resp, _ = f.get(t, "/v1/users/"+npub)
if resp.StatusCode != 200 {
t.Fatalf("npub lookup status %d", resp.StatusCode)
}
// Username unavailable now.
_, body = f.get(t, "/v1/usernames/alice/available")
_ = json.Unmarshal(body, &got)
if got["available"] != false {
t.Errorf("expected unavailable: %s", body)
}
// Extend.
resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/extend",
map[string]any{"years": 2})
if resp.StatusCode != 200 {
t.Fatalf("extend status %d: %s", resp.StatusCode, body)
}
// Update username.
resp, body = f.admin(t, "PUT", "/v1/admin/users/"+testHex,
map[string]any{"username": "alice2"})
if resp.StatusCode != 200 {
t.Fatalf("update status %d: %s", resp.StatusCode, body)
}
// Delete.
resp, _ = f.admin(t, "DELETE", "/v1/admin/users/"+testHex, nil)
if resp.StatusCode != 200 {
t.Fatalf("delete status %d", resp.StatusCode)
}
// Gone.
resp, _ = f.get(t, "/v1/users/"+testHex)
if resp.StatusCode != 404 {
t.Fatalf("expected 404 after delete, got %d", resp.StatusCode)
}
// Audit log should reflect every admin action.
wantActions := []string{"user.added", "user.extended", "user.username_changed", "user.deleted"}
rows, qerr := f.db.Query(`SELECT action FROM audit_log`)
if qerr != nil {
t.Fatal(qerr)
}
defer rows.Close()
seen := map[string]bool{}
for rows.Next() {
var action string
if err := rows.Scan(&action); err != nil {
t.Fatal(err)
}
seen[action] = true
}
for _, action := range wantActions {
if !seen[action] {
t.Errorf("missing audit action %q (got %v)", action, seen)
}
}
}
func TestAdminAdd_BadInputs(t *testing.T) {
f := newFixture(t)
resp, _ := f.admin(t, "POST", "/v1/admin/users", map[string]any{
"pubkey": "notapubkey", "username": "alice",
"subscription_type": "yearly",
})
if resp.StatusCode != 400 {
t.Errorf("bad pubkey: expected 400, got %d", resp.StatusCode)
}
resp, _ = f.admin(t, "POST", "/v1/admin/users", map[string]any{
"pubkey": testHex, "username": "admin",
"subscription_type": "yearly",
})
if resp.StatusCode == 200 || resp.StatusCode == 201 {
t.Errorf("reserved username accepted: %d", resp.StatusCode)
}
}
func TestOpenAPI(t *testing.T) {
f := newFixture(t)
resp, body := f.get(t, "/openapi.json")
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
var spec map[string]any
if err := json.Unmarshal(body, &spec); err != nil {
t.Fatalf("openapi not valid json: %v", err)
}
if spec["openapi"] == nil {
t.Errorf("missing openapi field: %s", body[:min(200, len(body))])
}
}
func TestDocsPage(t *testing.T) {
f := newFixture(t)
resp, body := f.get(t, "/docs")
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
if !bytes.Contains(body, []byte("swagger-ui")) {
t.Errorf("expected swagger UI markup")
}
}
func TestBodyLimit(t *testing.T) {
f := newFixture(t)
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB
body := []byte(`{"pubkey":"` + testHex + `","username":"alice","subscription_type":"yearly","data":"` + string(huge) + `"}`)
req, _ := http.NewRequest("POST", f.srv.URL+"/v1/admin/users", bytes.NewReader(body))
req.Header.Set("X-API-Key", testKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 413 {
t.Errorf("expected 413, got %d", resp.StatusCode)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

104
internal/invoice/lnbits.go Normal file
View File

@@ -0,0 +1,104 @@
package invoice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type LNbitsClient struct {
baseURL string
invoiceKey string
hc *http.Client
}
func NewLNbits(baseURL, invoiceKey string) *LNbitsClient {
return &LNbitsClient{
baseURL: strings.TrimRight(baseURL, "/"),
invoiceKey: invoiceKey,
hc: &http.Client{Timeout: 15 * time.Second},
}
}
type createReq struct {
Out bool `json:"out"`
Amount int64 `json:"amount"`
Memo string `json:"memo"`
Expiry int `json:"expiry,omitempty"`
}
type createResp struct {
PaymentHash string `json:"payment_hash"`
PaymentRequest string `json:"payment_request"`
BOLT11 string `json:"bolt11"`
}
type statusResp struct {
Paid bool `json:"paid"`
Pending bool `json:"pending"`
Details *struct {
Pending bool `json:"pending"`
Status string `json:"status"`
} `json:"details"`
}
func (c *LNbitsClient) Create(ctx context.Context, amountSats int64, memo string, expirySecs int) (string, string, error) {
body, err := json.Marshal(createReq{Out: false, Amount: amountSats, Memo: memo, Expiry: expirySecs})
if err != nil {
return "", "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/payments", bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("X-Api-Key", c.invoiceKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return "", "", fmt.Errorf("%w: %v", ErrLNbits, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return "", "", fmt.Errorf("%w: %s", ErrLNbits, string(b))
}
var cr createResp
if err := json.Unmarshal(b, &cr); err != nil {
return "", "", fmt.Errorf("%w: decode: %v", ErrLNbits, err)
}
pr := cr.PaymentRequest
if pr == "" {
pr = cr.BOLT11
}
return cr.PaymentHash, pr, nil
}
func (c *LNbitsClient) Status(ctx context.Context, hash string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/payments/"+hash, nil)
if err != nil {
return false, err
}
req.Header.Set("X-Api-Key", c.invoiceKey)
resp, err := c.hc.Do(req)
if err != nil {
return false, fmt.Errorf("%w: %v", ErrLNbits, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return false, nil
}
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(resp.Body)
return false, fmt.Errorf("%w: %s", ErrLNbits, string(b))
}
var sr statusResp
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
return false, err
}
return sr.Paid, nil
}

52
internal/invoice/model.go Normal file
View File

@@ -0,0 +1,52 @@
package invoice
import (
"errors"
"time"
"github.com/noderunners/nip05api/internal/user"
)
type PendingInvoice struct {
PaymentHash string
PaymentRequest string
Username string
Pubkey string
SubscriptionType user.SubscriptionType
Years int
AmountSats int64
ExpiresAt time.Time
Paid bool
IsRenewal bool
CreatedAt time.Time
// TargetExpiresAt captures the user's new expiry value at first
// confirmation. Persisted so retries after a crash apply the same
// absolute value and stay idempotent. nil for lifetime.
TargetExpiresAt *time.Time
// TargetSet is true when target_expires_at has been written (even if
// the value is NULL for lifetime). Distinguishes "unset" from "lifetime".
TargetSet bool
}
type Status string
const (
StatusPending Status = "pending"
StatusPaid Status = "paid"
StatusExpired Status = "expired"
)
var (
ErrInvoiceNotFound = errors.New("invoice not found")
ErrLNbits = errors.New("lnbits error")
)
func (p *PendingInvoice) Status() Status {
if p.Paid {
return StatusPaid
}
if time.Now().UTC().After(p.ExpiresAt) {
return StatusExpired
}
return StatusPending
}

163
internal/invoice/repo.go Normal file
View File

@@ -0,0 +1,163 @@
package invoice
import (
"context"
"database/sql"
"errors"
"time"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/user"
)
type Repo struct{ db *db.DB }
func NewRepo(d *db.DB) *Repo { return &Repo{db: d} }
const invCols = `payment_hash, payment_request, username, pubkey, subscription_type,
years, amount_sats, expires_at, paid, is_renewal, created_at, target_expires_at`
func scanInvoice(row interface{ Scan(...any) error }) (*PendingInvoice, error) {
var p PendingInvoice
var sub, expires, created string
var paid, renewal int
var target sql.NullString
if err := row.Scan(&p.PaymentHash, &p.PaymentRequest, &p.Username, &p.Pubkey,
&sub, &p.Years, &p.AmountSats, &expires, &paid, &renewal, &created, &target); err != nil {
return nil, err
}
p.SubscriptionType = user.SubscriptionType(sub)
if t, err := time.Parse(time.RFC3339, expires); err == nil {
p.ExpiresAt = t
}
if t, err := time.Parse(time.RFC3339, created); err == nil {
p.CreatedAt = t
} else if t, err := time.Parse("2006-01-02 15:04:05", created); err == nil {
p.CreatedAt = t
}
p.Paid = paid == 1
p.IsRenewal = renewal == 1
if target.Valid {
p.TargetSet = true
if target.String != "" {
if t, err := time.Parse(time.RFC3339, target.String); err == nil {
p.TargetExpiresAt = &t
}
}
}
return &p, nil
}
func (r *Repo) Insert(ctx context.Context, p *PendingInvoice) error {
var target any
if p.TargetSet {
if p.TargetExpiresAt != nil {
target = p.TargetExpiresAt.UTC().Format(time.RFC3339)
} else {
target = ""
}
}
_, err := r.db.ExecContext(ctx, `INSERT INTO pending_invoices
(payment_hash, payment_request, username, pubkey, subscription_type,
years, amount_sats, expires_at, paid, is_renewal, target_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.PaymentHash, p.PaymentRequest, p.Username, p.Pubkey,
string(p.SubscriptionType), p.Years, p.AmountSats,
p.ExpiresAt.UTC().Format(time.RFC3339),
boolToInt(p.Paid), boolToInt(p.IsRenewal), target)
return err
}
func (r *Repo) Get(ctx context.Context, hash string) (*PendingInvoice, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+invCols+` FROM pending_invoices WHERE payment_hash = ?`, hash)
p, err := scanInvoice(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvoiceNotFound
}
return p, err
}
func (r *Repo) MarkPaid(ctx context.Context, hash string) error {
_, err := r.db.ExecContext(ctx, `UPDATE pending_invoices SET paid = 1 WHERE payment_hash = ?`, hash)
return err
}
// SetTargetIfUnset writes target_expires_at only when currently NULL.
// Returns true if this call won the race. Lifetime is encoded as empty string,
// allowing the caller to distinguish "not yet set" (NULL) from "set to nil".
func (r *Repo) SetTargetIfUnset(ctx context.Context, hash string, target *time.Time) (bool, error) {
stored := ""
if target != nil {
stored = target.UTC().Format(time.RFC3339)
}
res, err := r.db.ExecContext(ctx,
`UPDATE pending_invoices SET target_expires_at = ? WHERE payment_hash = ? AND target_expires_at IS NULL`,
stored, hash)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// ClaimPaid atomically transitions paid 0 → 1. Returns true if the caller
// performed the transition (i.e. it was unpaid before this call).
func (r *Repo) ClaimPaid(ctx context.Context, hash string) (bool, error) {
res, err := r.db.ExecContext(ctx,
`UPDATE pending_invoices SET paid = 1 WHERE payment_hash = ? AND paid = 0`, hash)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
func (r *Repo) ListUnpaid(ctx context.Context) ([]*PendingInvoice, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+invCols+` FROM pending_invoices
WHERE paid = 0 AND expires_at > ?`,
time.Now().UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
defer rows.Close()
out := []*PendingInvoice{}
for rows.Next() {
p, err := scanInvoice(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// HasUnpaidForUsername returns true if there is an active unpaid invoice for the username.
func (r *Repo) HasUnpaidForUsername(ctx context.Context, username string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM pending_invoices
WHERE username = ? COLLATE NOCASE AND paid = 0 AND expires_at > ?`,
username, time.Now().UTC().Format(time.RFC3339)).Scan(&count)
return count > 0, err
}
// HasUnpaidForPubkey returns true if there is an active unpaid invoice for the pubkey.
func (r *Repo) HasUnpaidForPubkey(ctx context.Context, pubkey string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM pending_invoices
WHERE pubkey = ? AND paid = 0 AND expires_at > ?`,
pubkey, time.Now().UTC().Format(time.RFC3339)).Scan(&count)
return count > 0, err
}
func (r *Repo) PurgeOldUnpaid(ctx context.Context) error {
cutoff := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
_, err := r.db.ExecContext(ctx, `DELETE FROM pending_invoices WHERE paid = 0 AND expires_at < ?`, cutoff)
return err
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

186
internal/invoice/service.go Normal file
View File

@@ -0,0 +1,186 @@
package invoice
import (
"context"
"errors"
"fmt"
"time"
"github.com/noderunners/nip05api/internal/user"
)
type CreateRequest struct {
Username string
Pubkey string
SubscriptionType user.SubscriptionType
Years int
}
type Pricing struct {
YearlySats int64
LifetimeSats int64
ExpiryMins int
}
type Service struct {
repo *Repo
users *user.Service
lnbits *LNbitsClient
pricing Pricing
domain string
}
func NewService(repo *Repo, users *user.Service, ln *LNbitsClient, p Pricing, domain string) *Service {
return &Service{repo: repo, users: users, lnbits: ln, pricing: p, domain: domain}
}
func (s *Service) Repo() *Repo { return s.repo }
func (s *Service) Pricing() Pricing { return s.pricing }
var (
ErrUsernameMismatch = errors.New("username does not match existing record")
ErrUsernameTaken = errors.New("username taken")
ErrInvalidYears = errors.New("invalid years")
ErrLifetimeAccess = errors.New("user already has lifetime access")
ErrPendingInvoiceExists = errors.New("pending unpaid invoice already exists")
)
// Create computes amount, calls LNbits, persists pending invoice. Detects renewal.
func (s *Service) Create(ctx context.Context, req CreateRequest) (*PendingInvoice, error) {
if !req.SubscriptionType.Valid() {
return nil, fmt.Errorf("invalid subscription_type")
}
if req.SubscriptionType == user.SubYearly {
if req.Years <= 0 {
req.Years = 1
}
if req.Years > 10 {
return nil, ErrInvalidYears
}
} else {
req.Years = 0
}
hasPendingPubkey, err := s.repo.HasUnpaidForPubkey(ctx, req.Pubkey)
if err != nil {
return nil, err
}
if hasPendingPubkey {
return nil, ErrPendingInvoiceExists
}
username := user.NormalizeUsername(req.Username)
existing, err := s.users.Repo().GetByPubkey(ctx, req.Pubkey)
isRenewal := false
switch {
case err == nil:
if existing.IsLifetime() && existing.IsActive {
return nil, ErrLifetimeAccess
}
isRenewal = true
if username == "" {
username = existing.Username
} else if username != existing.Username {
return nil, ErrUsernameMismatch
}
case errors.Is(err, user.ErrUserNotFound):
if username == "" {
generated, gerr := s.allocateProvisionalUsername(ctx, req.Pubkey)
if gerr != nil {
return nil, gerr
}
username = generated
} else {
if err := user.ValidateUsername(username, s.users.Reserved()); err != nil {
return nil, err
}
taken, err := s.users.Repo().GetByUsername(ctx, username)
if err == nil && taken.Pubkey != req.Pubkey {
return nil, ErrUsernameTaken
}
hasPending, err := s.repo.HasUnpaidForUsername(ctx, username)
if err != nil {
return nil, err
}
if hasPending {
return nil, ErrUsernameTaken
}
}
default:
return nil, err
}
amount := s.pricing.YearlySats * int64(req.Years)
if req.SubscriptionType == user.SubLifetime {
amount = s.pricing.LifetimeSats
}
memo := fmt.Sprintf("%s@%s", username, s.domain)
if isRenewal {
memo = "renewal: " + memo
}
expirySecs := s.pricing.ExpiryMins * 60
hash, request, err := s.lnbits.Create(ctx, amount, memo, expirySecs)
if err != nil {
return nil, err
}
now := time.Now().UTC()
p := &PendingInvoice{
PaymentHash: hash,
PaymentRequest: request,
Username: username,
Pubkey: req.Pubkey,
SubscriptionType: req.SubscriptionType,
Years: req.Years,
AmountSats: amount,
ExpiresAt: now.Add(time.Duration(s.pricing.ExpiryMins) * time.Minute),
Paid: false,
IsRenewal: isRenewal,
CreatedAt: now,
}
if req.SubscriptionType == user.SubYearly {
var current *time.Time
if existing != nil {
current = existing.ExpiresAt
}
p.TargetExpiresAt = user.YearlyTargetExpiry(current, req.Years, now)
p.TargetSet = true
}
if err := s.repo.Insert(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// allocateProvisionalUsername finds a unique placeholder handle for a pubkey
// that has no chosen username yet. The base form derives from the pubkey, with
// numeric suffixes added on the rare collision.
func (s *Service) allocateProvisionalUsername(ctx context.Context, pubkey string) (string, error) {
base := user.ProvisionalUsername(pubkey)
for attempt := 0; attempt < 20; attempt++ {
candidate := base
if attempt > 0 {
candidate = fmt.Sprintf("%s_%d", base, attempt+1)
}
if err := user.ValidateUsername(candidate, s.users.Reserved()); err != nil {
continue
}
taken, err := s.users.Repo().GetByUsername(ctx, candidate)
if err == nil && taken.Pubkey != pubkey {
continue
}
hasPending, err := s.repo.HasUnpaidForUsername(ctx, candidate)
if err != nil {
return "", err
}
if hasPending {
continue
}
return candidate, nil
}
return "", fmt.Errorf("could not allocate provisional username")
}

View File

@@ -0,0 +1,227 @@
package invoice_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/user"
)
const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
func newServiceFixture(t *testing.T) (*invoice.Service, *user.Service, *httptest.Server) {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := d.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
ln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"payment_hash": "hash-" + r.URL.Path,
"payment_request": "lnbcfake",
})
}))
t.Cleanup(ln.Close)
users := user.NewService(user.NewRepo(d), []string{"admin", "root"})
client := invoice.NewLNbits(ln.URL, "key")
pricing := invoice.Pricing{YearlySats: 1000, LifetimeSats: 5000, ExpiryMins: 30}
svc := invoice.NewService(invoice.NewRepo(d), users, client, pricing, "test.local")
return svc, users, ln
}
func TestCreate_PubkeyOnlyDefaultsLifetime(t *testing.T) {
svc, _, _ := newServiceFixture(t)
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubLifetime,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if p.SubscriptionType != user.SubLifetime {
t.Errorf("expected lifetime, got %q", p.SubscriptionType)
}
if p.AmountSats != 5000 {
t.Errorf("expected lifetime price, got %d", p.AmountSats)
}
if !strings.HasPrefix(p.Username, "u_") {
t.Errorf("expected provisional username, got %q", p.Username)
}
if err := user.ValidateUsername(p.Username, nil); err != nil {
t.Errorf("provisional name should be valid: %v", err)
}
}
func TestCreate_YearlyDefaultsOneYear(t *testing.T) {
svc, _, _ := newServiceFixture(t)
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
Years: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if p.Years != 1 || p.AmountSats != 1000 {
t.Errorf("unexpected pending: years=%d amount=%d", p.Years, p.AmountSats)
}
if p.Username != "alice" {
t.Errorf("expected explicit username, got %q", p.Username)
}
}
func TestCreate_RenewalReusesUsername(t *testing.T) {
svc, users, _ := newServiceFixture(t)
if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil {
t.Fatal(err)
}
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubLifetime,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if !p.IsRenewal {
t.Error("expected renewal flag")
}
if p.Username != "alice" {
t.Errorf("expected stored username, got %q", p.Username)
}
}
func TestCreate_BlocksActiveLifetimeUser(t *testing.T) {
svc, users, _ := newServiceFixture(t)
if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubLifetime, 0, false); err != nil {
t.Fatal(err)
}
_, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
})
if !errors.Is(err, invoice.ErrLifetimeAccess) {
t.Fatalf("expected ErrLifetimeAccess, got %v", err)
}
}
func TestCreate_RejectsDuplicatePending(t *testing.T) {
svc, _, _ := newServiceFixture(t)
if _, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
Years: 1,
}); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
Years: 1,
})
if !errors.Is(err, invoice.ErrPendingInvoiceExists) {
t.Fatalf("expected ErrPendingInvoiceExists, got %v", err)
}
}
func TestCreate_YearlyPersistsTargetExpiry(t *testing.T) {
svc, users, _ := newServiceFixture(t)
if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil {
t.Fatal(err)
}
existing, err := users.Repo().GetByPubkey(context.Background(), testHex)
if err != nil {
t.Fatal(err)
}
before := time.Now().UTC()
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
stored, err := svc.Repo().Get(context.Background(), p.PaymentHash)
if err != nil {
t.Fatal(err)
}
if !stored.TargetSet || stored.TargetExpiresAt == nil {
t.Fatalf("expected target persisted, got set=%v at=%v", stored.TargetSet, stored.TargetExpiresAt)
}
want := existing.ExpiresAt.AddDate(1, 0, 0)
if !stored.TargetExpiresAt.Equal(want) {
t.Errorf("target stacking mismatch: got %v want %v", stored.TargetExpiresAt, want)
}
if stored.TargetExpiresAt.Before(before) {
t.Error("target should be in the future")
}
}
func TestCreate_YearlyExpiredUserStartsFromNow(t *testing.T) {
svc, users, _ := newServiceFixture(t)
if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil {
t.Fatal(err)
}
u, err := users.Repo().GetByPubkey(context.Background(), testHex)
if err != nil {
t.Fatal(err)
}
past := time.Now().UTC().AddDate(0, 0, -1)
u.ExpiresAt = &past
if err := users.Repo().Update(context.Background(), u); err != nil {
t.Fatal(err)
}
before := time.Now().UTC()
p, err := svc.Create(context.Background(), invoice.CreateRequest{
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
stored, err := svc.Repo().Get(context.Background(), p.PaymentHash)
if err != nil {
t.Fatal(err)
}
if stored.TargetExpiresAt == nil {
t.Fatal("expected target")
}
want := before.AddDate(1, 0, 0)
delta := stored.TargetExpiresAt.Sub(want)
if delta < -2*time.Second || delta > 2*time.Second {
t.Errorf("expected ~now+1y (%v), got %v", want, stored.TargetExpiresAt)
}
}

48
internal/log/log.go Normal file
View File

@@ -0,0 +1,48 @@
package log
import (
"context"
"log/slog"
"os"
"strings"
)
type ctxKey string
const requestIDKey ctxKey = "request_id"
func Setup(level string) *slog.Logger {
var lvl slog.Level
switch strings.ToLower(level) {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
logger := slog.New(h)
slog.SetDefault(logger)
return logger
}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestID(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return ""
}
func From(ctx context.Context) *slog.Logger {
if id := RequestID(ctx); id != "" {
return slog.Default().With("request_id", id)
}
return slog.Default()
}

View File

@@ -0,0 +1,36 @@
package messages
const defaultWelcome = `welcome to nip-05 on {domain}
you're now {username}@{domain}
expires: {expires_at}
manage your identity: {frontend_url}?pubkey={npub}
`
const defaultExpiringSoon = `heads up — your nip-05 {username}@{domain} expires in {days_remaining} days ({expires_at})
renew here: {frontend_url}?pubkey={npub}
yearly or lifetime, your call.
`
const defaultExpired = `your nip-05 {username}@{domain} has expired and been removed.
want it back? same username is reserved for you for {grace_days} days:
{frontend_url}?pubkey={npub}
`
const defaultExtended = `{username}@{domain} renewed
new expiry: {expires_at}
`
func defaultTemplates() map[string]string {
return map[string]string{
"welcome": defaultWelcome,
"expiring_soon": defaultExpiringSoon,
"expired": defaultExpired,
"extended": defaultExtended,
}
}

View File

@@ -0,0 +1,52 @@
package messages
import (
"errors"
"fmt"
"log/slog"
"os"
"gopkg.in/yaml.v3"
)
var ErrUnknownEvent = errors.New("unknown event type")
type Templates struct {
templates map[string]string
}
func Load(path string) (*Templates, error) {
t := &Templates{templates: defaultTemplates()}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
slog.Warn("messages file not found, using embedded defaults", "path", path)
return t, nil
}
return nil, fmt.Errorf("read messages file: %w", err)
}
parsed := map[string]string{}
if err := yaml.Unmarshal(b, &parsed); err != nil {
return nil, fmt.Errorf("parse messages file: %w", err)
}
for k, v := range parsed {
t.templates[k] = v
}
return t, nil
}
func (t *Templates) Get(eventType string) (string, error) {
tmpl, ok := t.templates[eventType]
if !ok {
return "", fmt.Errorf("%w: %s", ErrUnknownEvent, eventType)
}
return tmpl, nil
}
func (t *Templates) Has(eventType string) bool {
_, ok := t.templates[eventType]
return ok
}

View File

@@ -0,0 +1,18 @@
package messages
import "strings"
func (t *Templates) Render(eventType string, vars map[string]string) (string, error) {
tmpl, err := t.Get(eventType)
if err != nil {
return "", err
}
if tmpl == "" {
return "", nil
}
out := tmpl
for k, v := range vars {
out = strings.ReplaceAll(out, "{"+k+"}", v)
}
return strings.TrimSpace(out), nil
}

View File

@@ -0,0 +1,62 @@
package messages
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRender_Defaults(t *testing.T) {
t.Setenv("HOME", t.TempDir())
tpl, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
if err != nil {
t.Fatalf("load defaults: %v", err)
}
out, err := tpl.Render("welcome", map[string]string{
"username": "alice",
"domain": "azzamo.net",
"expires_at": "2027-01-01",
"npub": "npub1xxx",
"frontend_url": "https://azzamo.net/nip05",
})
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "alice@azzamo.net") {
t.Errorf("rendered: %q", out)
}
}
func TestRender_EmptyDisables(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "messages.yaml")
if err := os.WriteFile(path, []byte("welcome: \"\"\nextended: hi {username}\n"), 0o644); err != nil {
t.Fatal(err)
}
tpl, err := Load(path)
if err != nil {
t.Fatal(err)
}
out, err := tpl.Render("welcome", map[string]string{"username": "bob"})
if err != nil {
t.Fatalf("render welcome: %v", err)
}
if out != "" {
t.Errorf("empty template should render empty, got %q", out)
}
out2, _ := tpl.Render("extended", map[string]string{"username": "bob"})
if out2 != "hi bob" {
t.Errorf("extended got %q", out2)
}
}
func TestRender_UnknownEvent(t *testing.T) {
tpl, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
if err != nil {
t.Fatal(err)
}
if _, err := tpl.Render("does_not_exist", nil); err == nil {
t.Error("expected error for unknown event")
}
}

72
internal/nostr/keys.go Normal file
View File

@@ -0,0 +1,72 @@
package nostr
import (
"encoding/hex"
"errors"
"strings"
gn "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
)
var (
ErrInvalidPubkey = errors.New("invalid pubkey")
ErrInvalidNsec = errors.New("invalid nsec")
)
// NormalizePubkey accepts npub bech32 or 64-char hex and returns lowercase hex.
func NormalizePubkey(in string) (string, error) {
in = strings.TrimSpace(in)
if in == "" {
return "", ErrInvalidPubkey
}
if strings.HasPrefix(in, "npub1") {
prefix, data, err := nip19.Decode(in)
if err != nil {
return "", ErrInvalidPubkey
}
if prefix != "npub" {
return "", ErrInvalidPubkey
}
s, ok := data.(string)
if !ok {
return "", ErrInvalidPubkey
}
return strings.ToLower(s), nil
}
if len(in) != 64 {
return "", ErrInvalidPubkey
}
if _, err := hex.DecodeString(in); err != nil {
return "", ErrInvalidPubkey
}
if !gn.IsValidPublicKey(in) {
return "", ErrInvalidPubkey
}
return strings.ToLower(in), nil
}
// HexToNpub converts hex pubkey to npub bech32. Empty string on error.
func HexToNpub(hexpk string) string {
npub, err := nip19.EncodePublicKey(hexpk)
if err != nil {
return ""
}
return npub
}
// NsecToHex decodes nsec1... to hex private key.
func NsecToHex(nsec string) (string, error) {
prefix, data, err := nip19.Decode(strings.TrimSpace(nsec))
if err != nil {
return "", ErrInvalidNsec
}
if prefix != "nsec" {
return "", ErrInvalidNsec
}
s, ok := data.(string)
if !ok {
return "", ErrInvalidNsec
}
return strings.ToLower(s), nil
}

View File

@@ -0,0 +1,41 @@
package nostr
import (
"strings"
"testing"
)
func TestNormalizePubkey_Hex(t *testing.T) {
hex := "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
got, err := NormalizePubkey(hex)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != strings.ToLower(hex) {
t.Fatalf("got %q want %q", got, hex)
}
}
func TestNormalizePubkey_BadInput(t *testing.T) {
cases := []string{"", "abc", "not-an-npub", "npub1invalid", strings.Repeat("z", 64)}
for _, c := range cases {
if _, err := NormalizePubkey(c); err == nil {
t.Errorf("expected error for %q", c)
}
}
}
func TestNormalizePubkey_NpubRoundtrip(t *testing.T) {
hex := "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
npub := HexToNpub(hex)
if npub == "" || !strings.HasPrefix(npub, "npub1") {
t.Fatalf("HexToNpub failed: %q", npub)
}
got, err := NormalizePubkey(npub)
if err != nil {
t.Fatalf("decode npub: %v", err)
}
if got != hex {
t.Fatalf("roundtrip got %q want %q", got, hex)
}
}

81
internal/nostr/profile.go Normal file
View File

@@ -0,0 +1,81 @@
package nostr
import (
"context"
"encoding/json"
"time"
gn "github.com/nbd-wtf/go-nostr"
)
// Metadata mirrors the kind:0 profile JSON. We accept both snake_case
// (`display_name`, NIP-24) and the deprecated camelCase `displayName` since
// some clients still publish only the latter. `Username` is also deprecated
// but appears in older profiles; per NIP-24 it should be ignored in favor of
// `Name`, but we surface it as a last-resort fallback.
type Metadata struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
DisplayNameAlt string `json:"displayName"`
Username string `json:"username"`
NIP05 string `json:"nip05"`
About string `json:"about"`
Picture string `json:"picture"`
}
// ParseMetadata decodes a kind:0 content payload.
func ParseMetadata(content string) (*Metadata, error) {
var md Metadata
if err := json.Unmarshal([]byte(content), &md); err != nil {
return nil, err
}
return &md, nil
}
// FetchMetadata pulls the most recent kind:0 event for a hex pubkey across the pool.
// Each relay may return multiple replacement events; we keep the one with the
// highest CreatedAt across every relay reached before timeout.
func FetchMetadata(ctx context.Context, p *Pool, hexpk string) (*Metadata, error) {
filter := gn.Filter{
Kinds: []int{0},
Authors: []string{hexpk},
Limit: 100,
}
var newest *gn.Event
for _, url := range p.URLs() {
r, err := p.Connect(ctx, url)
if err != nil {
continue
}
subCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
sub, err := r.Subscribe(subCtx, gn.Filters{filter})
if err != nil {
cancel()
continue
}
loop:
for {
select {
case ev, ok := <-sub.Events:
if !ok {
break loop
}
if newest == nil || ev.CreatedAt > newest.CreatedAt {
newest = ev
}
case <-sub.EndOfStoredEvents:
break loop
case <-subCtx.Done():
break loop
}
}
sub.Unsub()
cancel()
}
if newest == nil {
return nil, nil
}
return ParseMetadata(newest.Content)
}

View File

@@ -0,0 +1,59 @@
package nostr
import "testing"
func TestParseMetadata(t *testing.T) {
cases := []struct {
desc, in string
name, dn, dnAlt string
username, nip05 string
}{
{
desc: "snake_case display_name",
in: `{"name":"alice","display_name":"Alice","nip05":"alice@azzamo.net"}`,
name: "alice", dn: "Alice", nip05: "alice@azzamo.net",
},
{
desc: "camelCase displayName preserved separately",
in: `{"displayName":"Alice"}`,
dnAlt: "Alice",
},
{
desc: "deprecated username field exposed",
in: `{"username":"legacy"}`,
username: "legacy",
},
{
desc: "all fields together",
in: `{"name":"a","display_name":"A","displayName":"AA","username":"u","nip05":"a@x"}`,
name: "a", dn: "A", dnAlt: "AA", username: "u", nip05: "a@x",
},
}
for _, tc := range cases {
md, err := ParseMetadata(tc.in)
if err != nil {
t.Fatalf("%s: parse error: %v", tc.desc, err)
}
if md.Name != tc.name {
t.Errorf("%s: Name=%q want %q", tc.desc, md.Name, tc.name)
}
if md.DisplayName != tc.dn {
t.Errorf("%s: DisplayName=%q want %q", tc.desc, md.DisplayName, tc.dn)
}
if md.DisplayNameAlt != tc.dnAlt {
t.Errorf("%s: DisplayNameAlt=%q want %q", tc.desc, md.DisplayNameAlt, tc.dnAlt)
}
if md.Username != tc.username {
t.Errorf("%s: Username=%q want %q", tc.desc, md.Username, tc.username)
}
if md.NIP05 != tc.nip05 {
t.Errorf("%s: NIP05=%q want %q", tc.desc, md.NIP05, tc.nip05)
}
}
}
func TestParseMetadataInvalidJSON(t *testing.T) {
if _, err := ParseMetadata("not json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}

36
internal/nostr/publish.go Normal file
View File

@@ -0,0 +1,36 @@
package nostr
import (
"context"
"errors"
gn "github.com/nbd-wtf/go-nostr"
)
var ErrNoRelayAccepted = errors.New("no relay accepted event")
// Publish sends an already-signed event to all relays in the pool.
// Success if at least one relay accepts.
func Publish(ctx context.Context, p *Pool, ev *gn.Event) error {
var lastErr error
accepted := 0
for _, url := range p.URLs() {
r, err := p.Connect(ctx, url)
if err != nil {
lastErr = err
continue
}
if err := r.Publish(ctx, *ev); err != nil {
lastErr = err
continue
}
accepted++
}
if accepted == 0 {
if lastErr != nil {
return lastErr
}
return ErrNoRelayAccepted
}
return nil
}

51
internal/nostr/relay.go Normal file
View File

@@ -0,0 +1,51 @@
package nostr
import (
"context"
"sync"
"time"
gn "github.com/nbd-wtf/go-nostr"
)
// Pool is a small relay-connection pool.
type Pool struct {
mu sync.Mutex
relays []string
active map[string]*gn.Relay
}
func NewPool(urls []string) *Pool {
return &Pool{relays: urls, active: map[string]*gn.Relay{}}
}
func (p *Pool) URLs() []string { return p.relays }
func (p *Pool) Connect(ctx context.Context, url string) (*gn.Relay, error) {
p.mu.Lock()
if r, ok := p.active[url]; ok && r.IsConnected() {
p.mu.Unlock()
return r, nil
}
p.mu.Unlock()
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
r, err := gn.RelayConnect(dialCtx, url)
if err != nil {
return nil, err
}
p.mu.Lock()
p.active[url] = r
p.mu.Unlock()
return r, nil
}
func (p *Pool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
for _, r := range p.active {
_ = r.Close()
}
p.active = map[string]*gn.Relay{}
}

View File

@@ -0,0 +1,69 @@
package payments
import (
"context"
"log/slog"
"strconv"
"time"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
func (w *Worker) dispatchEvents(ctx context.Context, u *user.User, p *invoice.PendingInvoice, ev dm.EventType) {
vars := buildVars(u, w.domain, w.frontend)
if err := w.dms.Send(ctx, ev, u.Pubkey, vars); err != nil {
slog.Error("dm enqueue", "err", err)
}
data := map[string]any{
"pubkey": u.Pubkey,
"npub": nostr.HexToNpub(u.Pubkey),
"username": u.Username,
"subscription_type": string(u.SubscriptionType),
"amount_sats": p.AmountSats,
"payment_hash": p.PaymentHash,
"is_renewal": p.IsRenewal,
}
if u.ExpiresAt != nil {
data["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
}
if err := w.hooks.Enqueue(ctx, webhook.EventUserPaid, data); err != nil {
slog.Error("webhook enqueue", "err", err)
}
w.audit.Log(ctx, audit.ActionPaymentConfirmed, audit.ActorSystem, u.Pubkey, map[string]any{
"payment_hash": p.PaymentHash,
"amount_sats": p.AmountSats,
"is_renewal": p.IsRenewal,
"event": string(ev),
})
slog.Info("payment confirmed",
"pubkey", u.Pubkey, "username", u.Username,
"amount_sats", p.AmountSats, "renewal", p.IsRenewal)
}
func buildVars(u *user.User, domain, frontend string) map[string]string {
expires := "lifetime"
days := ""
if u.ExpiresAt != nil {
expires = u.ExpiresAt.Format("2006-01-02")
d := int(time.Until(*u.ExpiresAt).Hours() / 24)
if d < 0 {
d = 0
}
days = strconv.Itoa(d)
}
return map[string]string{
"username": u.Username,
"npub": nostr.HexToNpub(u.Pubkey),
"pubkey": u.Pubkey,
"domain": domain,
"expires_at": expires,
"days_remaining": days,
"frontend_url": frontend,
"subscription_type": string(u.SubscriptionType),
}
}

173
internal/payments/worker.go Normal file
View File

@@ -0,0 +1,173 @@
package payments
import (
"context"
"errors"
"log/slog"
"time"
"github.com/noderunners/nip05api/internal/audit"
"github.com/noderunners/nip05api/internal/dm"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/user"
"github.com/noderunners/nip05api/internal/webhook"
)
type Worker struct {
invoices *invoice.Service
users *user.Service
lnbits *invoice.LNbitsClient
dms *dm.Service
hooks *webhook.Service
audit *audit.Logger
domain string
frontend string
interval time.Duration
enabled bool
}
func NewWorker(inv *invoice.Service, u *user.Service, ln *invoice.LNbitsClient, dms *dm.Service, hooks *webhook.Service, aud *audit.Logger, domain, frontend string, enabled bool) *Worker {
return &Worker{
invoices: inv,
users: u,
lnbits: ln,
dms: dms,
hooks: hooks,
audit: aud,
domain: domain,
frontend: frontend,
interval: 5 * time.Second,
enabled: enabled,
}
}
func (w *Worker) Run(ctx context.Context) {
if !w.enabled {
<-ctx.Done()
return
}
t := time.NewTicker(w.interval)
defer t.Stop()
cleanup := time.NewTicker(15 * time.Minute)
defer cleanup.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tick(ctx)
case <-cleanup.C:
_ = w.invoices.Repo().PurgeOldUnpaid(ctx)
}
}
}
func (w *Worker) tick(ctx context.Context) {
pending, err := w.invoices.Repo().ListUnpaid(ctx)
if err != nil {
slog.Error("payments list", "err", err)
return
}
for _, p := range pending {
paid, err := w.lnbits.Status(ctx, p.PaymentHash)
if err != nil {
slog.Warn("lnbits status", "hash", p.PaymentHash, "err", err)
continue
}
if !paid {
continue
}
if err := w.confirm(ctx, p); err != nil {
slog.Error("confirm payment", "hash", p.PaymentHash, "err", err)
}
}
}
// confirm completes a paid invoice idempotently. Crash recovery is safe at any
// point: we capture the target expiry once, then apply absolute updates.
func (w *Worker) confirm(ctx context.Context, p *invoice.PendingInvoice) error {
existing, getErr := w.users.Repo().GetByPubkey(ctx, p.Pubkey)
if getErr != nil && !errors.Is(getErr, user.ErrUserNotFound) {
return getErr
}
target, err := w.resolveTarget(ctx, p, existing)
if err != nil {
return err
}
wasNew := errors.Is(getErr, user.ErrUserNotFound)
if wasNew {
u := &user.User{
Pubkey: p.Pubkey,
Username: p.Username,
SubscriptionType: p.SubscriptionType,
ExpiresAt: target,
IsActive: true,
}
if err := w.users.Repo().Insert(ctx, u); err != nil {
// Likely UNIQUE constraint from a concurrent recovery attempt;
// re-fetch and treat as existing.
existing2, err2 := w.users.Repo().GetByPubkey(ctx, p.Pubkey)
if err2 != nil {
return err
}
existing = existing2
wasNew = false
}
}
if !wasNew {
if err := w.users.Repo().SetActiveExpiry(ctx, p.Pubkey, p.SubscriptionType, target); err != nil {
return err
}
}
claimed, err := w.invoices.Repo().ClaimPaid(ctx, p.PaymentHash)
if err != nil {
return err
}
if !claimed {
return nil // another tick already dispatched events
}
final, err := w.users.Repo().GetByPubkey(ctx, p.Pubkey)
if err != nil {
return err
}
dmEvent := dm.EventWelcome
if !wasNew && p.IsRenewal {
dmEvent = dm.EventExtended
}
w.dispatchEvents(ctx, final, p, dmEvent)
return nil
}
// resolveTarget returns the canonical expiry to apply to the user. Persisted
// on first call so retries see the same value.
func (w *Worker) resolveTarget(ctx context.Context, p *invoice.PendingInvoice, existing *user.User) (*time.Time, error) {
if p.TargetSet {
return p.TargetExpiresAt, nil
}
target := computeTarget(p, existing, time.Now().UTC())
if _, err := w.invoices.Repo().SetTargetIfUnset(ctx, p.PaymentHash, target); err != nil {
return nil, err
}
fresh, err := w.invoices.Repo().Get(ctx, p.PaymentHash)
if err != nil {
return nil, err
}
return fresh.TargetExpiresAt, nil
}
func computeTarget(p *invoice.PendingInvoice, existing *user.User, now time.Time) *time.Time {
if p.SubscriptionType == user.SubLifetime {
return nil
}
var current *time.Time
if existing != nil {
current = existing.ExpiresAt
}
return user.YearlyTargetExpiry(current, p.Years, now)
}

View File

@@ -0,0 +1,180 @@
package payments
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/noderunners/nip05api/internal/db"
"github.com/noderunners/nip05api/internal/invoice"
"github.com/noderunners/nip05api/internal/user"
)
const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
func newTestDB(t *testing.T) *db.DB {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := d.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return d
}
func TestComputeTarget_NewYearly(t *testing.T) {
now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1}
got := computeTarget(p, nil, now)
if got == nil || !got.Equal(now.AddDate(1, 0, 0)) {
t.Errorf("got %v want %v", got, now.AddDate(1, 0, 0))
}
}
func TestComputeTarget_RenewActive(t *testing.T) {
now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
current := now.AddDate(0, 6, 0)
p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1}
existing := &user.User{ExpiresAt: &current}
got := computeTarget(p, existing, now)
want := current.AddDate(1, 0, 0)
if !got.Equal(want) {
t.Errorf("got %v want %v", got, want)
}
}
func TestComputeTarget_RenewExpired(t *testing.T) {
now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
past := now.AddDate(0, -1, 0)
p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1}
existing := &user.User{ExpiresAt: &past}
got := computeTarget(p, existing, now)
want := now.AddDate(1, 0, 0)
if !got.Equal(want) {
t.Errorf("got %v want %v", got, want)
}
}
func TestComputeTarget_Lifetime(t *testing.T) {
now := time.Now()
p := &invoice.PendingInvoice{SubscriptionType: user.SubLifetime}
if got := computeTarget(p, nil, now); got != nil {
t.Errorf("expected nil, got %v", got)
}
}
// Idempotent confirm: applying the same target twice produces identical state.
func TestSetActiveExpiry_Idempotent(t *testing.T) {
d := newTestDB(t)
repo := user.NewRepo(d)
expires := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC)
u := &user.User{
Pubkey: testHex,
Username: "alice",
SubscriptionType: user.SubYearly,
ExpiresAt: &expires,
IsActive: true,
}
if err := repo.Insert(context.Background(), u); err != nil {
t.Fatal(err)
}
target := time.Date(2028, 6, 1, 0, 0, 0, 0, time.UTC)
if err := repo.SetActiveExpiry(context.Background(), testHex, user.SubYearly, &target); err != nil {
t.Fatal(err)
}
got, err := repo.GetByPubkey(context.Background(), testHex)
if err != nil {
t.Fatal(err)
}
if !got.ExpiresAt.Equal(target) {
t.Errorf("first apply: got %v want %v", got.ExpiresAt, target)
}
// Re-apply same target — must not advance further.
if err := repo.SetActiveExpiry(context.Background(), testHex, user.SubYearly, &target); err != nil {
t.Fatal(err)
}
got, _ = repo.GetByPubkey(context.Background(), testHex)
if !got.ExpiresAt.Equal(target) {
t.Errorf("re-apply changed value: got %v want %v", got.ExpiresAt, target)
}
}
func TestSetTargetIfUnset_OnlyOnce(t *testing.T) {
d := newTestDB(t)
repo := invoice.NewRepo(d)
target1 := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC)
p := &invoice.PendingInvoice{
PaymentHash: "hash1",
PaymentRequest: "lnbc1...",
Username: "alice",
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
AmountSats: 1000,
ExpiresAt: time.Now().Add(30 * time.Minute),
}
if err := repo.Insert(context.Background(), p); err != nil {
t.Fatal(err)
}
won, err := repo.SetTargetIfUnset(context.Background(), "hash1", &target1)
if err != nil || !won {
t.Fatalf("first set: won=%v err=%v", won, err)
}
target2 := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
won, err = repo.SetTargetIfUnset(context.Background(), "hash1", &target2)
if err != nil {
t.Fatal(err)
}
if won {
t.Error("second set should be a no-op")
}
fresh, err := repo.Get(context.Background(), "hash1")
if err != nil {
t.Fatal(err)
}
if !fresh.TargetSet || fresh.TargetExpiresAt == nil || !fresh.TargetExpiresAt.Equal(target1) {
t.Errorf("target should be the first value, got set=%v at=%v",
fresh.TargetSet, fresh.TargetExpiresAt)
}
}
func TestClaimPaid_Atomic(t *testing.T) {
d := newTestDB(t)
repo := invoice.NewRepo(d)
p := &invoice.PendingInvoice{
PaymentHash: "hash2",
PaymentRequest: "lnbc...",
Username: "bob",
Pubkey: testHex,
SubscriptionType: user.SubYearly,
Years: 1,
AmountSats: 1000,
ExpiresAt: time.Now().Add(30 * time.Minute),
}
if err := repo.Insert(context.Background(), p); err != nil {
t.Fatal(err)
}
won1, err := repo.ClaimPaid(context.Background(), "hash2")
if err != nil || !won1 {
t.Fatalf("first claim: won=%v err=%v", won1, err)
}
won2, err := repo.ClaimPaid(context.Background(), "hash2")
if err != nil {
t.Fatal(err)
}
if won2 {
t.Error("second claim should lose race")
}
}

139
internal/sync/worker.go Normal file
View File

@@ -0,0 +1,139 @@
package sync
import (
"context"
"errors"
"log/slog"
"time"
"github.com/noderunners/nip05api/internal/nostr"
"github.com/noderunners/nip05api/internal/user"
)
type Worker struct {
users *user.Service
pool *nostr.Pool
interval time.Duration
enabled bool
domain string
reserved []string
}
func NewWorker(users *user.Service, pool *nostr.Pool, intervalMins int, enabled bool, domain string, reserved []string) *Worker {
if intervalMins <= 0 {
intervalMins = 15
}
return &Worker{
users: users,
pool: pool,
interval: time.Duration(intervalMins) * time.Minute,
enabled: enabled,
domain: domain,
reserved: reserved,
}
}
func (w *Worker) Run(ctx context.Context) {
if !w.enabled {
<-ctx.Done()
return
}
t := time.NewTicker(w.interval)
defer t.Stop()
w.RunOnce(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.RunOnce(ctx)
}
}
}
func (w *Worker) RunOnce(ctx context.Context) {
runCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
stale := time.Now().Add(-24 * time.Hour)
users, err := w.users.Repo().ListForSync(runCtx, stale)
if err != nil {
slog.Error("sync list", "err", err)
return
}
if len(users) == 0 {
return
}
slog.Info("profile sync starting", "count", len(users))
updated := 0
for _, u := range users {
if runCtx.Err() != nil {
break
}
if w.syncOne(runCtx, u) {
updated++
}
}
slog.Info("profile sync complete", "updated", updated, "checked", len(users))
}
// syncOne fetches the latest kind:0 profile for u and rewrites the username
// when a better candidate is available. Returns true when the username was
// actually changed in the database. The user's last_synced_at is always
// touched so the worker doesn't re-poll the same pubkey on every tick.
func (w *Worker) syncOne(ctx context.Context, u *user.User) bool {
md, err := nostr.FetchMetadata(ctx, w.pool, u.Pubkey)
now := time.Now().UTC()
u.LastSyncedAt = &now
touch := func(reason string, extra ...any) {
if err := w.users.Repo().Update(ctx, u); err != nil {
slog.Error("sync update", "pubkey", u.Pubkey, "err", err)
return
}
args := append([]any{"pubkey", u.Pubkey, "reason", reason}, extra...)
slog.Debug("profile sync skipped", args...)
}
if err != nil {
touch("fetch_error", "err", err)
return false
}
if md == nil {
touch("no_metadata")
return false
}
candidate := user.CandidateFromMetadata(
md.Name, md.DisplayName, md.DisplayNameAlt, md.Username,
md.NIP05, w.domain, w.reserved,
)
if candidate == "" {
touch("empty_candidate")
return false
}
if candidate == u.Username {
touch("unchanged")
return false
}
other, err := w.users.Repo().GetByUsername(ctx, candidate)
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
touch("lookup_error", "err", err)
return false
}
if other != nil && other.Pubkey != u.Pubkey {
touch("taken", "candidate", candidate)
return false
}
previous := u.Username
u.Username = candidate
if err := w.users.Repo().Update(ctx, u); err != nil {
slog.Error("sync update", "pubkey", u.Pubkey, "err", err)
return false
}
slog.Info("profile sync username updated",
"pubkey", u.Pubkey, "from", previous, "to", candidate)
return true
}

74
internal/user/model.go Normal file
View File

@@ -0,0 +1,74 @@
package user
import (
"errors"
"regexp"
"strings"
"time"
)
type SubscriptionType string
const (
SubYearly SubscriptionType = "yearly"
SubLifetime SubscriptionType = "lifetime"
)
func (s SubscriptionType) Valid() bool {
return s == SubYearly || s == SubLifetime
}
type User struct {
ID int64
Pubkey string
Username string
SubscriptionType SubscriptionType
ExpiresAt *time.Time
IsActive bool
ManualUsername bool
LastSyncedAt *time.Time
ExpiringReminderSentAt *time.Time
DeactivatedAt *time.Time
CreatedAt time.Time
}
func (u *User) IsLifetime() bool { return u.SubscriptionType == SubLifetime }
func (u *User) InGrace() bool { return !u.IsActive && u.DeactivatedAt != nil }
var (
ErrInvalidUsername = errors.New("invalid username")
ErrUserNotFound = errors.New("user not found")
ErrUsernameTaken = errors.New("username taken")
)
// Username rules: 1-30 chars, [a-z0-9_-], lowercase, must start with alnum.
var usernameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,29}$`)
func ValidateUsername(name string, reserved []string) error {
name = strings.ToLower(strings.TrimSpace(name))
if !usernameRE.MatchString(name) {
return ErrInvalidUsername
}
for _, r := range reserved {
if strings.EqualFold(name, strings.TrimSpace(r)) {
return ErrInvalidUsername
}
}
return nil
}
func NormalizeUsername(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// ProvisionalUsername returns a deterministic placeholder handle for a pubkey
// when the caller did not supply one. The format `u_<first 16 hex>` keeps the
// result inside the 30-char username rule and matches usernameRE.
func ProvisionalUsername(pubkey string) string {
hex := strings.ToLower(strings.TrimSpace(pubkey))
if len(hex) > 16 {
hex = hex[:16]
}
return "u_" + hex
}

View File

@@ -0,0 +1,55 @@
package user
import "testing"
func TestValidateUsername(t *testing.T) {
cases := []struct {
name string
ok bool
}{
{"alice", true},
{"al-ice_42", true},
{"a", true},
{"", false},
{"-alice", false},
{"_alice", false},
{"thisusernameiswaytoolongtobevalid12345", false},
{"admin", false},
}
reserved := []string{"admin", "root"}
for _, tc := range cases {
err := ValidateUsername(tc.name, reserved)
if tc.ok && err != nil {
t.Errorf("%q expected ok, got %v", tc.name, err)
}
if !tc.ok && err == nil {
t.Errorf("%q expected fail", tc.name)
}
}
}
func TestProvisionalUsername(t *testing.T) {
const pk = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
got := ProvisionalUsername(pk)
want := "u_0e8c41ebcd55a8d8"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if err := ValidateUsername(got, nil); err != nil {
t.Fatalf("provisional name should validate: %v", err)
}
short := ProvisionalUsername("AbC")
if short != "u_abc" {
t.Errorf("expected lowercase trimmed prefix, got %q", short)
}
}
func TestSubscriptionType(t *testing.T) {
if !SubYearly.Valid() || !SubLifetime.Valid() {
t.Fatal("valid types reported invalid")
}
if SubscriptionType("monthly").Valid() {
t.Fatal("invalid type reported valid")
}
}

101
internal/user/nostr_sync.go Normal file
View File

@@ -0,0 +1,101 @@
package user
import (
"strings"
)
// SanitizeForUsername coerces an arbitrary profile string into a candidate
// that matches usernameRE: lowercase ASCII alphanumerics, `_`, and `-`,
// length <= 30, with an alphanumeric first character. Returns "" when no
// usable handle can be derived.
func SanitizeForUsername(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
var b strings.Builder
b.Grow(len(s))
prevSep := false
for _, r := range s {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevSep = false
case r == '-' || r == '_':
if b.Len() == 0 {
continue
}
if prevSep {
continue
}
b.WriteRune(r)
prevSep = true
default:
if b.Len() == 0 {
continue
}
if prevSep {
continue
}
b.WriteRune('_')
prevSep = true
}
}
out := strings.TrimRight(b.String(), "_-")
if len(out) > 30 {
out = strings.TrimRight(out[:30], "_-")
}
return out
}
// nip05LocalPart returns the local part of a NIP-05 identifier (`local@domain`)
// when its domain matches `serviceDomain` (case-insensitive). The bare form
// `local` (no `@`) per NIP-05 implies `_@domain`, which we ignore for sync
// since we cannot prove the domain match.
func nip05LocalPart(nip05, serviceDomain string) string {
nip05 = strings.TrimSpace(nip05)
if nip05 == "" || serviceDomain == "" {
return ""
}
at := strings.LastIndex(nip05, "@")
if at <= 0 || at == len(nip05)-1 {
return ""
}
local := nip05[:at]
domain := nip05[at+1:]
if !strings.EqualFold(domain, serviceDomain) {
return ""
}
return local
}
// CandidateFromMetadata returns a sanitized, validated username derived from a
// kind:0 profile, or "" if no field yields a usable handle. Precedence:
// 1. NIP-05 local part when its domain matches serviceDomain.
// 2. `name` (NIP-01).
// 3. `display_name` / `displayName` (NIP-24).
// 4. Deprecated `username` field.
func CandidateFromMetadata(name, displayName, displayNameAlt, username, nip05, serviceDomain string, reserved []string) string {
tryFields := []string{
nip05LocalPart(nip05, serviceDomain),
name,
displayName,
displayNameAlt,
username,
}
for _, raw := range tryFields {
if raw == "" {
continue
}
c := SanitizeForUsername(raw)
if c == "" {
continue
}
if err := ValidateUsername(c, reserved); err != nil {
continue
}
return c
}
return ""
}

View File

@@ -0,0 +1,119 @@
package user
import "testing"
func TestSanitizeForUsername(t *testing.T) {
cases := []struct {
in, want string
}{
{"alice", "alice"},
{"Alice", "alice"},
{" Alice ", "alice"},
{"Alice Bob", "alice_bob"},
{"alice bob", "alice_bob"},
{"Alice!@#Bob", "alice_bob"},
{"-alice", "alice"},
{"_alice", "alice"},
{"alice_", "alice"},
{"alice--bob", "alice-bob"},
{"alice__bob", "alice_bob"},
{" ", ""},
{"", ""},
{"!!!", ""},
{"日本語", ""},
{"alice日本", "alice"},
{"thisusernameiswaytoolongtobevalid12345", "thisusernameiswaytoolongtobeva"},
}
for _, tc := range cases {
got := SanitizeForUsername(tc.in)
if got != tc.want {
t.Errorf("SanitizeForUsername(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSanitizeForUsernamePassesValidate(t *testing.T) {
inputs := []string{"Alice Bob", " S0me User! ", "alice--bob"}
for _, in := range inputs {
s := SanitizeForUsername(in)
if s == "" {
t.Fatalf("unexpected empty sanitize for %q", in)
}
if err := ValidateUsername(s, nil); err != nil {
t.Errorf("sanitized %q -> %q failed ValidateUsername: %v", in, s, err)
}
}
}
func TestCandidateFromMetadataPrecedence(t *testing.T) {
const domain = "azzamo.net"
reserved := []string{"admin"}
cases := []struct {
desc string
name, dn, dnAlt, username, nip05, dom string
want string
}{
{
desc: "nip05 local part wins when domain matches",
name: "alice", nip05: "preferred@azzamo.net", dom: domain,
want: "preferred",
},
{
desc: "nip05 ignored when domain differs",
name: "alice", nip05: "preferred@other.example", dom: domain,
want: "alice",
},
{
desc: "name takes precedence over display_name",
name: "alice", dn: "Bob Builder", dom: domain,
want: "alice",
},
{
desc: "falls back to display_name when name empty",
dn: "Bob Builder", dom: domain,
want: "bob_builder",
},
{
desc: "falls back to camelCase displayName when others empty",
dnAlt: "Bob B", dom: domain,
want: "bob_b",
},
{
desc: "falls back to deprecated username last",
username: "legacy_user", dom: domain,
want: "legacy_user",
},
{
desc: "skips fields that sanitize to empty",
name: "!!!", dn: "Real Name", dom: domain,
want: "real_name",
},
{
desc: "skips reserved words and falls through",
name: "admin", dn: "Real Name", dom: domain,
want: "real_name",
},
{
desc: "no usable field returns empty",
name: "!!!", dom: domain,
want: "",
},
{
desc: "bare nip05 with no @ is ignored",
nip05: "alice", name: "fallback", dom: domain,
want: "fallback",
},
{
desc: "empty service domain ignores nip05",
name: "alice", nip05: "preferred@azzamo.net",
want: "alice",
},
}
for _, tc := range cases {
got := CandidateFromMetadata(tc.name, tc.dn, tc.dnAlt, tc.username, tc.nip05, tc.dom, reserved)
if got != tc.want {
t.Errorf("%s: got %q want %q", tc.desc, got, tc.want)
}
}
}

150
internal/user/repo.go Normal file
View File

@@ -0,0 +1,150 @@
package user
import (
"context"
"database/sql"
"errors"
"time"
"github.com/noderunners/nip05api/internal/db"
)
type Repo struct{ db *db.DB }
func NewRepo(d *db.DB) *Repo { return &Repo{db: d} }
func parseTime(s sql.NullString) *time.Time {
if !s.Valid || s.String == "" {
return nil
}
t, err := time.Parse(time.RFC3339, s.String)
if err != nil {
t, err = time.Parse("2006-01-02 15:04:05", s.String)
if err != nil {
return nil
}
}
return &t
}
func mustParseTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err == nil {
return t
}
t, err = time.Parse("2006-01-02 15:04:05", s)
if err == nil {
return t
}
return time.Time{}
}
func formatTime(t *time.Time) any {
if t == nil {
return nil
}
return t.UTC().Format(time.RFC3339)
}
func scanUser(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
var sub string
var expiresAt, lastSynced, reminderSent, deactivatedAt, createdAt sql.NullString
if err := row.Scan(
&u.ID, &u.Pubkey, &u.Username, &sub,
&expiresAt, &u.IsActive, &u.ManualUsername,
&lastSynced, &reminderSent, &deactivatedAt, &createdAt,
); err != nil {
return nil, err
}
u.SubscriptionType = SubscriptionType(sub)
u.ExpiresAt = parseTime(expiresAt)
u.LastSyncedAt = parseTime(lastSynced)
u.ExpiringReminderSentAt = parseTime(reminderSent)
u.DeactivatedAt = parseTime(deactivatedAt)
if t := parseTime(createdAt); t != nil {
u.CreatedAt = *t
}
return &u, nil
}
const userCols = `id, pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, expiring_reminder_sent_at, deactivated_at, created_at`
func (r *Repo) GetByPubkey(ctx context.Context, pubkey string) (*User, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE pubkey = ?`, pubkey)
u, err := scanUser(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return u, err
}
func (r *Repo) GetByUsername(ctx context.Context, username string) (*User, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ? COLLATE NOCASE`, username)
u, err := scanUser(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return u, err
}
func (r *Repo) Insert(ctx context.Context, u *User) error {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx, `INSERT INTO users
(pubkey, username, subscription_type, expires_at, is_active, manual_username, created_at)
VALUES (?, ?, ?, ?, 1, ?, ?)`,
u.Pubkey, u.Username, string(u.SubscriptionType),
formatTime(u.ExpiresAt), u.ManualUsername,
now.Format(time.RFC3339))
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
u.ID = id
u.CreatedAt = now
return nil
}
func (r *Repo) Update(ctx context.Context, u *User) error {
_, err := r.db.ExecContext(ctx, `UPDATE users SET
username = ?,
subscription_type = ?,
expires_at = ?,
is_active = ?,
manual_username = ?,
last_synced_at = ?,
expiring_reminder_sent_at = ?,
deactivated_at = ?
WHERE pubkey = ?`,
u.Username, string(u.SubscriptionType),
formatTime(u.ExpiresAt), u.IsActive, u.ManualUsername,
formatTime(u.LastSyncedAt), formatTime(u.ExpiringReminderSentAt),
formatTime(u.DeactivatedAt), u.Pubkey)
return err
}
func (r *Repo) Delete(ctx context.Context, pubkey string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE pubkey = ?`, pubkey)
return err
}
// SetActiveExpiry sets a user's expires_at to an absolute value and reactivates
// them. Idempotent: applying the same input twice produces the same end state.
func (r *Repo) SetActiveExpiry(ctx context.Context, pubkey string, sub SubscriptionType, expiresAt *time.Time) error {
_, err := r.db.ExecContext(ctx, `UPDATE users SET
is_active = 1,
deactivated_at = NULL,
expiring_reminder_sent_at = NULL,
subscription_type = ?,
expires_at = ?
WHERE pubkey = ?`,
string(sub), formatTime(expiresAt), pubkey)
return err
}

115
internal/user/repo_query.go Normal file
View File

@@ -0,0 +1,115 @@
package user
import (
"context"
"time"
)
type ListFilter struct {
ActiveOnly bool
Search string
Limit int
}
func (r *Repo) List(ctx context.Context, f ListFilter) ([]*User, error) {
q := `SELECT ` + userCols + ` FROM users WHERE 1=1`
args := []any{}
if f.ActiveOnly {
q += ` AND is_active = 1`
}
if f.Search != "" {
q += ` AND (username LIKE ? COLLATE NOCASE OR pubkey LIKE ?)`
args = append(args, "%"+f.Search+"%", "%"+f.Search+"%")
}
q += ` ORDER BY created_at DESC`
if f.Limit > 0 {
q += ` LIMIT ?`
args = append(args, f.Limit)
}
rows, err := r.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
return r.collect(rows)
}
func (r *Repo) ActiveByName(ctx context.Context) (map[string]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT username, pubkey FROM users WHERE is_active = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var u, p string
if err := rows.Scan(&u, &p); err != nil {
return nil, err
}
out[u] = p
}
return out, rows.Err()
}
func (r *Repo) collect(rows interface {
Next() bool
Scan(...any) error
Err() error
Close() error
}) ([]*User, error) {
defer rows.Close()
out := []*User{}
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (r *Repo) ListPendingReminders(ctx context.Context, days int, now time.Time) ([]*User, error) {
low := now.Add(time.Duration(days)*24*time.Hour - 12*time.Hour).UTC().Format(time.RFC3339)
high := now.Add(time.Duration(days)*24*time.Hour + 12*time.Hour).UTC().Format(time.RFC3339)
rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users
WHERE is_active = 1
AND subscription_type = 'yearly'
AND expires_at BETWEEN ? AND ?
AND (expiring_reminder_sent_at IS NULL OR expiring_reminder_sent_at < ?)`,
low, high, now.Add(-23*time.Hour).UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
return r.collect(rows)
}
func (r *Repo) ListExpired(ctx context.Context, now time.Time) ([]*User, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users
WHERE is_active = 1 AND subscription_type = 'yearly' AND expires_at < ?`,
now.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
return r.collect(rows)
}
func (r *Repo) ListGraceExpired(ctx context.Context, cutoff time.Time) ([]*User, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users
WHERE is_active = 0 AND deactivated_at IS NOT NULL AND deactivated_at < ?`,
cutoff.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
return r.collect(rows)
}
func (r *Repo) ListForSync(ctx context.Context, staleBefore time.Time) ([]*User, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users
WHERE is_active = 1 AND manual_username = 0
AND (last_synced_at IS NULL OR last_synced_at < ?)`,
staleBefore.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
return r.collect(rows)
}

View File

@@ -0,0 +1,56 @@
package user
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/noderunners/nip05api/internal/db"
)
func newTestRepo(t *testing.T) *Repo {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
if err := d.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
return NewRepo(d)
}
func TestRepo_InsertAndFetch(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
exp := time.Now().Add(365 * 24 * time.Hour)
u := &User{
Pubkey: "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c",
Username: "alice",
SubscriptionType: SubYearly,
ExpiresAt: &exp,
IsActive: true,
}
if err := repo.Insert(ctx, u); err != nil {
t.Fatalf("insert: %v", err)
}
got, err := repo.GetByPubkey(ctx, u.Pubkey)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Username != "alice" {
t.Errorf("got username %q", got.Username)
}
if !got.IsActive {
t.Error("expected active")
}
}
func TestRepo_GetByUsername_NotFound(t *testing.T) {
repo := newTestRepo(t)
if _, err := repo.GetByUsername(context.Background(), "nope"); err != ErrUserNotFound {
t.Errorf("got %v want ErrUserNotFound", err)
}
}

126
internal/user/service.go Normal file
View File

@@ -0,0 +1,126 @@
package user
import (
"context"
"errors"
"time"
)
type Service struct {
repo *Repo
reserved []string
}
func NewService(repo *Repo, reserved []string) *Service {
return &Service{repo: repo, reserved: reserved}
}
func (s *Service) Repo() *Repo { return s.repo }
func (s *Service) Reserved() []string { return s.reserved }
// IsAvailable returns true if no active/in-grace user has the username.
func (s *Service) IsAvailable(ctx context.Context, username string) (bool, error) {
if err := ValidateUsername(username, s.reserved); err != nil {
return false, err
}
u, err := s.repo.GetByUsername(ctx, NormalizeUsername(username))
if errors.Is(err, ErrUserNotFound) {
return true, nil
}
if err != nil {
return false, err
}
_ = u
return false, nil
}
// CreateOrActivate inserts a new active user. Caller is responsible for transactional
// concerns (e.g. payments worker uses this within a tx).
func (s *Service) CreateOrActivate(ctx context.Context, pubkey, username string, sub SubscriptionType, years int, manual bool) (*User, error) {
username = NormalizeUsername(username)
if err := ValidateUsername(username, s.reserved); err != nil {
return nil, err
}
now := time.Now().UTC()
expiresAt := computeExpiry(sub, years, time.Time{}, now)
u := &User{
Pubkey: pubkey,
Username: username,
SubscriptionType: sub,
ExpiresAt: expiresAt,
IsActive: true,
ManualUsername: manual,
}
if err := s.repo.Insert(ctx, u); err != nil {
return nil, err
}
return u, nil
}
// Renew extends an existing user. Used by payments worker and admin extend.
func (s *Service) Renew(ctx context.Context, u *User, sub SubscriptionType, years int) error {
now := time.Now().UTC()
var current time.Time
if u.ExpiresAt != nil {
current = *u.ExpiresAt
}
u.SubscriptionType = sub
u.ExpiresAt = computeExpiry(sub, years, current, now)
u.IsActive = true
u.DeactivatedAt = nil
u.ExpiringReminderSentAt = nil
return s.repo.Update(ctx, u)
}
func (s *Service) SetUsername(ctx context.Context, pubkey, username string) (*User, error) {
username = NormalizeUsername(username)
if err := ValidateUsername(username, s.reserved); err != nil {
return nil, err
}
u, err := s.repo.GetByPubkey(ctx, pubkey)
if err != nil {
return nil, err
}
if existing, err := s.repo.GetByUsername(ctx, username); err == nil && existing.Pubkey != pubkey {
return nil, ErrUsernameTaken
}
u.Username = username
u.ManualUsername = true
if err := s.repo.Update(ctx, u); err != nil {
return nil, err
}
return u, nil
}
func (s *Service) Delete(ctx context.Context, pubkey string) error {
return s.repo.Delete(ctx, pubkey)
}
// computeExpiry returns *time.Time (nil for lifetime).
func computeExpiry(sub SubscriptionType, years int, current time.Time, now time.Time) *time.Time {
if sub == SubLifetime {
return nil
}
var cur time.Time
if !current.IsZero() {
cur = current
}
return YearlyTargetExpiry(&cur, years, now)
}
// YearlyTargetExpiry computes the new expiry for a yearly subscription using
// effective_start = max(now, current_expiry); new_expiry = effective_start + years.
// currentExpiry may be nil or zero for first-time purchases.
func YearlyTargetExpiry(currentExpiry *time.Time, years int, now time.Time) *time.Time {
if years <= 0 {
years = 1
}
base := now
if currentExpiry != nil && currentExpiry.After(base) {
base = *currentExpiry
}
t := base.AddDate(years, 0, 0)
return &t
}

View File

@@ -0,0 +1,46 @@
package user
import (
"testing"
"time"
)
func TestComputeExpiry_NewYearly(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
exp := computeExpiry(SubYearly, 1, time.Time{}, now)
if exp == nil {
t.Fatal("nil expiry")
}
want := now.AddDate(1, 0, 0)
if !exp.Equal(want) {
t.Errorf("got %v want %v", exp, want)
}
}
func TestComputeExpiry_RenewActive(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
current := now.AddDate(0, 6, 0)
exp := computeExpiry(SubYearly, 1, current, now)
want := current.AddDate(1, 0, 0)
if !exp.Equal(want) {
t.Errorf("active renew: got %v want %v", exp, want)
}
}
func TestComputeExpiry_RenewExpired(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
past := now.AddDate(0, -1, 0)
exp := computeExpiry(SubYearly, 1, past, now)
want := now.AddDate(1, 0, 0)
if !exp.Equal(want) {
t.Errorf("expired renew: got %v want %v", exp, want)
}
}
func TestComputeExpiry_Lifetime(t *testing.T) {
now := time.Now()
exp := computeExpiry(SubLifetime, 1, time.Time{}, now)
if exp != nil {
t.Errorf("lifetime should be nil, got %v", exp)
}
}

39
internal/webhook/model.go Normal file
View File

@@ -0,0 +1,39 @@
package webhook
import "time"
type EventType string
const (
EventUserPaid EventType = "user.paid"
EventUserAdded EventType = "user.added"
EventUserExtended EventType = "user.extended"
EventUserRemoved EventType = "user.removed"
)
type Status string
const (
StatusPending Status = "pending"
StatusDelivered Status = "delivered"
StatusDead Status = "dead"
)
type Payload struct {
Event EventType `json:"event"`
Timestamp string `json:"timestamp"`
Domain string `json:"domain"`
Data map[string]any `json:"data"`
}
type OutboxItem struct {
ID int64
EventType EventType
Payload string
Attempts int
LastAttemptAt *time.Time
NextAttemptAt time.Time
Status Status
LastError string
CreatedAt time.Time
}

86
internal/webhook/repo.go Normal file
View File

@@ -0,0 +1,86 @@
package webhook
import (
"context"
"database/sql"
"time"
"github.com/noderunners/nip05api/internal/db"
)
type Repo struct{ db *db.DB }
func NewRepo(d *db.DB) *Repo { return &Repo{db: d} }
func (r *Repo) Insert(ctx context.Context, eventType EventType, payload string) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO webhook_outbox
(event_type, payload, next_attempt_at) VALUES (?, ?, ?)`,
string(eventType), payload, time.Now().UTC().Format(time.RFC3339))
return err
}
func (r *Repo) Claim(ctx context.Context, limit int) ([]*OutboxItem, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, event_type, payload, attempts,
last_attempt_at, next_attempt_at, status, COALESCE(last_error, ''), created_at
FROM webhook_outbox
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY next_attempt_at ASC LIMIT ?`,
time.Now().UTC().Format(time.RFC3339), limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*OutboxItem{}
for rows.Next() {
var it OutboxItem
var status, eventType string
var lastAttempt, nextAttempt, created sql.NullString
if err := rows.Scan(&it.ID, &eventType, &it.Payload, &it.Attempts,
&lastAttempt, &nextAttempt, &status, &it.LastError, &created); err != nil {
return nil, err
}
it.EventType = EventType(eventType)
it.Status = Status(status)
if lastAttempt.Valid {
if t, err := time.Parse(time.RFC3339, lastAttempt.String); err == nil {
it.LastAttemptAt = &t
}
}
if nextAttempt.Valid {
if t, err := time.Parse(time.RFC3339, nextAttempt.String); err == nil {
it.NextAttemptAt = t
}
}
if created.Valid {
if t, err := time.Parse(time.RFC3339, created.String); err == nil {
it.CreatedAt = t
} else if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
it.CreatedAt = t
}
}
out = append(out, &it)
}
return out, rows.Err()
}
func (r *Repo) MarkDelivered(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE webhook_outbox SET status = 'delivered',
last_attempt_at = ?, last_error = '' WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), id)
return err
}
func (r *Repo) MarkRetry(ctx context.Context, id int64, attempts int, nextAt time.Time, errMsg string) error {
_, err := r.db.ExecContext(ctx, `UPDATE webhook_outbox SET attempts = ?,
last_attempt_at = ?, next_attempt_at = ?, last_error = ? WHERE id = ?`,
attempts, time.Now().UTC().Format(time.RFC3339),
nextAt.UTC().Format(time.RFC3339), errMsg, id)
return err
}
func (r *Repo) MarkDead(ctx context.Context, id int64, errMsg string) error {
_, err := r.db.ExecContext(ctx, `UPDATE webhook_outbox SET status = 'dead',
last_attempt_at = ?, last_error = ? WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), errMsg, id)
return err
}

View File

@@ -0,0 +1,36 @@
package webhook
import (
"context"
"encoding/json"
"time"
)
type Service struct {
repo *Repo
domain string
enabled bool
}
func NewService(repo *Repo, domain string, enabled bool) *Service {
return &Service{repo: repo, domain: domain, enabled: enabled}
}
func (s *Service) Enabled() bool { return s.enabled }
func (s *Service) Enqueue(ctx context.Context, event EventType, data map[string]any) error {
if !s.enabled {
return nil
}
p := Payload{
Event: event,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Domain: s.domain,
Data: data,
}
b, err := json.Marshal(p)
if err != nil {
return err
}
return s.repo.Insert(ctx, event, string(b))
}

View File

@@ -0,0 +1,16 @@
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Sign(secret string, body []byte) string {
if secret == "" {
return ""
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}

View File

@@ -0,0 +1,21 @@
package webhook
import "testing"
func TestSign(t *testing.T) {
if Sign("", []byte("hello")) != "" {
t.Error("empty secret should return empty signature")
}
sig := Sign("supersecret", []byte("hello"))
if len(sig) != 64 {
t.Errorf("expected 64-char hex hmac, got %d: %q", len(sig), sig)
}
again := Sign("supersecret", []byte("hello"))
if sig != again {
t.Error("signature should be deterministic")
}
other := Sign("different", []byte("hello"))
if sig == other {
t.Error("different secret should produce different signature")
}
}

123
internal/webhook/worker.go Normal file
View File

@@ -0,0 +1,123 @@
package webhook
import (
"bytes"
"context"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
)
var retrySchedule = []time.Duration{
30 * time.Second,
2 * time.Minute,
10 * time.Minute,
1 * time.Hour,
6 * time.Hour,
}
type Worker struct {
repo *Repo
url string
secret string
timeout time.Duration
maxRetries int
hc *http.Client
}
func NewWorker(repo *Repo, url, secret string, timeoutSecs, maxRetries int) *Worker {
if maxRetries <= 0 {
maxRetries = len(retrySchedule)
}
return &Worker{
repo: repo,
url: url,
secret: secret,
timeout: time.Duration(timeoutSecs) * time.Second,
maxRetries: maxRetries,
hc: &http.Client{Timeout: time.Duration(timeoutSecs) * time.Second},
}
}
func (w *Worker) Run(ctx context.Context) {
if w.url == "" {
<-ctx.Done()
return
}
t := time.NewTicker(1 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tick(ctx)
}
}
}
func (w *Worker) tick(ctx context.Context) {
items, err := w.repo.Claim(ctx, 5)
if err != nil {
slog.Error("webhook claim", "err", err)
return
}
if len(items) == 0 {
return
}
var wg sync.WaitGroup
for _, it := range items {
wg.Add(1)
go func(it *OutboxItem) {
defer wg.Done()
w.deliver(ctx, it)
}(it)
}
wg.Wait()
}
func (w *Worker) deliver(ctx context.Context, it *OutboxItem) {
body := []byte(it.Payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
if err != nil {
w.handleErr(ctx, it, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "nip05api/1.0")
req.Header.Set("X-Webhook-Event", string(it.EventType))
if sig := Sign(w.secret, body); sig != "" {
req.Header.Set("X-Webhook-Signature", sig)
}
resp, err := w.hc.Do(req)
if err != nil {
w.handleErr(ctx, it, err)
return
}
defer resp.Body.Close()
if resp.StatusCode/100 == 2 {
_ = w.repo.MarkDelivered(ctx, it.ID)
slog.Info("webhook delivered", "event", it.EventType, "id", it.ID)
return
}
w.handleErr(ctx, it, fmt.Errorf("status %d", resp.StatusCode))
}
func (w *Worker) handleErr(ctx context.Context, it *OutboxItem, err error) {
attempts := it.Attempts + 1
if attempts >= w.maxRetries {
_ = w.repo.MarkDead(ctx, it.ID, err.Error())
slog.Error("webhook dead", "id", it.ID, "err", err)
return
}
idx := attempts - 1
if idx >= len(retrySchedule) {
idx = len(retrySchedule) - 1
}
next := time.Now().UTC().Add(retrySchedule[idx])
_ = w.repo.MarkRetry(ctx, it.ID, attempts, next, err.Error())
slog.Warn("webhook retry", "id", it.ID, "attempts", attempts, "next", next, "err", err)
}

View File

@@ -0,0 +1,179 @@
package webhook
import (
"context"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/noderunners/nip05api/internal/db"
)
func setupRepo(t *testing.T) *Repo {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := d.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return NewRepo(d)
}
func TestWorker_DeliversAndMarksDelivered(t *testing.T) {
repo := setupRepo(t)
var (
mu sync.Mutex
hits int32
gotSig string
gotEvent string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
mu.Lock()
gotSig = r.Header.Get("X-Webhook-Signature")
gotEvent = r.Header.Get("X-Webhook-Event")
mu.Unlock()
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
svc := NewService(repo, "test.local", true)
if err := svc.Enqueue(context.Background(), EventUserPaid, map[string]any{"username": "alice"}); err != nil {
t.Fatal(err)
}
w := &Worker{
repo: repo,
url: srv.URL,
secret: "topsecret",
timeout: 2 * time.Second,
maxRetries: 5,
hc: &http.Client{Timeout: 2 * time.Second},
}
w.tick(context.Background())
if atomic.LoadInt32(&hits) == 0 {
t.Fatal("webhook never received")
}
mu.Lock()
defer mu.Unlock()
if gotEvent != "user.paid" {
t.Errorf("event header: %q", gotEvent)
}
if len(gotSig) != 64 {
t.Errorf("signature header: %q", gotSig)
}
// Row should be marked delivered.
var status string
if err := repo.db.QueryRowContext(context.Background(),
`SELECT status FROM webhook_outbox LIMIT 1`).Scan(&status); err != nil {
t.Fatal(err)
}
if status != string(StatusDelivered) {
t.Errorf("expected delivered, got %s", status)
}
}
func TestWorker_RetriesOn5xx(t *testing.T) {
repo := setupRepo(t)
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
svc := NewService(repo, "test.local", true)
if err := svc.Enqueue(context.Background(), EventUserAdded, map[string]any{}); err != nil {
t.Fatal(err)
}
// First attempt fires immediately (next_attempt_at = now).
w := &Worker{
repo: repo,
url: srv.URL,
timeout: 1 * time.Second,
maxRetries: 5,
hc: &http.Client{Timeout: 1 * time.Second},
}
w.tick(context.Background())
if atomic.LoadInt32(&hits) != 1 {
t.Fatalf("expected 1 attempt, got %d", hits)
}
// Verify row updated with attempts=1, status still pending.
rows, err := repo.Claim(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Errorf("row should not be claimable yet (next_attempt_at in future), got %d", len(rows))
}
var attempts int
if err := repo.db.QueryRowContext(context.Background(),
`SELECT attempts FROM webhook_outbox LIMIT 1`).Scan(&attempts); err != nil {
t.Fatal(err)
}
if attempts != 1 {
t.Errorf("expected attempts=1, got %d", attempts)
}
}
func TestWorker_DeadAfterMaxRetries(t *testing.T) {
repo := setupRepo(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
svc := NewService(repo, "test.local", true)
_ = svc.Enqueue(context.Background(), EventUserAdded, map[string]any{})
w := &Worker{
repo: repo,
url: srv.URL,
timeout: 1 * time.Second,
maxRetries: 2,
hc: &http.Client{Timeout: 1 * time.Second},
}
// First failure → retry scheduled.
w.tick(context.Background())
// Force the row eligible again by rewinding next_attempt_at.
if _, err := repo.db.ExecContext(context.Background(),
`UPDATE webhook_outbox SET next_attempt_at = ?`,
time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
w.tick(context.Background())
var status string
if err := repo.db.QueryRowContext(context.Background(),
`SELECT status FROM webhook_outbox LIMIT 1`).Scan(&status); err != nil {
t.Fatal(err)
}
if status != string(StatusDead) {
t.Errorf("expected dead after 2 attempts, got %s", status)
}
}
func TestWorker_NoURL_NoOp(t *testing.T) {
repo := setupRepo(t)
w := NewWorker(repo, "", "", 5, 5)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
w.Run(ctx) // returns when ctx fires; should not hang.
}

38
messages.example.yaml Normal file
View File

@@ -0,0 +1,38 @@
# Available placeholders:
# {username} user's NIP-05 local part
# {npub} user's npub (bech32)
# {pubkey} user's hex pubkey
# {domain} configured domain
# {expires_at} formatted expiry (e.g. "2027-04-27") or "lifetime"
# {days_remaining} integer days until expiry (expiring_soon only)
# {grace_days} configured USERNAME_GRACE_DAYS (expired only)
# {frontend_url} configured FRONTEND_URL
# {subscription_type} "yearly" or "lifetime"
#
# Empty template ("") disables that DM type.
welcome: |
welcome to nip-05 on {domain}
you're now {username}@{domain}
expires: {expires_at}
manage your identity: {frontend_url}?pubkey={npub}
expiring_soon: |
heads up — your nip-05 {username}@{domain} expires in {days_remaining} days ({expires_at})
renew here: {frontend_url}?pubkey={npub}
yearly or lifetime, your call.
expired: |
your nip-05 {username}@{domain} has expired and been removed.
want it back? same username is reserved for you for {grace_days} days:
{frontend_url}?pubkey={npub}
extended: |
{username}@{domain} renewed
new expiry: {expires_at}