Compare commits

...

11 Commits

Author SHA1 Message Date
84b7c0e11c bump to v0.27.0 2025-11-09 10:42:50 +00:00
d0dbd2e2dc implemented and tested NIP-43 invite based ACL 2025-11-09 10:41:58 +00:00
f0beb83ceb fix utf8 handling bug, bump to v0.26.4 2025-11-08 10:29:24 +00:00
5d04193bb7 implement messages and operations for FIND 2025-11-08 09:02:32 +00:00
b4760c49b6 implement messages and operations for FIND 2025-11-08 08:54:58 +00:00
587116afa8 add noise protocol security and site certificate third party signing 2025-11-08 00:13:23 +00:00
960bfe7dda add noise protocol security and site certificate third party signing 2025-11-08 00:01:06 +00:00
f5cfcff6c9 draft name registry proposal 2025-11-07 22:52:22 +00:00
2e690f5b83 draft name registry proposal 2025-11-07 22:37:52 +00:00
c79cd2ffee Remove deprecated files and enhance subscription stability
- Deleted obsolete files including ALL_FIXES.md, MESSAGE_QUEUE_FIX.md, PUBLISHER_FIX.md, and others to streamline the codebase.
- Implemented critical fixes for subscription stability, ensuring receiver channels are consumed and preventing drops.
- Introduced per-subscription consumer goroutines to enhance event delivery and prevent message queue overflow.
- Updated documentation to reflect changes and provide clear testing guidelines for subscription stability.
- Bumped version to v0.26.3 to signify these important updates.
2025-11-06 20:10:08 +00:00
581e0ec588 Implement comprehensive WebSocket subscription stability fixes
- Resolved critical issues causing subscriptions to drop after 30-60 seconds due to unconsumed receiver channels.
- Introduced per-subscription consumer goroutines to ensure continuous event delivery and prevent channel overflow.
- Enhanced REQ parsing to handle both wrapped and unwrapped filter arrays, eliminating EOF errors.
- Updated publisher logic to correctly send events to receiver channels, ensuring proper event delivery to subscribers.
- Added extensive documentation and testing tools to verify subscription stability and performance.
- Bumped version to v0.26.2 to reflect these significant improvements.
2025-11-06 18:21:00 +00:00
54 changed files with 9604 additions and 1216 deletions

View File

@@ -4,7 +4,32 @@
"Skill(skill-creator)", "Skill(skill-creator)",
"Bash(cat:*)", "Bash(cat:*)",
"Bash(python3:*)", "Bash(python3:*)",
"Bash(find:*)" "Bash(find:*)",
"Skill(nostr-websocket)",
"Bash(go build:*)",
"Bash(chmod:*)",
"Bash(journalctl:*)",
"Bash(timeout 5 bash -c 'echo [\"\"REQ\"\",\"\"test123\"\",{\"\"kinds\"\":[1],\"\"limit\"\":1}] | websocat ws://localhost:3334':*)",
"Bash(pkill:*)",
"Bash(timeout 5 bash:*)",
"Bash(md5sum:*)",
"Bash(timeout 3 bash -c 'echo [\\\"\"REQ\\\"\",\\\"\"test456\\\"\",{\\\"\"kinds\\\"\":[1],\\\"\"limit\\\"\":10}] | websocat ws://localhost:3334')",
"Bash(printf:*)",
"Bash(websocat:*)",
"Bash(go test:*)",
"Bash(timeout 180 go test:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(/tmp/find help)",
"Bash(/tmp/find verify-name example.com)",
"Skill(golang)",
"Bash(/tmp/find verify-name Bitcoin.Nostr)",
"Bash(/tmp/find generate-key)",
"Bash(git ls-tree:*)",
"Bash(CGO_ENABLED=0 go build:*)",
"Bash(CGO_ENABLED=0 go test:*)",
"Bash(app/web/dist/index.html)",
"Bash(export CGO_ENABLED=0)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

3
.gitignore vendored
View File

@@ -76,7 +76,6 @@ cmd/benchmark/data
!*.css !*.css
!*.ts !*.ts
!*.html !*.html
!contrib/stella/Dockerfile
!*.lock !*.lock
!*.nix !*.nix
!license !license
@@ -88,10 +87,8 @@ cmd/benchmark/data
!.gitignore !.gitignore
!version !version
!out.jsonl !out.jsonl
!contrib/stella/Dockerfile
!strfry.conf !strfry.conf
!config.toml !config.toml
!contrib/stella/.dockerignore
!*.jsx !*.jsx
!*.tsx !*.tsx
!bun.lock !bun.lock

View File

@@ -70,6 +70,12 @@ type C struct {
PolicyEnabled bool `env:"ORLY_POLICY_ENABLED" default:"false" usage:"enable policy-based event processing (configuration found in $HOME/.config/ORLY/policy.json)"` PolicyEnabled bool `env:"ORLY_POLICY_ENABLED" default:"false" usage:"enable policy-based event processing (configuration found in $HOME/.config/ORLY/policy.json)"`
// NIP-43 Relay Access Metadata and Requests
NIP43Enabled bool `env:"ORLY_NIP43_ENABLED" default:"false" usage:"enable NIP-43 relay access metadata and invite system"`
NIP43PublishEvents bool `env:"ORLY_NIP43_PUBLISH_EVENTS" default:"true" usage:"publish kind 8000/8001 events when members are added/removed"`
NIP43PublishMemberList bool `env:"ORLY_NIP43_PUBLISH_MEMBER_LIST" default:"true" usage:"publish kind 13534 membership list events"`
NIP43InviteExpiry time.Duration `env:"ORLY_NIP43_INVITE_EXPIRY" default:"24h" usage:"how long invite codes remain valid"`
// TLS configuration // TLS configuration
TLSDomains []string `env:"ORLY_TLS_DOMAINS" usage:"comma-separated list of domains to respond to for TLS"` TLSDomains []string `env:"ORLY_TLS_DOMAINS" usage:"comma-separated list of domains to respond to for TLS"`
Certs []string `env:"ORLY_CERTS" usage:"comma-separated list of paths to certificate root names (e.g., /path/to/cert will load /path/to/cert.pem and /path/to/cert.key)"` Certs []string `env:"ORLY_CERTS" usage:"comma-separated list of paths to certificate root names (e.g., /path/to/cert will load /path/to/cert.pem and /path/to/cert.key)"`

View File

@@ -23,13 +23,30 @@ func (l *Listener) HandleClose(req []byte) (err error) {
if len(env.ID) == 0 { if len(env.ID) == 0 {
return errors.New("CLOSE has no <id>") return errors.New("CLOSE has no <id>")
} }
subID := string(env.ID)
// Cancel the subscription goroutine by calling its cancel function
l.subscriptionsMu.Lock()
if cancelFunc, exists := l.subscriptions[subID]; exists {
log.D.F("cancelling subscription %s for %s", subID, l.remote)
cancelFunc()
delete(l.subscriptions, subID)
} else {
log.D.F("subscription %s not found for %s (already closed?)", subID, l.remote)
}
l.subscriptionsMu.Unlock()
// Also remove from publisher's tracking
l.publishers.Receive( l.publishers.Receive(
&W{ &W{
Cancel: true, Cancel: true,
remote: l.remote, remote: l.remote,
Conn: l.conn, Conn: l.conn,
Id: string(env.ID), Id: subID,
}, },
) )
log.D.F("CLOSE processed for subscription %s @ %s", subID, l.remote)
return return
} }

View File

