Browse Source

implemented and tested NIP-43 invite based ACL

main
mleku 2 months ago
parent
commit
d0dbd2e2dc
No known key found for this signature in database
  1. 6
      .claude/settings.local.json
  2. 6
      app/config/config.go
  3. 18
      app/handle-event.go
  4. 254
      app/handle-nip43.go
  5. 570
      app/handle-nip43_test.go
  6. 18
      app/handle-relayinfo.go
  7. 35
      app/handle-req.go
  8. 9
      app/main.go
  9. 549
      app/nip43_e2e_test.go
  10. 4
      app/server.go
  11. 84
      app/web/dist/bundle.css
  12. 22
      app/web/dist/bundle.js
  13. 1
      app/web/dist/bundle.js.map
  14. BIN
      app/web/dist/favicon.png
  15. 69
      app/web/dist/global.css
  16. 18
      app/web/dist/index.html
  17. BIN
      app/web/dist/orly.png
  18. 2
      app/web/public/build/bundle.css
  19. 2
      app/web/public/build/bundle.js
  20. 259
      pkg/database/nip43.go
  21. 406
      pkg/database/nip43_test.go
  22. 312
      pkg/protocol/nip43/types.go
  23. 514
      pkg/protocol/nip43/types_test.go
  24. 2
      pkg/protocol/relayinfo/types.go
  25. 1
      pkg/version/version

6
.claude/settings.local.json

@ -25,7 +25,11 @@ @@ -25,7 +25,11 @@
"Skill(golang)",
"Bash(/tmp/find verify-name Bitcoin.Nostr)",
"Bash(/tmp/find generate-key)",
"Bash(git ls-tree:*)"
"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": [],
"ask": []

6
app/config/config.go

@ -70,6 +70,12 @@ type C struct { @@ -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)"`
// 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
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)"`

18
app/handle-event.go

@ -15,6 +15,7 @@ import ( @@ -15,6 +15,7 @@ import (
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/utils"
)
@ -207,6 +208,23 @@ func (l *Listener) HandleEvent(msg []byte) (err error) { @@ -207,6 +208,23 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
}
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
log.I.F(
"HandleEvent: checking ACL permissions for pubkey: %s",

254
app/handle-nip43.go

@ -0,0 +1,254 @@ @@ -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

@ -0,0 +1,570 @@ @@ -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)
}
}

18
app/handle-relayinfo.go

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

35
app/handle-req.go

@ -24,6 +24,7 @@ import ( @@ -24,6 +24,7 @@ import (
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/utils"
"next.orly.dev/pkg/utils/normalize"
"next.orly.dev/pkg/utils/pointers"
@ -107,6 +108,40 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -107,6 +108,40 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
// 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
// Create a single context for all filter queries, isolated from the connection context
// to prevent query timeouts from affecting the long-lived websocket connection

9
app/main.go

@ -18,6 +18,7 @@ import ( @@ -18,6 +18,7 @@ import (
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/spider"
dsync "next.orly.dev/pkg/sync"
@ -68,6 +69,14 @@ func Run( @@ -68,6 +69,14 @@ func Run(
publishers: publish.New(NewPublisher(ctx)),
Admins: adminKeys,
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

549
app/nip43_e2e_test.go

@ -0,0 +1,549 @@ @@ -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)
}
}

4
app/server.go

@ -25,6 +25,7 @@ import ( @@ -25,6 +25,7 @@ import (
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/auth"
"next.orly.dev/pkg/protocol/httpauth"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/spider"
dsync "next.orly.dev/pkg/sync"
@ -55,6 +56,9 @@ type Server struct { @@ -55,6 +56,9 @@ type Server struct {
relayGroupMgr *dsync.RelayGroupManager
clusterManager *dsync.ClusterManager
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

84
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

22
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

1
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

BIN
app/web/dist/favicon.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

69
app/web/dist/global.css vendored

@ -1,69 +0,0 @@ @@ -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;
}

18
app/web/dist/index.html vendored

@ -1,17 +1 @@ @@ -1,17 +1 @@
<!doctype html>
<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>
test

BIN
app/web/dist/orly.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

2
app/web/public/build/bundle.css

File diff suppressed because one or more lines are too long

2
app/web/public/build/bundle.js

File diff suppressed because one or more lines are too long

259
pkg/database/nip43.go

@ -0,0 +1,259 @@ @@ -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

@ -0,0 +1,406 @@ @@ -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)
}
}

312
pkg/protocol/nip43/types.go

@ -0,0 +1,312 @@ @@ -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, ""
}

514
pkg/protocol/nip43/types_test.go

@ -0,0 +1,514 @@ @@ -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)
}
})
}
}

2
pkg/protocol/relayinfo/types.go

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

1
pkg/version/version

@ -1 +0,0 @@ @@ -1 +0,0 @@
v0.26.4
Loading…
Cancel
Save