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

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.
}