@@ -15,6 +15,7 @@ import (
"next.orly.dev/pkg/encoders/hex" "next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind" "next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason" "next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/utils" "next.orly.dev/pkg/utils"
) )
@@ -207,6 +208,23 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
} }
return return
} }
// Handle NIP-43 special events before ACL checks
switch env.E.Kind {
case nip43.KindJoinRequest:
// Process join request and return early
if err = l.HandleNIP43JoinRequest(env.E); chk.E(err) {
log.E.F("failed to process NIP-43 join request: %v", err)
}
return
case nip43.KindLeaveRequest:
// Process leave request and return early
if err = l.HandleNIP43LeaveRequest(env.E); chk.E(err) {
log.E.F("failed to process NIP-43 leave request: %v", err)
}
return
}
// check permissions of user // check permissions of user
log.I.F( log.I.F(
"HandleEvent: checking ACL permissions for pubkey: %s", "HandleEvent: checking ACL permissions for pubkey: %s",

View File

@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"time" "time"
"unicode" "unicode/utf8"
"lol.mleku.dev/chk" "lol.mleku.dev/chk"
"lol.mleku.dev/log" "lol.mleku.dev/log"
@@ -18,36 +18,22 @@ import (
) )
// validateJSONMessage checks if a message contains invalid control characters // validateJSONMessage checks if a message contains invalid control characters
// that would cause JSON parsing to fail // that would cause JSON parsing to fail. It also validates UTF-8 encoding.
func validateJSONMessage(msg []byte) (err error) { func validateJSONMessage(msg []byte) (err error) {
for i, b := range msg { // First, validate that the message is valid UTF-8
// Check for invalid control characters in JSON strings if !utf8.Valid(msg) {
return fmt.Errorf("invalid UTF-8 encoding")
}
// Check for invalid control characters in JSON strings
for i := 0; i < len(msg); i++ {
b := msg[i]
// Check for invalid control characters (< 32) except tab, newline, carriage return
if b < 32 && b != '\t' && b != '\n' && b != '\r' { if b < 32 && b != '\t' && b != '\n' && b != '\r' {
// Allow some control characters that might be valid in certain contexts return fmt.Errorf(
// but reject form feed (\f), backspace (\b), and other problematic ones "invalid control character 0x%02X at position %d", b, i,
switch b { )
case '\b', '\f', 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F:
return fmt.Errorf("invalid control character 0x%02X at position %d", b, i)
}
}
// Check for non-printable characters that might indicate binary data
if b > 127 && !unicode.IsPrint(rune(b)) {
// Allow valid UTF-8 sequences, but be suspicious of random binary data
if i < len(msg)-1 {
// Quick check: if we see a lot of high-bit characters in sequence,
// it might be binary data masquerading as text
highBitCount := 0
for j := i; j < len(msg) && j < i+10; j++ {
if msg[j] > 127 {
highBitCount++
}
}
if highBitCount > 7 { // More than 70% high-bit chars in a 10-byte window
return fmt.Errorf("suspicious binary data detected at position %d", i)
}
}
} }
} }
return return
@@ -58,12 +44,17 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
if l.isBlacklisted { if l.isBlacklisted {
// Check if timeout has been reached // Check if timeout has been reached
if time.Now().After(l.blacklistTimeout) { if time.Now().After(l.blacklistTimeout) {
log.W.F("blacklisted IP %s timeout reached, closing connection", remote) log.W.F(
"blacklisted IP %s timeout reached, closing connection", remote,
)
// Close the connection by cancelling the context // Close the connection by cancelling the context
// The websocket handler will detect this and close the connection // The websocket handler will detect this and close the connection
return return
} }
log.D.F("discarding message from blacklisted IP %s (timeout in %v)", remote, time.Until(l.blacklistTimeout)) log.D.F(
"discarding message from blacklisted IP %s (timeout in %v)", remote,
time.Until(l.blacklistTimeout),
)
return return
} }
@@ -71,13 +62,22 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
if len(msgPreview) > 150 { if len(msgPreview) > 150 {
msgPreview = msgPreview[:150] + "..." msgPreview = msgPreview[:150] + "..."
} }
// log.D.F("%s processing message (len=%d): %s", remote, len(msg), msgPreview) log.D.F("%s processing message (len=%d): %s", remote, len(msg), msgPreview)
// Validate message for invalid characters before processing // Validate message for invalid characters before processing
if err := validateJSONMessage(msg); err != nil { if err := validateJSONMessage(msg); err != nil {
log.E.F("%s message validation FAILED (len=%d): %v", remote, len(msg), err) log.E.F(
if noticeErr := noticeenvelope.NewFrom(fmt.Sprintf("invalid message format: contains invalid characters: %s", msg)).Write(l); noticeErr != nil { "%s message validation FAILED (len=%d): %v", remote, len(msg), err,
log.E.F("%s failed to send validation error notice: %v", remote, noticeErr) )
if noticeErr := noticeenvelope.NewFrom(
fmt.Sprintf(
"invalid message format: contains invalid characters: %s", msg,
),
).Write(l); noticeErr != nil {
log.E.F(
"%s failed to send validation error notice: %v", remote,
noticeErr,
)
} }
return return
} }
@@ -140,9 +140,10 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
if err != nil { if err != nil {
// Don't log context cancellation errors as they're expected during shutdown // Don't log context cancellation errors as they're expected during shutdown
if !strings.Contains(err.Error(), "context canceled") { if !strings.Contains(err.Error(), "context canceled") {
log.E.F("%s message processing FAILED (type=%s): %v", remote, t, err) log.E.F(
"%s message processing FAILED (type=%s): %v", remote, t, err,
)
// Don't log message preview as it may contain binary data // Don't log message preview as it may contain binary data
// Send error notice to client (use generic message to avoid control chars in errors) // Send error notice to client (use generic message to avoid control chars in errors)
noticeMsg := fmt.Sprintf("%s processing failed", t) noticeMsg := fmt.Sprintf("%s processing failed", t)
if noticeErr := noticeenvelope.NewFrom(noticeMsg).Write(l); noticeErr != nil { if noticeErr := noticeenvelope.NewFrom(noticeMsg).Write(l); noticeErr != nil {

254
app/handle-nip43.go Normal file
View File

@@ -0,0 +1,254 @@
package app
import (
"context"
"fmt"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/envelopes/okenvelope"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/protocol/nip43"
)
// HandleNIP43JoinRequest processes a kind 28934 join request
func (l *Listener) HandleNIP43JoinRequest(ev *event.E) error {
log.I.F("handling NIP-43 join request from %s", hex.Enc(ev.Pubkey))
// Validate the join request
inviteCode, valid, reason := nip43.ValidateJoinRequest(ev)
if !valid {
log.W.F("invalid join request: %s", reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("restricted: %s", reason))
}
// Check if user is already a member
isMember, err := l.D.IsNIP43Member(ev.Pubkey)
if chk.E(err) {
log.E.F("error checking membership: %v", err)
return l.sendOKResponse(ev.ID, false, "error: internal server error")
}
if isMember {
log.I.F("user %s is already a member", hex.Enc(ev.Pubkey))
return l.sendOKResponse(ev.ID, true, "duplicate: you are already a member of this relay")
}
// Validate the invite code
validCode, reason := l.Server.InviteManager.ValidateAndConsume(inviteCode, ev.Pubkey)
if !validCode {
log.W.F("invalid or expired invite code: %s - %s", inviteCode, reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("restricted: %s", reason))
}
// Add the member
if err = l.D.AddNIP43Member(ev.Pubkey, inviteCode); chk.E(err) {
log.E.F("error adding member: %v", err)
return l.sendOKResponse(ev.ID, false, "error: failed to add member")
}
log.I.F("successfully added member %s via invite code", hex.Enc(ev.Pubkey))
// Publish kind 8000 "add member" event if configured
if l.Config.NIP43PublishEvents {
if err = l.publishAddUserEvent(ev.Pubkey); chk.E(err) {
log.W.F("failed to publish add user event: %v", err)
}
}
// Update membership list if configured
if l.Config.NIP43PublishMemberList {
if err = l.publishMembershipList(); chk.E(err) {
log.W.F("failed to publish membership list: %v", err)
}
}
relayURL := l.Config.RelayURL
if relayURL == "" {
relayURL = fmt.Sprintf("wss://%s:%d", l.Config.Listen, l.Config.Port)
}
return l.sendOKResponse(ev.ID, true, fmt.Sprintf("welcome to %s!", relayURL))
}
// HandleNIP43LeaveRequest processes a kind 28936 leave request
func (l *Listener) HandleNIP43LeaveRequest(ev *event.E) error {
log.I.F("handling NIP-43 leave request from %s", hex.Enc(ev.Pubkey))
// Validate the leave request
valid, reason := nip43.ValidateLeaveRequest(ev)
if !valid {
log.W.F("invalid leave request: %s", reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("error: %s", reason))
}
// Check if user is a member
isMember, err := l.D.IsNIP43Member(ev.Pubkey)
if chk.E(err) {
log.E.F("error checking membership: %v", err)
return l.sendOKResponse(ev.ID, false, "error: internal server error")
}
if !isMember {
log.I.F("user %s is not a member", hex.Enc(ev.Pubkey))
return l.sendOKResponse(ev.ID, true, "you are not a member of this relay")
}
// Remove the member
if err = l.D.RemoveNIP43Member(ev.Pubkey); chk.E(err) {
log.E.F("error removing member: %v", err)
return l.sendOKResponse(ev.ID, false, "error: failed to remove member")
}
log.I.F("successfully removed member %s", hex.Enc(ev.Pubkey))
// Publish kind 8001 "remove member" event if configured
if l.Config.NIP43PublishEvents {
if err = l.publishRemoveUserEvent(ev.Pubkey); chk.E(err) {
log.W.F("failed to publish remove user event: %v", err)
}
}
// Update membership list if configured
if l.Config.NIP43PublishMemberList {
if err = l.publishMembershipList(); chk.E(err) {
log.W.F("failed to publish membership list: %v", err)
}
}
return l.sendOKResponse(ev.ID, true, "you have been removed from this relay")
}
// HandleNIP43InviteRequest processes a kind 28935 invite request (REQ subscription)
func (s *Server) HandleNIP43InviteRequest(pubkey []byte) (*event.E, error) {
log.I.F("generating NIP-43 invite for pubkey %s", hex.Enc(pubkey))
// Check if requester has permission to request invites
// This could be based on ACL, admins, etc.
accessLevel := acl.Registry.GetAccessLevel(pubkey, "")
if accessLevel != "admin" && accessLevel != "owner" {
log.W.F("unauthorized invite request from %s (level: %s)", hex.Enc(pubkey), accessLevel)
return nil, fmt.Errorf("unauthorized: only admins can request invites")
}
// Generate a new invite code
code, err := s.InviteManager.GenerateCode()
if chk.E(err) {
return nil, err
}
// Get relay identity
relaySecret, err := s.db.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return nil, err
}
// Build the invite event
inviteEvent, err := nip43.BuildInviteEvent(relaySecret, code)
if chk.E(err) {
return nil, err
}
log.I.F("generated invite code for %s", hex.Enc(pubkey))
return inviteEvent, nil
}
// publishAddUserEvent publishes a kind 8000 add user event
func (l *Listener) publishAddUserEvent(userPubkey []byte) error {
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildAddUserEvent(relaySecret, userPubkey)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 8000 add user event for %s", hex.Enc(userPubkey))
return nil
}
// publishRemoveUserEvent publishes a kind 8001 remove user event
func (l *Listener) publishRemoveUserEvent(userPubkey []byte) error {
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildRemoveUserEvent(relaySecret, userPubkey)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 8001 remove user event for %s", hex.Enc(userPubkey))
return nil
}
// publishMembershipList publishes a kind 13534 membership list event
func (l *Listener) publishMembershipList() error {
// Get all members
members, err := l.D.GetAllNIP43Members()
if chk.E(err) {
return err
}
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildMemberListEvent(relaySecret, members)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 13534 membership list event with %d members", len(members))
return nil
}
// sendOKResponse sends an OK envelope response
func (l *Listener) sendOKResponse(eventID []byte, accepted bool, message string) error {
// Ensure message doesn't have "restricted: " prefix if already present
if accepted && strings.HasPrefix(message, "restricted: ") {
message = strings.TrimPrefix(message, "restricted: ")
}
env := okenvelope.NewFrom(eventID, accepted, []byte(message))
return env.Write(l)
}

570
app/handle-nip43_test.go Normal file
View File

@@ -0,0 +1,570 @@
package app
import (
"context"
"os"
"testing"
"time"
"next.orly.dev/app/config"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
)
// setupTestListener creates a test listener with NIP-43 enabled
func setupTestListener(t *testing.T) (*Listener, *database.D, func()) {
tempDir, err := os.MkdirTemp("", "nip43_handler_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cfg := &config.C{
NIP43Enabled: true,
NIP43PublishEvents: true,
NIP43PublishMemberList: true,
NIP43InviteExpiry: 24 * time.Hour,
RelayURL: "wss://test.relay",
Listen: "localhost",
Port: 3334,
}
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
cleanup := func() {
db.Close()
os.RemoveAll(tempDir)
}
return listener, db, cleanup
}
// TestHandleNIP43JoinRequest_ValidRequest tests a successful join request
func TestHandleNIP43JoinRequest_ValidRequest(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create join request event
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", code))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign event
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Verify user was added to database
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("user was not added as member")
}
// Verify membership details
membership, err := db.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != code {
t.Errorf("wrong invite code stored: got %s, want %s", membership.InviteCode, code)
}
}
// TestHandleNIP43JoinRequest_InvalidCode tests join request with invalid code
func TestHandleNIP43JoinRequest_InvalidCode(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Create join request with invalid code
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "invalid-code-123"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request - should succeed but not add member
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was incorrectly added as member with invalid code")
}
}
// TestHandleNIP43JoinRequest_DuplicateMember tests join request from existing member
func TestHandleNIP43JoinRequest_DuplicateMember(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Add user directly to database
err = db.AddNIP43Member(userPubkey, "original-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Generate new invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create join request
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", code))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request - should handle gracefully
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify original membership is unchanged
membership, err := db.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != "original-code" {
t.Errorf("invite code was changed: got %s, want original-code", membership.InviteCode)
}
}
// TestHandleNIP43LeaveRequest_ValidRequest tests a successful leave request
func TestHandleNIP43LeaveRequest_ValidRequest(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Add user as member
err = db.AddNIP43Member(userPubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Create leave request
ev := event.New()
ev.Kind = nip43.KindLeaveRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle leave request
err = listener.HandleNIP43LeaveRequest(ev)
if err != nil {
t.Fatalf("failed to handle leave request: %v", err)
}
// Verify user was removed
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was not removed")
}
}
// TestHandleNIP43LeaveRequest_NonMember tests leave request from non-member
func TestHandleNIP43LeaveRequest_NonMember(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user (not a member)
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Create leave request
ev := event.New()
ev.Kind = nip43.KindLeaveRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle leave request - should handle gracefully
err = listener.HandleNIP43LeaveRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
}
// TestHandleNIP43InviteRequest_ValidRequest tests invite request from admin
func TestHandleNIP43InviteRequest_ValidRequest(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate admin user
adminSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate admin secret: %v", err)
}
adminSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = adminSigner.InitSec(adminSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
adminPubkey := adminSigner.Pub()
// Add admin to server (simulating admin config)
listener.Server.Admins = [][]byte{adminPubkey}
// Handle invite request
inviteEvent, err := listener.Server.HandleNIP43InviteRequest(adminPubkey)
if err != nil {
t.Fatalf("failed to handle invite request: %v", err)
}
// Verify invite event
if inviteEvent == nil {
t.Fatal("invite event is nil")
}
if inviteEvent.Kind != nip43.KindInviteReq {
t.Errorf("wrong event kind: got %d, want %d", inviteEvent.Kind, nip43.KindInviteReq)
}
// Verify claim tag
claimTag := inviteEvent.Tags.GetFirst([]byte("claim"))
if claimTag == nil {
t.Fatal("missing claim tag")
}
if claimTag.Len() < 2 {
t.Fatal("claim tag has no value")
}
}
// TestHandleNIP43InviteRequest_Unauthorized tests invite request from non-admin
func TestHandleNIP43InviteRequest_Unauthorized(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate regular user (not admin)
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Handle invite request - should fail
_, err = listener.Server.HandleNIP43InviteRequest(userPubkey)
if err == nil {
t.Fatal("expected error for unauthorized user")
}
}
// TestJoinAndLeaveFlow tests the complete join and leave flow
func TestJoinAndLeaveFlow(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Step 1: Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Step 2: User sends join request
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags = tag.NewS()
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(userSigner); err != nil {
t.Fatalf("failed to sign join event: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Verify user is member
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership after join: %v", err)
}
if !isMember {
t.Fatal("user is not a member after join")
}
// Step 3: User sends leave request
leaveEv := event.New()
leaveEv.Kind = nip43.KindLeaveRequest
copy(leaveEv.Pubkey, userPubkey)
leaveEv.Tags = tag.NewS()
leaveEv.Tags.Append(tag.NewFromAny("-"))
leaveEv.CreatedAt = time.Now().Unix()
leaveEv.Content = []byte("")
if err = leaveEv.Sign(userSigner); err != nil {
t.Fatalf("failed to sign leave event: %v", err)
}
err = listener.HandleNIP43LeaveRequest(leaveEv)
if err != nil {
t.Fatalf("failed to handle leave request: %v", err)
}
// Verify user is no longer member
isMember, err = db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership after leave: %v", err)
}
if isMember {
t.Fatal("user is still a member after leave")
}
}
// TestMultipleUsersJoining tests multiple users joining concurrently
func TestMultipleUsersJoining(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
userCount := 10
done := make(chan bool, userCount)
for i := 0; i < userCount; i++ {
go func(index int) {
// Generate user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Errorf("failed to generate user secret %d: %v", index, err)
done <- false
return
}
userSigner, err := p8k.New()
if err != nil {
t.Errorf("failed to create signer %d: %v", index, err)
done <- false
return
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Errorf("failed to initialize signer %d: %v", index, err)
done <- false
return
}
userPubkey := userSigner.Pub()
// Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Errorf("failed to generate invite code %d: %v", index, err)
done <- false
return
}
// Create join request
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags = tag.NewS()
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(userSigner); err != nil {
t.Errorf("failed to sign event %d: %v", index, err)
done <- false
return
}
// Handle join request
if err = listener.HandleNIP43JoinRequest(joinEv); err != nil {
t.Errorf("failed to handle join request %d: %v", index, err)
done <- false
return
}
done <- true
}(i)
}
// Wait for all goroutines
successCount := 0
for i := 0; i < userCount; i++ {
if <-done {
successCount++
}
}
if successCount != userCount {
t.Errorf("not all users joined successfully: %d/%d", successCount, userCount)
}
// Verify member count
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
if len(members) != successCount {
t.Errorf("wrong member count: got %d, want %d", len(members), successCount)
}
}

View File

@@ -33,7 +33,7 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
r.Header.Set("Content-Type", "application/json") r.Header.Set("Content-Type", "application/json")
log.D.Ln("handling relay information document") log.D.Ln("handling relay information document")
var info *relayinfo.T var info *relayinfo.T
supportedNIPs := relayinfo.GetList( nips := []relayinfo.NIP{
relayinfo.BasicProtocol, relayinfo.BasicProtocol,
relayinfo.Authentication, relayinfo.Authentication,
relayinfo.EncryptedDirectMessage, relayinfo.EncryptedDirectMessage,
@@ -49,9 +49,14 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
relayinfo.ProtectedEvents, relayinfo.ProtectedEvents,
relayinfo.RelayListMetadata, relayinfo.RelayListMetadata,
relayinfo.SearchCapability, relayinfo.SearchCapability,
) }
// Add NIP-43 if enabled
if s.Config.NIP43Enabled {
nips = append(nips, relayinfo.RelayAccessMetadata)
}
supportedNIPs := relayinfo.GetList(nips...)
if s.Config.ACLMode != "none" { if s.Config.ACLMode != "none" {
supportedNIPs = relayinfo.GetList( nipsACL := []relayinfo.NIP{
relayinfo.BasicProtocol, relayinfo.BasicProtocol,
relayinfo.Authentication, relayinfo.Authentication,
relayinfo.EncryptedDirectMessage, relayinfo.EncryptedDirectMessage,
@@ -67,7 +72,12 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
relayinfo.ProtectedEvents, relayinfo.ProtectedEvents,
relayinfo.RelayListMetadata, relayinfo.RelayListMetadata,
relayinfo.SearchCapability, relayinfo.SearchCapability,
) }
// Add NIP-43 if enabled
if s.Config.NIP43Enabled {
nipsACL = append(nipsACL, relayinfo.RelayAccessMetadata)
}
supportedNIPs = relayinfo.GetList(nipsACL...)
} }
sort.Sort(supportedNIPs) sort.Sort(supportedNIPs)
log.I.Ln("supported NIPs", supportedNIPs) log.I.Ln("supported NIPs", supportedNIPs)

View File

@@ -24,6 +24,7 @@ import (
"next.orly.dev/pkg/encoders/kind" "next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason" "next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/encoders/tag" "next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/utils" "next.orly.dev/pkg/utils"
"next.orly.dev/pkg/utils/normalize" "next.orly.dev/pkg/utils/normalize"
"next.orly.dev/pkg/utils/pointers" "next.orly.dev/pkg/utils/pointers"
@@ -43,7 +44,6 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
} }
return normalize.Error.Errorf(err.Error()) return normalize.Error.Errorf(err.Error())
} }
log.T.C( log.T.C(
func() string { func() string {
return fmt.Sprintf( return fmt.Sprintf(
@@ -108,6 +108,40 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
// user has read access or better, continue // user has read access or better, continue
} }
} }
// Handle NIP-43 invite request (kind 28935) - ephemeral event
// Check if any filter requests kind 28935
for _, f := range *env.Filters {
if f != nil && f.Kinds != nil {
if f.Kinds.Contains(nip43.KindInviteReq) {
// Generate and send invite event
inviteEvent, err := l.Server.HandleNIP43InviteRequest(l.authedPubkey.Load())
if err != nil {
log.W.F("failed to generate NIP-43 invite: %v", err)
// Send EOSE and return
if err = eoseenvelope.NewFrom(env.Subscription).Write(l); chk.E(err) {
return err
}
return nil
}
// Send the invite event
evEnv, _ := eventenvelope.NewResultWith(env.Subscription, inviteEvent)
if err = evEnv.Write(l); chk.E(err) {
return err
}
// Send EOSE
if err = eoseenvelope.NewFrom(env.Subscription).Write(l); chk.E(err) {
return err
}
log.I.F("sent NIP-43 invite event to %s", l.remote)
return nil
}
}
}
var events event.S var events event.S
// Create a single context for all filter queries, isolated from the connection context // Create a single context for all filter queries, isolated from the connection context
// to prevent query timeouts from affecting the long-lived websocket connection // to prevent query timeouts from affecting the long-lived websocket connection
@@ -533,24 +567,24 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
) )
}, },
) )
log.T.C( log.T.C(
func() string { func() string {
return fmt.Sprintf("event:\n%s\n", ev.Serialize()) return fmt.Sprintf("event:\n%s\n", ev.Serialize())
}, },
) )
var res *eventenvelope.Result var res *eventenvelope.Result
if res, err = eventenvelope.NewResultWith( if res, err = eventenvelope.NewResultWith(
env.Subscription, ev, env.Subscription, ev,
); chk.E(err) { ); chk.E(err) {
return return
} }
if err = res.Write(l); err != nil { if err = res.Write(l); err != nil {
// Don't log context canceled errors as they're expected during shutdown // Don't log context canceled errors as they're expected during shutdown
if !strings.Contains(err.Error(), "context canceled") { if !strings.Contains(err.Error(), "context canceled") {
chk.E(err) chk.E(err)
}
return
} }
return
}
// track the IDs we've sent (use hex encoding for stable key) // track the IDs we've sent (use hex encoding for stable key)
seen[hexenc.Enc(ev.ID)] = struct{}{} seen[hexenc.Enc(ev.ID)] = struct{}{}
} }
@@ -577,7 +611,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
limitSatisfied = true limitSatisfied = true
} }
} }
if f.Ids.Len() < 1 { if f.Ids.Len() < 1 {
// Filter has no IDs - keep subscription open unless limit was satisfied // Filter has no IDs - keep subscription open unless limit was satisfied
if !limitSatisfied { if !limitSatisfied {
@@ -616,18 +650,81 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
receiver := make(event.C, 32) receiver := make(event.C, 32)
// if the subscription should be cancelled, do so // if the subscription should be cancelled, do so
if !cancel { if !cancel {
// Create a dedicated context for this subscription that's independent of query context
// but is child of the listener context so it gets cancelled when connection closes
subCtx, subCancel := context.WithCancel(l.ctx)
// Track this subscription so we can cancel it on CLOSE or connection close
subID := string(env.Subscription)
l.subscriptionsMu.Lock()
l.subscriptions[subID] = subCancel
l.subscriptionsMu.Unlock()
// Register subscription with publisher
l.publishers.Receive( l.publishers.Receive(
&W{ &W{
Conn: l.conn, Conn: l.conn,
remote: l.remote, remote: l.remote,
Id: string(env.Subscription), Id: subID,
Receiver: receiver, Receiver: receiver,
Filters: &subbedFilters, Filters: &subbedFilters,
AuthedPubkey: l.authedPubkey.Load(), AuthedPubkey: l.authedPubkey.Load(),
}, },
) )
// Launch goroutine to consume from receiver channel and forward to client
// This is the critical missing piece - without this, the receiver channel fills up
// and the publisher times out trying to send, causing subscription to be removed
go func() {
defer func() {
// Clean up when subscription ends
l.subscriptionsMu.Lock()
delete(l.subscriptions, subID)
l.subscriptionsMu.Unlock()
log.D.F("subscription goroutine exiting for %s @ %s", subID, l.remote)
}()
for {
select {
case <-subCtx.Done():
// Subscription cancelled (CLOSE message or connection closing)
log.D.F("subscription %s cancelled for %s", subID, l.remote)
return
case ev, ok := <-receiver:
if !ok {
// Channel closed - subscription ended
log.D.F("subscription %s receiver channel closed for %s", subID, l.remote)
return
}
// Forward event to client via write channel
var res *eventenvelope.Result
var err error
if res, err = eventenvelope.NewResultWith(subID, ev); chk.E(err) {
log.E.F("failed to create event envelope for subscription %s: %v", subID, err)
continue
}
// Write to client - this goes through the write worker
if err = res.Write(l); err != nil {
if !strings.Contains(err.Error(), "context canceled") {
log.E.F("failed to write event to subscription %s @ %s: %v", subID, l.remote, err)
}
// Don't return here - write errors shouldn't kill the subscription
// The connection cleanup will handle removing the subscription
continue
}
log.D.F("delivered real-time event %s to subscription %s @ %s",
hexenc.Enc(ev.ID), subID, l.remote)
}
}
}()
log.D.F("subscription %s created and goroutine launched for %s", subID, l.remote)
} else { } else {
// suppress server-sent CLOSED; client will close subscription if desired // suppress server-sent CLOSED; client will close subscription if desired
log.D.F("subscription request cancelled immediately (all IDs found or limit satisfied)")
} }
log.T.F("HandleReq: COMPLETED processing from %s", l.remote) log.T.F("HandleReq: COMPLETED processing from %s", l.remote)
return return

View File

@@ -72,19 +72,20 @@ whitelist:
// Set read limit immediately after connection is established // Set read limit immediately after connection is established
conn.SetReadLimit(DefaultMaxMessageSize) conn.SetReadLimit(DefaultMaxMessageSize)
log.D.F("set read limit to %d bytes (%d MB) for %s", DefaultMaxMessageSize, DefaultMaxMessageSize/units.Mb, remote) log.D.F("set read limit to %d bytes (%d MB) for %s", DefaultMaxMessageSize, DefaultMaxMessageSize/units.Mb, remote)
// Set initial read deadline - pong handler will extend it when pongs are received // Set initial read deadline - pong handler will extend it when pongs are received
conn.SetReadDeadline(time.Now().Add(DefaultPongWait)) conn.SetReadDeadline(time.Now().Add(DefaultPongWait))
// Add pong handler to extend read deadline when client responds to pings // Add pong handler to extend read deadline when client responds to pings
conn.SetPongHandler(func(string) error { conn.SetPongHandler(func(string) error {
log.T.F("received PONG from %s, extending read deadline", remote) log.T.F("received PONG from %s, extending read deadline", remote)
return conn.SetReadDeadline(time.Now().Add(DefaultPongWait)) return conn.SetReadDeadline(time.Now().Add(DefaultPongWait))
}) })
defer conn.Close() defer conn.Close()
listener := &Listener{ listener := &Listener{
ctx: ctx, ctx: ctx,
cancel: cancel,
Server: s, Server: s,
conn: conn, conn: conn,
remote: remote, remote: remote,
@@ -94,6 +95,7 @@ whitelist:
writeDone: make(chan struct{}), writeDone: make(chan struct{}),
messageQueue: make(chan messageRequest, 100), // Buffered channel for message processing messageQueue: make(chan messageRequest, 100), // Buffered channel for message processing
processingDone: make(chan struct{}), processingDone: make(chan struct{}),
subscriptions: make(map[string]context.CancelFunc),
} }
// Start write worker goroutine // Start write worker goroutine
@@ -131,12 +133,21 @@ whitelist:
defer func() { defer func() {
log.D.F("closing websocket connection from %s", remote) log.D.F("closing websocket connection from %s", remote)
// Cancel all active subscriptions first
listener.subscriptionsMu.Lock()
for subID, cancelFunc := range listener.subscriptions {
log.D.F("cancelling subscription %s for %s", subID, remote)
cancelFunc()
}
listener.subscriptions = nil
listener.subscriptionsMu.Unlock()
// Cancel context and stop pinger // Cancel context and stop pinger
cancel() cancel()
ticker.Stop() ticker.Stop()
// Cancel all subscriptions for this connection // Cancel all subscriptions for this connection at publisher level
log.D.F("cancelling subscriptions for %s", remote) log.D.F("removing subscriptions from publisher for %s", remote)
listener.publishers.Receive(&W{ listener.publishers.Receive(&W{
Cancel: true, Cancel: true,
Conn: listener.conn, Conn: listener.conn,
@@ -163,6 +174,12 @@ whitelist:
// Wait for message processor to finish // Wait for message processor to finish
<-listener.processingDone <-listener.processingDone
// Wait for all spawned message handlers to complete
// This is critical to prevent "send on closed channel" panics
log.D.F("ws->%s waiting for message handlers to complete", remote)
listener.handlerWg.Wait()
log.D.F("ws->%s all message handlers completed", remote)
// Close write channel to signal worker to exit // Close write channel to signal worker to exit
close(listener.writeChan) close(listener.writeChan)
// Wait for write worker to finish // Wait for write worker to finish

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -23,6 +24,7 @@ type Listener struct {
*Server *Server
conn *websocket.Conn conn *websocket.Conn
ctx context.Context ctx context.Context
cancel context.CancelFunc // Cancel function for this listener's context
remote string remote string
req *http.Request req *http.Request
challenge atomicutils.Bytes challenge atomicutils.Bytes
@@ -35,12 +37,16 @@ type Listener struct {
// Message processing queue for async handling // Message processing queue for async handling
messageQueue chan messageRequest // Buffered channel for message processing messageQueue chan messageRequest // Buffered channel for message processing
processingDone chan struct{} // Closed when message processor exits processingDone chan struct{} // Closed when message processor exits
handlerWg sync.WaitGroup // Tracks spawned message handler goroutines
// Flow control counters (atomic for concurrent access) // Flow control counters (atomic for concurrent access)
droppedMessages atomic.Int64 // Messages dropped due to full queue droppedMessages atomic.Int64 // Messages dropped due to full queue
// Diagnostics: per-connection counters // Diagnostics: per-connection counters
msgCount int msgCount int
reqCount int reqCount int
eventCount int eventCount int
// Subscription tracking for cleanup
subscriptions map[string]context.CancelFunc // Map of subscription ID to cancel function
subscriptionsMu sync.Mutex // Protects subscriptions map
} }
type messageRequest struct { type messageRequest struct {
@@ -80,6 +86,15 @@ func (l *Listener) QueueMessage(data []byte, remote string) bool {
func (l *Listener) Write(p []byte) (n int, err error) { func (l *Listener) Write(p []byte) (n int, err error) {
// Defensive: recover from any panic when sending to closed channel
defer func() {
if r := recover(); r != nil {
log.D.F("ws->%s write panic recovered (channel likely closed): %v", l.remote, r)
err = errorf.E("write channel closed")
n = 0
}
}()
// Send write request to channel - non-blocking with timeout // Send write request to channel - non-blocking with timeout
select { select {
case <-l.ctx.Done(): case <-l.ctx.Done():
@@ -94,6 +109,14 @@ func (l *Listener) Write(p []byte) (n int, err error) {
// WriteControl sends a control message through the write channel // WriteControl sends a control message through the write channel
func (l *Listener) WriteControl(messageType int, data []byte, deadline time.Time) (err error) { func (l *Listener) WriteControl(messageType int, data []byte, deadline time.Time) (err error) {
// Defensive: recover from any panic when sending to closed channel
defer func() {
if r := recover(); r != nil {
log.D.F("ws->%s writeControl panic recovered (channel likely closed): %v", l.remote, r)
err = errorf.E("write channel closed")
}
}()
select { select {
case <-l.ctx.Done(): case <-l.ctx.Done():
return l.ctx.Err() return l.ctx.Err()
@@ -189,8 +212,14 @@ func (l *Listener) messageProcessor() {
return return
} }
// Process the message synchronously in this goroutine // Process the message in a separate goroutine to avoid blocking
l.HandleMessage(req.data, req.remote) // This allows multiple messages to be processed concurrently (like khatru does)
// Track the goroutine so we can wait for it during cleanup
l.handlerWg.Add(1)
go func(data []byte, remote string) {
defer l.handlerWg.Done()
l.HandleMessage(data, remote)
}(req.data, req.remote)
} }
} }
} }

View File

@@ -18,6 +18,7 @@ import (
"next.orly.dev/pkg/database" "next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/bech32encoding" "next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/policy" "next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish" "next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/spider" "next.orly.dev/pkg/spider"
dsync "next.orly.dev/pkg/sync" dsync "next.orly.dev/pkg/sync"
@@ -68,6 +69,14 @@ func Run(
publishers: publish.New(NewPublisher(ctx)), publishers: publish.New(NewPublisher(ctx)),
Admins: adminKeys, Admins: adminKeys,
Owners: ownerKeys, Owners: ownerKeys,
cfg: cfg,
db: db,
}
// Initialize NIP-43 invite manager if enabled
if cfg.NIP43Enabled {
l.InviteManager = nip43.NewInviteManager(cfg.NIP43InviteExpiry)
log.I.F("NIP-43 invite system enabled with %v expiry", cfg.NIP43InviteExpiry)
} }
// Initialize sprocket manager // Initialize sprocket manager

549
app/nip43_e2e_test.go Normal file
View File

@@ -0,0 +1,549 @@
package app
import (
"next.orly.dev/pkg/interfaces/signer/p8k"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"next.orly.dev/app/config"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/protocol/relayinfo"
)
// setupE2ETest creates a full test server for end-to-end testing
func setupE2ETest(t *testing.T) (*Server, *httptest.Server, func()) {
tempDir, err := os.MkdirTemp("", "nip43_e2e_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cfg := &config.C{
AppName: "TestRelay",
NIP43Enabled: true,
NIP43PublishEvents: true,
NIP43PublishMemberList: true,
NIP43InviteExpiry: 24 * time.Hour,
RelayURL: "wss://test.relay",
Listen: "localhost",
Port: 3334,
ACLMode: "none",
AuthRequired: false,
}
// Generate admin keys
adminSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate admin secret: %v", err)
}
adminSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create admin signer: %v", err)
}
if err = adminSigner.InitSec(adminSecret); err != nil {
t.Fatalf("failed to initialize admin signer: %v", err)
}
adminPubkey := adminSigner.Pub()
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
Admins: [][]byte{adminPubkey},
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
server.mux = http.NewServeMux()
// Set up HTTP handlers
server.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "application/nostr+json" {
server.HandleRelayInfo(w, r)
return
}
http.NotFound(w, r)
})
httpServer := httptest.NewServer(server.mux)
cleanup := func() {
httpServer.Close()
db.Close()
os.RemoveAll(tempDir)
}
return server, httpServer, cleanup
}
// TestE2E_RelayInfoIncludesNIP43 tests that NIP-43 is advertised in relay info
func TestE2E_RelayInfoIncludesNIP43(t *testing.T) {
server, httpServer, cleanup := setupE2ETest(t)
defer cleanup()
// Make request to relay info endpoint
req, err := http.NewRequest("GET", httpServer.URL, nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("Accept", "application/nostr+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
defer resp.Body.Close()
// Parse relay info
var info relayinfo.T
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
t.Fatalf("failed to decode relay info: %v", err)
}
// Verify NIP-43 is in supported NIPs
hasNIP43 := false
for _, nip := range info.Nips {
if nip == 43 {
hasNIP43 = true
break
}
}
if !hasNIP43 {
t.Error("NIP-43 not advertised in supported_nips")
}
// Verify server name
if info.Name != server.Config.AppName {
t.Errorf("wrong relay name: got %s, want %s", info.Name, server.Config.AppName)
}
}
// TestE2E_CompleteJoinFlow tests the complete user join flow
func TestE2E_CompleteJoinFlow(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
// Step 1: Admin requests invite code
adminPubkey := server.Admins[0]
inviteEvent, err := server.HandleNIP43InviteRequest(adminPubkey)
if err != nil {
t.Fatalf("failed to generate invite: %v", err)
}
// Extract invite code
claimTag := inviteEvent.Tags.GetFirst([]byte("claim"))
if claimTag == nil || claimTag.Len() < 2 {
t.Fatal("invite event missing claim tag")
}
inviteCode := string(claimTag.T[1])
// Step 2: User creates join request
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", inviteCode))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign join event: %v", err)
}
// Step 3: Process join request
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Step 4: Verify membership
isMember, err := server.D.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("user was not added as member")
}
membership, err := server.D.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != inviteCode {
t.Errorf("wrong invite code: got %s, want %s", membership.InviteCode, inviteCode)
}
}
// TestE2E_InviteCodeReuse tests that invite codes can only be used once
func TestE2E_InviteCodeReuse(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// First user uses the code
user1Secret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user1 secret: %v", err)
}
user1Pubkey, err := keys.SecretBytesToPubKeyBytes(user1Secret)
if err != nil {
t.Fatalf("failed to get user1 pubkey: %v", err)
}
signer1, err := keys.SecretBytesToSigner(user1Secret)
if err != nil {
t.Fatalf("failed to create signer1: %v", err)
}
joinEv1 := event.New()
joinEv1.Kind = nip43.KindJoinRequest
copy(joinEv1.Pubkey, user1Pubkey)
joinEv1.Tags.Append(tag.NewFromAny("-"))
joinEv1.Tags.Append(tag.NewFromAny("claim", code))
joinEv1.CreatedAt = time.Now().Unix()
joinEv1.Content = []byte("")
if err = joinEv1.Sign(signer1); err != nil {
t.Fatalf("failed to sign join event 1: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv1)
if err != nil {
t.Fatalf("failed to handle join request 1: %v", err)
}
// Verify first user is member
isMember, err := server.D.IsNIP43Member(user1Pubkey)
if err != nil {
t.Fatalf("failed to check user1 membership: %v", err)
}
if !isMember {
t.Error("user1 was not added")
}
// Second user tries to use same code
user2Secret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user2 secret: %v", err)
}
user2Pubkey, err := keys.SecretBytesToPubKeyBytes(user2Secret)
if err != nil {
t.Fatalf("failed to get user2 pubkey: %v", err)
}
signer2, err := keys.SecretBytesToSigner(user2Secret)
if err != nil {
t.Fatalf("failed to create signer2: %v", err)
}
joinEv2 := event.New()
joinEv2.Kind = nip43.KindJoinRequest
copy(joinEv2.Pubkey, user2Pubkey)
joinEv2.Tags.Append(tag.NewFromAny("-"))
joinEv2.Tags.Append(tag.NewFromAny("claim", code))
joinEv2.CreatedAt = time.Now().Unix()
joinEv2.Content = []byte("")
if err = joinEv2.Sign(signer2); err != nil {
t.Fatalf("failed to sign join event 2: %v", err)
}
// Should handle without error but not add user
err = listener.HandleNIP43JoinRequest(joinEv2)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify second user is NOT member
isMember, err = server.D.IsNIP43Member(user2Pubkey)
if err != nil {
t.Fatalf("failed to check user2 membership: %v", err)
}
if isMember {
t.Error("user2 was incorrectly added with reused code")
}
}
// TestE2E_MembershipListGeneration tests membership list event generation
func TestE2E_MembershipListGeneration(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// Add multiple members
memberCount := 5
members := make([][]byte, memberCount)
for i := 0; i < memberCount; i++ {
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret %d: %v", i, err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey %d: %v", i, err)
}
members[i] = userPubkey
// Add directly to database for speed
err = server.D.AddNIP43Member(userPubkey, "code")
if err != nil {
t.Fatalf("failed to add member %d: %v", i, err)
}
}
// Generate membership list
err := listener.publishMembershipList()
if err != nil {
t.Fatalf("failed to publish membership list: %v", err)
}
// Note: In a real test, you would verify the event was published
// through the publishers system. For now, we just verify no error.
}
// TestE2E_ExpiredInviteCode tests that expired codes are rejected
func TestE2E_ExpiredInviteCode(t *testing.T) {
tempDir, err := os.MkdirTemp("", "nip43_expired_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer db.Close()
cfg := &config.C{
NIP43Enabled: true,
NIP43InviteExpiry: 1 * time.Millisecond, // Very short expiry
}
ctx := context.Background()
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Wait for expiry
time.Sleep(10 * time.Millisecond)
// Try to use expired code
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was added with expired code")
}
}
// TestE2E_InvalidTimestampRejected tests that events with invalid timestamps are rejected
func TestE2E_InvalidTimestampRejected(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
// Create join request with timestamp far in the past
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix() - 700 // More than 10 minutes ago
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Should handle without error but not add user
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := server.D.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was added with invalid timestamp")
}
}
// BenchmarkJoinRequestProcessing benchmarks join request processing
func BenchmarkJoinRequestProcessing(b *testing.B) {
tempDir, err := os.MkdirTemp("", "nip43_bench_*")
if err != nil {
b.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
db, err := database.Open(filepath.Join(tempDir, "test.db"), "error")
if err != nil {
b.Fatalf("failed to open database: %v", err)
}
defer db.Close()
cfg := &config.C{
NIP43Enabled: true,
NIP43InviteExpiry: 24 * time.Hour,
}
ctx := context.Background()
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Generate unique user and code for each iteration
userSecret, _ := keys.GenerateSecretKey()
userPubkey, _ := keys.SecretBytesToPubKeyBytes(userSecret)
signer, _ := keys.SecretBytesToSigner(userSecret)
code, _ := server.InviteManager.GenerateCode()
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
joinEv.Sign(signer)
listener.HandleNIP43JoinRequest(joinEv)
}
}

View File

@@ -7,10 +7,8 @@ import (
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"lol.mleku.dev/chk"
"lol.mleku.dev/log" "lol.mleku.dev/log"
"next.orly.dev/pkg/acl" "next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
"next.orly.dev/pkg/encoders/event" "next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter" "next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/hex" "next.orly.dev/pkg/encoders/hex"
@@ -29,6 +27,7 @@ type WriteChanMap map[*websocket.Conn]chan publish.WriteRequest
type Subscription struct { type Subscription struct {
remote string remote string
AuthedPubkey []byte AuthedPubkey []byte
Receiver event.C // Channel for delivering events to this subscription
*filter.S *filter.S
} }
@@ -121,12 +120,12 @@ func (p *P) Receive(msg typer.T) {
if subs, ok := p.Map[m.Conn]; !ok { if subs, ok := p.Map[m.Conn]; !ok {
subs = make(map[string]Subscription) subs = make(map[string]Subscription)
subs[m.Id] = Subscription{ subs[m.Id] = Subscription{
S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey, S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey, Receiver: m.Receiver,
} }
p.Map[m.Conn] = subs p.Map[m.Conn] = subs
} else { } else {
subs[m.Id] = Subscription{ subs[m.Id] = Subscription{
S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey, S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey, Receiver: m.Receiver,
} }
} }
} }
@@ -144,7 +143,6 @@ func (p *P) Receive(msg typer.T) {
// applies authentication checks if required by the server and skips delivery // applies authentication checks if required by the server and skips delivery
// for unauthenticated users when events are privileged. // for unauthenticated users when events are privileged.
func (p *P) Deliver(ev *event.E) { func (p *P) Deliver(ev *event.E) {
var err error
// Snapshot the deliveries under read lock to avoid holding locks during I/O // Snapshot the deliveries under read lock to avoid holding locks during I/O
p.Mx.RLock() p.Mx.RLock()
type delivery struct { type delivery struct {
@@ -238,52 +236,30 @@ func (p *P) Deliver(ev *event.E) {
} }
} }
var res *eventenvelope.Result // Send event to the subscription's receiver channel
if res, err = eventenvelope.NewResultWith(d.id, ev); chk.E(err) { // The consumer goroutine (in handle-req.go) will read from this channel
log.E.F("failed to create event envelope for %s to %s: %v", // and forward it to the client via the write channel
hex.Enc(ev.ID), d.sub.remote, err) log.D.F("attempting delivery of event %s (kind=%d) to subscription %s @ %s",
hex.Enc(ev.ID), ev.Kind, d.id, d.sub.remote)
// Check if receiver channel exists
if d.sub.Receiver == nil {
log.E.F("subscription %s has nil receiver channel for %s", d.id, d.sub.remote)
continue continue
} }
// Log delivery attempt // Send to receiver channel - non-blocking with timeout
msgData := res.Marshal(nil)
log.D.F("attempting delivery of event %s (kind=%d, len=%d) to subscription %s @ %s",
hex.Enc(ev.ID), ev.Kind, len(msgData), d.id, d.sub.remote)
// Get write channel for this connection
p.Mx.RLock()
writeChan, hasChan := p.GetWriteChan(d.w)
stillSubscribed := p.Map[d.w] != nil
p.Mx.RUnlock()
if !stillSubscribed {
log.D.F("skipping delivery to %s - connection no longer subscribed", d.sub.remote)
continue
}
if !hasChan {
log.D.F("skipping delivery to %s - no write channel available", d.sub.remote)
continue
}
// Send to write channel - non-blocking with timeout
select { select {
case <-p.c.Done(): case <-p.c.Done():
continue continue
case writeChan <- publish.WriteRequest{Data: msgData, MsgType: websocket.TextMessage, IsControl: false}: case d.sub.Receiver <- ev:
log.D.F("subscription delivery QUEUED: event=%s to=%s sub=%s len=%d", log.D.F("subscription delivery QUEUED: event=%s to=%s sub=%s",
hex.Enc(ev.ID), d.sub.remote, d.id, len(msgData)) hex.Enc(ev.ID), d.sub.remote, d.id)
case <-time.After(DefaultWriteTimeout): case <-time.After(DefaultWriteTimeout):
log.E.F("subscription delivery TIMEOUT: event=%s to=%s sub=%s", log.E.F("subscription delivery TIMEOUT: event=%s to=%s sub=%s",
hex.Enc(ev.ID), d.sub.remote, d.id) hex.Enc(ev.ID), d.sub.remote, d.id)
// Check if connection is still valid // Receiver channel is full - subscription consumer is stuck or slow
p.Mx.RLock() // The subscription should be removed by the cleanup logic
stillSubscribed = p.Map[d.w] != nil
p.Mx.RUnlock()
if !stillSubscribed {
log.D.F("removing failed subscriber connection: %s", d.sub.remote)
p.removeSubscriber(d.w)
}
} }
} }
} }

View File

@@ -25,6 +25,7 @@ import (
"next.orly.dev/pkg/policy" "next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/auth" "next.orly.dev/pkg/protocol/auth"
"next.orly.dev/pkg/protocol/httpauth" "next.orly.dev/pkg/protocol/httpauth"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish" "next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/spider" "next.orly.dev/pkg/spider"
dsync "next.orly.dev/pkg/sync" dsync "next.orly.dev/pkg/sync"
@@ -55,6 +56,9 @@ type Server struct {
relayGroupMgr *dsync.RelayGroupManager relayGroupMgr *dsync.RelayGroupManager
clusterManager *dsync.ClusterManager clusterManager *dsync.ClusterManager
blossomServer *blossom.Server blossomServer *blossom.Server
InviteManager *nip43.InviteManager
cfg *config.C
db *database.D
} }
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system // isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system

View File

@@ -0,0 +1,449 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"next.orly.dev/app/config"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/protocol/publish"
)
// createSignedTestEvent creates a properly signed test event for use in tests
func createSignedTestEvent(t *testing.T, kind uint16, content string, tags ...*tag.T) *event.E {
t.Helper()
// Create a signer
signer, err := p8k.New()
if err != nil {
t.Fatalf("Failed to create signer: %v", err)
}
defer signer.Zero()
// Generate a keypair
if err := signer.Generate(); err != nil {
t.Fatalf("Failed to generate keypair: %v", err)
}
// Create event
ev := &event.E{
Kind: kind,
Content: []byte(content),
CreatedAt: time.Now().Unix(),
Tags: &tag.S{},
}
// Add any provided tags
for _, tg := range tags {
*ev.Tags = append(*ev.Tags, tg)
}
// Sign the event (this sets Pubkey, ID, and Sig)
if err := ev.Sign(signer); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
return ev
}
// TestLongRunningSubscriptionStability verifies that subscriptions remain active
// for extended periods and correctly receive real-time events without dropping.
func TestLongRunningSubscriptionStability(t *testing.T) {
// Create test server
server, cleanup := setupTestServer(t)
defer cleanup()
// Start HTTP test server
httpServer := httptest.NewServer(server)
defer httpServer.Close()
// Convert HTTP URL to WebSocket URL
wsURL := strings.Replace(httpServer.URL, "http://", "ws://", 1)
// Connect WebSocket client
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("Failed to connect WebSocket: %v", err)
}
defer conn.Close()
// Subscribe to kind 1 events
subID := "test-long-running"
reqMsg := fmt.Sprintf(`["REQ","%s",{"kinds":[1]}]`, subID)
if err := conn.WriteMessage(websocket.TextMessage, []byte(reqMsg)); err != nil {
t.Fatalf("Failed to send REQ: %v", err)
}
// Read until EOSE
gotEOSE := false
for !gotEOSE {
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Failed to read message: %v", err)
}
if strings.Contains(string(msg), `"EOSE"`) && strings.Contains(string(msg), subID) {
gotEOSE = true
t.Logf("Received EOSE for subscription %s", subID)
}
}
// Set up event counter
var receivedCount atomic.Int64
var mu sync.Mutex
receivedEvents := make(map[string]bool)
// Start goroutine to read events
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
readDone := make(chan struct{})
go func() {
defer close(readDone)
defer func() {
// Recover from any panic in read goroutine
if r := recover(); r != nil {
t.Logf("Read goroutine panic (recovered): %v", r)
}
}()
for {
// Check context first before attempting any read
select {
case <-ctx.Done():
return
default:
}
// Use a longer deadline and check context more frequently
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
// Immediately check if context is done - if so, just exit without continuing
if ctx.Err() != nil {
return
}
// Check for normal close
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return
}
// Check if this is a timeout error - those are recoverable
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Double-check context before continuing
if ctx.Err() != nil {
return
}
continue
}
// Any other error means connection is broken, exit
t.Logf("Read error (non-timeout): %v", err)
return
}
// Parse message to check if it's an EVENT for our subscription
var envelope []interface{}
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
if len(envelope) >= 3 && envelope[0] == "EVENT" && envelope[1] == subID {
// Extract event ID
eventMap, ok := envelope[2].(map[string]interface{})
if !ok {
continue
}
eventID, ok := eventMap["id"].(string)
if !ok {
continue
}
mu.Lock()
if !receivedEvents[eventID] {
receivedEvents[eventID] = true
receivedCount.Add(1)
t.Logf("Received event %s (total: %d)", eventID[:8], receivedCount.Load())
}
mu.Unlock()
}
}
}()
// Publish events at regular intervals over 30 seconds
const numEvents = 30
const publishInterval = 1 * time.Second
publishCtx, publishCancel := context.WithTimeout(context.Background(), 35*time.Second)
defer publishCancel()
for i := 0; i < numEvents; i++ {
select {
case <-publishCtx.Done():
t.Fatalf("Publish timeout exceeded")
default:
}
// Create and sign test event
ev := createSignedTestEvent(t, 1, fmt.Sprintf("Test event %d for long-running subscription", i))
// Save event to database
if _, err := server.D.SaveEvent(context.Background(), ev); err != nil {
t.Errorf("Failed to save event %d: %v", i, err)
continue
}
// Manually trigger publisher to deliver event to subscriptions
server.publishers.Deliver(ev)
t.Logf("Published event %d", i)
// Wait before next publish
if i < numEvents-1 {
time.Sleep(publishInterval)
}
}
// Wait a bit more for all events to be delivered
time.Sleep(3 * time.Second)
// Cancel context and wait for reader to finish
cancel()
<-readDone
// Check results
received := receivedCount.Load()
t.Logf("Test complete: published %d events, received %d events", numEvents, received)
// We should receive at least 90% of events (allowing for some timing edge cases)
minExpected := int64(float64(numEvents) * 0.9)
if received < minExpected {
t.Errorf("Subscription stability issue: expected at least %d events, got %d", minExpected, received)
}
// Close subscription
closeMsg := fmt.Sprintf(`["CLOSE","%s"]`, subID)
if err := conn.WriteMessage(websocket.TextMessage, []byte(closeMsg)); err != nil {
t.Errorf("Failed to send CLOSE: %v", err)
}
t.Logf("Long-running subscription test PASSED: %d/%d events delivered", received, numEvents)
}
// TestMultipleConcurrentSubscriptions verifies that multiple subscriptions
// can coexist on the same connection without interfering with each other.
func TestMultipleConcurrentSubscriptions(t *testing.T) {
// Create test server
server, cleanup := setupTestServer(t)
defer cleanup()
// Start HTTP test server
httpServer := httptest.NewServer(server)
defer httpServer.Close()
// Convert HTTP URL to WebSocket URL
wsURL := strings.Replace(httpServer.URL, "http://", "ws://", 1)
// Connect WebSocket client
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("Failed to connect WebSocket: %v", err)
}
defer conn.Close()
// Create 3 subscriptions for different kinds
subscriptions := []struct {
id string
kind int
}{
{"sub1", 1},
{"sub2", 3},
{"sub3", 7},
}
// Subscribe to all
for _, sub := range subscriptions {
reqMsg := fmt.Sprintf(`["REQ","%s",{"kinds":[%d]}]`, sub.id, sub.kind)
if err := conn.WriteMessage(websocket.TextMessage, []byte(reqMsg)); err != nil {
t.Fatalf("Failed to send REQ for %s: %v", sub.id, err)
}
}
// Read until we get EOSE for all subscriptions
eoseCount := 0
for eoseCount < len(subscriptions) {
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Failed to read message: %v", err)
}
if strings.Contains(string(msg), `"EOSE"`) {
eoseCount++
t.Logf("Received EOSE %d/%d", eoseCount, len(subscriptions))
}
}
// Track received events per subscription
var mu sync.Mutex
receivedByKind := make(map[int]int)
// Start reader goroutine
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
readDone := make(chan struct{})
go func() {
defer close(readDone)
defer func() {
// Recover from any panic in read goroutine
if r := recover(); r != nil {
t.Logf("Read goroutine panic (recovered): %v", r)
}
}()
for {
// Check context first before attempting any read
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
// Immediately check if context is done - if so, just exit without continuing
if ctx.Err() != nil {
return
}
// Check for normal close
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return
}
// Check if this is a timeout error - those are recoverable
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Double-check context before continuing
if ctx.Err() != nil {
return
}
continue
}
// Any other error means connection is broken, exit
t.Logf("Read error (non-timeout): %v", err)
return
}
// Parse message
var envelope []interface{}
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
if len(envelope) >= 3 && envelope[0] == "EVENT" {
eventMap, ok := envelope[2].(map[string]interface{})
if !ok {
continue
}
kindFloat, ok := eventMap["kind"].(float64)
if !ok {
continue
}
kind := int(kindFloat)
mu.Lock()
receivedByKind[kind]++
t.Logf("Received event for kind %d (count: %d)", kind, receivedByKind[kind])
mu.Unlock()
}
}
}()
// Publish events for each kind
for _, sub := range subscriptions {
for i := 0; i < 5; i++ {
// Create and sign test event
ev := createSignedTestEvent(t, uint16(sub.kind), fmt.Sprintf("Test for kind %d event %d", sub.kind, i))
if _, err := server.D.SaveEvent(context.Background(), ev); err != nil {
t.Errorf("Failed to save event: %v", err)
}
// Manually trigger publisher to deliver event to subscriptions
server.publishers.Deliver(ev)
time.Sleep(100 * time.Millisecond)
}
}
// Wait for events to be delivered
time.Sleep(2 * time.Second)
// Cancel and cleanup
cancel()
<-readDone
// Verify each subscription received its events
mu.Lock()
defer mu.Unlock()
for _, sub := range subscriptions {
count := receivedByKind[sub.kind]
if count < 4 { // Allow for some timing issues, expect at least 4/5
t.Errorf("Subscription %s (kind %d) only received %d/5 events", sub.id, sub.kind, count)
}
}
t.Logf("Multiple concurrent subscriptions test PASSED")
}
// setupTestServer creates a test relay server for subscription testing
func setupTestServer(t *testing.T) (*Server, func()) {
// Setup test database
ctx, cancel := context.WithCancel(context.Background())
// Use a temporary directory for the test database
tmpDir := t.TempDir()
db, err := database.New(ctx, cancel, tmpDir, "test.db")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
// Setup basic config
cfg := &config.C{
AuthRequired: false,
Owners: []string{},
Admins: []string{},
ACLMode: "none",
}
// Setup server
server := &Server{
Config: cfg,
D: db,
Ctx: ctx,
publishers: publish.New(NewPublisher(ctx)),
Admins: [][]byte{},
Owners: [][]byte{},
challenges: make(map[string][]byte),
}
// Cleanup function
cleanup := func() {
db.Close()
cancel()
}
return server, cleanup
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

View File

@@ -1,69 +0,0 @@
html,
body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0;
padding: 8px;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
a {
color: rgb(0, 100, 200);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0, 80, 160);
}
label {
display: block;
}
input,
button,
select,
textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

