Serve generic Nostr events, metadata and health checks with configurable authentication and bounded connections. Own the event database under an exclusive lock, preserve integrated callback privacy by default, and verify wire interoperability and restart behavior.
- commit
- 8b59378664d2ea4084c0d57e0f0de789b9b3854f
- parent
- eb8e94f31eb336798a436e53264af9fde2b39657
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 233 | +++++++++++++++ |
| A | internal/ | 173 | +++++++++++ |
| M | internal/ | 7 | +- |
| A | tinyrelay/ | 481 | ++++++++++++++++++++++++++++++ |
| A | tinyrelay/ | 186 | ++++++++++++ |
| A | tinyrelay/ | 78 | +++++ |
@@ -0,0 +1,233 @@
1+// Package relayapp provides the storage-backed base relay used by the2+// standalone tinyrelay command. It deliberately contains no hosted3+// application features; those remain in daemon.4+package relayapp5+6+import (7+ "context"8+ "errors"9+ "fmt"10+ "os"11+ "path/filepath"12+ "sort"13+ "strings"14+ "sync"15+ "syscall"16+ "time"17+18+ "github.com/FelineStateMachine/tinyrelay/internal/event"19+ "github.com/FelineStateMachine/tinyrelay/internal/relay"20+ "github.com/FelineStateMachine/tinyrelay/internal/storage"21+ "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"22+)23+24+// Config controls one standalone relay database and its public metadata.25+type Config struct {26+ DataDir string27+ PublicURL string28+ Name string29+ Description string30+ Contact string31+ Owner string32+ AuthRequired bool33+ MaxMessageBytes int6434+ MaxPendingBytes int35+}36+37+// Backend is the storage-backed relay implementation. It can be passed to38+// relay.New and is safe to use until Close returns.39+type Backend struct {40+ store *storage.Store41+ lock *os.File42+ cfg Config43+ closeOnce sync.Once44+ closeErr error45+}46+47+// Open creates or opens a standalone relay database below Config.DataDir.48+func Open(ctx context.Context, cfg Config) (*Backend, error) {49+ if strings.TrimSpace(cfg.DataDir) == "" {50+ return nil, errors.New("relayapp: data directory is required")51+ }52+ dataDir, err := filepath.Abs(cfg.DataDir)53+ if err != nil {54+ return nil, fmt.Errorf("relayapp: resolve data directory: %w", err)55+ }56+ cfg.DataDir = dataDir57+ if err := os.MkdirAll(dataDir, 0700); err != nil {58+ return nil, fmt.Errorf("relayapp: create data directory: %w", err)59+ }60+ lock, err := os.OpenFile(filepath.Join(dataDir, ".tinyrelay.lock"), os.O_CREATE|os.O_RDWR, 0600)61+ if err != nil {62+ return nil, fmt.Errorf("relayapp: open data lock: %w", err)63+ }64+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {65+ if errors.Is(err, syscall.EWOULDBLOCK) {66+ return nil, errors.Join(errors.New("relayapp: data directory is already in use"), lock.Close())67+ }68+ return nil, errors.Join(fmt.Errorf("relayapp: lock data directory: %w", err), lock.Close())69+ }70+ store, err := storage.Open(ctx, filepath.Join(dataDir, "events.db"))71+ if err != nil {72+ return nil, errors.Join(err, syscall.Flock(int(lock.Fd()), syscall.LOCK_UN), lock.Close())73+ }74+ return &Backend{store: store, lock: lock, cfg: cfg}, nil75+}76+77+// Ready verifies that the database is available.78+func (b *Backend) Ready(ctx context.Context) error {79+ if b == nil || b.store == nil {80+ return errors.New("relayapp: backend is closed")81+ }82+ return b.store.DB().PingContext(ctx)83+}84+85+// Close releases the database.86+func (b *Backend) Close() error {87+ if b == nil || b.store == nil {88+ return nil89+ }90+ b.closeOnce.Do(func() {91+ storeErr := b.store.Close()92+ unlockErr := syscall.Flock(int(b.lock.Fd()), syscall.LOCK_UN)93+ closeLockErr := b.lock.Close()94+ b.closeErr = errors.Join(storeErr, unlockErr, closeLockErr)95+ })96+ return b.closeErr97+}98+99+func (b *Backend) Publish(ctx context.Context, e event.Event, s relay.Session) (string, error) {100+ if b.store == nil {101+ return "", errors.New("relayapp: backend is closed")102+ }103+ now := time.Now().Unix()104+ if b.cfg.AuthRequired && !contains(s.PubKeys, e.PubKey) {105+ return "", errors.New("auth-required: publishing requires AUTH")106+ }107+ if hasTag(e, "-") && !contains(s.PubKeys, e.PubKey) {108+ return "", errors.New("auth-required: this event may only be published by its author")109+ }110+ if e.Kind == event.KIND_AUTH {111+ return "", errors.New("blocked: AUTH events use AUTH")112+ }113+ if err := event.Validate(e); err != nil {114+ return "", fmt.Errorf("invalid: %w", err)115+ }116+ if e.CreatedAt > now+900 {117+ return "", errors.New("invalid: event creation date is too far off from the current time")118+ }119+ if expiration := event.Expiration(e); expiration > 0 && expiration <= now {120+ return "", errors.New("invalid: event has already expired")121+ }122+ _, err := b.store.Save(ctx, e, storage.SaveOptions{Now: now})123+ if errors.Is(err, storage.ErrDuplicate) {124+ return storage.ErrDuplicate.Error(), nil125+ }126+ return "", err127+}128+129+func (b *Backend) Query(ctx context.Context, filters []event.Filter, s relay.Session) ([]event.Event, error) {130+ if b.store == nil {131+ return nil, errors.New("relayapp: backend is closed")132+ }133+ if b.cfg.AuthRequired && len(s.PubKeys) == 0 {134+ return nil, errors.New("auth-required: this relay requires AUTH")135+ }136+ seen := make(map[string]struct{})137+ var result []event.Event138+ for _, f := range filters {139+ row, err := b.store.Query(ctx, f, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true, IncludeCallbackRegistrations: true}})140+ if err != nil {141+ return nil, err142+ }143+ for _, e := range row.Events {144+ if _, ok := seen[e.ID]; ok {145+ continue146+ }147+ seen[e.ID] = struct{}{}148+ result = append(result, e)149+ }150+ }151+ sort.Slice(result, func(i, j int) bool {152+ if result[i].CreatedAt == result[j].CreatedAt {153+ return result[i].ID < result[j].ID154+ }155+ return result[i].CreatedAt > result[j].CreatedAt156+ })157+ return result, nil158+}159+160+func (b *Backend) Count(ctx context.Context, filters []event.Filter, s relay.Session) (any, error) {161+ copyFilters := append([]event.Filter(nil), filters...)162+ for i := range copyFilters {163+ copyFilters[i].Limit = nil164+ }165+ events, err := b.Query(ctx, copyFilters, s)166+ if err != nil {167+ return nil, err168+ }169+ return map[string]any{"count": len(events)}, nil170+}171+172+func (b *Backend) Sync(ctx context.Context, f event.Filter, s relay.Session) ([]syncprotocol.Item, error) {173+ f.Limit = nil174+ events, err := b.Query(ctx, []event.Filter{f}, s)175+ if err != nil {176+ return nil, err177+ }178+ items := make([]syncprotocol.Item, len(events))179+ for i, e := range events {180+ items[i] = syncprotocol.Item{ID: e.ID, Timestamp: e.CreatedAt}181+ }182+ return items, nil183+}184+185+// CanRead applies current policy to a live event.186+func (b *Backend) CanRead(e event.Event, s relay.Session) bool {187+ return (!b.cfg.AuthRequired || len(s.PubKeys) > 0) && visible(e)188+}189+190+// CanReadFilter applies current policy with subscription filter context.191+func (b *Backend) CanReadFilter(e event.Event, s relay.Session, f *event.Filter) bool {192+ return b.CanRead(e, s)193+}194+195+func visible(e event.Event) bool {196+ expires := event.Expiration(e)197+ return expires == 0 || expires > time.Now().Unix()198+}199+200+func hasTag(e event.Event, name string) bool {201+ for _, tag := range e.Tags {202+ if len(tag) > 0 && tag[0] == name {203+ return true204+ }205+ }206+ return false207+}208+209+// Information returns conservative NIP-11 metadata for this relay.210+func (b *Backend) Information() map[string]any {211+ info := map[string]any{"name": b.cfg.Name, "description": b.cfg.Description, "supported_nips": []int{1, 11, 42, 45, 77}, "software": "https://github.com/FelineStateMachine/tinyrelay"}212+ limitation := map[string]any{"auth_required": b.cfg.AuthRequired}213+ if b.cfg.MaxMessageBytes > 0 {214+ limitation["max_message_length"] = b.cfg.MaxMessageBytes215+ }216+ info["limitation"] = limitation217+ if b.cfg.Contact != "" {218+ info["contact"] = b.cfg.Contact219+ }220+ if b.cfg.Owner != "" {221+ info["pubkey"] = b.cfg.Owner222+ }223+ return info224+}225+226+func contains(values []string, value string) bool {227+ for _, v := range values {228+ if v == value {229+ return true230+ }231+ }232+ return false233+}@@ -0,0 +1,173 @@
1+package relayapp2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/event"11+ "github.com/FelineStateMachine/tinyrelay/internal/relay"12+)13+14+func TestStandaloneBackendPublishesAndQueriesSignedEvents(t *testing.T) {15+ ctx := context.Background()16+ b, err := Open(ctx, Config{DataDir: t.TempDir(), Owner: strings.Repeat("a", 64)})17+ if err != nil {18+ t.Fatal(err)19+ }20+ defer b.Close()21+ secret := strings.Repeat("1", 64)22+ e := event.Event{CreatedAt: time.Now().Unix(), Kind: 1, Tags: [][]string{}, Content: "hello"}23+ if err := event.Sign(&e, secret); err != nil {24+ t.Fatal(err)25+ }26+ if _, err := b.Publish(ctx, e, relay.Session{}); err != nil {27+ t.Fatal(err)28+ }29+ rows, err := b.Query(ctx, []event.Filter{{IDs: []string{e.ID}}}, relay.Session{})30+ if err != nil || len(rows) != 1 || rows[0].ID != e.ID {31+ t.Fatalf("query = %#v, %v", rows, err)32+ }33+ if reason, err := b.Publish(ctx, e, relay.Session{}); err != nil || !strings.Contains(reason, "duplicate") {34+ t.Fatalf("duplicate = %q, %v", reason, err)35+ }36+}37+38+func TestStandaloneAcceptsGenericNostrKinds(t *testing.T) {39+ ctx := context.Background()40+ b, err := Open(ctx, Config{DataDir: t.TempDir()})41+ if err != nil {42+ t.Fatal(err)43+ }44+ defer b.Close()45+ secret := strings.Repeat("1", 64)46+ pubkey, err := event.PublicKey(secret)47+ if err != nil {48+ t.Fatal(err)49+ }50+ ids := make([]string, 0, 3)51+ for i, kind := range []int{24133, 5000, 4242, 30390} {52+ tags := [][]string{}53+ if kind == 24133 {54+ tags = [][]string{{"p", pubkey}}55+ }56+ if kind == 30390 {57+ tags = [][]string{{"d", "opaque"}}58+ }59+ e := event.Event{CreatedAt: time.Now().Unix() + int64(i), Kind: kind, Tags: tags, Content: "generic"}60+ if err := event.Sign(&e, secret); err != nil {61+ t.Fatal(err)62+ }63+ if _, err := b.Publish(ctx, e, relay.Session{}); err != nil {64+ t.Fatalf("kind %d: %v", kind, err)65+ }66+ if kind == 24133 && !b.CanRead(e, relay.Session{}) {67+ t.Fatal("anonymous NIP-46 event was blocked")68+ }69+ if kind != 24133 {70+ ids = append(ids, e.ID)71+ }72+ }73+ rows, err := b.Query(ctx, []event.Filter{{IDs: ids}}, relay.Session{PubKeys: []string{pubkey}})74+ if err != nil || len(rows) != len(ids) {75+ got := make([]string, len(rows))76+ for i, row := range rows {77+ got[i] = row.ID78+ }79+ t.Fatalf("generic query = %d/%d ids=%v want=%v, %v", len(rows), len(ids), got, ids, err)80+ }81+}82+83+func TestStandaloneNIP70AuthorOnlyPublication(t *testing.T) {84+ ctx := context.Background()85+ b, err := Open(ctx, Config{DataDir: t.TempDir()})86+ if err != nil {87+ t.Fatal(err)88+ }89+ defer b.Close()90+ secret := strings.Repeat("1", 64)91+ e := event.Event{CreatedAt: time.Now().Unix(), Kind: 1, Tags: [][]string{{"-"}}, Content: "protected"}92+ if err := event.Sign(&e, secret); err != nil {93+ t.Fatal(err)94+ }95+ if _, err := b.Publish(ctx, e, relay.Session{}); err == nil {96+ t.Fatal("unauthenticated NIP-70 publication accepted")97+ }98+ pubkey, err := event.PublicKey(secret)99+ if err != nil {100+ t.Fatal(err)101+ }102+ if _, err := b.Publish(ctx, e, relay.Session{PubKeys: []string{pubkey}}); err != nil {103+ t.Fatal(err)104+ }105+}106+107+func TestStandaloneAuthRequiredAppliesToReadsAndWrites(t *testing.T) {108+ ctx := context.Background()109+ b, err := Open(ctx, Config{DataDir: t.TempDir(), Owner: strings.Repeat("a", 64), AuthRequired: true})110+ if err != nil {111+ t.Fatal(err)112+ }113+ defer b.Close()114+ secret := strings.Repeat("1", 64)115+ e := event.Event{CreatedAt: time.Now().Unix(), Kind: 1, Tags: [][]string{}, Content: "hello"}116+ if err := event.Sign(&e, secret); err != nil {117+ t.Fatal(err)118+ }119+ if _, err := b.Publish(ctx, e, relay.Session{}); err == nil {120+ t.Fatal("unauthenticated publish accepted")121+ }122+ owner, err := event.PublicKey(secret)123+ if err != nil {124+ t.Fatal(err)125+ }126+ if _, err := b.Publish(ctx, e, relay.Session{PubKeys: []string{owner}}); err != nil {127+ t.Fatal(err)128+ }129+ if _, err := b.Query(ctx, []event.Filter{{IDs: []string{e.ID}}}, relay.Session{}); err == nil {130+ t.Fatal("unauthenticated query accepted")131+ }132+}133+134+func TestInformationDoesNotAdvertiseHostedExtensions(t *testing.T) {135+ b, err := Open(context.Background(), Config{DataDir: t.TempDir()})136+ if err != nil {137+ t.Fatal(err)138+ }139+ defer b.Close()140+ info := b.Information()141+ for _, n := range info["supported_nips"].([]int) {142+ if n == 86 {143+ t.Fatal("standalone relay advertises NIP-86")144+ }145+ }146+}147+148+func TestOpenResolvesDataDirectoryAndReopens(t *testing.T) {149+ base := t.TempDir()150+ t.Chdir(base)151+ relative := "relay"152+ b, err := Open(context.Background(), Config{DataDir: relative})153+ if err != nil {154+ t.Fatal(err)155+ }156+ if !filepath.IsAbs(b.cfg.DataDir) {157+ t.Fatalf("data directory is not absolute: %q", b.cfg.DataDir)158+ }159+ if _, err := Open(context.Background(), Config{DataDir: relative}); err == nil {160+ t.Fatal("second backend opened the same data directory")161+ }162+ if err := b.Close(); err != nil {163+ t.Fatal(err)164+ }165+ reopened, err := Open(context.Background(), Config{DataDir: relative})166+ if err != nil {167+ t.Fatal(err)168+ }169+ defer reopened.Close()170+ if err := reopened.Ready(context.Background()); err != nil {171+ t.Fatal(err)172+ }173+}@@ -17,6 +17,9 @@ import (
1717 type Access struct {1818 PubKeys []string1919 All bool20+ // IncludeCallbackRegistrations opts out of the daemon's callback privacy21+ // filter. Standalone relays use this to preserve opaque Nostr events.22+ IncludeCallbackRegistrations bool2023 }2124 2225 // QueryOptions supplies query time, access principals, an optional result@@ -121,7 +124,9 @@ func addAccess(p *predicate, who Access) error {
121124 return fmt.Errorf("encode read principals: %w", err)122125 }123126 // Callback registrations never become visible through internal all-access.124- p.add("(kind<>"+fmt.Sprint(event.KIND_PUSH_REGISTRATION)+" OR pubkey IN(SELECT value FROM json_each(?)))", string(keys))127+ if !who.IncludeCallbackRegistrations {128+ p.add("(kind<>"+fmt.Sprint(event.KIND_PUSH_REGISTRATION)+" OR pubkey IN(SELECT value FROM json_each(?)))", string(keys))129+ }125130 if who.All {126131 return nil127132 }@@ -0,0 +1,481 @@
1+package tinyrelay_test2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"14+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"15+ "github.com/FelineStateMachine/tinyrelay/tinyrelay"16+ "github.com/coder/websocket"17+)18+19+type wireClient struct {20+ t *testing.T21+ conn *websocket.Conn22+ challenge string23+}24+25+func openRelay(t *testing.T, authRequired bool) (*tinyrelay.Server, *httptest.Server, *wireClient) {26+ t.Helper()27+ ctx := context.Background()28+ s, err := tinyrelay.OpenServer(ctx, tinyrelay.ServerConfig{29+ DataDir: t.TempDir(),30+ PublicURL: "ws://relay.example",31+ AuthRequired: authRequired,32+ })33+ if err != nil {34+ t.Fatal(err)35+ }36+ httpServer := httptest.NewServer(s)37+ t.Cleanup(func() {38+ httpServer.Close()39+ if err := s.Close(); err != nil {40+ t.Errorf("close relay: %v", err)41+ }42+ })43+ conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(httpServer.URL, "http"), nil)44+ if err != nil {45+ t.Fatal(err)46+ }47+ client := &wireClient{t: t, conn: conn}48+ t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "test complete") })49+ auth := client.expect("AUTH", 2)50+ if err := json.Unmarshal(auth[1], &client.challenge); err != nil {51+ t.Fatalf("decode relay challenge: %v", err)52+ }53+ return s, httpServer, client54+}55+56+func (c *wireClient) send(v ...any) {57+ c.t.Helper()58+ raw, err := json.Marshal(v)59+ if err != nil {60+ c.t.Fatal(err)61+ }62+ if err := c.conn.Write(context.Background(), websocket.MessageText, raw); err != nil {63+ c.t.Fatal(err)64+ }65+}66+67+func (c *wireClient) read() []json.RawMessage {68+ c.t.Helper()69+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)70+ defer cancel()71+ typ, data, err := c.conn.Read(ctx)72+ if err != nil {73+ c.t.Fatalf("read relay message: %v", err)74+ }75+ if typ != websocket.MessageText {76+ c.t.Fatalf("relay message type = %v, want text", typ)77+ }78+ var msg []json.RawMessage79+ if err := json.Unmarshal(data, &msg); err != nil {80+ c.t.Fatalf("decode relay message %q: %v", data, err)81+ }82+ return msg83+}84+85+func (c *wireClient) expect(kind string, fields int) []json.RawMessage {86+ c.t.Helper()87+ msg := c.read()88+ var got string89+ if len(msg) == 0 || json.Unmarshal(msg[0], &got) != nil || got != kind {90+ c.t.Fatalf("message kind = %q, want %q", got, kind)91+ }92+ if fields > 0 && len(msg) != fields {93+ c.t.Fatalf("%s field count = %d, want %d", kind, len(msg), fields)94+ }95+ return msg96+}97+98+func signedEvent(t *testing.T, secret string, kind int, created int64, tags [][]string, content string) nostr.Event {99+ t.Helper()100+ e := nostr.Event{Kind: kind, CreatedAt: created, Tags: tags, Content: content}101+ if err := nostr.Sign(&e, secret); err != nil {102+ t.Fatal(err)103+ }104+ return e105+}106+107+func publish(t *testing.T, c *wireClient, e nostr.Event) []json.RawMessage {108+ t.Helper()109+ c.send("EVENT", e)110+ msg := c.expect("OK", 4)111+ var id string112+ if err := json.Unmarshal(msg[1], &id); err != nil || id != e.ID {113+ t.Fatalf("OK id = %q, want %q", id, e.ID)114+ }115+ return msg116+}117+118+func assertAccepted(t *testing.T, msg []json.RawMessage, want bool) {119+ t.Helper()120+ var accepted bool121+ if err := json.Unmarshal(msg[2], &accepted); err != nil {122+ t.Fatalf("decode OK accepted flag: %v", err)123+ }124+ if accepted != want {125+ t.Fatalf("OK accepted = %v, want %v", accepted, want)126+ }127+}128+129+func TestStandaloneRelayNIP11AndNIP01Wire(t *testing.T) {130+ _, httpServer, publisher := openRelay(t, false)131+ req, err := http.NewRequest(http.MethodGet, httpServer.URL+"/", nil)132+ if err != nil {133+ t.Fatal(err)134+ }135+ req.Header.Set("Accept", "application/nostr+json")136+ resp, err := http.DefaultClient.Do(req)137+ if err != nil {138+ t.Fatal(err)139+ }140+ defer resp.Body.Close()141+ if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/nostr+json") {142+ t.Fatalf("NIP-11 content type = %q", got)143+ }144+ var info struct {145+ Name string `json:"name"`146+ }147+ if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {148+ t.Fatalf("decode NIP-11: %v", err)149+ }150+ if info.Name == "" {151+ t.Fatal("NIP-11 has no relay name")152+ }153+154+ secret, err := nostr.GenerateKey()155+ if err != nil {156+ t.Fatal(err)157+ }158+ now := time.Now().Unix()159+ e := signedEvent(t, secret, 1, now, nil, "first")160+ publish(t, publisher, e)161+ duplicate := publish(t, publisher, e)162+ var reason string163+ _ = json.Unmarshal(duplicate[3], &reason)164+ if !strings.Contains(reason, "duplicate") {165+ t.Fatalf("duplicate reason = %q", reason)166+ }167+168+ reader := func() *wireClient {169+ conn, _, err := websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(httpServer.URL, "http"), nil)170+ if err != nil {171+ t.Fatal(err)172+ }173+ c := &wireClient{t: t, conn: conn}174+ t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "test complete") })175+ auth := c.expect("AUTH", 2)176+ if err := json.Unmarshal(auth[1], &c.challenge); err != nil {177+ t.Fatal(err)178+ }179+ return c180+ }()181+ reader.send("REQ", "notes", map[string]any{"ids": []string{e.ID}})182+ got := reader.expect("EVENT", 3)183+ var returned nostr.Event184+ if err := json.Unmarshal(got[2], &returned); err != nil || returned.ID != e.ID {185+ t.Fatalf("REQ returned wrong event: %v", err)186+ }187+ reader.expect("EOSE", 2)188+ reader.send("CLOSE", "notes")189+ reader.send("REQ", "live", map[string]any{"kinds": []int{1}})190+ for {191+ msg := reader.read()192+ var kind string193+ _ = json.Unmarshal(msg[0], &kind)194+ if kind == "EOSE" {195+ break196+ }197+ if kind != "EVENT" {198+ t.Fatalf("live query response = %q", kind)199+ }200+ }201+ // Generic Nostr reports are ordinary events for a standalone relay. They202+ // must not trigger the daemon's application ACL subscription invalidation.203+ report := signedEvent(t, secret, 1984, now+1, nil, "ordinary report")204+ publish(t, publisher, report)205+ live := signedEvent(t, secret, 1, now+1, nil, "live")206+ publish(t, publisher, live)207+ liveMessage := reader.expect("EVENT", 3)208+ var liveEvent nostr.Event209+ if err := json.Unmarshal(liveMessage[2], &liveEvent); err != nil || liveEvent.ID != live.ID {210+ t.Fatalf("live event = %q, want %q", liveEvent.ID, live.ID)211+ }212+ reader.send("CLOSE", "live")213+214+ invalid := e215+ invalid.Content = "tampered"216+ publisher.send("EVENT", invalid)217+ assertAccepted(t, publisher.expect("OK", 4), false)218+}219+220+func TestStandaloneRelayEventLifecycleAndCount(t *testing.T) {221+ _, _, c := openRelay(t, false)222+ secret, err := nostr.GenerateKey()223+ if err != nil {224+ t.Fatal(err)225+ }226+ now := time.Now().Unix()227+ first := signedEvent(t, secret, 0, now, nil, "old profile")228+ publish(t, c, first)229+ second := signedEvent(t, secret, 0, now+1, nil, "new profile")230+ publish(t, c, second)231+ deleted := signedEvent(t, secret, 5, now+2, [][]string{{"e", second.ID}}, "remove")232+ publish(t, c, deleted)233+ expired := signedEvent(t, secret, 1, now+3, [][]string{{"expiration", fmt.Sprint(now - 1)}}, "expired")234+ publish(t, c, expired)235+ ephemeral := signedEvent(t, secret, 20001, now+4, nil, "live only")236+ publish(t, c, ephemeral)237+ noteA := signedEvent(t, secret, 1, now+5, nil, "count A")238+ publish(t, c, noteA)239+ noteB := signedEvent(t, secret, 1, now+6, nil, "count B")240+ publish(t, c, noteB)241+242+ c.send("REQ", "state", map[string]any{"authors": []string{first.PubKey}, "kinds": []int{0, 1, 20001}})243+ for {244+ msg := c.read()245+ var kind string246+ _ = json.Unmarshal(msg[0], &kind)247+ if kind == "EOSE" {248+ break249+ }250+ if kind == "EVENT" {251+ var got nostr.Event252+ if err := json.Unmarshal(msg[2], &got); err != nil {253+ t.Fatal(err)254+ }255+ if got.ID == first.ID || got.ID == second.ID || got.ID == expired.ID || got.ID == ephemeral.ID {256+ t.Fatalf("lifecycle query returned excluded event %s", got.ID)257+ }258+ }259+ }260+261+ c.send("COUNT", "count", map[string]any{"kinds": []int{1}, "limit": 1}, map[string]any{"ids": []string{noteA.ID, noteB.ID}, "limit": 1}, map[string]any{"ids": []string{noteA.ID}, "limit": 1})262+ count := c.expect("COUNT", 3)263+ var result struct {264+ Count int `json:"count"`265+ }266+ if err := json.Unmarshal(count[2], &result); err != nil {267+ t.Fatal(err)268+ }269+ if result.Count != 2 {270+ t.Fatalf("COUNT = %d, want 2 across limited filters", result.Count)271+ }272+}273+274+func TestStandaloneRelayReplaceableAddressableAndRestart(t *testing.T) {275+ dataDir := t.TempDir()276+ start := func() (*tinyrelay.Server, *httptest.Server, *wireClient) {277+ s, err := tinyrelay.OpenServer(context.Background(), tinyrelay.ServerConfig{DataDir: dataDir, PublicURL: "ws://relay.example"})278+ if err != nil {279+ t.Fatal(err)280+ }281+ ts := httptest.NewServer(s)282+ conn, _, err := websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(ts.URL, "http"), nil)283+ if err != nil {284+ t.Fatal(err)285+ }286+ c := &wireClient{t: t, conn: conn}287+ auth := c.expect("AUTH", 2)288+ if err := json.Unmarshal(auth[1], &c.challenge); err != nil {289+ t.Fatal(err)290+ }291+ return s, ts, c292+ }293+ closeServer := func(s *tinyrelay.Server, ts *httptest.Server, c *wireClient) {294+ _ = c.conn.Close(websocket.StatusNormalClosure, "restart")295+ ts.Close()296+ if err := s.Close(); err != nil {297+ t.Error(err)298+ }299+ }300+301+ s, ts, c := start()302+ secret, err := nostr.GenerateKey()303+ if err != nil {304+ t.Fatal(err)305+ }306+ now := time.Now().Unix()307+ profileOld := signedEvent(t, secret, 0, now, nil, "old")308+ profileNew := signedEvent(t, secret, 0, now+1, nil, "new")309+ addressOld := signedEvent(t, secret, 30000, now, [][]string{{"d", "profile"}}, "old address")310+ addressNew := signedEvent(t, secret, 30000, now+1, [][]string{{"d", "profile"}}, "new address")311+ for _, e := range []nostr.Event{profileOld, profileNew, addressOld, addressNew} {312+ publish(t, c, e)313+ }314+ c.send("REQ", "current", map[string]any{"authors": []string{profileOld.PubKey}, "kinds": []int{0, 30000}})315+ ids := map[string]bool{}316+ for {317+ msg := c.read()318+ var kind string319+ _ = json.Unmarshal(msg[0], &kind)320+ if kind == "EOSE" {321+ break322+ }323+ if kind != "EVENT" {324+ t.Fatalf("replaceable query response = %q", kind)325+ }326+ var e nostr.Event327+ if err := json.Unmarshal(msg[2], &e); err != nil {328+ t.Fatal(err)329+ }330+ ids[e.ID] = true331+ }332+ if len(ids) != 2 || !ids[profileNew.ID] || !ids[addressNew.ID] || ids[profileOld.ID] || ids[addressOld.ID] {333+ t.Fatalf("current replaceable events = %v", ids)334+ }335+ closeServer(s, ts, c)336+337+ s, ts, c = start()338+ t.Cleanup(func() { closeServer(s, ts, c) })339+ c.send("REQ", "after-restart", map[string]any{"ids": []string{profileNew.ID, addressNew.ID}})340+ ids = map[string]bool{}341+ for {342+ msg := c.read()343+ var kind string344+ _ = json.Unmarshal(msg[0], &kind)345+ if kind == "EOSE" {346+ break347+ }348+ if kind == "EVENT" {349+ var e nostr.Event350+ if err := json.Unmarshal(msg[2], &e); err != nil {351+ t.Fatal(err)352+ }353+ ids[e.ID] = true354+ }355+ }356+ if len(ids) != 2 {357+ t.Fatalf("events after restart = %v", ids)358+ }359+}360+361+func TestStandaloneRelayNIP42AndNIP77(t *testing.T) {362+ _, _, c := openRelay(t, true)363+ secret, err := nostr.GenerateKey()364+ if err != nil {365+ t.Fatal(err)366+ }367+ c.send("REQ", "before-auth", map[string]any{})368+ deniedRead := c.expect("CLOSED", 3)369+ var deniedReason string370+ _ = json.Unmarshal(deniedRead[2], &deniedReason)371+ if !strings.Contains(deniedReason, "auth-required") {372+ t.Fatalf("unauthenticated read reason = %q", deniedReason)373+ }374+ initiator, err := syncprotocol.NewSession(nil, true)375+ if err != nil {376+ t.Fatal(err)377+ }378+ start, err := initiator.Start()379+ if err != nil {380+ t.Fatal(err)381+ }382+ c.send("NEG-OPEN", "before-auth-sync", map[string]any{}, start)383+ negDenied := c.expect("NEG-ERR", 3)384+ var negReason string385+ _ = json.Unmarshal(negDenied[2], &negReason)386+ if !strings.Contains(negReason, "auth-required") {387+ t.Fatalf("unauthenticated sync reason = %q", negReason)388+ }389+ unauthenticated := signedEvent(t, secret, 1, time.Now().Unix(), nil, "before auth")390+ c.send("EVENT", unauthenticated)391+ assertAccepted(t, c.expect("OK", 4), false)392+ protected := signedEvent(t, secret, 1, time.Now().Unix(), [][]string{{"-"}}, "protected")393+ c.send("EVENT", protected)394+ protectedReply := c.expect("OK", 4)395+ assertAccepted(t, protectedReply, false)396+ var protectedReason string397+ _ = json.Unmarshal(protectedReply[3], &protectedReason)398+ if !strings.Contains(protectedReason, "auth-required") {399+ t.Fatalf("unauthenticated protected publish reason = %q", protectedReason)400+ }401+ auth := signedEvent(t, secret, 22242, time.Now().Unix(), [][]string{{"relay", "ws://relay.example"}, {"challenge", c.challenge}}, "")402+ c.send("AUTH", auth)403+ authReply := c.expect("OK", 4)404+ var accepted bool405+ if err := json.Unmarshal(authReply[2], &accepted); err != nil || !accepted {406+ t.Fatalf("AUTH rejected: %s", authReply[3])407+ }408+ publish(t, c, unauthenticated)409+ publish(t, c, protected)410+ otherSecret, err := nostr.GenerateKey()411+ if err != nil {412+ t.Fatal(err)413+ }414+ other := signedEvent(t, otherSecret, 1, time.Now().Unix(), nil, "other author")415+ c.send("EVENT", other)416+ assertAccepted(t, c.expect("OK", 4), false)417+ otherProtected := signedEvent(t, otherSecret, 1, time.Now().Unix(), [][]string{{"-"}}, "other protected")418+ c.send("EVENT", otherProtected)419+ assertAccepted(t, c.expect("OK", 4), false)420+ // An empty client snapshot reconciles with the relay and exercises the421+ // externally visible NEG-OPEN/NEG-CLOSE exchange without private storage APIs.422+ initiator, err = syncprotocol.NewSession(nil, true)423+ if err != nil {424+ t.Fatal(err)425+ }426+ start, err = initiator.Start()427+ if err != nil {428+ t.Fatal(err)429+ }430+ c.send("NEG-OPEN", "sync", map[string]any{}, start)431+ msg := c.read()432+ var kind string433+ _ = json.Unmarshal(msg[0], &kind)434+ if kind != "NEG-CLOSE" && kind != "NEG-MSG" {435+ t.Fatalf("NEG-OPEN response kind = %q", kind)436+ }437+}438+439+func TestStandaloneRelayGenericInboxAndRegistrationKinds(t *testing.T) {440+ _, httpServer, publisher := openRelay(t, false)441+ recipientSecret, err := nostr.GenerateKey()442+ if err != nil {443+ t.Fatal(err)444+ }445+ recipient, err := nostr.PublicKey(recipientSecret)446+ if err != nil {447+ t.Fatal(err)448+ }449+ conn, _, err := websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(httpServer.URL, "http"), nil)450+ if err != nil {451+ t.Fatal(err)452+ }453+ reader := &wireClient{t: t, conn: conn}454+ t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "test complete") })455+ auth := reader.expect("AUTH", 2)456+ if err := json.Unmarshal(auth[1], &reader.challenge); err != nil {457+ t.Fatal(err)458+ }459+ reader.send("REQ", "inbox", map[string]any{"kinds": []int{24133}, "#p": []string{recipient}})460+ reader.expect("EOSE", 2)461+ secret, err := nostr.GenerateKey()462+ if err != nil {463+ t.Fatal(err)464+ }465+ inbox := signedEvent(t, secret, 24133, time.Now().Unix(), [][]string{{"p", recipient}}, "encrypted reply")466+ publish(t, publisher, inbox)467+ live := reader.expect("EVENT", 3)468+ var liveEvent nostr.Event469+ if err := json.Unmarshal(live[2], &liveEvent); err != nil || liveEvent.ID != inbox.ID {470+ t.Fatalf("live inbox event = %q, want %q", liveEvent.ID, inbox.ID)471+ }472+ registration := signedEvent(t, secret, 30390, time.Now().Unix()+1, [][]string{{"d", "device"}}, "registration")473+ publish(t, publisher, registration)474+ reader.send("REQ", "registration", map[string]any{"kinds": []int{30390}, "#d": []string{"device"}})475+ got := reader.expect("EVENT", 3)476+ var stored nostr.Event477+ if err := json.Unmarshal(got[2], &stored); err != nil || stored.ID != registration.ID {478+ t.Fatalf("registration event = %q, want %q", stored.ID, registration.ID)479+ }480+ reader.expect("EOSE", 2)481+}@@ -0,0 +1,186 @@
1+package tinyrelay2+3+import (4+ "context"5+ "encoding/hex"6+ "encoding/json"7+ "errors"8+ "net/http"9+ "net/url"10+ "strings"11+ "sync"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/relayapp"14+)15+16+// ServerConfig configures a standalone relay. Owner is an optional operator17+// public key for NIP-11 metadata; no signing key is required. AuthRequired18+// requires NIP-42 authentication for reads and each publishing author.19+type ServerConfig struct {20+ DataDir string21+ PublicURL string22+ Name string23+ Description string24+ Contact string25+ Owner string26+ AuthRequired bool27+ MaxMessageBytes int6428+ MaxPendingBytes int29+}30+31+// Server owns a standalone relay and its durable event store. It serves Nostr32+// WebSockets, NIP-11 metadata, /healthz and /readyz without application services.33+type Server struct {34+ relay *Relay35+ backend *relayapp.Backend36+ relayPath string37+ mu sync.Mutex38+ closed bool39+ requests sync.WaitGroup40+ closeOnce sync.Once41+ closeErr error42+}43+44+var _ http.Handler = (*Server)(nil)45+46+// OpenServer opens a database in DataDir without starting an HTTP listener.47+// PublicURL must identify the client-facing relay, including any proxy prefix.48+// HTTP(S) URLs are accepted and converted to WS(S) for authentication. Zero49+// limits select defaults of 1 MiB per message and 4 MiB queued per connection.50+func OpenServer(ctx context.Context, cfg ServerConfig) (*Server, error) {51+ cfg, relayPath, err := normalizeServerConfig(cfg)52+ if err != nil {53+ return nil, err54+ }55+ backend, err := relayapp.Open(ctx, relayapp.Config(cfg))56+ if err != nil {57+ return nil, err58+ }59+ runtime, err := New(backend, Config{60+ RelayURL: cfg.PublicURL, MaxMessageBytes: cfg.MaxMessageBytes,61+ MaxPendingBytes: cfg.MaxPendingBytes, OriginPatterns: []string{"*"},62+ })63+ if err != nil {64+ return nil, errors.Join(err, backend.Close())65+ }66+ return &Server{relay: runtime, backend: backend, relayPath: relayPath}, nil67+}68+69+func normalizeServerConfig(cfg ServerConfig) (ServerConfig, string, error) {70+ u, err := url.Parse(cfg.PublicURL)71+ if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {72+ return cfg, "", errors.New("tinyrelay: public URL must identify a relay without credentials, query or fragment")73+ }74+ switch u.Scheme {75+ case "http":76+ u.Scheme = "ws"77+ case "https":78+ u.Scheme = "wss"79+ case "ws", "wss":80+ default:81+ return cfg, "", errors.New("tinyrelay: public URL must use http, https, ws or wss")82+ }83+ u.Path = strings.TrimRight(u.Path, "/")84+ u.RawPath = ""85+ cfg.PublicURL = u.String()86+ if cfg.Owner != "" {87+ key, err := hex.DecodeString(cfg.Owner)88+ if err != nil || len(key) != 32 || cfg.Owner != strings.ToLower(cfg.Owner) {89+ return cfg, "", errors.New("tinyrelay: owner must be a lowercase 64-character hex public key")90+ }91+ }92+ if cfg.MaxMessageBytes < 0 || cfg.MaxPendingBytes < 0 {93+ return cfg, "", errors.New("tinyrelay: byte limits cannot be negative")94+ }95+ if cfg.MaxMessageBytes == 0 {96+ cfg.MaxMessageBytes = 1 << 2097+ }98+ if cfg.MaxPendingBytes == 0 {99+ cfg.MaxPendingBytes = 4 << 20100+ }101+ if cfg.Name == "" {102+ cfg.Name = "tinyrelay"103+ }104+ return cfg, u.Path, nil105+}106+107+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {108+ s.mu.Lock()109+ if s.closed {110+ s.mu.Unlock()111+ http.Error(w, "relay is closed", http.StatusServiceUnavailable)112+ return113+ }114+ s.requests.Add(1)115+ s.mu.Unlock()116+ defer s.requests.Done()117+ w.Header().Set("Access-Control-Allow-Origin", "*")118+ w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type")119+ w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")120+ if r.URL.Path != "/" && r.URL.Path != s.relayPath && r.URL.Path != "/healthz" && r.URL.Path != "/readyz" {121+ http.NotFound(w, r)122+ return123+ }124+ if r.Method == http.MethodOptions {125+ w.WriteHeader(http.StatusNoContent)126+ return127+ }128+ if r.Method != http.MethodGet && r.Method != http.MethodHead {129+ w.Header().Set("Allow", "GET, HEAD, OPTIONS")130+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)131+ return132+ }133+ if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {134+ s.serveHealth(w, r)135+ return136+ }137+ if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {138+ s.relay.ServeHTTP(w, r)139+ return140+ }141+ s.serveInformation(w, r)142+}143+144+func (s *Server) serveHealth(w http.ResponseWriter, r *http.Request) {145+ if r.URL.Path == "/readyz" {146+ if err := s.backend.Ready(r.Context()); err != nil {147+ http.Error(w, "relay is not ready", http.StatusServiceUnavailable)148+ return149+ }150+ }151+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")152+ if r.Method != http.MethodHead {153+ _, _ = w.Write([]byte("ok\n"))154+ }155+}156+157+func (s *Server) serveInformation(w http.ResponseWriter, r *http.Request) {158+ w.Header().Set("Vary", "Accept")159+ if strings.Contains(r.Header.Get("Accept"), "application/nostr+json") {160+ w.Header().Set("Content-Type", "application/nostr+json")161+ if r.Method != http.MethodHead {162+ // Information contains only JSON-safe metadata. A response write163+ // failure means the requesting client has disconnected.164+ _ = json.NewEncoder(w).Encode(s.backend.Information())165+ }166+ return167+ }168+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")169+ if r.Method != http.MethodHead {170+ _, _ = w.Write([]byte("tinyrelay Nostr relay\nConnect with a Nostr WebSocket client.\n"))171+ }172+}173+174+// Close rejects new requests, joins active connections and releases storage.175+// It is safe to call concurrently or more than once. The HTTP listener remains176+// owned by the caller and should also be shut down.177+func (s *Server) Close() error {178+ s.closeOnce.Do(func() {179+ s.mu.Lock()180+ s.closed = true181+ s.mu.Unlock()182+ s.requests.Wait()183+ s.closeErr = errors.Join(s.relay.Close(context.Background()), s.backend.Close())184+ })185+ return s.closeErr186+}@@ -0,0 +1,78 @@
1+package tinyrelay_test2+3+import (4+ "context"5+ "net/http"6+ "net/http/httptest"7+ "sync"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/tinyrelay"12+)13+14+func TestStandaloneCloseDrainsWebSockets(t *testing.T) {15+ s, _, client := openRelay(t, false)16+ client.send("REQ", "open", map[string]any{"kinds": []int{1}})17+ client.expect("EOSE", 2)18+ if err := s.Close(); err != nil {19+ t.Fatal(err)20+ }21+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)22+ defer cancel()23+ if _, _, err := client.conn.Read(ctx); err == nil {24+ t.Fatal("connection remained open after server close")25+ } else if ctx.Err() != nil {26+ t.Fatal("close did not disconnect the client")27+ }28+}29+30+func TestStandaloneServerRoutesAndClose(t *testing.T) {31+ s, err := tinyrelay.OpenServer(context.Background(), tinyrelay.ServerConfig{DataDir: t.TempDir(), PublicURL: "https://relay.example/nostr"})32+ if err != nil {33+ t.Fatal(err)34+ }35+ defer s.Close()36+ for _, path := range []string{"/", "/nostr", "/healthz", "/readyz"} {37+ response := httptest.NewRecorder()38+ s.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))39+ if response.Code != http.StatusOK {40+ t.Errorf("%s: %d", path, response.Code)41+ }42+ }43+ for _, path := range []string{"/git", "/mcp", "/rooms"} {44+ response := httptest.NewRecorder()45+ s.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))46+ if response.Code != http.StatusNotFound {47+ t.Errorf("%s: %d", path, response.Code)48+ }49+ }50+ var wg sync.WaitGroup51+ for range 4 {52+ wg.Add(1)53+ go func() {54+ defer wg.Done()55+ if err := s.Close(); err != nil {56+ t.Error(err)57+ }58+ }()59+ }60+ wg.Wait()61+ response := httptest.NewRecorder()62+ s.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))63+ if response.Code != http.StatusServiceUnavailable {64+ t.Fatalf("closed server: %d", response.Code)65+ }66+}67+68+func TestStandaloneServerRejectsInvalidConfig(t *testing.T) {69+ for _, publicURL := range []string{"", "file:///tmp/relay", "wss://user:pass@relay.example", "wss://relay.example?token=x", "wss://relay.example#fragment"} {70+ t.Run(publicURL, func(t *testing.T) {71+ s, err := tinyrelay.OpenServer(context.Background(), tinyrelay.ServerConfig{DataDir: t.TempDir(), PublicURL: publicURL})72+ if err == nil {73+ s.Close()74+ t.Fatal("accepted invalid public URL")75+ }76+ })77+ }78+}