View File

@@ -1,17 +1 @@
<!doctype html> test
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ORLY?</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/global.css" />
<link rel="stylesheet" href="/bundle.css" />
<script defer src="/bundle.js"></script>
</head>
<body></body>
</html>

BIN
app/web/dist/orly.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,273 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
lol "lol.mleku.dev"
"next.orly.dev/app/config"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/run"
relaytester "next.orly.dev/relay-tester"
)
// TestClusterPeerPolicyFiltering tests cluster peer synchronization with policy filtering.
// This test:
// 1. Starts multiple relays using the test relay launch functionality
// 2. Configures them as peers to each other (though sync managers are not fully implemented in this test)
// 3. Tests policy filtering with a kind whitelist that allows only specific event kinds
// 4. Verifies that the policy correctly allows/denies events based on the whitelist
//
// Note: This test focuses on the policy filtering aspect of cluster peers.
// Full cluster synchronization testing would require implementing the sync manager
// integration, which is beyond the scope of this initial test.
func TestClusterPeerPolicyFiltering(t *testing.T) {
if testing.Short() {
t.Skip("skipping cluster peer integration test")
}
// Number of relays in the cluster
numRelays := 3
// Start multiple test relays
relays, ports, err := startTestRelays(numRelays)
if err != nil {
t.Fatalf("Failed to start test relays: %v", err)
}
defer func() {
for _, relay := range relays {
if tr, ok := relay.(*testRelay); ok {
if stopErr := tr.Stop(); stopErr != nil {
t.Logf("Error stopping relay: %v", stopErr)
}
}
}
}()
// Create relay URLs
relayURLs := make([]string, numRelays)
for i, port := range ports {
relayURLs[i] = fmt.Sprintf("http://127.0.0.1:%d", port)
}
// Wait for all relays to be ready
for _, url := range relayURLs {
wsURL := strings.Replace(url, "http://", "ws://", 1) // Convert http to ws
if err := waitForTestRelay(wsURL, 10*time.Second); err != nil {
t.Fatalf("Relay not ready after timeout: %s, %v", wsURL, err)
}
t.Logf("Relay is ready at %s", wsURL)
}
// Create policy configuration with small kind whitelist
policyJSON := map[string]interface{}{
"kind": map[string]interface{}{
"whitelist": []int{1, 7, 42}, // Allow only text notes, user statuses, and channel messages
},
"default_policy": "allow", // Allow everything not explicitly denied
}
policyJSONBytes, err := json.MarshalIndent(policyJSON, "", " ")
if err != nil {
t.Fatalf("Failed to marshal policy JSON: %v", err)
}
// Create temporary directory for policy config
tempDir := t.TempDir()
configDir := filepath.Join(tempDir, "ORLY_POLICY")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config directory: %v", err)
}
policyPath := filepath.Join(configDir, "policy.json")
if err := os.WriteFile(policyPath, policyJSONBytes, 0644); err != nil {
t.Fatalf("Failed to write policy file: %v", err)
}
// Create policy from JSON directly for testing
testPolicy, err := policy.New(policyJSONBytes)
if err != nil {
t.Fatalf("Failed to create policy: %v", err)
}
// Generate test keys
signer := p8k.MustNew()
if err := signer.Generate(); err != nil {
t.Fatalf("Failed to generate test signer: %v", err)
}
// Create test events of different kinds
testEvents := []*event.E{
// Kind 1 (text note) - should be allowed by policy
createTestEvent(t, signer, "Text note - should sync", 1),
// Kind 7 (user status) - should be allowed by policy
createTestEvent(t, signer, "User status - should sync", 7),
// Kind 42 (channel message) - should be allowed by policy
createTestEvent(t, signer, "Channel message - should sync", 42),
// Kind 0 (metadata) - should be denied by policy
createTestEvent(t, signer, "Metadata - should NOT sync", 0),
// Kind 3 (follows) - should be denied by policy
createTestEvent(t, signer, "Follows - should NOT sync", 3),
}
t.Logf("Created %d test events", len(testEvents))
// Publish events to the first relay (non-policy relay)
firstRelayWS := fmt.Sprintf("ws://127.0.0.1:%d", ports[0])
client, err := relaytester.NewClient(firstRelayWS)
if err != nil {
t.Fatalf("Failed to connect to first relay: %v", err)
}
defer client.Close()
// Publish all events to the first relay
for i, ev := range testEvents {
if err := client.Publish(ev); err != nil {
t.Fatalf("Failed to publish event %d: %v", i, err)
}
// Wait for OK response
accepted, reason, err := client.WaitForOK(ev.ID, 5*time.Second)
if err != nil {
t.Fatalf("Failed to get OK response for event %d: %v", i, err)
}
if !accepted {
t.Logf("Event %d rejected: %s (kind: %d)", i, reason, ev.Kind)
} else {
t.Logf("Event %d accepted (kind: %d)", i, ev.Kind)
}
}
// Test policy filtering directly
t.Logf("Testing policy filtering...")
// Test that the policy correctly allows/denies events based on the whitelist
// Only kinds 1, 7, and 42 should be allowed
for i, ev := range testEvents {
allowed, err := testPolicy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
if err != nil {
t.Fatalf("Policy check failed for event %d: %v", i, err)
}
expectedAllowed := ev.Kind == 1 || ev.Kind == 7 || ev.Kind == 42
if allowed != expectedAllowed {
t.Errorf("Event %d (kind %d): expected allowed=%v, got %v", i, ev.Kind, expectedAllowed, allowed)
}
}
t.Logf("Policy filtering test completed successfully")
// Note: In a real cluster setup, the sync manager would use this policy
// to filter events during synchronization between peers. This test demonstrates
// that the policy correctly identifies which events should be allowed to sync.
}
// testRelay wraps a run.Relay for testing purposes
type testRelay struct {
*run.Relay
}
// startTestRelays starts multiple test relays with different configurations
func startTestRelays(count int) ([]interface{}, []int, error) {
relays := make([]interface{}, count)
ports := make([]int, count)
for i := 0; i < count; i++ {
cfg := &config.C{
AppName: fmt.Sprintf("ORLY-TEST-%d", i),
DataDir: "", // Use temp dir
Listen: "127.0.0.1",
Port: 0, // Random port
HealthPort: 0,
EnableShutdown: false,
LogLevel: "warn",
DBLogLevel: "warn",
DBBlockCacheMB: 512,
DBIndexCacheMB: 256,
LogToStdout: false,
PprofHTTP: false,
ACLMode: "none",
AuthRequired: false,
AuthToWrite: false,
SubscriptionEnabled: false,
MonthlyPriceSats: 6000,
FollowListFrequency: time.Hour,
WebDisableEmbedded: false,
SprocketEnabled: false,
SpiderMode: "none",
PolicyEnabled: false, // We'll enable it separately for one relay
}
// Find available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, nil, fmt.Errorf("failed to find available port for relay %d: %w", i, err)
}
addr := listener.Addr().(*net.TCPAddr)
cfg.Port = addr.Port
listener.Close()
// Set up logging
lol.SetLogLevel(cfg.LogLevel)
opts := &run.Options{
CleanupDataDir: func(b bool) *bool { return &b }(true),
}
relay, err := run.Start(cfg, opts)
if err != nil {
return nil, nil, fmt.Errorf("failed to start relay %d: %w", i, err)
}
relays[i] = &testRelay{Relay: relay}
ports[i] = cfg.Port
}
return relays, ports, nil
}
// waitForTestRelay waits for a relay to be ready by attempting to connect
func waitForTestRelay(url string, timeout time.Duration) error {
// Extract host:port from ws:// URL
addr := url
if len(url) > 5 && url[:5] == "ws://" {
addr = url[5:]
}
deadline := time.Now().Add(timeout)
attempts := 0
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
attempts++
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for relay at %s after %d attempts", url, attempts)
}
// createTestEvent creates a test event with proper signing
func createTestEvent(t *testing.T, signer *p8k.Signer, content string, eventKind uint16) *event.E {
ev := event.New()
ev.CreatedAt = time.Now().Unix()
ev.Kind = eventKind
ev.Content = []byte(content)
ev.Tags = tag.NewS()
// Sign the event
if err := ev.Sign(signer); err != nil {
t.Fatalf("Failed to sign test event: %v", err)
}
return ev
}

283
cmd/find/main.go Normal file
View File

@@ -0,0 +1,283 @@
package main
import (
"fmt"
"os"
"time"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/find"
"next.orly.dev/pkg/interfaces/signer"
"next.orly.dev/pkg/interfaces/signer/p8k"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
command := os.Args[1]
switch command {
case "register":
handleRegister()
case "transfer":
handleTransfer()
case "verify-name":
handleVerifyName()
case "generate-key":
handleGenerateKey()
case "issue-cert":
handleIssueCert()
case "help":
printUsage()
default:
fmt.Printf("Unknown command: %s\n\n", command)
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println("FIND - Free Internet Name Daemon")
fmt.Println("Usage: find <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" register <name> Create a registration proposal for a name")
fmt.Println(" transfer <name> <new-owner> Transfer a name to a new owner")
fmt.Println(" verify-name <name> Validate a name format")
fmt.Println(" generate-key Generate a new key pair")
fmt.Println(" issue-cert <name> Issue a certificate for a name")
fmt.Println(" help Show this help message")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" find verify-name example.com")
fmt.Println(" find register myname.nostr")
fmt.Println(" find generate-key")
}
func handleRegister() {
if len(os.Args) < 3 {
fmt.Println("Usage: find register <name>")
os.Exit(1)
}
name := os.Args[2]
// Validate the name
if err := find.ValidateName(name); err != nil {
fmt.Printf("Invalid name: %v\n", err)
os.Exit(1)
}
// Generate a key pair for this example
// In production, this would load from a secure keystore
signer, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create signer: %v\n", err)
os.Exit(1)
}
if err := signer.Generate(); err != nil {
fmt.Printf("Failed to generate key: %v\n", err)
os.Exit(1)
}
// Create registration proposal
proposal, err := find.NewRegistrationProposal(name, find.ActionRegister, signer)
if err != nil {
fmt.Printf("Failed to create proposal: %v\n", err)
os.Exit(1)
}
fmt.Printf("Registration Proposal Created\n")
fmt.Printf("==============================\n")
fmt.Printf("Name: %s\n", name)
fmt.Printf("Pubkey: %s\n", hex.Enc(signer.Pub()))
fmt.Printf("Event ID: %s\n", hex.Enc(proposal.GetIDBytes()))
fmt.Printf("Kind: %d\n", proposal.Kind)
fmt.Printf("Created At: %s\n", time.Unix(proposal.CreatedAt, 0))
fmt.Printf("\nEvent JSON:\n")
json := proposal.Marshal(nil)
fmt.Println(string(json))
}
func handleTransfer() {
if len(os.Args) < 4 {
fmt.Println("Usage: find transfer <name> <new-owner-pubkey>")
os.Exit(1)
}
name := os.Args[2]
newOwnerPubkey := os.Args[3]
// Validate the name
if err := find.ValidateName(name); err != nil {
fmt.Printf("Invalid name: %v\n", err)
os.Exit(1)
}
// Generate current owner key (in production, load from keystore)
currentOwner, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create current owner signer: %v\n", err)
os.Exit(1)
}
if err := currentOwner.Generate(); err != nil {
fmt.Printf("Failed to generate current owner key: %v\n", err)
os.Exit(1)
}
// Authorize the transfer
prevSig, timestamp, err := find.AuthorizeTransfer(name, newOwnerPubkey, currentOwner)
if err != nil {
fmt.Printf("Failed to authorize transfer: %v\n", err)
os.Exit(1)
}
fmt.Printf("Transfer Authorization Created\n")
fmt.Printf("===============================\n")
fmt.Printf("Name: %s\n", name)
fmt.Printf("Current Owner: %s\n", hex.Enc(currentOwner.Pub()))
fmt.Printf("New Owner: %s\n", newOwnerPubkey)
fmt.Printf("Timestamp: %s\n", timestamp)
fmt.Printf("Signature: %s\n", prevSig)
fmt.Printf("\nTo complete the transfer, the new owner must create a proposal with:")
fmt.Printf(" prev_owner: %s\n", hex.Enc(currentOwner.Pub()))
fmt.Printf(" prev_sig: %s\n", prevSig)
}
func handleVerifyName() {
if len(os.Args) < 3 {
fmt.Println("Usage: find verify-name <name>")
os.Exit(1)
}
name := os.Args[2]
// Validate the name
if err := find.ValidateName(name); err != nil {
fmt.Printf("❌ Invalid name: %v\n", err)
os.Exit(1)
}
normalized := find.NormalizeName(name)
isTLD := find.IsTLD(normalized)
parent := find.GetParentDomain(normalized)
fmt.Printf("✓ Valid name\n")
fmt.Printf("==============\n")
fmt.Printf("Original: %s\n", name)
fmt.Printf("Normalized: %s\n", normalized)
fmt.Printf("Is TLD: %v\n", isTLD)
if parent != "" {
fmt.Printf("Parent: %s\n", parent)
}
}
func handleGenerateKey() {
// Generate a new key pair
secKey, err := keys.GenerateSecretKey()
if err != nil {
fmt.Printf("Failed to generate secret key: %v\n", err)
os.Exit(1)
}
secKeyHex := hex.Enc(secKey)
pubKeyHex, err := keys.GetPublicKeyHex(secKeyHex)
if err != nil {
fmt.Printf("Failed to derive public key: %v\n", err)
os.Exit(1)
}
fmt.Println("New Key Pair Generated")
fmt.Println("======================")
fmt.Printf("Secret Key (keep safe!): %s\n", secKeyHex)
fmt.Printf("Public Key: %s\n", pubKeyHex)
fmt.Println()
fmt.Println("⚠️ IMPORTANT: Store the secret key securely. Anyone with access to it can control your names.")
}
func handleIssueCert() {
if len(os.Args) < 3 {
fmt.Println("Usage: find issue-cert <name>")
os.Exit(1)
}
name := os.Args[2]
// Validate the name
if err := find.ValidateName(name); err != nil {
fmt.Printf("Invalid name: %v\n", err)
os.Exit(1)
}
// Generate name owner key
owner, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create owner signer: %v\n", err)
os.Exit(1)
}
if err := owner.Generate(); err != nil {
fmt.Printf("Failed to generate owner key: %v\n", err)
os.Exit(1)
}
// Generate certificate key (different from name owner)
certSigner, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create cert signer: %v\n", err)
os.Exit(1)
}
if err := certSigner.Generate(); err != nil {
fmt.Printf("Failed to generate cert key: %v\n", err)
os.Exit(1)
}
certPubkey := hex.Enc(certSigner.Pub())
// Generate 3 witness signers (in production, these would be separate services)
var witnesses []signer.I
for i := 0; i < 3; i++ {
witness, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create witness %d: %v\n", i, err)
os.Exit(1)
}
if err := witness.Generate(); err != nil {
fmt.Printf("Failed to generate witness %d key: %v\n", i, err)
os.Exit(1)
}
witnesses = append(witnesses, witness)
}
// Issue certificate (90 day validity)
cert, err := find.IssueCertificate(name, certPubkey, find.CertificateValidity, owner, witnesses)
if err != nil {
fmt.Printf("Failed to issue certificate: %v\n", err)
os.Exit(1)
}
fmt.Printf("Certificate Issued\n")
fmt.Printf("==================\n")
fmt.Printf("Name: %s\n", cert.Name)
fmt.Printf("Cert Pubkey: %s\n", cert.CertPubkey)
fmt.Printf("Valid From: %s\n", cert.ValidFrom)
fmt.Printf("Valid Until: %s\n", cert.ValidUntil)
fmt.Printf("Challenge: %s\n", cert.Challenge)
fmt.Printf("Witnesses: %d\n", len(cert.Witnesses))
fmt.Printf("Algorithm: %s\n", cert.Algorithm)
fmt.Printf("Usage: %s\n", cert.Usage)
fmt.Printf("\nWitness Pubkeys:\n")
for i, w := range cert.Witnesses {
fmt.Printf(" %d: %s\n", i+1, w.Pubkey)
}
}

View File

@@ -0,0 +1,268 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/gorilla/websocket"
)
var (
relayURL = flag.String("url", "ws://localhost:3334", "Relay WebSocket URL")
duration = flag.Int("duration", 120, "Test duration in seconds")
)
func main() {
flag.Parse()
log.SetFlags(log.Ltime)
fmt.Println("===================================")
fmt.Println("Simple Subscription Stability Test")
fmt.Println("===================================")
fmt.Printf("Relay: %s\n", *relayURL)
fmt.Printf("Duration: %d seconds\n", *duration)
fmt.Println()
fmt.Println("This test verifies that subscriptions remain")
fmt.Println("active without dropping over the test period.")
fmt.Println()
// Connect to relay
log.Printf("Connecting to %s...", *relayURL)
conn, _, err := websocket.DefaultDialer.Dial(*relayURL, nil)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
log.Printf("✓ Connected")
// Context for the test
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*duration+10)*time.Second)
defer cancel()
// Handle interrupts
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("\nInterrupted, shutting down...")
cancel()
}()
// Subscribe
subID := fmt.Sprintf("stability-test-%d", time.Now().Unix())
reqMsg := []interface{}{"REQ", subID, map[string]interface{}{"kinds": []int{1}}}
reqMsgBytes, _ := json.Marshal(reqMsg)
log.Printf("Sending subscription: %s", subID)
if err := conn.WriteMessage(websocket.TextMessage, reqMsgBytes); err != nil {
log.Fatalf("Failed to send REQ: %v", err)
}
// Track connection health
lastMessageTime := time.Now()
gotEOSE := false
messageCount := 0
pingCount := 0
// Read goroutine
readDone := make(chan struct{})
go func() {
defer close(readDone)
for {
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
msgType, msg, err := conn.ReadMessage()
if err != nil {
if ctx.Err() != nil {
return
}
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
continue
}
log.Printf("Read error: %v", err)
return
}
lastMessageTime = time.Now()
messageCount++
// Handle PING
if msgType == websocket.PingMessage {
pingCount++
log.Printf("Received PING #%d, sending PONG", pingCount)
conn.WriteMessage(websocket.PongMessage, nil)
continue
}
// Parse message
var envelope []json.RawMessage
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
if len(envelope) < 2 {
continue
}
var msgTypeStr string
json.Unmarshal(envelope[0], &msgTypeStr)
switch msgTypeStr {
case "EOSE":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subID && !gotEOSE {
gotEOSE = true
log.Printf("✓ Received EOSE - subscription is active")
}
case "EVENT":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subID {
log.Printf("Received EVENT (subscription still active)")
}
case "CLOSED":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subID {
log.Printf("⚠ Subscription CLOSED by relay!")
cancel()
return
}
case "NOTICE":
var notice string
json.Unmarshal(envelope[1], &notice)
log.Printf("NOTICE: %s", notice)
}
}
}()
// Wait for EOSE
log.Println("Waiting for EOSE...")
for !gotEOSE && ctx.Err() == nil {
time.Sleep(100 * time.Millisecond)
}
if !gotEOSE {
log.Fatal("Did not receive EOSE")
}
// Monitor loop
startTime := time.Now()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
log.Println()
log.Printf("Subscription is active. Monitoring for %d seconds...", *duration)
log.Println("(Subscription should stay active even without events)")
log.Println()
for {
select {
case <-ctx.Done():
goto done
case <-ticker.C:
elapsed := time.Since(startTime)
timeSinceMessage := time.Since(lastMessageTime)
log.Printf("[%3.0fs/%ds] Messages: %d | Last message: %.0fs ago | Status: %s",
elapsed.Seconds(),
*duration,
messageCount,
timeSinceMessage.Seconds(),
getStatus(timeSinceMessage),
)
// Check if we've reached duration
if elapsed >= time.Duration(*duration)*time.Second {
goto done
}
}
}
done:
cancel()
// Wait for reader
select {
case <-readDone:
case <-time.After(2 * time.Second):
}
// Send CLOSE
closeMsg := []interface{}{"CLOSE", subID}
closeMsgBytes, _ := json.Marshal(closeMsg)
conn.WriteMessage(websocket.TextMessage, closeMsgBytes)
// Results
elapsed := time.Since(startTime)
timeSinceMessage := time.Since(lastMessageTime)
fmt.Println()
fmt.Println("===================================")
fmt.Println("Test Results")
fmt.Println("===================================")
fmt.Printf("Duration: %.1f seconds\n", elapsed.Seconds())
fmt.Printf("Total messages: %d\n", messageCount)
fmt.Printf("Last message: %.0f seconds ago\n", timeSinceMessage.Seconds())
fmt.Println()
// Determine success
if timeSinceMessage < 15*time.Second {
// Recent message - subscription is alive
fmt.Println("✓ TEST PASSED")
fmt.Println("Subscription remained active throughout test period.")
fmt.Println("Recent messages indicate healthy connection.")
} else if timeSinceMessage < 30*time.Second {
// Somewhat recent - probably OK
fmt.Println("✓ TEST LIKELY PASSED")
fmt.Println("Subscription appears active (message received recently).")
fmt.Println("Some delay is normal if relay is idle.")
} else if messageCount > 0 {
// Got EOSE but nothing since
fmt.Println("⚠ INCONCLUSIVE")
fmt.Println("Subscription was established but no activity since.")
fmt.Println("This is expected if relay has no events and doesn't send pings.")
fmt.Println("To properly test, publish events during the test period.")
} else {
// No messages at all
fmt.Println("✗ TEST FAILED")
fmt.Println("No messages received - subscription may have failed.")
}
fmt.Println()
fmt.Println("Note: This test verifies the subscription stays registered.")
fmt.Println("For full testing, publish events while this runs and verify")
fmt.Println("they are received throughout the entire test duration.")
}
func getStatus(timeSince time.Duration) string {
seconds := timeSince.Seconds()
switch {
case seconds < 10:
return "ACTIVE (recent message)"
case seconds < 30:
return "IDLE (normal)"
case seconds < 60:
return "QUIET (possibly normal)"
default:
return "STALE (may have dropped)"
}
}

View File

@@ -0,0 +1,347 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
"github.com/gorilla/websocket"
)
var (
relayURL = flag.String("url", "ws://localhost:3334", "Relay WebSocket URL")
duration = flag.Int("duration", 60, "Test duration in seconds")
eventKind = flag.Int("kind", 1, "Event kind to subscribe to")
verbose = flag.Bool("v", false, "Verbose output")
subID = flag.String("sub", "", "Subscription ID (default: auto-generated)")
)
type NostrEvent struct {
ID string `json:"id"`
PubKey string `json:"pubkey"`
CreatedAt int64 `json:"created_at"`
Kind int `json:"kind"`
Tags [][]string `json:"tags"`
Content string `json:"content"`
Sig string `json:"sig"`
}
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lmicroseconds)
// Generate subscription ID if not provided
subscriptionID := *subID
if subscriptionID == "" {
subscriptionID = fmt.Sprintf("test-%d", time.Now().Unix())
}
log.Printf("Starting subscription stability test")
log.Printf("Relay: %s", *relayURL)
log.Printf("Duration: %d seconds", *duration)
log.Printf("Event kind: %d", *eventKind)
log.Printf("Subscription ID: %s", subscriptionID)
log.Println()
// Connect to relay
log.Printf("Connecting to %s...", *relayURL)
conn, _, err := websocket.DefaultDialer.Dial(*relayURL, nil)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
log.Printf("✓ Connected")
log.Println()
// Context for the test
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*duration+10)*time.Second)
defer cancel()
// Handle interrupts
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("\nInterrupted, shutting down...")
cancel()
}()
// Counters
var receivedCount atomic.Int64
var lastEventTime atomic.Int64
lastEventTime.Store(time.Now().Unix())
// Subscribe
reqMsg := map[string]interface{}{
"kinds": []int{*eventKind},
}
reqMsgBytes, _ := json.Marshal(reqMsg)
subscribeMsg := []interface{}{"REQ", subscriptionID, json.RawMessage(reqMsgBytes)}
subscribeMsgBytes, _ := json.Marshal(subscribeMsg)
log.Printf("Sending REQ: %s", string(subscribeMsgBytes))
if err := conn.WriteMessage(websocket.TextMessage, subscribeMsgBytes); err != nil {
log.Fatalf("Failed to send REQ: %v", err)
}
// Read messages
gotEOSE := false
readDone := make(chan struct{})
consecutiveTimeouts := 0
maxConsecutiveTimeouts := 20 // Exit if we get too many consecutive timeouts
go func() {
defer close(readDone)
for {
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
// Check for normal close
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Println("Connection closed normally")
return
}
// Check if context was cancelled
if ctx.Err() != nil {
return
}
// Check for timeout errors (these are expected during idle periods)
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
consecutiveTimeouts++
if consecutiveTimeouts >= maxConsecutiveTimeouts {
log.Printf("Too many consecutive read timeouts (%d), connection may be dead", consecutiveTimeouts)
return
}
// Only log every 5th timeout to avoid spam
if *verbose && consecutiveTimeouts%5 == 0 {
log.Printf("Read timeout (idle period, %d consecutive)", consecutiveTimeouts)
}
continue
}
// For any other error, log and exit
log.Printf("Read error: %v", err)
return
}
// Reset timeout counter on successful read
consecutiveTimeouts = 0
// Parse message
var envelope []json.RawMessage
if err := json.Unmarshal(msg, &envelope); err != nil {
if *verbose {
log.Printf("Failed to parse message: %v", err)
}
continue
}
if len(envelope) < 2 {
continue
}
var msgType string
json.Unmarshal(envelope[0], &msgType)
// Check message type
switch msgType {
case "EOSE":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subscriptionID {
if !gotEOSE {
gotEOSE = true
log.Printf("✓ Received EOSE - subscription is active")
log.Println()
log.Println("Waiting for real-time events...")
log.Println()
}
}
case "EVENT":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subscriptionID {
var event NostrEvent
if err := json.Unmarshal(envelope[2], &event); err == nil {
count := receivedCount.Add(1)
lastEventTime.Store(time.Now().Unix())
eventIDShort := event.ID
if len(eventIDShort) > 8 {
eventIDShort = eventIDShort[:8]
}
log.Printf("[EVENT #%d] id=%s kind=%d created=%d",
count, eventIDShort, event.Kind, event.CreatedAt)
if *verbose {
log.Printf(" content: %s", event.Content)
}
}
}
case "NOTICE":
var notice string
json.Unmarshal(envelope[1], &notice)
log.Printf("[NOTICE] %s", notice)
case "CLOSED":
var recvSubID, reason string
json.Unmarshal(envelope[1], &recvSubID)
if len(envelope) > 2 {
json.Unmarshal(envelope[2], &reason)
}
if recvSubID == subscriptionID {
log.Printf("⚠ Subscription CLOSED by relay: %s", reason)
cancel()
return
}
case "OK":
// Ignore OK messages for this test
default:
if *verbose {
log.Printf("Unknown message type: %s", msgType)
}
}
}
}()
// Wait for EOSE with timeout
eoseTimeout := time.After(10 * time.Second)
for !gotEOSE {
select {
case <-eoseTimeout:
log.Printf("⚠ Warning: No EOSE received within 10 seconds")
gotEOSE = true // Continue anyway
case <-ctx.Done():
log.Println("Test cancelled before EOSE")
return
case <-time.After(100 * time.Millisecond):
// Keep waiting
}
}
// Monitor for subscription drops
startTime := time.Now()
endTime := startTime.Add(time.Duration(*duration) * time.Second)
// Start monitoring goroutine
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
elapsed := time.Since(startTime).Seconds()
lastEvent := lastEventTime.Load()
timeSinceLastEvent := time.Now().Unix() - lastEvent
log.Printf("[STATUS] Elapsed: %.0fs/%ds | Events: %d | Last event: %ds ago",
elapsed, *duration, receivedCount.Load(), timeSinceLastEvent)
// Warn if no events for a while (but only if we've seen events before)
if receivedCount.Load() > 0 && timeSinceLastEvent > 30 {
log.Printf("⚠ Warning: No events received for %ds - subscription may have dropped", timeSinceLastEvent)
}
}
}
}()
// Wait for test duration
log.Printf("Test running for %d seconds...", *duration)
log.Println("(You can publish events to the relay in another terminal)")
log.Println()
select {
case <-ctx.Done():
// Test completed or interrupted
case <-time.After(time.Until(endTime)):
// Duration elapsed
}
// Wait a bit for final events
time.Sleep(2 * time.Second)
cancel()
// Wait for reader to finish
select {
case <-readDone:
case <-time.After(5 * time.Second):
log.Println("Reader goroutine didn't exit cleanly")
}
// Send CLOSE
closeMsg := []interface{}{"CLOSE", subscriptionID}
closeMsgBytes, _ := json.Marshal(closeMsg)
conn.WriteMessage(websocket.TextMessage, closeMsgBytes)
// Print results
log.Println()
log.Println("===================================")
log.Println("Test Results")
log.Println("===================================")
log.Printf("Duration: %.1f seconds", time.Since(startTime).Seconds())
log.Printf("Events received: %d", receivedCount.Load())
log.Printf("Subscription ID: %s", subscriptionID)
lastEvent := lastEventTime.Load()
if lastEvent > startTime.Unix() {
log.Printf("Last event: %ds ago", time.Now().Unix()-lastEvent)
}
log.Println()
// Determine pass/fail
received := receivedCount.Load()
testDuration := time.Since(startTime).Seconds()
if received == 0 {
log.Println("⚠ No events received during test")
log.Println("This is expected if no events were published")
log.Println("To test properly, publish events while this is running:")
log.Println()
log.Println(" # In another terminal:")
log.Printf(" ./orly # Make sure relay is running\n")
log.Println()
log.Println(" # Then publish test events (implementation-specific)")
} else {
eventsPerSecond := float64(received) / testDuration
log.Printf("Rate: %.2f events/second", eventsPerSecond)
lastEvent := lastEventTime.Load()
timeSinceLastEvent := time.Now().Unix() - lastEvent
if timeSinceLastEvent < 10 {
log.Println()
log.Println("✓ TEST PASSED - Subscription remained stable")
log.Println("Events were received recently, indicating subscription is still active")
} else {
log.Println()
log.Printf("⚠ Potential issue - Last event was %ds ago", timeSinceLastEvent)
log.Println("Subscription may have dropped if events were still being published")
}
}
}

View File

@@ -0,0 +1,694 @@
# Go Reference Type Complexity Analysis and Simplification Proposal
## Executive Summary
Go's "reference types" (slices, maps, channels) introduce significant cognitive load and parsing complexity due to their implicit reference semantics that differ from regular value types. This analysis proposes making these types explicitly pointer-based to reduce language complexity, improve safety, and make concurrent programming more predictable.
## Current State: The Reference Type Problem
### 1. Slices - The "Fat Pointer" Confusion
**Current Behavior:**
```go
// Slice is a struct: {ptr *T, len int, cap int}
// Copying a slice copies this struct, NOT the underlying array
s1 := []int{1, 2, 3}
s2 := s1 // Copies the slice header, shares underlying array
s2[0] = 99 // Modifies shared array - affects s1!
s2 = append(s2, 4) // May or may not affect s1 depending on capacity
```
**Problems:**
- **Implicit sharing**: Assignment copies reference, not data
- **Append confusion**: Sometimes mutates original, sometimes doesn't
- **Race conditions**: Multiple goroutines accessing shared slice need explicit locks
- **Hidden allocations**: Append may allocate without warning
- **Capacity vs length**: Two separate concepts that confuse new users
- **Nil vs empty**: `nil` slice vs `[]T{}` behave differently
**Syntax Complexity:**
```go
// Multiple ways to create slices
var s []int // nil slice
s := []int{} // empty slice (non-nil)
s := make([]int, 10) // length 10, capacity 10
s := make([]int, 10, 20) // length 10, capacity 20
s := []int{1, 2, 3} // literal
s := arr[:] // from array
s := arr[1:3] // subslice
s := arr[1:3:5] // subslice with capacity
```
### 2. Maps - The Always-Reference Type
**Current Behavior:**
```go
// Map is a pointer to a hash table structure
// Assignment ALWAYS copies the pointer
m1 := make(map[string]int)
m2 := m1 // Both point to same map
m2["key"] = 42 // Modifies shared map - affects m1!
var m3 map[string]int // nil map - reads panic!
m3 = make(map[string]int) // Must initialize before use
```
**Problems:**
- **Always reference**: No way to copy a map with simple assignment
- **Nil map trap**: Reading from nil map works, writing panics
- **No built-in copy**: Must manually iterate to copy
- **Concurrent access**: Requires explicit sync.Map or manual locking
- **Non-deterministic iteration**: Range order is randomized
- **Memory leaks**: Map never shrinks, deleted keys hold memory
**Syntax Complexity:**
```go
// Creating maps
var m map[K]V // nil map
m := map[K]V{} // empty map
m := make(map[K]V) // empty map
m := make(map[K]V, 100) // with capacity hint
m := map[K]V{k1: v1, k2: v2} // literal
// Checking existence requires two-value form
value, ok := m[key] // ok is false if not present
value := m[key] // returns zero value if not present
```
### 3. Channels - The Most Complex Reference Type
**Current Behavior:**
```go
// Channel is a pointer to a channel structure
// Extremely complex semantics
ch := make(chan int) // unbuffered - blocks on send
ch := make(chan int, 10) // buffered - blocks when full
ch <- 42 // Send (blocks if full/unbuffered)
x := <-ch // Receive (blocks if empty)
x, ok := <-ch // Receive with closed check
close(ch) // Close channel
// Sending to closed channel: PANIC
// Closing closed channel: PANIC
// Receiving from closed: returns zero value + ok=false
```
**Problems:**
- **Directional types**: `chan T`, `chan<- T`, `<-chan T` add complexity
- **Close semantics**: Only sender should close, hard to enforce
- **Select complexity**: `select` statement is a mini-language
- **Nil channel**: Sending/receiving on nil blocks forever (trap!)
- **Buffered vs unbuffered**: Completely different semantics
- **No channel copy**: Impossible to copy a channel
- **Deadlock detection**: Runtime detection adds complexity
**Syntax Complexity:**
```go
// Channel operations
ch := make(chan T) // unbuffered
ch := make(chan T, N) // buffered
ch <- v // send
v := <-ch // receive
v, ok := <-ch // receive with status
close(ch) // close
<-ch // receive and discard
// Directional channels
func send(ch chan<- int) {} // send-only
func recv(ch <-chan int) {} // receive-only
// Select statement
select {
case v := <-ch1:
// handle
case ch2 <- v:
// handle
case <-time.After(timeout):
// timeout
default:
// non-blocking
}
// Range over channel
for v := range ch {
// must be closed by sender or infinite loop
}
```
## Complexity Metrics
### Current Go Reference Types
| Feature | Syntax Variants | Special Cases | Runtime Behaviors | Total Complexity |
|---------|----------------|---------------|-------------------|-----------------|
| **Slices** | 8 creation forms | nil vs empty, capacity vs length | append reallocation, sharing semantics | **HIGH** |
| **Maps** | 5 creation forms | nil map panic, no shrinking | randomized iteration, no copy | **HIGH** |
| **Channels** | 6 operation forms | close rules, directional types | buffered vs unbuffered, select | **VERY HIGH** |
### Parser Complexity
Current Go requires parsing:
- **8 forms of slice expressions**: `a[:]`, `a[i:]`, `a[:j]`, `a[i:j]`, `a[i:j:k]`, etc.
- **3 channel operators**: `<-`, `chan<-`, `<-chan` (context-dependent)
- **Select statement**: Unique control flow structure
- **Range statement**: 4 different forms for different types
- **Make vs new**: Two allocation functions with different semantics
## Proposed Simplifications
### Core Principle: Explicit Is Better Than Implicit
Make all reference types use explicit pointer syntax. This:
1. Makes copying behavior obvious
2. Eliminates special case handling
3. Reduces parser complexity
4. Improves concurrent safety
5. Unifies type system
### 1. Explicit Slice Pointers
**Proposed Syntax:**
```go
// Slices become explicit pointers to dynamic arrays
var s *[]int = nil // explicit nil pointer
s = &[]int{1, 2, 3} // explicit allocation
s2 := &[]int{1, 2, 3} // short form
// Accessing requires dereference (or auto-deref like methods)
(*s)[0] = 42 // explicit dereference
s[0] = 42 // auto-deref (like struct methods)
// Copying requires explicit clone
s2 := s.Clone() // explicit copy operation
s2 := &[]int(*s) // alternative: copy via literal
// Appending creates new allocation or mutates
s.Append(42) // mutates in place (may reallocate)
s2 := s.Clone().Append(42) // copy-on-write pattern
```
**Benefits:**
- **Explicit allocation**: `&[]T{...}` makes heap allocation clear
- **No hidden sharing**: Assignment copies pointer, obviously
- **Explicit cloning**: Must call `.Clone()` to copy data
- **Clear ownership**: Pointer semantics match other types
- **Simpler grammar**: Eliminates slice-specific syntax like `make([]T, len, cap)`
**Eliminate:**
- `make([]T, ...)` - replaced by `&[]T{...}` or `&[cap]T{}[:len]`
- Multi-index slicing `a[i:j:k]` - too complex, rarely used
- Implicit capacity - arrays have size, slices are just `&[]T`
### 2. Explicit Map Pointers
**Proposed Syntax:**
```go
// Maps become explicit pointers to hash tables
var m *map[string]int = nil // explicit nil pointer
m = &map[string]int{} // explicit allocation
m := &map[string]int{ // literal initialization
"key": 42,
}
// Accessing requires dereference (or auto-deref)
(*m)["key"] = 42 // explicit
m["key"] = 42 // auto-deref
// Copying requires explicit clone
m2 := m.Clone() // explicit copy operation
// Nil pointer behavior is consistent
if m == nil {
m = &map[string]int{}
}
m["key"] = 42 // no special nil handling
```
**Benefits:**
- **No nil map trap**: Nil pointer is consistently nil
- **Explicit cloning**: Must call `.Clone()` to copy
- **Unified semantics**: Works like all other pointer types
- **Clear ownership**: Pointer passing is obvious
**Eliminate:**
- `make(map[K]V)` - replaced by `&map[K]V{}`
- Special nil map read-only behavior
- Capacity hints (premature optimization)
### 3. Simplify or Eliminate Channels
**Option A: Eliminate Channels Entirely**
Replace with explicit concurrency primitives:
```go
// Instead of channels, use explicit queues
type Queue[T any] struct {
items []T
mu sync.Mutex
cond *sync.Cond
}
func (q *Queue[T]) Send(v T) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, v)
q.cond.Signal()
}
func (q *Queue[T]) Recv() T {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.cond.Wait()
}
v := q.items[0]
q.items = q.items[1:]
return v
}
```
**Benefits:**
- **No special syntax**: Uses standard types and methods
- **Explicit locking**: Clear where synchronization happens
- **No close semantics**: Just stop sending
- **No directional types**: Use interfaces if needed
- **Debuggable**: Standard data structures
**Option B: Explicit Channel Pointers**
If keeping channels:
```go
// Channels become explicit pointers
ch := &chan int{} // unbuffered
ch := &chan int{cap: 10} // buffered
ch.Send(42) // method instead of operator
v := ch.Recv() // method instead of operator
v, ok := ch.TryRecv() // non-blocking receive
ch.Close() // explicit close
// No directional types - use interfaces
type Sender[T] interface { Send(T) }
type Receiver[T] interface { Recv() T }
```
**Eliminate:**
- `<-` operator entirely (use methods)
- `select` statement (use explicit polling or wait groups)
- Directional channel types
- `make(chan T)` syntax
- `range` over channels
### 4. Unified Allocation
**Current Go:**
```go
new(T) // returns *T, zero value
make([]T, n) // returns []T (slice)
make(map[K]V) // returns map[K]V (map)
make(chan T) // returns chan T (channel)
```
**Proposed:**
```go
new(T) // returns *T, zero value (keep this)
&T{} // returns *T, composite literal (keep this)
&[]T{} // returns *[]T, slice
&[n]T{} // returns *[n]T, array
&map[K]V{} // returns *map[K]V, map
// Eliminate make() entirely
```
### 5. Simplified Type System
**Before (reference types as special):**
```
Types:
- Value types: int, float, struct, array, pointer
- Reference types: slice, map, channel (special semantics)
```
**After (everything is value or pointer):**
```
Types:
- Value types: int, float, struct, [N]T (array)
- Pointer types: *T (including *[]T, *map[K]V)
```
## Complexity Reduction Analysis
### Grammar Simplification
**Eliminated Syntax:**
1. **Slice expressions** (8 forms → 1):
-`a[:]`, `a[i:]`, `a[:j]`, `a[i:j]`, `a[i:j:k]`
-`a[i]` (single index only, or use methods like `.Slice(i, j)`)
2. **Make function** (3 forms → 0):
-`make([]T, len)`, `make([]T, len, cap)`, `make(map[K]V)`, `make(chan T)`
-`&[]T{}`, `&map[K]V{}`
3. **Channel operators** (3 forms → 0):
-`<-ch`, `ch<-`, `<-chan`, `chan<-`
-`.Send()`, `.Recv()` methods
4. **Select statement** (1 form → 0):
-`select { case ... }`
- ✅ Regular if/switch with polling or wait groups
5. **Range variants** (4 forms → 2):
-`for v := range ch` (channel)
-`for i, v := range slice` (special case)
-`for i := 0; i < len(slice); i++` (explicit)
### Semantic Simplification
**Eliminated Special Cases:**
1. **Nil map read-only behavior** → Standard nil pointer
2. **Append reallocation magic** → Explicit `.Append()` or `.Grow()`
3. **Channel close-twice panic** → No special close semantics
4. **Slice capacity vs length** → Explicit growth methods
5. **Non-deterministic map iteration** → Option to make deterministic
### Runtime Simplification
**Eliminated Runtime Features:**
1. **Deadlock detection** → User responsibility with explicit locks
2. **Channel close tracking** → No close needed
3. **Select fairness** → No select statement
4. **Goroutine channel blocking** → Explicit condition variables
## Concurrency Safety Improvements
### Before: Implicit Sharing Causes Races
```go
// Easy to create race conditions
s := []int{1, 2, 3}
m := map[string]int{"key": 42}
go func() {
s[0] = 99 // RACE: implicit sharing
m["key"] = 100 // RACE: implicit sharing
}()
s[1] = 88 // RACE: concurrent access
m["key"] = 200 // RACE: concurrent access
```
### After: Explicit Pointers Make Sharing Obvious
```go
// Clear that pointers are shared
s := &[]int{1, 2, 3}
m := &map[string]int{"key": 42}
go func() {
s[0] = 99 // RACE: obvious pointer sharing
m["key"] = 100 // RACE: obvious pointer sharing
}()
// Must explicitly protect
var mu sync.Mutex
mu.Lock()
s[1] = 88
mu.Unlock()
// Or use pass-by-value (copy)
s2 := &[]int(*s) // explicit copy
go func(local *[]int) {
local[0] = 99 // NO RACE: different slice
}(s2)
```
### Pattern: Immutable by Default
```go
// Current Go: easy to accidentally mutate
func process(s []int) {
s[0] = 99 // Mutates caller's slice!
}
// Proposed: explicit mutation
func process(s *[]int) {
(*s)[0] = 99 // Clear mutation
}
// Or use value semantics
func process(s []int) {
s[0] = 99 // Only mutates local copy
return s
}
```
## Migration Path
### Phase 1: Add Explicit Syntax (Backward Compatible)
```go
// Allow both forms initially
s1 := []int{1, 2, 3} // old style
s2 := &[]int{1, 2, 3} // new style (same runtime behavior)
// Add methods to support new style
s2.Append(4)
s3 := s2.Clone()
```
### Phase 2: Deprecate Implicit Forms
```go
// Warn on old syntax
s := make([]int, 10) // WARNING: Use &[]int{} or &[10]int{}
ch := make(chan int) // WARNING: Use &chan int{} or Queue[int]
ch <- 42 // WARNING: Use ch.Send(42)
```
### Phase 3: Remove Implicit Forms
```go
// Only explicit forms allowed
s := &[]int{1, 2, 3} // OK
m := &map[K]V{} // OK
ch := &chan int{} // OK (or removed entirely)
make([]int, 10) // ERROR: Use &[]int{} or explicit loop
ch <- 42 // ERROR: Use ch.Send(42)
```
## Comparison: Before and After
### Slice Example
**Before:**
```go
func AppendUnique(s []int, v int) []int {
for _, existing := range s {
if existing == v {
return s
}
}
return append(s, v) // May or may not mutate caller's slice!
}
s := []int{1, 2, 3}
s = AppendUnique(s, 4) // Must reassign to avoid bugs
```
**After:**
```go
func AppendUnique(s *[]int, v int) {
for _, existing := range *s {
if existing == v {
return
}
}
s.Append(v) // Always mutates, clear semantics
}
s := &[]int{1, 2, 3}
AppendUnique(s, 4) // No reassignment needed
```
### Map Example
**Before:**
```go
func Merge(dst, src map[string]int) {
for k, v := range src {
dst[k] = v // Mutates dst (caller's map)
}
}
m1 := map[string]int{"a": 1}
m2 := map[string]int{"b": 2}
Merge(m1, m2) // m1 is mutated
```
**After:**
```go
func Merge(dst, src *map[string]int) {
for k, v := range *src {
(*dst)[k] = v // Clear mutation
}
}
m1 := &map[string]int{"a": 1}
m2 := &map[string]int{"b": 2}
Merge(m1, m2) // Clear that m1 is mutated
```
### Channel Example (Option B: Keep Channels)
**Before:**
```go
func Worker(jobs <-chan Job, results chan<- Result) {
for job := range jobs {
results <- process(job)
}
}
jobs := make(chan Job, 10)
results := make(chan Result, 10)
go Worker(jobs, results)
```
**After:**
```go
func Worker(jobs Receiver[Job], results Sender[Result]) {
for {
job, ok := jobs.TryRecv()
if !ok {
break
}
results.Send(process(job))
}
}
jobs := &Queue[Job]{cap: 10}
results := &Queue[Result]{cap: 10}
go Worker(jobs, results)
```
## Implementation Impact
### Compiler Changes
**Simplified:**
- ✅ Remove slice expression parsing (8 forms → 1)
- ✅ Remove `make()` built-in
- ✅ Remove `<-` operator
- ✅ Remove `select` statement
- ✅ Remove directional channel types
- ✅ Unify reference types with pointer types
**Modified:**
- 🔄 Auto-dereference for `*[]T`, `*map[K]V` (like struct methods)
- 🔄 Add built-in `.Clone()`, `.Append()`, `.Grow()` methods
- 🔄 Array → Slice conversion: `&[N]T{} → *[]T`
### Runtime Changes
**Simplified:**
- ✅ Remove deadlock detection (no channels)
- ✅ Remove select fairness logic
- ✅ Remove channel close tracking
- ✅ Simpler type reflection (fewer special cases)
**Preserved:**
- ✅ Garbage collection (now simpler with fewer types)
- ✅ Goroutine scheduler (unchanged)
- ✅ Slice/map internal structure (same layout)
### Standard Library Changes
**Packages to Update:**
- `sync` - Keep Mutex, RWMutex, WaitGroup; enhance Cond
- `container` - Add generic Queue, Stack types
- `slices` - Methods become methods on `*[]T`
- `maps` - Methods become methods on `*map[K]V`
**Packages to Remove/Simplify:**
- `sync.Map` - No longer needed (use `*map[K]V` with mutex)
- Channel-based packages - Rewrite with explicit queues
## Conclusion
### Complexity Reduction Summary
| Metric | Before | After | Reduction |
|--------|--------|-------|-----------|
| **Reference type forms** | 3 (slice, map, chan) | 0 (all pointers) | **100%** |
| **Allocation functions** | 2 (new, make) | 1 (new/&) | **50%** |
| **Slice syntax variants** | 8 | 1 | **87.5%** |
| **Channel operators** | 3 | 0 | **100%** |
| **Special statements** | 2 (select, range-chan) | 0 | **100%** |
| **Type system special cases** | 6+ | 0 | **100%** |
### Benefits
1. **Simpler Language Definition**
- Fewer special types and operators
- Unified pointer semantics
- Easier to specify and implement
2. **Easier to Learn**
- No hidden reference behavior
- Explicit allocation and copying
- Consistent with other pointer types
3. **Safer Concurrent Code**
- Obvious when data is shared
- Explicit synchronization required
- No hidden race conditions
4. **Better Tooling**
- Simpler parser (fewer special cases)
- Better static analysis (explicit sharing)
- Easier code generation
5. **Maintained Performance**
- Same runtime representation
- Same memory layout
- Same GC behavior
- Potential optimizations preserved
### Trade-offs
**Lost:**
- Channel select (must use explicit polling)
- Syntactic sugar for send/receive (`<-`)
- Make function convenience
- Slice expression shortcuts
**Gained:**
- Explicit, obvious semantics
- Unified type system
- Simpler language specification
- Better concurrent safety
- Easier to parse and analyze
### Recommendation
Adopt explicit pointer syntax for all reference types. This change:
- Reduces language complexity by ~40% (by eliminating special cases)
- Improves safety and predictability
- Maintains performance characteristics
- Simplifies compiler and tooling implementation
- Makes Go easier to learn and use correctly
The migration path is clear and could be done gradually with deprecation warnings before breaking changes.

1922
docs/names.md Normal file

File diff suppressed because it is too large Load Diff

259
pkg/database/nip43.go Normal file
View File

@@ -0,0 +1,259 @@
package database
import (
"encoding/binary"
"fmt"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/hex"
)
// NIP43Membership represents membership metadata for NIP-43
type NIP43Membership struct {
Pubkey []byte
AddedAt time.Time
InviteCode string
}
// Database key prefixes for NIP-43
const (
nip43MemberPrefix = "nip43:member:"
nip43InvitePrefix = "nip43:invite:"
)
// AddNIP43Member adds a member to the NIP-43 membership list
func (d *D) AddNIP43Member(pubkey []byte, inviteCode string) error {
if len(pubkey) != 32 {
return fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
// Create membership record
membership := NIP43Membership{
Pubkey: pubkey,
AddedAt: time.Now(),
InviteCode: inviteCode,
}
// Serialize membership data
val := serializeNIP43Membership(membership)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Set(key, val)
})
}
// RemoveNIP43Member removes a member from the NIP-43 membership list
func (d *D) RemoveNIP43Member(pubkey []byte) error {
if len(pubkey) != 32 {
return fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Delete(key)
})
}
// IsNIP43Member checks if a pubkey is a NIP-43 member
func (d *D) IsNIP43Member(pubkey []byte) (isMember bool, err error) {
if len(pubkey) != 32 {
return false, fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
err = d.DB.View(func(txn *badger.Txn) error {
_, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
isMember = false
return nil
}
if err != nil {
return err
}
isMember = true
return nil
})
return isMember, err
}
// GetNIP43Membership retrieves membership details for a pubkey
func (d *D) GetNIP43Membership(pubkey []byte) (*NIP43Membership, error) {
if len(pubkey) != 32 {
return nil, fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
var membership *NIP43Membership
err := d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if err != nil {
return err
}
return item.Value(func(val []byte) error {
membership = deserializeNIP43Membership(val)
return nil
})
})
if err != nil {
return nil, err
}
return membership, nil
}
// GetAllNIP43Members returns all NIP-43 members
func (d *D) GetAllNIP43Members() ([][]byte, error) {
var members [][]byte
prefix := []byte(nip43MemberPrefix)
err := d.DB.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
opts.PrefetchValues = false // We only need keys
it := txn.NewIterator(opts)
defer it.Close()
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
item := it.Item()
key := item.Key()
// Extract pubkey from key (skip prefix)
pubkey := make([]byte, 32)
copy(pubkey, key[len(prefix):])
members = append(members, pubkey)
}
return nil
})
return members, err
}
// StoreInviteCode stores an invite code with expiry
func (d *D) StoreInviteCode(code string, expiresAt time.Time) error {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
// Serialize expiry time as unix timestamp
val := make([]byte, 8)
binary.BigEndian.PutUint64(val, uint64(expiresAt.Unix()))
return d.DB.Update(func(txn *badger.Txn) error {
entry := badger.NewEntry(key, val).WithTTL(time.Until(expiresAt))
return txn.SetEntry(entry)
})
}
// ValidateInviteCode checks if an invite code is valid and not expired
func (d *D) ValidateInviteCode(code string) (valid bool, err error) {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
err = d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
valid = false
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if len(val) != 8 {
return fmt.Errorf("invalid invite code value")
}
expiresAt := int64(binary.BigEndian.Uint64(val))
valid = time.Now().Unix() < expiresAt
return nil
})
})
return valid, err
}
// DeleteInviteCode removes an invite code (after use)
func (d *D) DeleteInviteCode(code string) error {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Delete(key)
})
}
// Helper functions for serialization
func serializeNIP43Membership(m NIP43Membership) []byte {
// Format: [pubkey(32)] [timestamp(8)] [invite_code_len(2)] [invite_code]
codeBytes := []byte(m.InviteCode)
codeLen := len(codeBytes)
buf := make([]byte, 32+8+2+codeLen)
// Copy pubkey
copy(buf[0:32], m.Pubkey)
// Write timestamp
binary.BigEndian.PutUint64(buf[32:40], uint64(m.AddedAt.Unix()))
// Write invite code length
binary.BigEndian.PutUint16(buf[40:42], uint16(codeLen))
// Write invite code
copy(buf[42:], codeBytes)
return buf
}
func deserializeNIP43Membership(data []byte) *NIP43Membership {
if len(data) < 42 {
return nil
}
m := &NIP43Membership{}
// Read pubkey
m.Pubkey = make([]byte, 32)
copy(m.Pubkey, data[0:32])
// Read timestamp
timestamp := binary.BigEndian.Uint64(data[32:40])
m.AddedAt = time.Unix(int64(timestamp), 0)
// Read invite code
codeLen := binary.BigEndian.Uint16(data[40:42])
if len(data) >= 42+int(codeLen) {
m.InviteCode = string(data[42 : 42+codeLen])
}
return m
}
// PublishNIP43MembershipEvent publishes membership change events
func (d *D) PublishNIP43MembershipEvent(kind int, pubkey []byte) error {
log.I.F("publishing NIP-43 event kind %d for pubkey %s", kind, hex.Enc(pubkey))
// Get relay identity
relaySecret, err := d.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
// This would integrate with the event publisher
// For now, just log it
log.D.F("would publish kind %d event for member %s", kind, hex.Enc(pubkey))
// The actual publishing will be done by the handler
_ = relaySecret
return nil
}

406
pkg/database/nip43_test.go Normal file
View File

@@ -0,0 +1,406 @@
package database
import (
"context"
"os"
"testing"
"time"
)
func setupNIP43TestDB(t *testing.T) (*D, func()) {
tempDir, err := os.MkdirTemp("", "nip43_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cleanup := func() {
db.Close()
os.RemoveAll(tempDir)
}
return db, cleanup
}
// TestAddNIP43Member tests adding a member
func TestAddNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
inviteCode := "test-invite-123"
err := db.AddNIP43Member(pubkey, inviteCode)
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Verify member was added
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("member was not added")
}
}
// TestAddNIP43Member_InvalidPubkey tests adding member with invalid pubkey
func TestAddNIP43Member_InvalidPubkey(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
// Test with wrong length
invalidPubkey := make([]byte, 16)
err := db.AddNIP43Member(invalidPubkey, "test-code")
if err == nil {
t.Error("expected error for invalid pubkey length")
}
}
// TestRemoveNIP43Member tests removing a member
func TestRemoveNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
// Add member
err := db.AddNIP43Member(pubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Remove member
err = db.RemoveNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to remove member: %v", err)
}
// Verify member was removed
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("member was not removed")
}
}
// TestIsNIP43Member tests membership checking
func TestIsNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
// Check non-existent member
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("non-existent member reported as member")
}
// Add member
err = db.AddNIP43Member(pubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Check existing member
isMember, err = db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("existing member not found")
}
}
// TestGetNIP43Membership tests retrieving membership details
func TestGetNIP43Membership(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
inviteCode := "test-invite-abc123"
// Add member
beforeAdd := time.Now()
err := db.AddNIP43Member(pubkey, inviteCode)
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
afterAdd := time.Now()
// Get membership
membership, err := db.GetNIP43Membership(pubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
// Verify details
if len(membership.Pubkey) != 32 {
t.Errorf("wrong pubkey length: got %d, want 32", len(membership.Pubkey))
}
for i := range pubkey {
if membership.Pubkey[i] != pubkey[i] {
t.Errorf("pubkey mismatch at index %d", i)
break
}
}
if membership.InviteCode != inviteCode {
t.Errorf("invite code mismatch: got %s, want %s", membership.InviteCode, inviteCode)
}
// Allow some tolerance for timestamp (database operations may take time)
if membership.AddedAt.Before(beforeAdd.Add(-5*time.Second)) || membership.AddedAt.After(afterAdd.Add(5*time.Second)) {
t.Errorf("AddedAt timestamp out of expected range: got %v, expected between %v and %v",
membership.AddedAt, beforeAdd, afterAdd)
}
}
// TestGetAllNIP43Members tests retrieving all members
func TestGetAllNIP43Members(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
// Add multiple members
memberCount := 5
for i := 0; i < memberCount; i++ {
pubkey := make([]byte, 32)
for j := range pubkey {
pubkey[j] = byte(i*10 + j)
}
err := db.AddNIP43Member(pubkey, "code-"+string(rune(i)))
if err != nil {
t.Fatalf("failed to add member %d: %v", i, err)
}
}
// Get all members
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
if len(members) != memberCount {
t.Errorf("wrong member count: got %d, want %d", len(members), memberCount)
}
// Verify each member has valid pubkey
for i, member := range members {
if len(member) != 32 {
t.Errorf("member %d has invalid pubkey length: %d", i, len(member))
}
}
}
// TestStoreInviteCode tests storing invite codes
func TestStoreInviteCode(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "test-invite-xyz789"
expiresAt := time.Now().Add(24 * time.Hour)
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Validate the code
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if !valid {
t.Error("stored invite code is not valid")
}
}
// TestValidateInviteCode_Expired tests expired invite code handling
func TestValidateInviteCode_Expired(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "expired-code"
expiresAt := time.Now().Add(-1 * time.Hour) // Already expired
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Validate the code - should be invalid because it's expired
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if valid {
t.Error("expired invite code reported as valid")
}
}
// TestValidateInviteCode_NonExistent tests non-existent code validation
func TestValidateInviteCode_NonExistent(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
valid, err := db.ValidateInviteCode("non-existent-code")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if valid {
t.Error("non-existent code reported as valid")
}
}
// TestDeleteInviteCode tests deleting invite codes
func TestDeleteInviteCode(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "delete-me-code"
expiresAt := time.Now().Add(24 * time.Hour)
// Store code
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Verify it exists
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if !valid {
t.Error("stored code is not valid")
}
// Delete code
err = db.DeleteInviteCode(code)
if err != nil {
t.Fatalf("failed to delete invite code: %v", err)
}
// Verify it's gone
valid, err = db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate after delete: %v", err)
}
if valid {
t.Error("deleted code still valid")
}
}
// TestNIP43Membership_Serialization tests membership serialization
func TestNIP43Membership_Serialization(t *testing.T) {
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
original := NIP43Membership{
Pubkey: pubkey,
AddedAt: time.Now(),
InviteCode: "test-code-123",
}
// Serialize
data := serializeNIP43Membership(original)
// Deserialize
deserialized := deserializeNIP43Membership(data)
// Verify
if deserialized == nil {
t.Fatal("deserialization returned nil")
}
if len(deserialized.Pubkey) != 32 {
t.Errorf("wrong pubkey length: got %d, want 32", len(deserialized.Pubkey))
}
for i := range pubkey {
if deserialized.Pubkey[i] != pubkey[i] {
t.Errorf("pubkey mismatch at index %d", i)
break
}
}
if deserialized.InviteCode != original.InviteCode {
t.Errorf("invite code mismatch: got %s, want %s", deserialized.InviteCode, original.InviteCode)
}
// Allow 1 second tolerance for timestamp comparison (due to Unix conversion)
timeDiff := deserialized.AddedAt.Sub(original.AddedAt)
if timeDiff < -1*time.Second || timeDiff > 1*time.Second {
t.Errorf("timestamp mismatch: got %v, want %v (diff: %v)", deserialized.AddedAt, original.AddedAt, timeDiff)
}
}
// TestNIP43Membership_ConcurrentAccess tests concurrent access to membership
func TestNIP43Membership_ConcurrentAccess(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
const goroutines = 10
const membersPerGoroutine = 5
done := make(chan bool, goroutines)
// Add members concurrently
for g := 0; g < goroutines; g++ {
go func(offset int) {
for i := 0; i < membersPerGoroutine; i++ {
pubkey := make([]byte, 32)
for j := range pubkey {
pubkey[j] = byte((offset*membersPerGoroutine+i)*10 + j)
}
if err := db.AddNIP43Member(pubkey, "code"); err != nil {
t.Errorf("failed to add member: %v", err)
}
}
done <- true
}(g)
}
// Wait for all goroutines
for i := 0; i < goroutines; i++ {
<-done
}
// Verify all members were added
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
expected := goroutines * membersPerGoroutine
if len(members) != expected {
t.Errorf("wrong member count: got %d, want %d", len(members), expected)
}
}

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"lol.mleku.dev/chk" "lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/envelopes" "next.orly.dev/pkg/encoders/envelopes"
"next.orly.dev/pkg/encoders/filter" "next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/text" "next.orly.dev/pkg/encoders/text"
@@ -85,19 +86,24 @@ func (en *T) Marshal(dst []byte) (b []byte) {
// string is correctly unescaped by NIP-01 escaping rules. // string is correctly unescaped by NIP-01 escaping rules.
func (en *T) Unmarshal(b []byte) (r []byte, err error) { func (en *T) Unmarshal(b []byte) (r []byte, err error) {
r = b r = b
log.I.F("%s", r)
if en.Subscription, r, err = text.UnmarshalQuoted(r); chk.E(err) { if en.Subscription, r, err = text.UnmarshalQuoted(r); chk.E(err) {
return return
} }
log.I.F("%s", r)
if r, err = text.Comma(r); chk.E(err) { if r, err = text.Comma(r); chk.E(err) {
return return
} }
log.I.F("%s", r)
en.Filters = new(filter.S) en.Filters = new(filter.S)
if r, err = en.Filters.Unmarshal(r); chk.E(err) { if r, err = en.Filters.Unmarshal(r); chk.E(err) {
return return
} }
log.I.F("%s", r)
if r, err = envelopes.SkipToTheEnd(r); chk.E(err) { if r, err = envelopes.SkipToTheEnd(r); chk.E(err) {
return return
} }
log.I.F("%s", r)
return return
} }

View File

@@ -47,17 +47,24 @@ func (s *S) Marshal(dst []byte) (b []byte) {
} }
// Unmarshal decodes one or more filters from JSON. // Unmarshal decodes one or more filters from JSON.
// This handles both array-wrapped filters [{},...] and unwrapped filters {},...
func (s *S) Unmarshal(b []byte) (r []byte, err error) { func (s *S) Unmarshal(b []byte) (r []byte, err error) {
r = b r = b
if len(r) == 0 { if len(r) == 0 {
return return
} }
r = r[1:]
// Handle empty array "[]" // Check if filters are wrapped in an array
if len(r) > 0 && r[0] == ']' { isArrayWrapped := r[0] == '['
if isArrayWrapped {
r = r[1:] r = r[1:]
return // Handle empty array "[]"
if len(r) > 0 && r[0] == ']' {
r = r[1:]
return
}
} }
for { for {
if len(r) == 0 { if len(r) == 0 {
return return
@@ -73,13 +80,17 @@ func (s *S) Unmarshal(b []byte) (r []byte, err error) {
return return
} }
if r[0] == ',' { if r[0] == ',' {
// Next element in the array // Next element
r = r[1:] r = r[1:]
continue continue
} }
if r[0] == ']' { if r[0] == ']' {
// End of the enclosed array; consume and return // End of array or envelope
r = r[1:] if isArrayWrapped {
// Consume the closing bracket of the filter array
r = r[1:]
}
// Otherwise leave it for the envelope parser
return return
} }
// Unexpected token // Unexpected token

388
pkg/find/builder.go Normal file
View File

@@ -0,0 +1,388 @@
package find
import (
"fmt"
"strconv"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/encoders/timestamp"
"next.orly.dev/pkg/interfaces/signer"
)
// NewRegistrationProposal creates a new registration proposal event (kind 30100)
func NewRegistrationProposal(name, action string, signer signer.I) (*event.E, error) {
// Validate and normalize name
name = NormalizeName(name)
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Validate action
if action != ActionRegister && action != ActionTransfer {
return nil, fmt.Errorf("invalid action: must be %s or %s", ActionRegister, ActionTransfer)
}
// Create event
ev := event.New()
ev.Kind = KindRegistrationProposal
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", name))
tags.Append(tag.NewFromAny("action", action))
// Add expiration tag (5 minutes from now)
expiration := time.Now().Add(ProposalExpiry).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign event: %w", err)
}
return ev, nil
}
// NewRegistrationProposalWithTransfer creates a transfer proposal with previous owner signature
func NewRegistrationProposalWithTransfer(name, prevOwner, prevSig string, signer signer.I) (*event.E, error) {
// Create base proposal
ev, err := NewRegistrationProposal(name, ActionTransfer, signer)
if err != nil {
return nil, err
}
// Add transfer-specific tags
ev.Tags.Append(tag.NewFromAny("prev_owner", prevOwner))
ev.Tags.Append(tag.NewFromAny("prev_sig", prevSig))
// Re-sign after adding tags
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign transfer event: %w", err)
}
return ev, nil
}
// NewAttestation creates a new attestation event (kind 20100)
func NewAttestation(proposalID, decision string, weight int, reason, serviceURL string, signer signer.I) (*event.E, error) {
// Validate decision
if decision != DecisionApprove && decision != DecisionReject && decision != DecisionAbstain {
return nil, fmt.Errorf("invalid decision: must be approve, reject, or abstain")
}
// Create event
ev := event.New()
ev.Kind = KindAttestation
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("e", proposalID))
tags.Append(tag.NewFromAny("decision", decision))
if weight > 0 {
tags.Append(tag.NewFromAny("weight", strconv.Itoa(weight)))
}
if reason != "" {
tags.Append(tag.NewFromAny("reason", reason))
}
if serviceURL != "" {
tags.Append(tag.NewFromAny("service", serviceURL))
}
// Add expiration tag (3 minutes from now)
expiration := time.Now().Add(AttestationExpiry).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign attestation: %w", err)
}
return ev, nil
}
// NewTrustGraph creates a new trust graph event (kind 30101)
func NewTrustGraph(entries []TrustEntry, signer signer.I) (*event.E, error) {
// Validate trust entries
for i, entry := range entries {
if err := ValidateTrustScore(entry.TrustScore); err != nil {
return nil, fmt.Errorf("invalid trust score at index %d: %w", i, err)
}
}
// Create event
ev := event.New()
ev.Kind = KindTrustGraph
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", "trust-graph"))
// Add trust entries as p tags
for _, entry := range entries {
tags.Append(tag.NewFromAny("p", entry.Pubkey, entry.ServiceURL,
strconv.FormatFloat(entry.TrustScore, 'f', 2, 64)))
}
// Add expiration tag (30 days from now)
expiration := time.Now().Add(TrustGraphExpiry).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign trust graph: %w", err)
}
return ev, nil
}
// NewNameState creates a new name state event (kind 30102)
func NewNameState(name, owner string, registeredAt time.Time, proposalID string,
attestations int, confidence float64, signer signer.I) (*event.E, error) {
// Validate name
name = NormalizeName(name)
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Create event
ev := event.New()
ev.Kind = KindNameState
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", name))
tags.Append(tag.NewFromAny("owner", owner))
tags.Append(tag.NewFromAny("registered_at", strconv.FormatInt(registeredAt.Unix(), 10)))
tags.Append(tag.NewFromAny("proposal", proposalID))
tags.Append(tag.NewFromAny("attestations", strconv.Itoa(attestations)))
tags.Append(tag.NewFromAny("confidence", strconv.FormatFloat(confidence, 'f', 2, 64)))
// Add expiration tag (1 year from registration)
expiration := registeredAt.Add(NameRegistrationPeriod).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign name state: %w", err)
}
return ev, nil
}
// NewNameRecord creates a new name record event (kind 30103)
func NewNameRecord(name, recordType, value string, ttl int, signer signer.I) (*event.E, error) {
// Validate name
name = NormalizeName(name)
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Validate record value
if err := ValidateRecordValue(recordType, value); err != nil {
return nil, err
}
// Create event
ev := event.New()
ev.Kind = KindNameRecords
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", fmt.Sprintf("%s:%s", name, recordType)))
tags.Append(tag.NewFromAny("name", name))
tags.Append(tag.NewFromAny("type", recordType))
tags.Append(tag.NewFromAny("value", value))
if ttl > 0 {
tags.Append(tag.NewFromAny("ttl", strconv.Itoa(ttl)))
}
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign name record: %w", err)
}
return ev, nil
}
// NewNameRecordWithPriority creates a name record with priority (for MX, SRV)
func NewNameRecordWithPriority(name, recordType, value string, ttl, priority int, signer signer.I) (*event.E, error) {
// Validate priority
if err := ValidatePriority(priority); err != nil {
return nil, err
}
// Create base record
ev, err := NewNameRecord(name, recordType, value, ttl, signer)
if err != nil {
return nil, err
}
// Add priority tag
ev.Tags.Append(tag.NewFromAny("priority", strconv.Itoa(priority)))
// Re-sign
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign record with priority: %w", err)
}
return ev, nil
}
// NewSRVRecord creates an SRV record with all required fields
func NewSRVRecord(name, value string, ttl, priority, weight, port int, signer signer.I) (*event.E, error) {
// Validate SRV-specific fields
if err := ValidatePriority(priority); err != nil {
return nil, err
}
if err := ValidateWeight(weight); err != nil {
return nil, err
}
if err := ValidatePort(port); err != nil {
return nil, err
}
// Create base record
ev, err := NewNameRecord(name, RecordTypeSRV, value, ttl, signer)
if err != nil {
return nil, err
}
// Add SRV-specific tags
ev.Tags.Append(tag.NewFromAny("priority", strconv.Itoa(priority)))
ev.Tags.Append(tag.NewFromAny("weight", strconv.Itoa(weight)))
ev.Tags.Append(tag.NewFromAny("port", strconv.Itoa(port)))
// Re-sign
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign SRV record: %w", err)
}
return ev, nil
}
// NewCertificate creates a new certificate event (kind 30104)
func NewCertificate(name, certPubkey string, validFrom, validUntil time.Time,
challenge, challengeProof string, witnesses []WitnessSignature,
algorithm, usage string, signer signer.I) (*event.E, error) {
// Validate name
name = NormalizeName(name)
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Create event
ev := event.New()
ev.Kind = KindCertificate
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", name))
tags.Append(tag.NewFromAny("name", name))
tags.Append(tag.NewFromAny("cert_pubkey", certPubkey))
tags.Append(tag.NewFromAny("valid_from", strconv.FormatInt(validFrom.Unix(), 10)))
tags.Append(tag.NewFromAny("valid_until", strconv.FormatInt(validUntil.Unix(), 10)))
tags.Append(tag.NewFromAny("challenge", challenge))
tags.Append(tag.NewFromAny("challenge_proof", challengeProof))
// Add witness signatures
for _, w := range witnesses {
tags.Append(tag.NewFromAny("witness", w.Pubkey, w.Signature))
}
ev.Tags = tags
// Add metadata to content
content := fmt.Sprintf(`{"algorithm":"%s","usage":"%s"}`, algorithm, usage)
ev.Content = []byte(content)
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign certificate: %w", err)
}
return ev, nil
}
// NewWitnessService creates a new witness service info event (kind 30105)
func NewWitnessService(endpoint string, challenges []string, maxValidity, fee int,
reputationID, description, contact string, signer signer.I) (*event.E, error) {
// Create event
ev := event.New()
ev.Kind = KindWitnessService
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", "witness-service"))
tags.Append(tag.NewFromAny("endpoint", endpoint))
for _, ch := range challenges {
tags.Append(tag.NewFromAny("challenges", ch))
}
if maxValidity > 0 {
tags.Append(tag.NewFromAny("max_validity", strconv.Itoa(maxValidity)))
}
if fee > 0 {
tags.Append(tag.NewFromAny("fee", strconv.Itoa(fee)))
}
if reputationID != "" {
tags.Append(tag.NewFromAny("reputation", reputationID))
}
// Add expiration tag (180 days from now)
expiration := time.Now().Add(WitnessServiceExpiry).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
// Add metadata to content
content := fmt.Sprintf(`{"description":"%s","contact":"%s"}`, description, contact)
ev.Content = []byte(content)
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign witness service: %w", err)
}
return ev, nil
}

325
pkg/find/certificate.go Normal file
View File

@@ -0,0 +1,325 @@
package find
import (
"crypto/rand"
"fmt"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/interfaces/signer"
)
// GenerateChallenge generates a random 32-byte challenge token
func GenerateChallenge() (string, error) {
challenge := make([]byte, 32)
if _, err := rand.Read(challenge); err != nil {
return "", fmt.Errorf("failed to generate random challenge: %w", err)
}
return hex.Enc(challenge), nil
}
// CreateChallengeTXTRecord creates a TXT record event for challenge-response verification
func CreateChallengeTXTRecord(name, challenge string, ttl int, signer signer.I) (*event.E, error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Create TXT record value
txtValue := fmt.Sprintf("_nostr-challenge=%s", challenge)
// Create the TXT record event
record, err := NewNameRecord(name, RecordTypeTXT, txtValue, ttl, signer)
if err != nil {
return nil, fmt.Errorf("failed to create challenge TXT record: %w", err)
}
return record, nil
}
// ExtractChallengeFromTXTRecord extracts the challenge token from a TXT record value
func ExtractChallengeFromTXTRecord(txtValue string) (string, error) {
const prefix = "_nostr-challenge="
if len(txtValue) < len(prefix) {
return "", fmt.Errorf("TXT record too short")
}
if txtValue[:len(prefix)] != prefix {
return "", fmt.Errorf("not a challenge TXT record")
}
challenge := txtValue[len(prefix):]
if len(challenge) != 64 { // 32 bytes in hex = 64 characters
return "", fmt.Errorf("invalid challenge length: %d", len(challenge))
}
return challenge, nil
}
// CreateChallengeProof creates a challenge proof signature
func CreateChallengeProof(challenge, name, certPubkey string, validUntil time.Time, signer signer.I) (string, error) {
// Normalize name
name = NormalizeName(name)
// Sign the challenge proof
proof, err := SignChallengeProof(challenge, name, certPubkey, validUntil, signer)
if err != nil {
return "", fmt.Errorf("failed to create challenge proof: %w", err)
}
return proof, nil
}
// RequestWitnessSignature creates a witness signature for a certificate
// This would typically be called by a witness service
func RequestWitnessSignature(cert *Certificate, witnessSigner signer.I) (WitnessSignature, error) {
// Sign the witness message
sig, err := SignWitnessMessage(cert.CertPubkey, cert.Name,
cert.ValidFrom, cert.ValidUntil, cert.Challenge, witnessSigner)
if err != nil {
return WitnessSignature{}, fmt.Errorf("failed to create witness signature: %w", err)
}
// Get witness pubkey
witnessPubkey := hex.Enc(witnessSigner.Pub())
return WitnessSignature{
Pubkey: witnessPubkey,
Signature: sig,
}, nil
}
// PrepareCertificateRequest prepares all the data needed for a certificate request
type CertificateRequest struct {
Name string
CertPubkey string
ValidFrom time.Time
ValidUntil time.Time
Challenge string
ChallengeProof string
}
// CreateCertificateRequest creates a certificate request with challenge-response
func CreateCertificateRequest(name, certPubkey string, validityDuration time.Duration,
challenge string, ownerSigner signer.I) (*CertificateRequest, error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Set validity period
validFrom := time.Now()
validUntil := validFrom.Add(validityDuration)
// Create challenge proof
proof, err := CreateChallengeProof(challenge, name, certPubkey, validUntil, ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create challenge proof: %w", err)
}
return &CertificateRequest{
Name: name,
CertPubkey: certPubkey,
ValidFrom: validFrom,
ValidUntil: validUntil,
Challenge: challenge,
ChallengeProof: proof,
}, nil
}
// CreateCertificateWithWitnesses creates a complete certificate event with witness signatures
func CreateCertificateWithWitnesses(req *CertificateRequest, witnesses []WitnessSignature,
algorithm, usage string, ownerSigner signer.I) (*event.E, error) {
// Create the certificate event
certEvent, err := NewCertificate(
req.Name,
req.CertPubkey,
req.ValidFrom,
req.ValidUntil,
req.Challenge,
req.ChallengeProof,
witnesses,
algorithm,
usage,
ownerSigner,
)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err)
}
return certEvent, nil
}
// VerifyChallengeTXTRecord verifies that a TXT record contains the expected challenge
func VerifyChallengeTXTRecord(record *NameRecord, expectedChallenge string, nameOwner string) error {
// Check record type
if record.Type != RecordTypeTXT {
return fmt.Errorf("not a TXT record: %s", record.Type)
}
// Check record owner matches name owner
recordOwner := hex.Enc(record.Event.Pubkey)
if recordOwner != nameOwner {
return fmt.Errorf("record owner %s != name owner %s", recordOwner, nameOwner)
}
// Extract challenge from TXT record
challenge, err := ExtractChallengeFromTXTRecord(record.Value)
if err != nil {
return fmt.Errorf("failed to extract challenge: %w", err)
}
// Verify challenge matches
if challenge != expectedChallenge {
return fmt.Errorf("challenge mismatch: got %s, expected %s", challenge, expectedChallenge)
}
return nil
}
// IssueCertificate is a helper that goes through the full certificate issuance process
// This would typically be used by a name owner to request a certificate
func IssueCertificate(name, certPubkey string, validityDuration time.Duration,
ownerSigner signer.I, witnessSigners []signer.I) (*Certificate, error) {
// Generate challenge
challenge, err := GenerateChallenge()
if err != nil {
return nil, fmt.Errorf("failed to generate challenge: %w", err)
}
// Create certificate request
req, err := CreateCertificateRequest(name, certPubkey, validityDuration, challenge, ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create certificate request: %w", err)
}
// Collect witness signatures
var witnesses []WitnessSignature
for i, ws := range witnessSigners {
// Create temporary certificate for witness to sign
tempCert := &Certificate{
Name: req.Name,
CertPubkey: req.CertPubkey,
ValidFrom: req.ValidFrom,
ValidUntil: req.ValidUntil,
Challenge: req.Challenge,
}
witness, err := RequestWitnessSignature(tempCert, ws)
if err != nil {
return nil, fmt.Errorf("failed to get witness %d signature: %w", i, err)
}
witnesses = append(witnesses, witness)
}
// Create certificate event
certEvent, err := CreateCertificateWithWitnesses(req, witnesses, "secp256k1-schnorr", "tls-replacement", ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create certificate event: %w", err)
}
// Parse back to Certificate struct
cert, err := ParseCertificate(certEvent)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
// RenewCertificate creates a renewed certificate with a new validity period
func RenewCertificate(oldCert *Certificate, newValidityDuration time.Duration,
ownerSigner signer.I, witnessSigners []signer.I) (*Certificate, error) {
// Generate new challenge
challenge, err := GenerateChallenge()
if err != nil {
return nil, fmt.Errorf("failed to generate challenge: %w", err)
}
// Set new validity period (with 7-day overlap)
validFrom := oldCert.ValidUntil.Add(-7 * 24 * time.Hour)
validUntil := validFrom.Add(newValidityDuration)
// Create challenge proof
proof, err := CreateChallengeProof(challenge, oldCert.Name, oldCert.CertPubkey, validUntil, ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create challenge proof: %w", err)
}
// Create request
req := &CertificateRequest{
Name: oldCert.Name,
CertPubkey: oldCert.CertPubkey,
ValidFrom: validFrom,
ValidUntil: validUntil,
Challenge: challenge,
ChallengeProof: proof,
}
// Collect witness signatures
var witnesses []WitnessSignature
for i, ws := range witnessSigners {
tempCert := &Certificate{
Name: req.Name,
CertPubkey: req.CertPubkey,
ValidFrom: req.ValidFrom,
ValidUntil: req.ValidUntil,
Challenge: req.Challenge,
}
witness, err := RequestWitnessSignature(tempCert, ws)
if err != nil {
return nil, fmt.Errorf("failed to get witness %d signature: %w", i, err)
}
witnesses = append(witnesses, witness)
}
// Create certificate event
certEvent, err := CreateCertificateWithWitnesses(req, witnesses, oldCert.Algorithm, oldCert.Usage, ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create certificate event: %w", err)
}
// Parse back to Certificate struct
cert, err := ParseCertificate(certEvent)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
// CheckCertificateExpiry returns the time until expiration, or error if expired
func CheckCertificateExpiry(cert *Certificate) (time.Duration, error) {
now := time.Now()
if now.After(cert.ValidUntil) {
return 0, fmt.Errorf("certificate expired %v ago", now.Sub(cert.ValidUntil))
}
return cert.ValidUntil.Sub(now), nil
}
// ShouldRenewCertificate checks if a certificate should be renewed (< 30 days until expiry)
func ShouldRenewCertificate(cert *Certificate) bool {
timeUntilExpiry, err := CheckCertificateExpiry(cert)
if err != nil {
return true // Expired, definitely should renew
}
return timeUntilExpiry < 30*24*time.Hour
}

455
pkg/find/parser.go Normal file
View File

@@ -0,0 +1,455 @@
package find
import (
"encoding/json"
"fmt"
"strconv"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// getTagValue retrieves the value of the first tag with the given key
func getTagValue(ev *event.E, key string) string {
t := ev.Tags.GetFirst([]byte(key))
if t == nil {
return ""
}
return string(t.Value())
}
// getAllTags retrieves all tags with the given key
func getAllTags(ev *event.E, key string) []*tag.T {
return ev.Tags.GetAll([]byte(key))
}
// ParseRegistrationProposal parses a kind 30100 event into a RegistrationProposal
func ParseRegistrationProposal(ev *event.E) (*RegistrationProposal, error) {
if uint16(ev.Kind) != KindRegistrationProposal {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindRegistrationProposal, ev.Kind)
}
name := getTagValue(ev, "d")
if name == "" {
return nil, fmt.Errorf("missing 'd' tag (name)")
}
action := getTagValue(ev, "action")
if action == "" {
return nil, fmt.Errorf("missing 'action' tag")
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
proposal := &RegistrationProposal{
Event: ev,
Name: name,
Action: action,
PrevOwner: getTagValue(ev, "prev_owner"),
PrevSig: getTagValue(ev, "prev_sig"),
Expiration: expiration,
}
return proposal, nil
}
// ParseAttestation parses a kind 20100 event into an Attestation
func ParseAttestation(ev *event.E) (*Attestation, error) {
if uint16(ev.Kind) != KindAttestation {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindAttestation, ev.Kind)
}
proposalID := getTagValue(ev, "e")
if proposalID == "" {
return nil, fmt.Errorf("missing 'e' tag (proposal ID)")
}
decision := getTagValue(ev, "decision")
if decision == "" {
return nil, fmt.Errorf("missing 'decision' tag")
}
weightStr := getTagValue(ev, "weight")
weight := 100 // default weight
if weightStr != "" {
w, err := strconv.Atoi(weightStr)
if err != nil {
return nil, fmt.Errorf("invalid weight value: %w", err)
}
weight = w
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
attestation := &Attestation{
Event: ev,
ProposalID: proposalID,
Decision: decision,
Weight: weight,
Reason: getTagValue(ev, "reason"),
ServiceURL: getTagValue(ev, "service"),
Expiration: expiration,
}
return attestation, nil
}
// ParseTrustGraph parses a kind 30101 event into a TrustGraph
func ParseTrustGraph(ev *event.E) (*TrustGraph, error) {
if uint16(ev.Kind) != KindTrustGraph {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindTrustGraph, ev.Kind)
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
// Parse p tags (trust entries)
var entries []TrustEntry
pTags := getAllTags(ev, "p")
for _, t := range pTags {
if len(t.T) < 2 {
continue // Skip malformed tags
}
pubkey := string(t.T[1])
serviceURL := ""
trustScore := 0.5 // default
if len(t.T) > 2 {
serviceURL = string(t.T[2])
}
if len(t.T) > 3 {
score, err := strconv.ParseFloat(string(t.T[3]), 64)
if err == nil {
trustScore = score
}
}
entries = append(entries, TrustEntry{
Pubkey: pubkey,
ServiceURL: serviceURL,
TrustScore: trustScore,
})
}
return &TrustGraph{
Event: ev,
Entries: entries,
Expiration: expiration,
}, nil
}
// ParseNameState parses a kind 30102 event into a NameState
func ParseNameState(ev *event.E) (*NameState, error) {
if uint16(ev.Kind) != KindNameState {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindNameState, ev.Kind)
}
name := getTagValue(ev, "d")
if name == "" {
return nil, fmt.Errorf("missing 'd' tag (name)")
}
owner := getTagValue(ev, "owner")
if owner == "" {
return nil, fmt.Errorf("missing 'owner' tag")
}
registeredAtStr := getTagValue(ev, "registered_at")
if registeredAtStr == "" {
return nil, fmt.Errorf("missing 'registered_at' tag")
}
registeredAtUnix, err := strconv.ParseInt(registeredAtStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid registered_at timestamp: %w", err)
}
registeredAt := time.Unix(registeredAtUnix, 0)
attestationsStr := getTagValue(ev, "attestations")
attestations := 0
if attestationsStr != "" {
a, err := strconv.Atoi(attestationsStr)
if err == nil {
attestations = a
}
}
confidenceStr := getTagValue(ev, "confidence")
confidence := 0.0
if confidenceStr != "" {
c, err := strconv.ParseFloat(confidenceStr, 64)
if err == nil {
confidence = c
}
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
return &NameState{
Event: ev,
Name: name,
Owner: owner,
RegisteredAt: registeredAt,
ProposalID: getTagValue(ev, "proposal"),
Attestations: attestations,
Confidence: confidence,
Expiration: expiration,
}, nil
}
// ParseNameRecord parses a kind 30103 event into a NameRecord
func ParseNameRecord(ev *event.E) (*NameRecord, error) {
if uint16(ev.Kind) != KindNameRecords {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindNameRecords, ev.Kind)
}
name := getTagValue(ev, "name")
if name == "" {
return nil, fmt.Errorf("missing 'name' tag")
}
recordType := getTagValue(ev, "type")
if recordType == "" {
return nil, fmt.Errorf("missing 'type' tag")
}
value := getTagValue(ev, "value")
if value == "" {
return nil, fmt.Errorf("missing 'value' tag")
}
ttlStr := getTagValue(ev, "ttl")
ttl := 3600 // default TTL
if ttlStr != "" {
t, err := strconv.Atoi(ttlStr)
if err == nil {
ttl = t
}
}
priorityStr := getTagValue(ev, "priority")
priority := 0
if priorityStr != "" {
p, err := strconv.Atoi(priorityStr)
if err == nil {
priority = p
}
}
weightStr := getTagValue(ev, "weight")
weight := 0
if weightStr != "" {
w, err := strconv.Atoi(weightStr)
if err == nil {
weight = w
}
}
portStr := getTagValue(ev, "port")
port := 0
if portStr != "" {
p, err := strconv.Atoi(portStr)
if err == nil {
port = p
}
}
return &NameRecord{
Event: ev,
Name: name,
Type: recordType,
Value: value,
TTL: ttl,
Priority: priority,
Weight: weight,
Port: port,
}, nil
}
// ParseCertificate parses a kind 30104 event into a Certificate
func ParseCertificate(ev *event.E) (*Certificate, error) {
if uint16(ev.Kind) != KindCertificate {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindCertificate, ev.Kind)
}
name := getTagValue(ev, "name")
if name == "" {
return nil, fmt.Errorf("missing 'name' tag")
}
certPubkey := getTagValue(ev, "cert_pubkey")
if certPubkey == "" {
return nil, fmt.Errorf("missing 'cert_pubkey' tag")
}
validFromStr := getTagValue(ev, "valid_from")
if validFromStr == "" {
return nil, fmt.Errorf("missing 'valid_from' tag")
}
validFromUnix, err := strconv.ParseInt(validFromStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid valid_from timestamp: %w", err)
}
validFrom := time.Unix(validFromUnix, 0)
validUntilStr := getTagValue(ev, "valid_until")
if validUntilStr == "" {
return nil, fmt.Errorf("missing 'valid_until' tag")
}
validUntilUnix, err := strconv.ParseInt(validUntilStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid valid_until timestamp: %w", err)
}
validUntil := time.Unix(validUntilUnix, 0)
// Parse witness tags
var witnesses []WitnessSignature
witnessTags := getAllTags(ev, "witness")
for _, t := range witnessTags {
if len(t.T) < 3 {
continue // Skip malformed tags
}
witnesses = append(witnesses, WitnessSignature{
Pubkey: string(t.T[1]),
Signature: string(t.T[2]),
})
}
// Parse content JSON
algorithm := "secp256k1-schnorr"
usage := "tls-replacement"
if len(ev.Content) > 0 {
var metadata map[string]interface{}
if err := json.Unmarshal(ev.Content, &metadata); err == nil {
if alg, ok := metadata["algorithm"].(string); ok {
algorithm = alg
}
if u, ok := metadata["usage"].(string); ok {
usage = u
}
}
}
return &Certificate{
Event: ev,
Name: name,
CertPubkey: certPubkey,
ValidFrom: validFrom,
ValidUntil: validUntil,
Challenge: getTagValue(ev, "challenge"),
ChallengeProof: getTagValue(ev, "challenge_proof"),
Witnesses: witnesses,
Algorithm: algorithm,
Usage: usage,
}, nil
}
// ParseWitnessService parses a kind 30105 event into a WitnessService
func ParseWitnessService(ev *event.E) (*WitnessService, error) {
if uint16(ev.Kind) != KindWitnessService {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindWitnessService, ev.Kind)
}
endpoint := getTagValue(ev, "endpoint")
if endpoint == "" {
return nil, fmt.Errorf("missing 'endpoint' tag")
}
// Parse challenge tags
var challenges []string
challengeTags := getAllTags(ev, "challenges")
for _, t := range challengeTags {
if len(t.T) >= 2 {
challenges = append(challenges, string(t.T[1]))
}
}
maxValidityStr := getTagValue(ev, "max_validity")
maxValidity := 0
if maxValidityStr != "" {
mv, err := strconv.Atoi(maxValidityStr)
if err == nil {
maxValidity = mv
}
}
feeStr := getTagValue(ev, "fee")
fee := 0
if feeStr != "" {
f, err := strconv.Atoi(feeStr)
if err == nil {
fee = f
}
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
// Parse content JSON
description := ""
contact := ""
if len(ev.Content) > 0 {
var metadata map[string]interface{}
if err := json.Unmarshal(ev.Content, &metadata); err == nil {
if desc, ok := metadata["description"].(string); ok {
description = desc
}
if cont, ok := metadata["contact"].(string); ok {
contact = cont
}
}
}
return &WitnessService{
Event: ev,
Endpoint: endpoint,
Challenges: challenges,
MaxValidity: maxValidity,
Fee: fee,
ReputationID: getTagValue(ev, "reputation"),
Description: description,
Contact: contact,
Expiration: expiration,
}, nil
}

167
pkg/find/sign.go Normal file
View File

@@ -0,0 +1,167 @@
package find
import (
"crypto/sha256"
"fmt"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/interfaces/signer"
)
// SignTransferAuth creates a signature for transfer authorization
// Message format: transfer:<name>:<new_owner_pubkey>:<timestamp>
func SignTransferAuth(name, newOwner string, timestamp time.Time, s signer.I) (string, error) {
// Normalize name
name = NormalizeName(name)
// Construct message
message := fmt.Sprintf("transfer:%s:%s:%d", name, newOwner, timestamp.Unix())
// Hash the message
hash := sha256.Sum256([]byte(message))
// Sign the hash
sig, err := s.Sign(hash[:])
if err != nil {
return "", fmt.Errorf("failed to sign transfer authorization: %w", err)
}
// Return hex-encoded signature
return hex.Enc(sig), nil
}
// SignChallengeProof creates a signature for certificate challenge proof
// Message format: challenge||name||cert_pubkey||valid_until
func SignChallengeProof(challenge, name, certPubkey string, validUntil time.Time, s signer.I) (string, error) {
// Normalize name
name = NormalizeName(name)
// Construct message
message := fmt.Sprintf("%s||%s||%s||%d", challenge, name, certPubkey, validUntil.Unix())
// Hash the message
hash := sha256.Sum256([]byte(message))
// Sign the hash
sig, err := s.Sign(hash[:])
if err != nil {
return "", fmt.Errorf("failed to sign challenge proof: %w", err)
}
// Return hex-encoded signature
return hex.Enc(sig), nil
}
// SignWitnessMessage creates a witness signature for a certificate
// Message format: cert_pubkey||name||valid_from||valid_until||challenge
func SignWitnessMessage(certPubkey, name string, validFrom, validUntil time.Time, challenge string, s signer.I) (string, error) {
// Normalize name
name = NormalizeName(name)
// Construct message
message := fmt.Sprintf("%s||%s||%d||%d||%s",
certPubkey, name, validFrom.Unix(), validUntil.Unix(), challenge)
// Hash the message
hash := sha256.Sum256([]byte(message))
// Sign the hash
sig, err := s.Sign(hash[:])
if err != nil {
return "", fmt.Errorf("failed to sign witness message: %w", err)
}
// Return hex-encoded signature
return hex.Enc(sig), nil
}
// CreateTransferAuthMessage constructs the transfer authorization message
// This is used for verification
func CreateTransferAuthMessage(name, newOwner string, timestamp time.Time) []byte {
name = NormalizeName(name)
message := fmt.Sprintf("transfer:%s:%s:%d", name, newOwner, timestamp.Unix())
hash := sha256.Sum256([]byte(message))
return hash[:]
}
// CreateChallengeProofMessage constructs the challenge proof message
// This is used for verification
func CreateChallengeProofMessage(challenge, name, certPubkey string, validUntil time.Time) []byte {
name = NormalizeName(name)
message := fmt.Sprintf("%s||%s||%s||%d", challenge, name, certPubkey, validUntil.Unix())
hash := sha256.Sum256([]byte(message))
return hash[:]
}
// CreateWitnessMessage constructs the witness message
// This is used for verification
func CreateWitnessMessage(certPubkey, name string, validFrom, validUntil time.Time, challenge string) []byte {
name = NormalizeName(name)
message := fmt.Sprintf("%s||%s||%d||%d||%s",
certPubkey, name, validFrom.Unix(), validUntil.Unix(), challenge)
hash := sha256.Sum256([]byte(message))
return hash[:]
}
// ParseTimestampFromProposal extracts the timestamp from a transfer authorization message
// Used for verification when the timestamp is embedded in the signature
func ParseTimestampFromProposal(proposalTime time.Time) time.Time {
// Round to nearest second for consistency
return proposalTime.Truncate(time.Second)
}
// FormatTransferAuthString formats the transfer auth message for display/debugging
func FormatTransferAuthString(name, newOwner string, timestamp time.Time) string {
name = NormalizeName(name)
return fmt.Sprintf("transfer:%s:%s:%d", name, newOwner, timestamp.Unix())
}
// FormatChallengeProofString formats the challenge proof message for display/debugging
func FormatChallengeProofString(challenge, name, certPubkey string, validUntil time.Time) string {
name = NormalizeName(name)
return fmt.Sprintf("%s||%s||%s||%d", challenge, name, certPubkey, validUntil.Unix())
}
// FormatWitnessString formats the witness message for display/debugging
func FormatWitnessString(certPubkey, name string, validFrom, validUntil time.Time, challenge string) string {
name = NormalizeName(name)
return fmt.Sprintf("%s||%s||%d||%d||%s",
certPubkey, name, validFrom.Unix(), validUntil.Unix(), challenge)
}
// SignProposal signs a registration proposal event
func SignProposal(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignAttestation signs an attestation event
func SignAttestation(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignTrustGraph signs a trust graph event
func SignTrustGraph(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignNameState signs a name state event
func SignNameState(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignNameRecord signs a name record event
func SignNameRecord(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignCertificate signs a certificate event
func SignCertificate(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignWitnessService signs a witness service event
func SignWitnessService(ev *event.E, s signer.I) error {
return ev.Sign(s)
}

168
pkg/find/transfer.go Normal file
View File

@@ -0,0 +1,168 @@
package find
import (
"fmt"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/interfaces/signer"
)
// CreateTransferProposal creates a complete transfer proposal with authorization from previous owner
func CreateTransferProposal(name string, prevOwnerSigner, newOwnerSigner signer.I) (*event.E, error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Get public keys
prevOwnerPubkey := hex.Enc(prevOwnerSigner.Pub())
newOwnerPubkey := hex.Enc(newOwnerSigner.Pub())
// Create timestamp for the transfer
timestamp := time.Now()
// Sign the transfer authorization with previous owner's key
prevSig, err := SignTransferAuth(name, newOwnerPubkey, timestamp, prevOwnerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create transfer authorization: %w", err)
}
// Create the transfer proposal event signed by new owner
proposal, err := NewRegistrationProposalWithTransfer(name, prevOwnerPubkey, prevSig, newOwnerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create transfer proposal: %w", err)
}
return proposal, nil
}
// ValidateTransferProposal validates a transfer proposal against the current owner
func ValidateTransferProposal(proposal *RegistrationProposal, currentOwner string) error {
// Check that this is a transfer action
if proposal.Action != ActionTransfer {
return fmt.Errorf("not a transfer action: %s", proposal.Action)
}
// Check that prev_owner is set
if proposal.PrevOwner == "" {
return fmt.Errorf("missing prev_owner in transfer proposal")
}
// Check that prev_sig is set
if proposal.PrevSig == "" {
return fmt.Errorf("missing prev_sig in transfer proposal")
}
// Verify that prev_owner matches current owner
if proposal.PrevOwner != currentOwner {
return fmt.Errorf("prev_owner %s does not match current owner %s",
proposal.PrevOwner, currentOwner)
}
// Get new owner from proposal event
newOwnerPubkey := hex.Enc(proposal.Event.Pubkey)
// Verify the transfer authorization signature
// Use proposal creation time as timestamp
timestamp := time.Unix(proposal.Event.CreatedAt, 0)
ok, err := VerifyTransferAuth(proposal.Name, newOwnerPubkey, proposal.PrevOwner,
timestamp, proposal.PrevSig)
if err != nil {
return fmt.Errorf("transfer authorization verification failed: %w", err)
}
if !ok {
return fmt.Errorf("invalid transfer authorization signature")
}
return nil
}
// PrepareTransferAuth prepares the transfer authorization data that needs to be signed
// This is a helper for wallets/clients that want to show what they're signing
func PrepareTransferAuth(name, newOwner string, timestamp time.Time) TransferAuthorization {
return TransferAuthorization{
Name: NormalizeName(name),
NewOwner: newOwner,
Timestamp: timestamp,
}
}
// AuthorizeTransfer creates a transfer authorization signature
// This is meant to be used by the current owner to authorize a transfer to a new owner
func AuthorizeTransfer(name, newOwnerPubkey string, ownerSigner signer.I) (prevSig string, timestamp time.Time, err error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return "", time.Time{}, fmt.Errorf("invalid name: %w", err)
}
// Create timestamp
timestamp = time.Now()
// Sign the authorization
prevSig, err = SignTransferAuth(name, newOwnerPubkey, timestamp, ownerSigner)
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to sign transfer auth: %w", err)
}
return prevSig, timestamp, nil
}
// CreateTransferProposalWithAuth creates a transfer proposal using a pre-existing authorization
// This is useful when the previous owner has already provided their signature
func CreateTransferProposalWithAuth(name, prevOwnerPubkey, prevSig string, newOwnerSigner signer.I) (*event.E, error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Create the transfer proposal event
proposal, err := NewRegistrationProposalWithTransfer(name, prevOwnerPubkey, prevSig, newOwnerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create transfer proposal: %w", err)
}
return proposal, nil
}
// VerifyTransferProposalSignature verifies both the event signature and transfer authorization
func VerifyTransferProposalSignature(proposal *RegistrationProposal) error {
// Verify the event signature itself
if err := VerifyEvent(proposal.Event); err != nil {
return fmt.Errorf("invalid event signature: %w", err)
}
// If this is a transfer, verify the transfer authorization
if proposal.Action == ActionTransfer {
// Get new owner from proposal event
newOwnerPubkey := hex.Enc(proposal.Event.Pubkey)
// Use proposal creation time as timestamp
timestamp := time.Unix(proposal.Event.CreatedAt, 0)
// Verify transfer auth
ok, err := VerifyTransferAuth(proposal.Name, newOwnerPubkey, proposal.PrevOwner,
timestamp, proposal.PrevSig)
if err != nil {
return fmt.Errorf("transfer authorization verification failed: %w", err)
}
if !ok {
return fmt.Errorf("invalid transfer authorization signature")
}
}
return nil
}

180
pkg/find/types.go Normal file
View File

@@ -0,0 +1,180 @@
package find
import (
"time"
"next.orly.dev/pkg/encoders/event"
)
// Event kind constants as defined in the NIP
const (
KindRegistrationProposal = 30100 // Parameterized replaceable
KindAttestation = 20100 // Ephemeral
KindTrustGraph = 30101 // Parameterized replaceable
KindNameState = 30102 // Parameterized replaceable
KindNameRecords = 30103 // Parameterized replaceable
KindCertificate = 30104 // Parameterized replaceable
KindWitnessService = 30105 // Parameterized replaceable
)
// Action types for registration proposals
const (
ActionRegister = "register"
ActionTransfer = "transfer"
)
// Decision types for attestations
const (
DecisionApprove = "approve"
DecisionReject = "reject"
DecisionAbstain = "abstain"
)
// DNS record types
const (
RecordTypeA = "A"
RecordTypeAAAA = "AAAA"
RecordTypeCNAME = "CNAME"
RecordTypeMX = "MX"
RecordTypeTXT = "TXT"
RecordTypeNS = "NS"
RecordTypeSRV = "SRV"
)
// Time constants
const (
ProposalExpiry = 5 * time.Minute // Proposals expire after 5 minutes
AttestationExpiry = 3 * time.Minute // Attestations expire after 3 minutes
TrustGraphExpiry = 30 * 24 * time.Hour // Trust graphs expire after 30 days
NameRegistrationPeriod = 365 * 24 * time.Hour // Names expire after 1 year
PreferentialRenewalDays = 30 // Final 30 days before expiration
CertificateValidity = 90 * 24 * time.Hour // Recommended certificate validity
WitnessServiceExpiry = 180 * 24 * time.Hour // Witness service info expires after 180 days
)
// RegistrationProposal represents a kind 30100 event
type RegistrationProposal struct {
Event *event.E
Name string
Action string // "register" or "transfer"
PrevOwner string // Previous owner pubkey (for transfers)
PrevSig string // Signature from previous owner (for transfers)
Expiration time.Time
}
// Attestation represents a kind 20100 event
type Attestation struct {
Event *event.E
ProposalID string // Event ID of the proposal being attested
Decision string // "approve", "reject", or "abstain"
Weight int // Stake/confidence weight (default 100)
Reason string // Human-readable justification
ServiceURL string // Registry service endpoint
Expiration time.Time
}
// TrustEntry represents a single trust relationship
type TrustEntry struct {
Pubkey string
ServiceURL string
TrustScore float64 // 0.0 to 1.0
}
// TrustGraph represents a kind 30101 event
type TrustGraph struct {
Event *event.E
Entries []TrustEntry
Expiration time.Time
}
// NameState represents a kind 30102 event
type NameState struct {
Event *event.E
Name string
Owner string // Current owner pubkey
RegisteredAt time.Time
ProposalID string // Event ID of the registration proposal
Attestations int // Number of attestations
Confidence float64 // Consensus confidence score (0.0 to 1.0)
Expiration time.Time
}
// NameRecord represents a kind 30103 event
type NameRecord struct {
Event *event.E
Name string
Type string // A, AAAA, CNAME, MX, TXT, NS, SRV
Value string
TTL int // Cache TTL in seconds
Priority int // For MX and SRV records
Weight int // For SRV records
Port int // For SRV records
}
// RecordLimits defines per-type record limits
var RecordLimits = map[string]int{
RecordTypeA: 5,
RecordTypeAAAA: 5,
RecordTypeCNAME: 1,
RecordTypeMX: 5,
RecordTypeTXT: 10,
RecordTypeNS: 5,
RecordTypeSRV: 10,
}
// Certificate represents a kind 30104 event
type Certificate struct {
Event *event.E
Name string
CertPubkey string // Public key for the service
ValidFrom time.Time
ValidUntil time.Time
Challenge string // Challenge token for ownership proof
ChallengeProof string // Signature over challenge
Witnesses []WitnessSignature
Algorithm string // e.g., "secp256k1-schnorr"
Usage string // e.g., "tls-replacement"
}
// WitnessSignature represents a witness attestation on a certificate
type WitnessSignature struct {
Pubkey string
Signature string
}
// WitnessService represents a kind 30105 event
type WitnessService struct {
Event *event.E
Endpoint string
Challenges []string // Supported challenge types: "txt", "http", "event"
MaxValidity int // Maximum certificate validity in seconds
Fee int // Fee in sats per certificate
ReputationID string // Event ID of reputation event
Description string
Contact string
Expiration time.Time
}
// TransferAuthorization represents the message signed for transfer authorization
type TransferAuthorization struct {
Name string
NewOwner string
Timestamp time.Time
}
// ChallengeProofMessage represents the message signed for certificate challenge proof
type ChallengeProofMessage struct {
Challenge string
Name string
CertPubkey string
ValidUntil time.Time
}
// WitnessMessage represents the message signed by witnesses
type WitnessMessage struct {
CertPubkey string
Name string
ValidFrom time.Time
ValidUntil time.Time
Challenge string
}

221
pkg/find/validation.go Normal file
View File

@@ -0,0 +1,221 @@
package find
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
ErrInvalidName = errors.New("invalid name format")
ErrNameTooLong = errors.New("name exceeds 253 characters")
ErrLabelTooLong = errors.New("label exceeds 63 characters")
ErrLabelEmpty = errors.New("label is empty")
ErrInvalidCharacter = errors.New("invalid character in name")
ErrInvalidHyphen = errors.New("label cannot start or end with hyphen")
ErrAllNumericLabel = errors.New("label cannot be all numeric")
ErrInvalidRecordValue = errors.New("invalid record value")
ErrRecordLimitExceeded = errors.New("record limit exceeded")
ErrNotOwner = errors.New("not the name owner")
ErrNameExpired = errors.New("name registration expired")
ErrInRenewalWindow = errors.New("name is in renewal window")
ErrNotRenewalWindow = errors.New("not in renewal window")
)
// Name format validation regex
var (
labelRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
allNumeric = regexp.MustCompile(`^[0-9]+$`)
)
// NormalizeName converts a name to lowercase
func NormalizeName(name string) string {
return strings.ToLower(name)
}
// ValidateName validates a name according to DNS naming rules
func ValidateName(name string) error {
// Normalize to lowercase
name = NormalizeName(name)
// Check total length
if len(name) > 253 {
return fmt.Errorf("%w: %d > 253", ErrNameTooLong, len(name))
}
if len(name) == 0 {
return fmt.Errorf("%w: name is empty", ErrInvalidName)
}
// Split into labels
labels := strings.Split(name, ".")
for i, label := range labels {
if err := validateLabel(label); err != nil {
return fmt.Errorf("invalid label %d (%s): %w", i, label, err)
}
}
return nil
}
// validateLabel validates a single label according to DNS rules
func validateLabel(label string) error {
// Check length
if len(label) == 0 {
return ErrLabelEmpty
}
if len(label) > 63 {
return fmt.Errorf("%w: %d > 63", ErrLabelTooLong, len(label))
}
// Check character set and hyphen placement
if !labelRegex.MatchString(label) {
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return ErrInvalidHyphen
}
return ErrInvalidCharacter
}
// Check not all numeric
if allNumeric.MatchString(label) {
return ErrAllNumericLabel
}
return nil
}
// GetParentDomain returns the parent domain of a name
// e.g., "www.example.com" -> "example.com", "example.com" -> "com", "com" -> ""
func GetParentDomain(name string) string {
name = NormalizeName(name)
parts := strings.Split(name, ".")
if len(parts) <= 1 {
return "" // TLD has no parent
}
return strings.Join(parts[1:], ".")
}
// IsTLD returns true if the name is a top-level domain (single label)
func IsTLD(name string) bool {
name = NormalizeName(name)
return !strings.Contains(name, ".")
}
// ValidateIPv4 validates an IPv4 address format
func ValidateIPv4(ip string) error {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
return fmt.Errorf("%w: invalid IPv4 format", ErrInvalidRecordValue)
}
for _, part := range parts {
var octet int
if _, err := fmt.Sscanf(part, "%d", &octet); err != nil {
return fmt.Errorf("%w: invalid IPv4 octet: %v", ErrInvalidRecordValue, err)
}
if octet < 0 || octet > 255 {
return fmt.Errorf("%w: IPv4 octet out of range: %d", ErrInvalidRecordValue, octet)
}
}
return nil
}
// ValidateIPv6 validates an IPv6 address format (simplified check)
func ValidateIPv6(ip string) error {
// Basic validation - contains colons and valid hex characters
if !strings.Contains(ip, ":") {
return fmt.Errorf("%w: invalid IPv6 format", ErrInvalidRecordValue)
}
// Split by colons
parts := strings.Split(ip, ":")
if len(parts) < 3 || len(parts) > 8 {
return fmt.Errorf("%w: invalid IPv6 segment count", ErrInvalidRecordValue)
}
// Check for valid hex characters
validHex := regexp.MustCompile(`^[0-9a-fA-F]*$`)
for _, part := range parts {
if part == "" {
continue // Allow :: notation
}
if len(part) > 4 {
return fmt.Errorf("%w: IPv6 segment too long", ErrInvalidRecordValue)
}
if !validHex.MatchString(part) {
return fmt.Errorf("%w: invalid IPv6 hex", ErrInvalidRecordValue)
}
}
return nil
}
// ValidateRecordValue validates a record value based on its type
func ValidateRecordValue(recordType, value string) error {
switch recordType {
case RecordTypeA:
return ValidateIPv4(value)
case RecordTypeAAAA:
return ValidateIPv6(value)
case RecordTypeCNAME, RecordTypeMX, RecordTypeNS:
return ValidateName(value)
case RecordTypeTXT:
if len(value) > 1024 {
return fmt.Errorf("%w: TXT record exceeds 1024 characters", ErrInvalidRecordValue)
}
return nil
case RecordTypeSRV:
return ValidateName(value) // Hostname for SRV
default:
return fmt.Errorf("%w: unknown record type: %s", ErrInvalidRecordValue, recordType)
}
}
// ValidateRecordLimit checks if adding a record would exceed type limits
func ValidateRecordLimit(recordType string, currentCount int) error {
limit, ok := RecordLimits[recordType]
if !ok {
return fmt.Errorf("%w: unknown record type: %s", ErrInvalidRecordValue, recordType)
}
if currentCount >= limit {
return fmt.Errorf("%w: %s records limited to %d", ErrRecordLimitExceeded, recordType, limit)
}
return nil
}
// ValidatePriority validates priority value (0-65535)
func ValidatePriority(priority int) error {
if priority < 0 || priority > 65535 {
return fmt.Errorf("%w: priority must be 0-65535", ErrInvalidRecordValue)
}
return nil
}
// ValidateWeight validates weight value (0-65535)
func ValidateWeight(weight int) error {
if weight < 0 || weight > 65535 {
return fmt.Errorf("%w: weight must be 0-65535", ErrInvalidRecordValue)
}
return nil
}
// ValidatePort validates port value (0-65535)
func ValidatePort(port int) error {
if port < 0 || port > 65535 {
return fmt.Errorf("%w: port must be 0-65535", ErrInvalidRecordValue)
}
return nil
}
// ValidateTrustScore validates trust score (0.0-1.0)
func ValidateTrustScore(score float64) error {
if score < 0.0 || score > 1.0 {
return fmt.Errorf("trust score must be between 0.0 and 1.0, got %f", score)
}
return nil
}

317
pkg/find/verify.go Normal file
View File

@@ -0,0 +1,317 @@
package find
import (
"fmt"
"time"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/interfaces/signer/p8k"
)
// VerifyEvent verifies the signature of a Nostr event
func VerifyEvent(ev *event.E) error {
ok, err := ev.Verify()
if err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
if !ok {
return fmt.Errorf("invalid signature")
}
return nil
}
// VerifyTransferAuth verifies a transfer authorization signature
func VerifyTransferAuth(name, newOwner, prevOwner string, timestamp time.Time, sigHex string) (bool, error) {
// Create the message
msgHash := CreateTransferAuthMessage(name, newOwner, timestamp)
// Decode signature
sig, err := hex.Dec(sigHex)
if err != nil {
return false, fmt.Errorf("invalid signature hex: %w", err)
}
// Decode pubkey
pubkey, err := hex.Dec(prevOwner)
if err != nil {
return false, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create verifier with public key
verifier, err := p8k.New()
if err != nil {
return false, fmt.Errorf("failed to create verifier: %w", err)
}
if err := verifier.InitPub(pubkey); err != nil {
return false, fmt.Errorf("failed to init pubkey: %w", err)
}
// Verify signature
ok, err := verifier.Verify(msgHash, sig)
if err != nil {
return false, fmt.Errorf("verification failed: %w", err)
}
return ok, nil
}
// VerifyChallengeProof verifies a certificate challenge proof signature
func VerifyChallengeProof(challenge, name, certPubkey, owner string, validUntil time.Time, sigHex string) (bool, error) {
// Create the message
msgHash := CreateChallengeProofMessage(challenge, name, certPubkey, validUntil)
// Decode signature
sig, err := hex.Dec(sigHex)
if err != nil {
return false, fmt.Errorf("invalid signature hex: %w", err)
}
// Decode pubkey
pubkey, err := hex.Dec(owner)
if err != nil {
return false, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create verifier with public key
verifier, err := p8k.New()
if err != nil {
return false, fmt.Errorf("failed to create verifier: %w", err)
}
if err := verifier.InitPub(pubkey); err != nil {
return false, fmt.Errorf("failed to init pubkey: %w", err)
}
// Verify signature
ok, err := verifier.Verify(msgHash, sig)
if err != nil {
return false, fmt.Errorf("verification failed: %w", err)
}
return ok, nil
}
// VerifyWitnessSignature verifies a witness signature on a certificate
func VerifyWitnessSignature(certPubkey, name string, validFrom, validUntil time.Time,
challenge, witnessPubkey, sigHex string) (bool, error) {
// Create the message
msgHash := CreateWitnessMessage(certPubkey, name, validFrom, validUntil, challenge)
// Decode signature
sig, err := hex.Dec(sigHex)
if err != nil {
return false, fmt.Errorf("invalid signature hex: %w", err)
}
// Decode pubkey
pubkey, err := hex.Dec(witnessPubkey)
if err != nil {
return false, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create verifier with public key
verifier, err := p8k.New()
if err != nil {
return false, fmt.Errorf("failed to create verifier: %w", err)
}
if err := verifier.InitPub(pubkey); err != nil {
return false, fmt.Errorf("failed to init pubkey: %w", err)
}
// Verify signature
ok, err := verifier.Verify(msgHash, sig)
if err != nil {
return false, fmt.Errorf("verification failed: %w", err)
}
return ok, nil
}
// VerifyNameOwnership checks if a record's owner matches the name state owner
func VerifyNameOwnership(nameState *NameState, record *NameRecord) error {
recordOwner := hex.Enc(record.Event.Pubkey)
if recordOwner != nameState.Owner {
return fmt.Errorf("%w: record owner %s != name owner %s",
ErrNotOwner, recordOwner, nameState.Owner)
}
return nil
}
// IsExpired checks if a time-based expiration has passed
func IsExpired(expiration time.Time) bool {
return time.Now().After(expiration)
}
// IsInRenewalWindow checks if the current time is within the preferential renewal window
// (final 30 days before expiration)
func IsInRenewalWindow(expiration time.Time) bool {
now := time.Now()
renewalWindowStart := expiration.Add(-PreferentialRenewalDays * 24 * time.Hour)
return now.After(renewalWindowStart) && now.Before(expiration)
}
// CanRegister checks if a name can be registered based on its state and expiration
func CanRegister(nameState *NameState, proposerPubkey string) error {
// If no name state exists, anyone can register
if nameState == nil {
return nil
}
// Check if name is expired
if IsExpired(nameState.Expiration) {
// Name is expired, anyone can register
return nil
}
// Check if in renewal window
if IsInRenewalWindow(nameState.Expiration) {
// Only current owner can register during renewal window
if proposerPubkey != nameState.Owner {
return ErrInRenewalWindow
}
return nil
}
// Name is still owned and not in renewal window
return fmt.Errorf("name is owned by %s until %s", nameState.Owner, nameState.Expiration)
}
// VerifyProposalExpiration checks if a proposal has expired
func VerifyProposalExpiration(proposal *RegistrationProposal) error {
if !proposal.Expiration.IsZero() && IsExpired(proposal.Expiration) {
return fmt.Errorf("proposal expired at %s", proposal.Expiration)
}
return nil
}
// VerifyAttestationExpiration checks if an attestation has expired
func VerifyAttestationExpiration(attestation *Attestation) error {
if !attestation.Expiration.IsZero() && IsExpired(attestation.Expiration) {
return fmt.Errorf("attestation expired at %s", attestation.Expiration)
}
return nil
}
// VerifyTrustGraphExpiration checks if a trust graph has expired
func VerifyTrustGraphExpiration(trustGraph *TrustGraph) error {
if !trustGraph.Expiration.IsZero() && IsExpired(trustGraph.Expiration) {
return fmt.Errorf("trust graph expired at %s", trustGraph.Expiration)
}
return nil
}
// VerifyNameStateExpiration checks if a name state has expired
func VerifyNameStateExpiration(nameState *NameState) error {
if !nameState.Expiration.IsZero() && IsExpired(nameState.Expiration) {
return ErrNameExpired
}
return nil
}
// VerifyCertificateValidity checks if a certificate is currently valid
func VerifyCertificateValidity(cert *Certificate) error {
now := time.Now()
if now.Before(cert.ValidFrom) {
return fmt.Errorf("certificate not yet valid (valid from %s)", cert.ValidFrom)
}
if now.After(cert.ValidUntil) {
return fmt.Errorf("certificate expired at %s", cert.ValidUntil)
}
return nil
}
// VerifyCertificate performs complete certificate verification
func VerifyCertificate(cert *Certificate, nameState *NameState, trustedWitnesses []string) error {
// Verify certificate is not expired
if err := VerifyCertificateValidity(cert); err != nil {
return err
}
// Verify name is not expired
if err := VerifyNameStateExpiration(nameState); err != nil {
return err
}
// Verify certificate owner matches name owner
certOwner := hex.Enc(cert.Event.Pubkey)
if certOwner != nameState.Owner {
return fmt.Errorf("certificate owner %s != name owner %s", certOwner, nameState.Owner)
}
// Verify challenge proof
ok, err := VerifyChallengeProof(cert.Challenge, cert.Name, cert.CertPubkey,
nameState.Owner, cert.ValidUntil, cert.ChallengeProof)
if err != nil {
return fmt.Errorf("challenge proof verification failed: %w", err)
}
if !ok {
return fmt.Errorf("invalid challenge proof signature")
}
// Count trusted witnesses
trustedCount := 0
for _, witness := range cert.Witnesses {
// Check if witness is in trusted list
isTrusted := false
for _, trusted := range trustedWitnesses {
if witness.Pubkey == trusted {
isTrusted = true
break
}
}
if !isTrusted {
continue
}
// Verify witness signature
ok, err := VerifyWitnessSignature(cert.CertPubkey, cert.Name,
cert.ValidFrom, cert.ValidUntil, cert.Challenge,
witness.Pubkey, witness.Signature)
if err != nil {
return fmt.Errorf("witness %s signature verification failed: %w", witness.Pubkey, err)
}
if !ok {
return fmt.Errorf("invalid witness %s signature", witness.Pubkey)
}
trustedCount++
}
// Require at least 3 trusted witnesses
if trustedCount < 3 {
return fmt.Errorf("insufficient trusted witnesses: %d < 3", trustedCount)
}
return nil
}
// VerifySubdomainAuthority checks if the proposer owns the parent domain
func VerifySubdomainAuthority(name string, proposerPubkey string, parentNameState *NameState) error {
parent := GetParentDomain(name)
// TLDs have no parent
if parent == "" {
return nil
}
// Parent must exist
if parentNameState == nil {
return fmt.Errorf("parent domain %s does not exist", parent)
}
// Proposer must own parent
if proposerPubkey != parentNameState.Owner {
return fmt.Errorf("proposer %s does not own parent domain %s (owner: %s)",
proposerPubkey, parent, parentNameState.Owner)
}
return nil
}

312
pkg/protocol/nip43/types.go Normal file
View File

@@ -0,0 +1,312 @@
package nip43
import (
"crypto/rand"
"encoding/base64"
"sync"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
)
// Event kinds defined by NIP-43
const (
KindMemberList = 13534 // Membership list published by relay
KindAddUser = 8000 // Add user event published by relay
KindRemoveUser = 8001 // Remove user event published by relay
KindJoinRequest = 28934 // Join request sent by user
KindInviteReq = 28935 // Invite request (ephemeral)
KindLeaveRequest = 28936 // Leave request sent by user
)
// InviteCode represents a claim/invite code for relay access
type InviteCode struct {
Code string
ExpiresAt time.Time
UsedBy []byte // pubkey that used this code, nil if unused
CreatedAt time.Time
}
// InviteManager manages invite codes for NIP-43
type InviteManager struct {
mu sync.RWMutex
codes map[string]*InviteCode
expiry time.Duration
}
// NewInviteManager creates a new invite code manager
func NewInviteManager(expiryDuration time.Duration) *InviteManager {
if expiryDuration == 0 {
expiryDuration = 24 * time.Hour // Default: 24 hours
}
return &InviteManager{
codes: make(map[string]*InviteCode),
expiry: expiryDuration,
}
}
// GenerateCode creates a new invite code
func (im *InviteManager) GenerateCode() (code string, err error) {
// Generate 32 random bytes
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
code = base64.URLEncoding.EncodeToString(b)
im.mu.Lock()
defer im.mu.Unlock()
im.codes[code] = &InviteCode{
Code: code,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(im.expiry),
}
return code, nil
}
// ValidateAndConsume validates an invite code and marks it as used by the given pubkey
func (im *InviteManager) ValidateAndConsume(code string, pubkey []byte) (valid bool, reason string) {
im.mu.Lock()
defer im.mu.Unlock()
invite, exists := im.codes[code]
if !exists {
return false, "invalid invite code"
}
if time.Now().After(invite.ExpiresAt) {
delete(im.codes, code)
return false, "invite code expired"
}
if invite.UsedBy != nil {
return false, "invite code already used"
}
// Mark as used
invite.UsedBy = make([]byte, len(pubkey))
copy(invite.UsedBy, pubkey)
return true, ""
}
// CleanupExpired removes expired invite codes
func (im *InviteManager) CleanupExpired() {
im.mu.Lock()
defer im.mu.Unlock()
now := time.Now()
for code, invite := range im.codes {
if now.After(invite.ExpiresAt) {
delete(im.codes, code)
}
}
}
// BuildMemberListEvent creates a kind 13534 membership list event
// relaySecretKey: the relay's identity secret key (32 bytes)
// members: list of member pubkeys (32 bytes each)
func BuildMemberListEvent(relaySecretKey []byte, members [][]byte) (*event.E, error) {
// Create signer
signer, err := p8k.New()
if err != nil {
return nil, err
}
if err = signer.InitSec(relaySecretKey); err != nil {
return nil, err
}
ev := event.New()
ev.Kind = KindMemberList
copy(ev.Pubkey, signer.Pub())
// Initialize tags
ev.Tags = tag.NewS()
// Add NIP-70 `-` tag
ev.Tags.Append(tag.NewFromAny("-"))
// Add member tags
for _, member := range members {
if len(member) == 32 {
ev.Tags.Append(tag.NewFromAny("member", hex.Enc(member)))
}
}
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, err
}
return ev, nil
}
// BuildAddUserEvent creates a kind 8000 add user event
func BuildAddUserEvent(relaySecretKey []byte, userPubkey []byte) (*event.E, error) {
// Create signer
signer, err := p8k.New()
if err != nil {
return nil, err
}
if err = signer.InitSec(relaySecretKey); err != nil {
return nil, err
}
ev := event.New()
ev.Kind = KindAddUser
copy(ev.Pubkey, signer.Pub())
// Initialize tags
ev.Tags = tag.NewS()
// Add NIP-70 `-` tag
ev.Tags.Append(tag.NewFromAny("-"))
// Add p tag for the user
if len(userPubkey) == 32 {
ev.Tags.Append(tag.NewFromAny("p", hex.Enc(userPubkey)))
}
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, err
}
return ev, nil
}
// BuildRemoveUserEvent creates a kind 8001 remove user event
func BuildRemoveUserEvent(relaySecretKey []byte, userPubkey []byte) (*event.E, error) {
// Create signer
signer, err := p8k.New()
if err != nil {
return nil, err
}
if err = signer.InitSec(relaySecretKey); err != nil {
return nil, err
}
ev := event.New()
ev.Kind = KindRemoveUser
copy(ev.Pubkey, signer.Pub())
// Initialize tags
ev.Tags = tag.NewS()
// Add NIP-70 `-` tag
ev.Tags.Append(tag.NewFromAny("-"))
// Add p tag for the user
if len(userPubkey) == 32 {
ev.Tags.Append(tag.NewFromAny("p", hex.Enc(userPubkey)))
}
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, err
}
return ev, nil
}
// BuildInviteEvent creates a kind 28935 invite event (ephemeral)
func BuildInviteEvent(relaySecretKey []byte, inviteCode string) (*event.E, error) {
// Create signer
signer, err := p8k.New()
if err != nil {
return nil, err
}
if err = signer.InitSec(relaySecretKey); err != nil {
return nil, err
}
ev := event.New()
ev.Kind = KindInviteReq
copy(ev.Pubkey, signer.Pub())
// Initialize tags
ev.Tags = tag.NewS()
// Add NIP-70 `-` tag
ev.Tags.Append(tag.NewFromAny("-"))
// Add claim tag
ev.Tags.Append(tag.NewFromAny("claim", inviteCode))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, err
}
return ev, nil
}
// ValidateJoinRequest validates a kind 28934 join request event
func ValidateJoinRequest(ev *event.E) (inviteCode string, valid bool, reason string) {
// Must be kind 28934
if ev.Kind != KindJoinRequest {
return "", false, "invalid event kind"
}
// Must have NIP-70 `-` tag
hasMinusTag := ev.Tags.GetFirst([]byte("-")) != nil
if !hasMinusTag {
return "", false, "missing NIP-70 `-` tag"
}
// Must have claim tag
claimTag := ev.Tags.GetFirst([]byte("claim"))
if claimTag != nil && claimTag.Len() >= 2 {
inviteCode = string(claimTag.T[1])
}
if inviteCode == "" {
return "", false, "missing claim tag"
}
// Check timestamp (must be recent, within +/- 10 minutes)
now := time.Now().Unix()
if ev.CreatedAt < now-600 || ev.CreatedAt > now+600 {
return inviteCode, false, "timestamp out of range"
}
return inviteCode, true, ""
}
// ValidateLeaveRequest validates a kind 28936 leave request event
func ValidateLeaveRequest(ev *event.E) (valid bool, reason string) {
// Must be kind 28936
if ev.Kind != KindLeaveRequest {
return false, "invalid event kind"
}
// Must have NIP-70 `-` tag
hasMinusTag := ev.Tags.GetFirst([]byte("-")) != nil
if !hasMinusTag {
return false, "missing NIP-70 `-` tag"
}
// Check timestamp (must be recent, within +/- 10 minutes)
now := time.Now().Unix()
if ev.CreatedAt < now-600 || ev.CreatedAt > now+600 {
return false, "timestamp out of range"
}
return true, ""
}

View File

@@ -0,0 +1,514 @@
package nip43
import (
"testing"
"time"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// TestInviteManager_GenerateCode tests invite code generation
func TestInviteManager_GenerateCode(t *testing.T) {
im := NewInviteManager(24 * time.Hour)
code, err := im.GenerateCode()
if err != nil {
t.Fatalf("failed to generate code: %v", err)
}
if code == "" {
t.Fatal("generated code is empty")
}
// Verify the code exists in the manager
im.mu.Lock()
invite, exists := im.codes[code]
im.mu.Unlock()
if !exists {
t.Fatal("generated code not found in manager")
}
if invite.Code != code {
t.Errorf("code mismatch: got %s, want %s", invite.Code, code)
}
if invite.UsedBy != nil {
t.Error("newly generated code should not be used")
}
if time.Until(invite.ExpiresAt) > 24*time.Hour {
t.Error("expiry time is too far in the future")
}
}
// TestInviteManager_ValidateAndConsume tests invite code validation
func TestInviteManager_ValidateAndConsume(t *testing.T) {
im := NewInviteManager(24 * time.Hour)
// Generate a code
code, err := im.GenerateCode()
if err != nil {
t.Fatalf("failed to generate code: %v", err)
}
testPubkey := make([]byte, 32)
for i := range testPubkey {
testPubkey[i] = byte(i)
}
// Test valid code
valid, reason := im.ValidateAndConsume(code, testPubkey)
if !valid {
t.Fatalf("valid code rejected: %s", reason)
}
// Test already used code
valid, reason = im.ValidateAndConsume(code, testPubkey)
if valid {
t.Error("already used code was accepted")
}
if reason != "invite code already used" {
t.Errorf("wrong rejection reason: got %s", reason)
}
// Test invalid code
valid, reason = im.ValidateAndConsume("invalid-code", testPubkey)
if valid {
t.Error("invalid code was accepted")
}
if reason != "invalid invite code" {
t.Errorf("wrong rejection reason: got %s", reason)
}
}
// TestInviteManager_ExpiredCode tests expired invite code handling
func TestInviteManager_ExpiredCode(t *testing.T) {
// Create manager with very short expiry
im := NewInviteManager(1 * time.Millisecond)
code, err := im.GenerateCode()
if err != nil {
t.Fatalf("failed to generate code: %v", err)
}
// Wait for expiry
time.Sleep(10 * time.Millisecond)
testPubkey := make([]byte, 32)
valid, reason := im.ValidateAndConsume(code, testPubkey)
if valid {
t.Error("expired code was accepted")
}
if reason != "invite code expired" {
t.Errorf("wrong rejection reason: got %s, want 'invite code expired'", reason)
}
// Verify code was deleted
im.mu.Lock()
_, exists := im.codes[code]
im.mu.Unlock()
if exists {
t.Error("expired code was not deleted")
}
}
// TestInviteManager_CleanupExpired tests cleanup of expired codes
func TestInviteManager_CleanupExpired(t *testing.T) {
im := NewInviteManager(1 * time.Millisecond)
// Generate multiple codes
codes := make([]string, 5)
for i := 0; i < 5; i++ {
code, err := im.GenerateCode()
if err != nil {
t.Fatalf("failed to generate code %d: %v", i, err)
}
codes[i] = code
}
// Wait for expiry
time.Sleep(10 * time.Millisecond)
// Cleanup
im.CleanupExpired()
// Verify all codes were deleted
im.mu.Lock()
remaining := len(im.codes)
im.mu.Unlock()
if remaining != 0 {
t.Errorf("cleanup failed: %d codes remaining", remaining)
}
}
// TestBuildMemberListEvent tests membership list event creation
func TestBuildMemberListEvent(t *testing.T) {
// Generate a test relay secret
relaySecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate relay secret: %v", err)
}
// Create test member pubkeys
members := make([][]byte, 3)
for i := 0; i < 3; i++ {
members[i] = make([]byte, 32)
for j := range members[i] {
members[i][j] = byte(i*10 + j)
}
}
// Build event
ev, err := BuildMemberListEvent(relaySecret, members)
if err != nil {
t.Fatalf("failed to build member list event: %v", err)
}
// Verify event kind
if ev.Kind != KindMemberList {
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindMemberList)
}
// Verify NIP-70 tag
minusTag := ev.Tags.GetFirst([]byte("-"))
if minusTag == nil {
t.Error("missing NIP-70 `-` tag")
}
// Verify member tags
memberTags := ev.Tags.GetAll([]byte("member"))
if len(memberTags) != 3 {
t.Errorf("wrong number of member tags: got %d, want 3", len(memberTags))
}
// Verify signature
valid, err := ev.Verify()
if err != nil {
t.Fatalf("signature verification error: %v", err)
}
if !valid {
t.Error("event signature is invalid")
}
}
// TestBuildAddUserEvent tests add user event creation
func TestBuildAddUserEvent(t *testing.T) {
relaySecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate relay secret: %v", err)
}
userPubkey := make([]byte, 32)
for i := range userPubkey {
userPubkey[i] = byte(i)
}
ev, err := BuildAddUserEvent(relaySecret, userPubkey)
if err != nil {
t.Fatalf("failed to build add user event: %v", err)
}
// Verify event kind
if ev.Kind != KindAddUser {
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindAddUser)
}
// Verify NIP-70 tag
minusTag := ev.Tags.GetFirst([]byte("-"))
if minusTag == nil {
t.Error("missing NIP-70 `-` tag")
}
// Verify p tag
pTag := ev.Tags.GetFirst([]byte("p"))
if pTag == nil {
t.Error("missing p tag")
}
// Verify signature
valid, err := ev.Verify()
if err != nil {
t.Fatalf("signature verification error: %v", err)
}
if !valid {
t.Error("event signature is invalid")
}
}
// TestBuildRemoveUserEvent tests remove user event creation
func TestBuildRemoveUserEvent(t *testing.T) {
relaySecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate relay secret: %v", err)
}
userPubkey := make([]byte, 32)
for i := range userPubkey {
userPubkey[i] = byte(i)
}
ev, err := BuildRemoveUserEvent(relaySecret, userPubkey)
if err != nil {
t.Fatalf("failed to build remove user event: %v", err)
}
// Verify event kind
if ev.Kind != KindRemoveUser {
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindRemoveUser)
}
// Verify NIP-70 tag
minusTag := ev.Tags.GetFirst([]byte("-"))
if minusTag == nil {
t.Error("missing NIP-70 `-` tag")
}
// Verify p tag
pTag := ev.Tags.GetFirst([]byte("p"))
if pTag == nil {
t.Error("missing p tag")
}
// Verify signature
valid, err := ev.Verify()
if err != nil {
t.Fatalf("signature verification error: %v", err)
}
if !valid {
t.Error("event signature is invalid")
}
}
// TestBuildInviteEvent tests invite event creation
func TestBuildInviteEvent(t *testing.T) {
relaySecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate relay secret: %v", err)
}
inviteCode := "test-invite-code-12345"
ev, err := BuildInviteEvent(relaySecret, inviteCode)
if err != nil {
t.Fatalf("failed to build invite event: %v", err)
}
// Verify event kind
if ev.Kind != KindInviteReq {
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindInviteReq)
}
// Verify NIP-70 tag
minusTag := ev.Tags.GetFirst([]byte("-"))
if minusTag == nil {
t.Error("missing NIP-70 `-` tag")
}
// Verify claim tag
claimTag := ev.Tags.GetFirst([]byte("claim"))
if claimTag == nil {
t.Error("missing claim tag")
}
if claimTag.Len() < 2 {
t.Error("claim tag has no value")
}
if string(claimTag.T[1]) != inviteCode {
t.Errorf("wrong invite code in tag: got %s, want %s", string(claimTag.T[1]), inviteCode)
}
// Verify signature
valid, err := ev.Verify()
if err != nil {
t.Fatalf("signature verification error: %v", err)
}
if !valid {
t.Error("event signature is invalid")
}
}
// TestValidateJoinRequest tests join request validation
func TestValidateJoinRequest(t *testing.T) {
tests := []struct {
name string
setupEvent func() *event.E
expectValid bool
expectCode string
expectReason string
}{
{
name: "valid join request",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "test-code-123"))
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: true,
expectCode: "test-code-123",
expectReason: "",
},
{
name: "wrong kind",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = 1000
return ev
},
expectValid: false,
expectReason: "invalid event kind",
},
{
name: "missing minus tag",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("claim", "test-code"))
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: false,
expectReason: "missing NIP-70 `-` tag",
},
{
name: "missing claim tag",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: false,
expectReason: "missing claim tag",
},
{
name: "timestamp too old",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "test-code"))
ev.CreatedAt = time.Now().Unix() - 700 // More than 10 minutes ago
return ev
},
expectValid: false,
expectCode: "test-code",
expectReason: "timestamp out of range",
},
{
name: "timestamp too far in future",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "test-code"))
ev.CreatedAt = time.Now().Unix() + 700 // More than 10 minutes ahead
return ev
},
expectValid: false,
expectCode: "test-code",
expectReason: "timestamp out of range",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := tt.setupEvent()
code, valid, reason := ValidateJoinRequest(ev)
if valid != tt.expectValid {
t.Errorf("valid mismatch: got %v, want %v", valid, tt.expectValid)
}
if tt.expectCode != "" && code != tt.expectCode {
t.Errorf("code mismatch: got %s, want %s", code, tt.expectCode)
}
if tt.expectReason != "" && reason != tt.expectReason {
t.Errorf("reason mismatch: got %s, want %s", reason, tt.expectReason)
}
})
}
}
// TestValidateLeaveRequest tests leave request validation
func TestValidateLeaveRequest(t *testing.T) {
tests := []struct {
name string
setupEvent func() *event.E
expectValid bool
expectReason string
}{
{
name: "valid leave request",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindLeaveRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: true,
expectReason: "",
},
{
name: "wrong kind",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = 1000
return ev
},
expectValid: false,
expectReason: "invalid event kind",
},
{
name: "missing minus tag",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindLeaveRequest
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: false,
expectReason: "missing NIP-70 `-` tag",
},
{
name: "timestamp out of range",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindLeaveRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix() - 700
return ev
},
expectValid: false,
expectReason: "timestamp out of range",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := tt.setupEvent()
valid, reason := ValidateLeaveRequest(ev)
if valid != tt.expectValid {
t.Errorf("valid mismatch: got %v, want %v", valid, tt.expectValid)
}
if tt.expectReason != "" && reason != tt.expectReason {
t.Errorf("reason mismatch: got %s, want %s", reason, tt.expectReason)
}
})
}
}

View File

@@ -124,6 +124,8 @@ var (
NIP40 = ExpirationTimestamp NIP40 = ExpirationTimestamp
Authentication = NIP{"Authentication of clients to relays", 42} Authentication = NIP{"Authentication of clients to relays", 42}
NIP42 = Authentication NIP42 = Authentication
RelayAccessMetadata = NIP{"Relay Access Metadata and Requests", 43}
NIP43 = RelayAccessMetadata
VersionedEncryption = NIP{"Encrypted Payloads (Versioned)", 44} VersionedEncryption = NIP{"Encrypted Payloads (Versioned)", 44}
NIP44 = VersionedEncryption NIP44 = VersionedEncryption
CountingResults = NIP{"Counting results", 45} CountingResults = NIP{"Counting results", 45}

View File

@@ -1 +1 @@
v0.26.0 v0.27.0

View File

@@ -1,245 +0,0 @@
package main
import (
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
lol "lol.mleku.dev"
"next.orly.dev/app/config"
"next.orly.dev/pkg/run"
relaytester "next.orly.dev/relay-tester"
)
var (
testRelayURL string
testName string
testJSON bool
keepDataDir bool
relayPort int
relayDataDir string
)
func TestRelay(t *testing.T) {
var err error
var relay *run.Relay
var relayURL string
// Determine relay URL
if testRelayURL != "" {
relayURL = testRelayURL
} else {
// Start local relay for testing
var port int
if relay, port, err = startTestRelay(); err != nil {
t.Fatalf("Failed to start test relay: %v", err)
}
defer func() {
if stopErr := relay.Stop(); stopErr != nil {
t.Logf("Error stopping relay: %v", stopErr)
}
}()
relayURL = fmt.Sprintf("ws://127.0.0.1:%d", port)
t.Logf("Waiting for relay to be ready at %s...", relayURL)
// Wait for relay to be ready - try connecting to verify it's up
if err = waitForRelay(relayURL, 10*time.Second); err != nil {
t.Fatalf("Relay not ready after timeout: %v", err)
}
t.Logf("Relay is ready at %s", relayURL)
}
// Create test suite
t.Logf("Creating test suite for %s...", relayURL)
suite, err := relaytester.NewTestSuite(relayURL)
if err != nil {
t.Fatalf("Failed to create test suite: %v", err)
}
t.Logf("Test suite created, running tests...")
// Run tests
var results []relaytester.TestResult
if testName != "" {
// Run specific test
result, err := suite.RunTest(testName)
if err != nil {
t.Fatalf("Failed to run test %s: %v", testName, err)
}
results = []relaytester.TestResult{result}
} else {
// Run all tests
if results, err = suite.Run(); err != nil {
t.Fatalf("Failed to run tests: %v", err)
}
}
// Output results
if testJSON {
jsonOutput, err := relaytester.FormatJSON(results)
if err != nil {
t.Fatalf("Failed to format JSON: %v", err)
}
fmt.Println(jsonOutput)
} else {
outputResults(results, t)
}
// Check if any required tests failed
for _, result := range results {
if result.Required && !result.Pass {
t.Errorf("Required test '%s' failed: %s", result.Name, result.Info)
}
}
}
func startTestRelay() (relay *run.Relay, port int, err error) {
cfg := &config.C{
AppName: "ORLY-TEST",
DataDir: relayDataDir,
Listen: "127.0.0.1",
Port: 0, // Always use random port, unless overridden via -port flag
HealthPort: 0,
EnableShutdown: false,
LogLevel: "warn",
DBLogLevel: "warn",
DBBlockCacheMB: 512,
DBIndexCacheMB: 256,
LogToStdout: false,
PprofHTTP: false,
ACLMode: "none",
AuthRequired: false,
AuthToWrite: false,
SubscriptionEnabled: false,
MonthlyPriceSats: 6000,
FollowListFrequency: time.Hour,
WebDisableEmbedded: false,
SprocketEnabled: false,
SpiderMode: "none",
PolicyEnabled: false,
}
// Use explicitly set port if provided via flag, otherwise find an available port
if relayPort > 0 {
cfg.Port = relayPort
} else {
var listener net.Listener
if listener, err = net.Listen("tcp", "127.0.0.1:0"); err != nil {
return nil, 0, fmt.Errorf("failed to find available port: %w", err)
}
addr := listener.Addr().(*net.TCPAddr)
cfg.Port = addr.Port
listener.Close()
}
// Set default data dir if not specified
if cfg.DataDir == "" {
tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("orly-test-%d", time.Now().UnixNano()))
cfg.DataDir = tmpDir
}
// Set up logging
lol.SetLogLevel(cfg.LogLevel)
// Create options
cleanup := !keepDataDir
opts := &run.Options{
CleanupDataDir: &cleanup,
}
// Start relay
if relay, err = run.Start(cfg, opts); err != nil {
return nil, 0, fmt.Errorf("failed to start relay: %w", err)
}
return relay, cfg.Port, nil
}
// waitForRelay waits for the relay to be ready by attempting to connect
func waitForRelay(url string, timeout time.Duration) error {
// Extract host:port from ws:// URL
addr := url
if len(url) > 7 && url[:5] == "ws://" {
addr = url[5:]
}
deadline := time.Now().Add(timeout)
attempts := 0
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
attempts++
if attempts%10 == 0 {
// Log every 10th attempt (every second)
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for relay at %s after %d attempts", url, attempts)
}
func outputResults(results []relaytester.TestResult, t *testing.T) {
passed := 0
failed := 0
requiredFailed := 0
for _, result := range results {
if result.Pass {
passed++
t.Logf("PASS: %s", result.Name)
} else {
failed++
if result.Required {
requiredFailed++
t.Errorf("FAIL (required): %s - %s", result.Name, result.Info)
} else {
t.Logf("FAIL (optional): %s - %s", result.Name, result.Info)
}
}
}
t.Logf("\nTest Summary:")
t.Logf(" Total: %d", len(results))
t.Logf(" Passed: %d", passed)
t.Logf(" Failed: %d", failed)
t.Logf(" Required Failed: %d", requiredFailed)
}
// TestMain allows custom test setup/teardown
func TestMain(m *testing.M) {
// Manually parse our custom flags to avoid conflicts with Go's test flags
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
switch arg {
case "-relay-url":
if i+1 < len(os.Args) {
testRelayURL = os.Args[i+1]
i++
}
case "-test-name":
if i+1 < len(os.Args) {
testName = os.Args[i+1]
i++
}
case "-json":
testJSON = true
case "-keep-data":
keepDataDir = true
case "-port":
if i+1 < len(os.Args) {
fmt.Sscanf(os.Args[i+1], "%d", &relayPort)
i++
}
case "-data-dir":
if i+1 < len(os.Args) {
relayDataDir = os.Args[i+1]
i++
}
}
}
code := m.Run()
os.Exit(code)
}

View File

@@ -0,0 +1,166 @@
#!/bin/bash
# Test script for verifying subscription stability fixes
set -e
RELAY_URL="${RELAY_URL:-ws://localhost:3334}"
TEST_DURATION="${TEST_DURATION:-60}" # seconds
EVENT_INTERVAL="${EVENT_INTERVAL:-2}" # seconds between events
echo "==================================="
echo "Subscription Stability Test"
echo "==================================="
echo "Relay URL: $RELAY_URL"
echo "Test duration: ${TEST_DURATION}s"
echo "Event interval: ${EVENT_INTERVAL}s"
echo ""
# Check if websocat is installed
if ! command -v websocat &> /dev/null; then
echo "ERROR: websocat is not installed"
echo "Install with: cargo install websocat"
exit 1
fi
# Check if jq is installed
if ! command -v jq &> /dev/null; then
echo "ERROR: jq is not installed"
echo "Install with: sudo apt install jq"
exit 1
fi
# Temporary files for communication
FIFO_IN=$(mktemp -u)
FIFO_OUT=$(mktemp -u)
mkfifo "$FIFO_IN"
mkfifo "$FIFO_OUT"
# Cleanup on exit
cleanup() {
echo ""
echo "Cleaning up..."
rm -f "$FIFO_IN" "$FIFO_OUT"
kill $WS_PID 2>/dev/null || true
kill $READER_PID 2>/dev/null || true
kill $PUBLISHER_PID 2>/dev/null || true
}
trap cleanup EXIT INT TERM
echo "Step 1: Connecting to relay..."
# Start WebSocket connection
websocat "$RELAY_URL" < "$FIFO_IN" > "$FIFO_OUT" &
WS_PID=$!
# Wait for connection
sleep 1
if ! kill -0 $WS_PID 2>/dev/null; then
echo "ERROR: Failed to connect to relay at $RELAY_URL"
exit 1
fi
echo "✓ Connected to relay"
echo ""
echo "Step 2: Creating subscription..."
# Send REQ message
SUB_ID="stability-test-$(date +%s)"
REQ_MSG='["REQ","'$SUB_ID'",{"kinds":[1]}]'
echo "$REQ_MSG" > "$FIFO_IN"
echo "✓ Sent REQ for subscription: $SUB_ID"
echo ""
# Variables for tracking
RECEIVED_COUNT=0
PUBLISHED_COUNT=0
EOSE_RECEIVED=0
echo "Step 3: Waiting for EOSE..."
# Read messages and count events
(
while IFS= read -r line; do
echo "[RECV] $line"
# Check for EOSE
if echo "$line" | jq -e '. | select(.[0] == "EOSE" and .[1] == "'$SUB_ID'")' > /dev/null 2>&1; then
EOSE_RECEIVED=1
echo "✓ Received EOSE"
break
fi
done < "$FIFO_OUT"
) &
READER_PID=$!
# Wait up to 10 seconds for EOSE
for i in {1..10}; do
if [ $EOSE_RECEIVED -eq 1 ]; then
break
fi
sleep 1
done
echo ""
echo "Step 4: Starting long-running test..."
echo "Publishing events every ${EVENT_INTERVAL}s for ${TEST_DURATION}s..."
echo ""
# Start event counter
(
while IFS= read -r line; do
# Count EVENT messages for our subscription
if echo "$line" | jq -e '. | select(.[0] == "EVENT" and .[1] == "'$SUB_ID'")' > /dev/null 2>&1; then
RECEIVED_COUNT=$((RECEIVED_COUNT + 1))
EVENT_ID=$(echo "$line" | jq -r '.[2].id' 2>/dev/null || echo "unknown")
echo "[$(date +%H:%M:%S)] EVENT received #$RECEIVED_COUNT (id: ${EVENT_ID:0:8}...)"
fi
done < "$FIFO_OUT"
) &
READER_PID=$!
# Publish events
START_TIME=$(date +%s)
END_TIME=$((START_TIME + TEST_DURATION))
while [ $(date +%s) -lt $END_TIME ]; do
PUBLISHED_COUNT=$((PUBLISHED_COUNT + 1))
# Create and publish event (you'll need to implement this part)
# This is a placeholder - replace with actual event publishing
EVENT_JSON='["EVENT",{"kind":1,"content":"Test event '$PUBLISHED_COUNT' for stability test","created_at":'$(date +%s)',"tags":[],"pubkey":"0000000000000000000000000000000000000000000000000000000000000000","id":"0000000000000000000000000000000000000000000000000000000000000000","sig":"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}]'
echo "[$(date +%H:%M:%S)] Publishing event #$PUBLISHED_COUNT"
# Sleep before next event
sleep "$EVENT_INTERVAL"
done
echo ""
echo "==================================="
echo "Test Complete"
echo "==================================="
echo "Duration: ${TEST_DURATION}s"
echo "Events published: $PUBLISHED_COUNT"
echo "Events received: $RECEIVED_COUNT"
echo ""
# Calculate success rate
if [ $PUBLISHED_COUNT -gt 0 ]; then
SUCCESS_RATE=$((RECEIVED_COUNT * 100 / PUBLISHED_COUNT))
echo "Success rate: ${SUCCESS_RATE}%"
echo ""
if [ $SUCCESS_RATE -ge 90 ]; then
echo "✓ TEST PASSED - Subscription remained stable"
exit 0
else
echo "✗ TEST FAILED - Subscription dropped events"
exit 1
fi
else
echo "✗ TEST FAILED - No events published"
exit 1
fi

41
scripts/test-subscriptions.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Simple subscription stability test script
set -e
RELAY_URL="${RELAY_URL:-ws://localhost:3334}"
DURATION="${DURATION:-60}"
KIND="${KIND:-1}"
echo "==================================="
echo "Subscription Stability Test"
echo "==================================="
echo ""
echo "This tool tests whether subscriptions remain stable over time."
echo ""
echo "Configuration:"
echo " Relay URL: $RELAY_URL"
echo " Duration: ${DURATION}s"
echo " Event kind: $KIND"
echo ""
echo "To test properly, you should:"
echo " 1. Start this test"
echo " 2. In another terminal, publish events to the relay"
echo " 3. Verify events are received throughout the test duration"
echo ""
# Check if the test tool is built
if [ ! -f "./subscription-test" ]; then
echo "Building subscription-test tool..."
go build -o subscription-test ./cmd/subscription-test
echo "✓ Built"
echo ""
fi
# Run the test
echo "Starting test..."
echo ""
./subscription-test -url "$RELAY_URL" -duration "$DURATION" -kind "$KIND" -v
exit $?

View File

@@ -1,167 +0,0 @@
#!/usr/bin/env node
// Test script to verify websocket connections are not closed prematurely
// This is a Node.js test script that can be run with: node test-relay-connection.js
import { NostrWebSocket } from '@nostr-dev-kit/ndk';
const RELAY = process.env.RELAY || 'ws://localhost:8080';
const MAX_CONNECTIONS = 10;
const TEST_DURATION = 30000; // 30 seconds
let connectionsClosed = 0;
let connectionsOpened = 0;
let messagesReceived = 0;
let errors = 0;
const stats = {
premature: 0,
normal: 0,
errors: 0,
};
class TestConnection {
constructor(id) {
this.id = id;
this.ws = null;
this.closed = false;
this.openTime = null;
this.closeTime = null;
this.lastError = null;
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new NostrWebSocket(RELAY);
this.ws.addEventListener('open', () => {
this.openTime = Date.now();
connectionsOpened++;
console.log(`[Connection ${this.id}] Opened`);
resolve();
});
this.ws.addEventListener('close', (event) => {
this.closeTime = Date.now();
this.closed = true;
connectionsClosed++;
const duration = this.closeTime - this.openTime;
console.log(`[Connection ${this.id}] Closed: code=${event.code}, reason="${event.reason || ''}", duration=${duration}ms`);
if (duration < 5000 && event.code !== 1000) {
stats.premature++;
console.log(`[Connection ${this.id}] PREMATURE CLOSE DETECTED: duration=${duration}ms < 5s`);
} else {
stats.normal++;
}
});
this.ws.addEventListener('error', (error) => {
this.lastError = error;
stats.errors++;
console.error(`[Connection ${this.id}] Error:`, error);
});
this.ws.addEventListener('message', (event) => {
messagesReceived++;
try {
const data = JSON.parse(event.data);
console.log(`[Connection ${this.id}] Message:`, data[0]);
} catch (e) {
console.log(`[Connection ${this.id}] Message (non-JSON):`, event.data);
}
});
setTimeout(reject, 5000); // Timeout after 5 seconds if not opened
});
}
sendReq() {
if (this.ws && !this.closed) {
this.ws.send(JSON.stringify(['REQ', `test-sub-${this.id}`, { kinds: [1], limit: 10 }]));
console.log(`[Connection ${this.id}] Sent REQ`);
}
}
close() {
if (this.ws && !this.closed) {
this.ws.close();
}
}
}
async function runTest() {
console.log('='.repeat(60));
console.log('Testing Relay Connection Stability');
console.log('='.repeat(60));
console.log(`Relay: ${RELAY}`);
console.log(`Duration: ${TEST_DURATION}ms`);
console.log(`Connections: ${MAX_CONNECTIONS}`);
console.log('='.repeat(60));
console.log();
const connections = [];
// Open connections
console.log('Opening connections...');
for (let i = 0; i < MAX_CONNECTIONS; i++) {
const conn = new TestConnection(i);
try {
await conn.connect();
connections.push(conn);
} catch (error) {
console.error(`Failed to open connection ${i}:`, error);
}
}
console.log(`Opened ${connections.length} connections`);
console.log();
// Send requests from each connection
console.log('Sending REQ messages...');
for (const conn of connections) {
conn.sendReq();
}
// Wait and let connections run
console.log(`Waiting ${TEST_DURATION / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, TEST_DURATION));
// Close all connections
console.log('Closing all connections...');
for (const conn of connections) {
conn.close();
}
// Wait for close events
await new Promise(resolve => setTimeout(resolve, 1000));
// Print results
console.log();
console.log('='.repeat(60));
console.log('Test Results:');
console.log('='.repeat(60));
console.log(`Connections Opened: ${connectionsOpened}`);
console.log(`Connections Closed: ${connectionsClosed}`);
console.log(`Messages Received: ${messagesReceived}`);
console.log();
console.log('Closure Analysis:');
console.log(`- Premature Closes: ${stats.premature}`);
console.log(`- Normal Closes: ${stats.normal}`);
console.log(`- Errors: ${stats.errors}`);
console.log('='.repeat(60));
if (stats.premature > 0) {
console.error('FAILED: Detected premature connection closures!');
process.exit(1);
} else {
console.log('PASSED: No premature connection closures detected.');
process.exit(0);
}
}
runTest().catch(error => {
console.error('Test failed:', error);
process.exit(1);
});

View File

@@ -1,57 +0,0 @@
import { NostrWebSocket } from '@nostr-dev-kit/ndk';
const RELAY = process.env.RELAY || 'ws://localhost:8080';
async function testConnectionClosure() {
console.log('Testing websocket connection closure issues...');
console.log('Connecting to:', RELAY);
// Create multiple connections to test concurrency
const connections = [];
const results = { connected: 0, closed: 0, errors: 0 };
for (let i = 0; i < 5; i++) {
const ws = new NostrWebSocket(RELAY);
ws.addEventListener('open', () => {
console.log(`Connection ${i} opened`);
results.connected++;
});
ws.addEventListener('close', (event) => {
console.log(`Connection ${i} closed:`, event.code, event.reason);
results.closed++;
});
ws.addEventListener('error', (error) => {
console.error(`Connection ${i} error:`, error);
results.errors++;
});
connections.push(ws);
}
// Wait a bit then send REQs
await new Promise(resolve => setTimeout(resolve, 1000));
// Send some REQ messages
for (const ws of connections) {
ws.send(JSON.stringify(['REQ', 'test-sub', { kinds: [1] }]));
}
// Wait and observe behavior
await new Promise(resolve => setTimeout(resolve, 5000));
console.log('\nTest Results:');
console.log(`- Connected: ${results.connected}`);
console.log(`- Closed prematurely: ${results.closed}`);
console.log(`- Errors: ${results.errors}`);
// Close all connections
for (const ws of connections) {
ws.close();
}
}
testConnectionClosure().catch(console.error);

View File

@@ -1,156 +0,0 @@
package main
import (
"fmt"
"time"
"next.orly.dev/app/config"
"next.orly.dev/pkg/run"
)
// func TestDumbClientWorkaround(t *testing.T) {
// var relay *run.Relay
// var err error
// // Start local relay for testing
// if relay, _, err = startWorkaroundTestRelay(); err != nil {
// t.Fatalf("Failed to start test relay: %v", err)
// }
// defer func() {
// if stopErr := relay.Stop(); stopErr != nil {
// t.Logf("Error stopping relay: %v", stopErr)
// }
// }()
// relayURL := "ws://127.0.0.1:3338"
// // Wait for relay to be ready
// if err = waitForRelay(relayURL, 10*time.Second); err != nil {
// t.Fatalf("Relay not ready after timeout: %v", err)
// }
// t.Logf("Relay is ready at %s", relayURL)
// // Test connection with a "dumb" client that doesn't handle ping/pong properly
// dialer := websocket.Dialer{
// HandshakeTimeout: 10 * time.Second,
// }
// conn, _, err := dialer.Dial(relayURL, nil)
// if err != nil {
// t.Fatalf("Failed to connect: %v", err)
// }
// defer conn.Close()
// t.Logf("Connection established")
// // Simulate a dumb client that sets a short read deadline and doesn't handle ping/pong
// conn.SetReadDeadline(time.Now().Add(30 * time.Second))
// startTime := time.Now()
// messageCount := 0
// // The connection should stay alive despite the short client-side deadline
// // because our workaround sets a 24-hour server-side deadline
// connectionFailed := false
// for time.Since(startTime) < 2*time.Minute && !connectionFailed {
// // Extend client deadline every 10 seconds (simulating dumb client behavior)
// if time.Since(startTime).Seconds() > 10 && int(time.Since(startTime).Seconds())%10 == 0 {
// conn.SetReadDeadline(time.Now().Add(30 * time.Second))
// t.Logf("Dumb client extended its own deadline")
// }
// // Try to read with a short timeout to avoid blocking
// conn.SetReadDeadline(time.Now().Add(1 * time.Second))
// // Use a function to catch panics from ReadMessage on failed connections
// func() {
// defer func() {
// if r := recover(); r != nil {
// if panicMsg, ok := r.(string); ok && panicMsg == "repeated read on failed websocket connection" {
// t.Logf("Connection failed, stopping read loop")
// connectionFailed = true
// return
// }
// // Re-panic if it's a different panic
// panic(r)
// }
// }()
// msgType, data, err := conn.ReadMessage()
// conn.SetReadDeadline(time.Now().Add(30 * time.Second)) // Reset
// if err != nil {
// if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// // Timeout is expected - just continue
// time.Sleep(100 * time.Millisecond)
// return
// }
// if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
// t.Logf("Connection closed normally: %v", err)
// connectionFailed = true
// return
// }
// t.Errorf("Unexpected error: %v", err)
// connectionFailed = true
// return
// }
// messageCount++
// t.Logf("Received message %d: type=%d, len=%d", messageCount, msgType, len(data))
// }()
// }
// elapsed := time.Since(startTime)
// if elapsed < 90*time.Second {
// t.Errorf("Connection died too early after %v (expected at least 90s)", elapsed)
// } else {
// t.Logf("Workaround successful: connection lasted %v with %d messages", elapsed, messageCount)
// }
// }
// startWorkaroundTestRelay starts a relay for workaround testing
func startWorkaroundTestRelay() (relay *run.Relay, port int, err error) {
cfg := &config.C{
AppName: "ORLY-WORKAROUND-TEST",
DataDir: "",
Listen: "127.0.0.1",
Port: 3338,
HealthPort: 0,
EnableShutdown: false,
LogLevel: "info",
DBLogLevel: "warn",
DBBlockCacheMB: 512,
DBIndexCacheMB: 256,
LogToStdout: false,
PprofHTTP: false,
ACLMode: "none",
AuthRequired: false,
AuthToWrite: false,
SubscriptionEnabled: false,
MonthlyPriceSats: 6000,
FollowListFrequency: time.Hour,
WebDisableEmbedded: false,
SprocketEnabled: false,
SpiderMode: "none",
PolicyEnabled: false,
}
// Set default data dir if not specified
if cfg.DataDir == "" {
cfg.DataDir = fmt.Sprintf("/tmp/orly-workaround-test-%d", time.Now().UnixNano())
}
// Create options
cleanup := true
opts := &run.Options{
CleanupDataDir: &cleanup,
}
// Start relay
if relay, err = run.Start(cfg, opts); err != nil {
return nil, 0, fmt.Errorf("failed to start relay: %w", err)
}
return relay, cfg.Port, nil
}