forked from mleku/next.orly.dev
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
09bcbac20d
|
|||
|
84b7c0e11c
|
|||
|
d0dbd2e2dc
|
@@ -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": []
|
||||
|
||||
@@ -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)"`
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
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
Normal file
254
app/handle-nip43.go
Normal 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
570
app/handle-nip43_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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) {
|
||||
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)
|
||||
|
||||
@@ -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) {
|
||||
// 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
|
||||
|
||||
@@ -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(
|
||||
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
Normal file
549
app/nip43_e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
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
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
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
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
BIN
app/web/dist/favicon.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 379 KiB |
69
app/web/dist/global.css
vendored
69
app/web/dist/global.css
vendored
@@ -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
18
app/web/dist/index.html
vendored
@@ -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
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
259
pkg/database/nip43.go
Normal file
259
pkg/database/nip43.go
Normal 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
406
pkg/database/nip43_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -104,21 +104,25 @@ done
|
||||
b.Fatalf("Failed to create test script: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
manager := &PolicyManager{
|
||||
ctx: ctx,
|
||||
configDir: tempDir,
|
||||
scriptPath: scriptPath,
|
||||
enabled: true,
|
||||
responseChan: make(chan PolicyResponse, 100),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: tempDir,
|
||||
scriptPath: scriptPath,
|
||||
enabled: true,
|
||||
runners: make(map[string]*ScriptRunner),
|
||||
}
|
||||
|
||||
// Start the policy manager
|
||||
err = manager.StartPolicy()
|
||||
// Get or create runner and start it
|
||||
runner := manager.getOrCreateRunner(scriptPath)
|
||||
err = runner.Start()
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to start policy: %v", err)
|
||||
b.Fatalf("Failed to start policy script: %v", err)
|
||||
}
|
||||
defer manager.StopPolicy()
|
||||
defer runner.Stop()
|
||||
|
||||
// Give the script time to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
@@ -119,10 +119,9 @@ type PolicyResponse struct {
|
||||
Msg string `json:"msg"` // NIP-20 response message (only used for reject)
|
||||
}
|
||||
|
||||
// PolicyManager handles policy script execution and management.
|
||||
// It manages the lifecycle of policy scripts, handles communication with them,
|
||||
// and provides resilient operation with automatic restart capabilities.
|
||||
type PolicyManager struct {
|
||||
// ScriptRunner manages a single policy script process.
|
||||
// Each unique script path gets its own independent runner with its own goroutine.
|
||||
type ScriptRunner struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
configDir string
|
||||
@@ -132,7 +131,6 @@ type PolicyManager struct {
|
||||
mutex sync.RWMutex
|
||||
isRunning bool
|
||||
isStarting bool
|
||||
enabled bool
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
stderr io.ReadCloser
|
||||
@@ -140,6 +138,20 @@ type PolicyManager struct {
|
||||
startupChan chan error
|
||||
}
|
||||
|
||||
// PolicyManager handles multiple policy script runners.
|
||||
// It manages the lifecycle of policy scripts, handles communication with them,
|
||||
// and provides resilient operation with automatic restart capabilities.
|
||||
// Each unique script path gets its own ScriptRunner instance.
|
||||
type PolicyManager struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
configDir string
|
||||
scriptPath string // Default script path for backward compatibility
|
||||
enabled bool
|
||||
mutex sync.RWMutex
|
||||
runners map[string]*ScriptRunner // Map of script path -> runner
|
||||
}
|
||||
|
||||
// P represents a complete policy configuration for a Nostr relay.
|
||||
// It defines access control rules, kind filtering, and default behavior.
|
||||
// Policies are evaluated in order: global rules, kind filtering, specific rules, then default policy.
|
||||
@@ -199,13 +211,12 @@ func NewWithManager(ctx context.Context, appName string, enabled bool) *P {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
manager := &PolicyManager{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: configDir,
|
||||
scriptPath: scriptPath,
|
||||
enabled: enabled,
|
||||
responseChan: make(chan PolicyResponse, 100), // Buffered channel for responses
|
||||
startupChan: make(chan error, 1), // Channel for startup completion
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: configDir,
|
||||
scriptPath: scriptPath,
|
||||
enabled: enabled,
|
||||
runners: make(map[string]*ScriptRunner),
|
||||
}
|
||||
|
||||
// Load policy configuration from JSON file
|
||||
@@ -231,6 +242,406 @@ func NewWithManager(ctx context.Context, appName string, enabled bool) *P {
|
||||
return policy
|
||||
}
|
||||
|
||||
// getOrCreateRunner gets an existing runner for the script path or creates a new one.
|
||||
// This method is thread-safe and ensures only one runner exists per unique script path.
|
||||
func (pm *PolicyManager) getOrCreateRunner(scriptPath string) *ScriptRunner {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
// Check if runner already exists
|
||||
if runner, exists := pm.runners[scriptPath]; exists {
|
||||
return runner
|
||||
}
|
||||
|
||||
// Create new runner
|
||||
runnerCtx, runnerCancel := context.WithCancel(pm.ctx)
|
||||
runner := &ScriptRunner{
|
||||
ctx: runnerCtx,
|
||||
cancel: runnerCancel,
|
||||
configDir: pm.configDir,
|
||||
scriptPath: scriptPath,
|
||||
responseChan: make(chan PolicyResponse, 100),
|
||||
startupChan: make(chan error, 1),
|
||||
}
|
||||
|
||||
pm.runners[scriptPath] = runner
|
||||
|
||||
// Start periodic check for this runner
|
||||
go runner.periodicCheck()
|
||||
|
||||
return runner
|
||||
}
|
||||
|
||||
// ScriptRunner methods
|
||||
|
||||
// IsRunning returns whether the script is currently running.
|
||||
func (sr *ScriptRunner) IsRunning() bool {
|
||||
sr.mutex.RLock()
|
||||
defer sr.mutex.RUnlock()
|
||||
return sr.isRunning
|
||||
}
|
||||
|
||||
// ensureRunning ensures the script is running, starting it if necessary.
|
||||
func (sr *ScriptRunner) ensureRunning() error {
|
||||
sr.mutex.Lock()
|
||||
// Check if already running
|
||||
if sr.isRunning {
|
||||
sr.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if already starting
|
||||
if sr.isStarting {
|
||||
sr.mutex.Unlock()
|
||||
// Wait for startup to complete
|
||||
select {
|
||||
case err := <-sr.startupChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("script startup failed: %v", err)
|
||||
}
|
||||
// Double-check it's actually running after receiving signal
|
||||
sr.mutex.RLock()
|
||||
running := sr.isRunning
|
||||
sr.mutex.RUnlock()
|
||||
if !running {
|
||||
return fmt.Errorf("script startup completed but process is not running")
|
||||
}
|
||||
return nil
|
||||
case <-time.After(10 * time.Second):
|
||||
return fmt.Errorf("script startup timeout")
|
||||
case <-sr.ctx.Done():
|
||||
return fmt.Errorf("script context cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as starting
|
||||
sr.isStarting = true
|
||||
sr.mutex.Unlock()
|
||||
|
||||
// Start the script in a goroutine
|
||||
go func() {
|
||||
err := sr.Start()
|
||||
sr.mutex.Lock()
|
||||
sr.isStarting = false
|
||||
sr.mutex.Unlock()
|
||||
// Signal startup completion (non-blocking)
|
||||
// Drain any stale value first, then send
|
||||
select {
|
||||
case <-sr.startupChan:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case sr.startupChan <- err:
|
||||
default:
|
||||
// Channel should be empty now, but if it's full, try again
|
||||
sr.startupChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for startup to complete
|
||||
select {
|
||||
case err := <-sr.startupChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("script startup failed: %v", err)
|
||||
}
|
||||
// Double-check it's actually running after receiving signal
|
||||
sr.mutex.RLock()
|
||||
running := sr.isRunning
|
||||
sr.mutex.RUnlock()
|
||||
if !running {
|
||||
return fmt.Errorf("script startup completed but process is not running")
|
||||
}
|
||||
return nil
|
||||
case <-time.After(10 * time.Second):
|
||||
sr.mutex.Lock()
|
||||
sr.isStarting = false
|
||||
sr.mutex.Unlock()
|
||||
return fmt.Errorf("script startup timeout")
|
||||
case <-sr.ctx.Done():
|
||||
sr.mutex.Lock()
|
||||
sr.isStarting = false
|
||||
sr.mutex.Unlock()
|
||||
return fmt.Errorf("script context cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the script process.
|
||||
func (sr *ScriptRunner) Start() error {
|
||||
sr.mutex.Lock()
|
||||
defer sr.mutex.Unlock()
|
||||
|
||||
if sr.isRunning {
|
||||
return fmt.Errorf("script is already running")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(sr.scriptPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("script does not exist at %s", sr.scriptPath)
|
||||
}
|
||||
|
||||
// Create a new context for this command
|
||||
cmdCtx, cmdCancel := context.WithCancel(sr.ctx)
|
||||
|
||||
// Make the script executable
|
||||
if err := os.Chmod(sr.scriptPath, 0755); chk.E(err) {
|
||||
cmdCancel()
|
||||
return fmt.Errorf("failed to make script executable: %v", err)
|
||||
}
|
||||
|
||||
// Start the script
|
||||
cmd := exec.CommandContext(cmdCtx, sr.scriptPath)
|
||||
cmd.Dir = sr.configDir
|
||||
|
||||
// Set up stdio pipes for communication
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if chk.E(err) {
|
||||
cmdCancel()
|
||||
return fmt.Errorf("failed to create stdin pipe: %v", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if chk.E(err) {
|
||||
cmdCancel()
|
||||
stdin.Close()
|
||||
return fmt.Errorf("failed to create stdout pipe: %v", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if chk.E(err) {
|
||||
cmdCancel()
|
||||
stdin.Close()
|
||||
stdout.Close()
|
||||
return fmt.Errorf("failed to create stderr pipe: %v", err)
|
||||
}
|
||||
|
||||
// Start the command
|
||||
if err := cmd.Start(); chk.E(err) {
|
||||
cmdCancel()
|
||||
stdin.Close()
|
||||
stdout.Close()
|
||||
stderr.Close()
|
||||
return fmt.Errorf("failed to start script: %v", err)
|
||||
}
|
||||
|
||||
sr.currentCmd = cmd
|
||||
sr.currentCancel = cmdCancel
|
||||
sr.stdin = stdin
|
||||
sr.stdout = stdout
|
||||
sr.stderr = stderr
|
||||
sr.isRunning = true
|
||||
|
||||
// Start response reader in background
|
||||
go sr.readResponses()
|
||||
|
||||
// Log stderr output in background
|
||||
go sr.logOutput(stdout, stderr)
|
||||
|
||||
// Monitor the process
|
||||
go sr.monitorProcess()
|
||||
|
||||
log.I.F("policy script started: %s (pid=%d)", sr.scriptPath, cmd.Process.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the script gracefully.
|
||||
func (sr *ScriptRunner) Stop() error {
|
||||
sr.mutex.Lock()
|
||||
defer sr.mutex.Unlock()
|
||||
|
||||
if !sr.isRunning || sr.currentCmd == nil {
|
||||
return fmt.Errorf("script is not running")
|
||||
}
|
||||
|
||||
// Close stdin first to signal the script to exit
|
||||
if sr.stdin != nil {
|
||||
sr.stdin.Close()
|
||||
}
|
||||
|
||||
// Cancel the context
|
||||
if sr.currentCancel != nil {
|
||||
sr.currentCancel()
|
||||
}
|
||||
|
||||
// Wait for graceful shutdown with timeout
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- sr.currentCmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Process exited gracefully
|
||||
log.I.F("policy script stopped: %s", sr.scriptPath)
|
||||
case <-time.After(5 * time.Second):
|
||||
// Force kill after 5 seconds
|
||||
log.W.F("policy script did not stop gracefully, sending SIGKILL: %s", sr.scriptPath)
|
||||
if err := sr.currentCmd.Process.Kill(); chk.E(err) {
|
||||
log.E.F("failed to kill script process: %v", err)
|
||||
}
|
||||
<-done // Wait for the kill to complete
|
||||
}
|
||||
|
||||
// Clean up pipes
|
||||
if sr.stdin != nil {
|
||||
sr.stdin.Close()
|
||||
sr.stdin = nil
|
||||
}
|
||||
if sr.stdout != nil {
|
||||
sr.stdout.Close()
|
||||
sr.stdout = nil
|
||||
}
|
||||
if sr.stderr != nil {
|
||||
sr.stderr.Close()
|
||||
sr.stderr = nil
|
||||
}
|
||||
|
||||
sr.isRunning = false
|
||||
sr.currentCmd = nil
|
||||
sr.currentCancel = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessEvent sends an event to the script and waits for a response.
|
||||
func (sr *ScriptRunner) ProcessEvent(evt *PolicyEvent) (*PolicyResponse, error) {
|
||||
sr.mutex.RLock()
|
||||
if !sr.isRunning || sr.stdin == nil {
|
||||
sr.mutex.RUnlock()
|
||||
return nil, fmt.Errorf("script is not running")
|
||||
}
|
||||
stdin := sr.stdin
|
||||
sr.mutex.RUnlock()
|
||||
|
||||
// Serialize the event to JSON
|
||||
eventJSON, err := json.Marshal(evt)
|
||||
if chk.E(err) {
|
||||
return nil, fmt.Errorf("failed to serialize event: %v", err)
|
||||
}
|
||||
|
||||
// Send the event JSON to the script (newline-terminated)
|
||||
if _, err := stdin.Write(append(eventJSON, '\n')); chk.E(err) {
|
||||
return nil, fmt.Errorf("failed to write event to script: %v", err)
|
||||
}
|
||||
|
||||
// Wait for response with timeout
|
||||
select {
|
||||
case response := <-sr.responseChan:
|
||||
return &response, nil
|
||||
case <-time.After(5 * time.Second):
|
||||
return nil, fmt.Errorf("script response timeout")
|
||||
case <-sr.ctx.Done():
|
||||
return nil, fmt.Errorf("script context cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// readResponses reads JSONL responses from the script
|
||||
func (sr *ScriptRunner) readResponses() {
|
||||
if sr.stdout == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(sr.stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var response PolicyResponse
|
||||
if err := json.Unmarshal([]byte(line), &response); chk.E(err) {
|
||||
log.E.F("failed to parse policy response from %s: %v", sr.scriptPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send response to channel (non-blocking)
|
||||
select {
|
||||
case sr.responseChan <- response:
|
||||
default:
|
||||
log.W.F("policy response channel full for %s, dropping response", sr.scriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); chk.E(err) {
|
||||
log.E.F("error reading policy responses from %s: %v", sr.scriptPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// logOutput logs the output from stderr
|
||||
func (sr *ScriptRunner) logOutput(stdout, stderr io.ReadCloser) {
|
||||
defer stderr.Close()
|
||||
|
||||
// Only log stderr, stdout is used by readResponses
|
||||
go func() {
|
||||
io.Copy(os.Stderr, stderr)
|
||||
}()
|
||||
}
|
||||
|
||||
// monitorProcess monitors the script process and cleans up when it exits
|
||||
func (sr *ScriptRunner) monitorProcess() {
|
||||
if sr.currentCmd == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := sr.currentCmd.Wait()
|
||||
|
||||
sr.mutex.Lock()
|
||||
defer sr.mutex.Unlock()
|
||||
|
||||
// Clean up pipes
|
||||
if sr.stdin != nil {
|
||||
sr.stdin.Close()
|
||||
sr.stdin = nil
|
||||
}
|
||||
if sr.stdout != nil {
|
||||
sr.stdout.Close()
|
||||
sr.stdout = nil
|
||||
}
|
||||
if sr.stderr != nil {
|
||||
sr.stderr.Close()
|
||||
sr.stderr = nil
|
||||
}
|
||||
|
||||
sr.isRunning = false
|
||||
sr.currentCmd = nil
|
||||
sr.currentCancel = nil
|
||||
|
||||
if err != nil {
|
||||
log.E.F("policy script exited with error: %s: %v, will retry periodically", sr.scriptPath, err)
|
||||
} else {
|
||||
log.I.F("policy script exited normally: %s", sr.scriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
// periodicCheck periodically checks if script becomes available and attempts to restart failed scripts.
|
||||
func (sr *ScriptRunner) periodicCheck() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sr.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
sr.mutex.RLock()
|
||||
running := sr.isRunning
|
||||
sr.mutex.RUnlock()
|
||||
|
||||
// Check if script is not running and try to start it
|
||||
if !running {
|
||||
if _, err := os.Stat(sr.scriptPath); err == nil {
|
||||
// Script exists but not running, try to start
|
||||
go func() {
|
||||
if err := sr.Start(); err != nil {
|
||||
log.E.F("failed to restart policy script %s: %v, will retry in next cycle", sr.scriptPath, err)
|
||||
} else {
|
||||
log.I.F("policy script restarted successfully: %s", sr.scriptPath)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromFile loads policy configuration from a JSON file.
|
||||
// Returns an error if the file doesn't exist, can't be read, or contains invalid JSON.
|
||||
func (p *P) LoadFromFile(configPath string) error {
|
||||
@@ -285,7 +696,7 @@ func (p *P) CheckPolicy(access string, ev *event.E, loggedInPubkey []byte, ipAdd
|
||||
if rule.Script != "" && p.Manager != nil {
|
||||
if p.Manager.IsEnabled() {
|
||||
// Check if script file exists before trying to use it
|
||||
if _, err := os.Stat(p.Manager.GetScriptPath()); err == nil {
|
||||
if _, err := os.Stat(rule.Script); err == nil {
|
||||
// Script exists, try to use it
|
||||
allowed, err := p.checkScriptPolicy(access, ev, rule.Script, loggedInPubkey, ipAddress)
|
||||
if err == nil {
|
||||
@@ -482,16 +893,19 @@ func (p *P) checkScriptPolicy(access string, ev *event.E, scriptPath string, log
|
||||
return p.getDefaultPolicyAction(), nil
|
||||
}
|
||||
|
||||
// Policy is enabled, check if it's running
|
||||
if !p.Manager.IsRunning() {
|
||||
// Check if script file exists
|
||||
if _, err := os.Stat(p.Manager.GetScriptPath()); os.IsNotExist(err) {
|
||||
// Script doesn't exist, return error so caller can fall back to other criteria
|
||||
return false, fmt.Errorf("policy script does not exist at %s", p.Manager.GetScriptPath())
|
||||
}
|
||||
// Check if script file exists
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
// Script doesn't exist, return error so caller can fall back to other criteria
|
||||
return false, fmt.Errorf("policy script does not exist at %s", scriptPath)
|
||||
}
|
||||
|
||||
// Try to start the policy and wait for it
|
||||
if err := p.Manager.ensureRunning(); err != nil {
|
||||
// Get or create a runner for this specific script path
|
||||
runner := p.Manager.getOrCreateRunner(scriptPath)
|
||||
|
||||
// Policy is enabled, check if this runner is running
|
||||
if !runner.IsRunning() {
|
||||
// Try to start this runner and wait for it
|
||||
if err := runner.ensureRunning(); err != nil {
|
||||
// Startup failed, return error so caller can fall back to other criteria
|
||||
return false, fmt.Errorf("failed to start policy script: %v", err)
|
||||
}
|
||||
@@ -505,7 +919,7 @@ func (p *P) checkScriptPolicy(access string, ev *event.E, scriptPath string, log
|
||||
}
|
||||
|
||||
// Process event through policy script
|
||||
response, scriptErr := p.Manager.ProcessEvent(policyEvent)
|
||||
response, scriptErr := runner.ProcessEvent(policyEvent)
|
||||
if chk.E(scriptErr) {
|
||||
log.E.F("policy rule for kind %d failed (script processing error: %v), falling back to default policy (%s)", ev.Kind, scriptErr, p.DefaultPolicy)
|
||||
// Fall back to default policy on script failure
|
||||
@@ -527,413 +941,70 @@ func (p *P) checkScriptPolicy(access string, ev *event.E, scriptPath string, log
|
||||
}
|
||||
}
|
||||
|
||||
// PolicyManager methods (similar to SprocketManager)
|
||||
// PolicyManager methods
|
||||
|
||||
// periodicCheck periodically checks if policy script becomes available and attempts to restart failed scripts.
|
||||
// Runs every 60 seconds (1 minute) to provide resilient script management.
|
||||
// periodicCheck periodically checks if the default policy script becomes available.
|
||||
// This is for backward compatibility with the default script path.
|
||||
func (pm *PolicyManager) periodicCheck() {
|
||||
ticker := time.NewTicker(60 * time.Second) // Check every 60 seconds (1 minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-pm.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pm.mutex.RLock()
|
||||
running := pm.isRunning
|
||||
pm.mutex.RUnlock()
|
||||
|
||||
// Check if policy script is not running and try to start it
|
||||
if !running {
|
||||
if _, err := os.Stat(pm.scriptPath); err == nil {
|
||||
// Script exists but policy isn't running, try to start
|
||||
go func() {
|
||||
if err := pm.StartPolicy(); err != nil {
|
||||
log.E.F("failed to restart policy: %v, will retry in next cycle", err)
|
||||
} else {
|
||||
log.I.F("policy restarted successfully")
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Get or create runner for the default script path
|
||||
// This will also start its own periodic check
|
||||
pm.getOrCreateRunner(pm.scriptPath)
|
||||
}
|
||||
|
||||
// startPolicyIfExists starts the policy script if the file exists
|
||||
// startPolicyIfExists starts the default policy script if the file exists.
|
||||
// This is for backward compatibility with the default script path.
|
||||
func (pm *PolicyManager) startPolicyIfExists() {
|
||||
if _, err := os.Stat(pm.scriptPath); err == nil {
|
||||
if err := pm.StartPolicy(); err != nil {
|
||||
log.E.F("failed to start policy: %v, will retry periodically", err)
|
||||
// Don't disable policy manager, just log the error and let periodic check retry
|
||||
// Get or create runner for the default script, which will start it
|
||||
runner := pm.getOrCreateRunner(pm.scriptPath)
|
||||
if err := runner.Start(); err != nil {
|
||||
log.E.F("failed to start default policy script: %v, will retry periodically", err)
|
||||
}
|
||||
} else {
|
||||
log.W.F("policy script not found at %s, will retry periodically", pm.scriptPath)
|
||||
// Don't disable policy manager, just log and let periodic check retry
|
||||
}
|
||||
}
|
||||
|
||||
// ensureRunning ensures the policy is running, starting it if necessary.
|
||||
// It waits for startup to complete with a timeout and returns an error if startup fails.
|
||||
func (pm *PolicyManager) ensureRunning() error {
|
||||
pm.mutex.Lock()
|
||||
// Check if already running
|
||||
if pm.isRunning {
|
||||
pm.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if already starting
|
||||
if pm.isStarting {
|
||||
pm.mutex.Unlock()
|
||||
// Wait for startup to complete
|
||||
select {
|
||||
case err := <-pm.startupChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("policy startup failed: %v", err)
|
||||
}
|
||||
// Double-check it's actually running after receiving signal
|
||||
pm.mutex.RLock()
|
||||
running := pm.isRunning
|
||||
pm.mutex.RUnlock()
|
||||
if !running {
|
||||
return fmt.Errorf("policy startup completed but process is not running")
|
||||
}
|
||||
return nil
|
||||
case <-time.After(10 * time.Second):
|
||||
return fmt.Errorf("policy startup timeout")
|
||||
case <-pm.ctx.Done():
|
||||
return fmt.Errorf("policy context cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as starting
|
||||
pm.isStarting = true
|
||||
pm.mutex.Unlock()
|
||||
|
||||
// Start the policy in a goroutine
|
||||
go func() {
|
||||
err := pm.StartPolicy()
|
||||
pm.mutex.Lock()
|
||||
pm.isStarting = false
|
||||
pm.mutex.Unlock()
|
||||
// Signal startup completion (non-blocking)
|
||||
// Drain any stale value first, then send
|
||||
select {
|
||||
case <-pm.startupChan:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case pm.startupChan <- err:
|
||||
default:
|
||||
// Channel should be empty now, but if it's full, try again
|
||||
pm.startupChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for startup to complete
|
||||
select {
|
||||
case err := <-pm.startupChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("policy startup failed: %v", err)
|
||||
}
|
||||
// Double-check it's actually running after receiving signal
|
||||
pm.mutex.RLock()
|
||||
running := pm.isRunning
|
||||
pm.mutex.RUnlock()
|
||||
if !running {
|
||||
return fmt.Errorf("policy startup completed but process is not running")
|
||||
}
|
||||
return nil
|
||||
case <-time.After(10 * time.Second):
|
||||
pm.mutex.Lock()
|
||||
pm.isStarting = false
|
||||
pm.mutex.Unlock()
|
||||
return fmt.Errorf("policy startup timeout")
|
||||
case <-pm.ctx.Done():
|
||||
pm.mutex.Lock()
|
||||
pm.isStarting = false
|
||||
pm.mutex.Unlock()
|
||||
return fmt.Errorf("policy context cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// StartPolicy starts the policy script process.
|
||||
// Returns an error if the script doesn't exist, can't be executed, or is already running.
|
||||
func (pm *PolicyManager) StartPolicy() error {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
if pm.isRunning {
|
||||
return fmt.Errorf("policy is already running")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(pm.scriptPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("policy script does not exist")
|
||||
}
|
||||
|
||||
// Create a new context for this command
|
||||
cmdCtx, cmdCancel := context.WithCancel(pm.ctx)
|
||||
|
||||
// Make the script executable
|
||||
if err := os.Chmod(pm.scriptPath, 0755); chk.E(err) {
|
||||
cmdCancel()
|
||||
return fmt.Errorf("failed to make script executable: %v", err)
|
||||
}
|
||||
|
||||
// Start the script
|
||||
cmd := exec.CommandContext(cmdCtx, pm.scriptPath)
|
||||
cmd.Dir = pm.configDir
|
||||
|
||||
// Set up stdio pipes for communication
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if chk.E(err) {
|
||||
cmdCancel()
|
||||
return fmt.Errorf("failed to create stdin pipe: %v", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if chk.E(err) {
|
||||
cmdCancel()
|
||||
stdin.Close()
|
||||
return fmt.Errorf("failed to create stdout pipe: %v", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if chk.E(err) {
|
||||
cmdCancel()
|
||||
stdin.Close()
|
||||
stdout.Close()
|
||||
return fmt.Errorf("failed to create stderr pipe: %v", err)
|
||||
}
|
||||
|
||||
// Start the command
|
||||
if err := cmd.Start(); chk.E(err) {
|
||||
cmdCancel()
|
||||
stdin.Close()
|
||||
stdout.Close()
|
||||
stderr.Close()
|
||||
return fmt.Errorf("failed to start policy: %v", err)
|
||||
}
|
||||
|
||||
pm.currentCmd = cmd
|
||||
pm.currentCancel = cmdCancel
|
||||
pm.stdin = stdin
|
||||
pm.stdout = stdout
|
||||
pm.stderr = stderr
|
||||
pm.isRunning = true
|
||||
|
||||
// Start response reader in background
|
||||
go pm.readResponses()
|
||||
|
||||
// Log stderr output in background
|
||||
go pm.logOutput(stdout, stderr)
|
||||
|
||||
// Monitor the process
|
||||
go pm.monitorProcess()
|
||||
|
||||
log.I.F("policy started (pid=%d)", cmd.Process.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopPolicy stops the policy script gracefully with SIGTERM, falling back to SIGKILL if needed.
|
||||
// Returns an error if the policy is not currently running.
|
||||
func (pm *PolicyManager) StopPolicy() error {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
if !pm.isRunning || pm.currentCmd == nil {
|
||||
return fmt.Errorf("policy is not running")
|
||||
}
|
||||
|
||||
// Close stdin first to signal the script to exit
|
||||
if pm.stdin != nil {
|
||||
pm.stdin.Close()
|
||||
}
|
||||
|
||||
// Cancel the context
|
||||
if pm.currentCancel != nil {
|
||||
pm.currentCancel()
|
||||
}
|
||||
|
||||
// Wait for graceful shutdown with timeout
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- pm.currentCmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Process exited gracefully
|
||||
log.I.F("policy stopped gracefully")
|
||||
case <-time.After(5 * time.Second):
|
||||
// Force kill after 5 seconds
|
||||
log.W.F("policy did not stop gracefully, sending SIGKILL")
|
||||
if err := pm.currentCmd.Process.Kill(); chk.E(err) {
|
||||
log.E.F("failed to kill policy process: %v", err)
|
||||
}
|
||||
<-done // Wait for the kill to complete
|
||||
}
|
||||
|
||||
// Clean up pipes
|
||||
if pm.stdin != nil {
|
||||
pm.stdin.Close()
|
||||
pm.stdin = nil
|
||||
}
|
||||
if pm.stdout != nil {
|
||||
pm.stdout.Close()
|
||||
pm.stdout = nil
|
||||
}
|
||||
if pm.stderr != nil {
|
||||
pm.stderr.Close()
|
||||
pm.stderr = nil
|
||||
}
|
||||
|
||||
pm.isRunning = false
|
||||
pm.currentCmd = nil
|
||||
pm.currentCancel = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessEvent sends an event to the policy script and waits for a response.
|
||||
// Returns the script's decision or an error if the script is not running or communication fails.
|
||||
func (pm *PolicyManager) ProcessEvent(evt *PolicyEvent) (*PolicyResponse, error) {
|
||||
pm.mutex.RLock()
|
||||
if !pm.isRunning || pm.stdin == nil {
|
||||
pm.mutex.RUnlock()
|
||||
return nil, fmt.Errorf("policy is not running")
|
||||
}
|
||||
stdin := pm.stdin
|
||||
pm.mutex.RUnlock()
|
||||
|
||||
// Serialize the event to JSON
|
||||
eventJSON, err := json.Marshal(evt)
|
||||
if chk.E(err) {
|
||||
return nil, fmt.Errorf("failed to serialize event: %v", err)
|
||||
}
|
||||
|
||||
// Send the event JSON to the policy script (newline-terminated for shell-readers)
|
||||
if _, err := stdin.Write(append(eventJSON, '\n')); chk.E(err) {
|
||||
return nil, fmt.Errorf("failed to write event to policy: %v", err)
|
||||
}
|
||||
|
||||
// Wait for response with timeout
|
||||
select {
|
||||
case response := <-pm.responseChan:
|
||||
return &response, nil
|
||||
case <-time.After(5 * time.Second):
|
||||
return nil, fmt.Errorf("policy response timeout")
|
||||
case <-pm.ctx.Done():
|
||||
return nil, fmt.Errorf("policy context cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// readResponses reads JSONL responses from the policy script
|
||||
func (pm *PolicyManager) readResponses() {
|
||||
if pm.stdout == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(pm.stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var response PolicyResponse
|
||||
if err := json.Unmarshal([]byte(line), &response); chk.E(err) {
|
||||
log.E.F("failed to parse policy response: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send response to channel (non-blocking)
|
||||
select {
|
||||
case pm.responseChan <- response:
|
||||
default:
|
||||
log.W.F("policy response channel full, dropping response")
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); chk.E(err) {
|
||||
log.E.F("error reading policy responses: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// logOutput logs the output from stdout and stderr
|
||||
func (pm *PolicyManager) logOutput(stdout, stderr io.ReadCloser) {
|
||||
defer stderr.Close()
|
||||
|
||||
// Only log stderr, stdout is used by readResponses
|
||||
go func() {
|
||||
io.Copy(os.Stderr, stderr)
|
||||
}()
|
||||
}
|
||||
|
||||
// monitorProcess monitors the policy process and cleans up when it exits
|
||||
func (pm *PolicyManager) monitorProcess() {
|
||||
if pm.currentCmd == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := pm.currentCmd.Wait()
|
||||
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
// Clean up pipes
|
||||
if pm.stdin != nil {
|
||||
pm.stdin.Close()
|
||||
pm.stdin = nil
|
||||
}
|
||||
if pm.stdout != nil {
|
||||
pm.stdout.Close()
|
||||
pm.stdout = nil
|
||||
}
|
||||
if pm.stderr != nil {
|
||||
pm.stderr.Close()
|
||||
pm.stderr = nil
|
||||
}
|
||||
|
||||
pm.isRunning = false
|
||||
pm.currentCmd = nil
|
||||
pm.currentCancel = nil
|
||||
|
||||
if err != nil {
|
||||
log.E.F("policy process exited with error: %v, will retry periodically", err)
|
||||
// Don't disable policy manager, let periodic check handle restart
|
||||
log.W.F("policy script crashed - events will fall back to default policy until restart (script location: %s)", pm.scriptPath)
|
||||
} else {
|
||||
log.I.F("policy process exited normally")
|
||||
log.W.F("default policy script not found at %s, will be started if it appears", pm.scriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the policy manager is enabled.
|
||||
// This is set during initialization and doesn't change during runtime.
|
||||
func (pm *PolicyManager) IsEnabled() bool {
|
||||
return pm.enabled
|
||||
}
|
||||
|
||||
// IsRunning returns whether the policy script is currently running.
|
||||
// This can change during runtime as scripts start, stop, or crash.
|
||||
// IsRunning returns whether the default policy script is currently running.
|
||||
// Deprecated: Use getOrCreateRunner(scriptPath).IsRunning() for specific scripts.
|
||||
func (pm *PolicyManager) IsRunning() bool {
|
||||
pm.mutex.RLock()
|
||||
defer pm.mutex.RUnlock()
|
||||
return pm.isRunning
|
||||
|
||||
// Check if default script runner exists and is running
|
||||
if runner, exists := pm.runners[pm.scriptPath]; exists {
|
||||
return runner.IsRunning()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetScriptPath returns the path to the policy script.
|
||||
// GetScriptPath returns the default script path.
|
||||
func (pm *PolicyManager) GetScriptPath() string {
|
||||
return pm.scriptPath
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the policy manager.
|
||||
// It cancels the context and stops any running policy script.
|
||||
// Shutdown gracefully shuts down the policy manager and all running scripts.
|
||||
func (pm *PolicyManager) Shutdown() {
|
||||
pm.cancel()
|
||||
if pm.isRunning {
|
||||
pm.StopPolicy()
|
||||
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
|
||||
// Stop all running scripts
|
||||
for path, runner := range pm.runners {
|
||||
if runner.IsRunning() {
|
||||
log.I.F("stopping policy script: %s", path)
|
||||
runner.Stop()
|
||||
}
|
||||
// Cancel the runner's context
|
||||
runner.cancel()
|
||||
}
|
||||
|
||||
// Clear runners map
|
||||
pm.runners = make(map[string]*ScriptRunner)
|
||||
}
|
||||
|
||||
@@ -715,12 +715,12 @@ func TestPolicyManagerLifecycle(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
manager := &PolicyManager{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: "/tmp",
|
||||
scriptPath: "/tmp/policy.sh",
|
||||
enabled: true,
|
||||
responseChan: make(chan PolicyResponse, 100),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: "/tmp",
|
||||
scriptPath: "/tmp/policy.sh",
|
||||
enabled: true,
|
||||
runners: make(map[string]*ScriptRunner),
|
||||
}
|
||||
|
||||
// Test manager state
|
||||
@@ -732,31 +732,37 @@ func TestPolicyManagerLifecycle(t *testing.T) {
|
||||
t.Error("Expected policy manager to not be running initially")
|
||||
}
|
||||
|
||||
// Test getting or creating a runner for a non-existent script
|
||||
runner := manager.getOrCreateRunner("/tmp/policy.sh")
|
||||
if runner == nil {
|
||||
t.Fatal("Expected runner to be created")
|
||||
}
|
||||
|
||||
// Test starting with non-existent script (should fail gracefully)
|
||||
err := manager.StartPolicy()
|
||||
err := runner.Start()
|
||||
if err == nil {
|
||||
t.Error("Expected error when starting policy with non-existent script")
|
||||
t.Error("Expected error when starting script with non-existent file")
|
||||
}
|
||||
|
||||
// Test stopping when not running (should fail gracefully)
|
||||
err = manager.StopPolicy()
|
||||
err = runner.Stop()
|
||||
if err == nil {
|
||||
t.Error("Expected error when stopping policy that's not running")
|
||||
t.Error("Expected error when stopping script that's not running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyManagerProcessEvent(t *testing.T) {
|
||||
// Test processing event when manager is not running (should fail gracefully)
|
||||
// Test processing event when runner is not running (should fail gracefully)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
manager := &PolicyManager{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: "/tmp",
|
||||
scriptPath: "/tmp/policy.sh",
|
||||
enabled: true,
|
||||
responseChan: make(chan PolicyResponse, 100),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: "/tmp",
|
||||
scriptPath: "/tmp/policy.sh",
|
||||
enabled: true,
|
||||
runners: make(map[string]*ScriptRunner),
|
||||
}
|
||||
|
||||
// Generate real keypair for testing
|
||||
@@ -772,10 +778,13 @@ func TestPolicyManagerProcessEvent(t *testing.T) {
|
||||
IPAddress: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Get or create a runner
|
||||
runner := manager.getOrCreateRunner("/tmp/policy.sh")
|
||||
|
||||
// Process event when not running (should fail gracefully)
|
||||
_, err := manager.ProcessEvent(policyEvent)
|
||||
_, err := runner.ProcessEvent(policyEvent)
|
||||
if err == nil {
|
||||
t.Error("Expected error when processing event with non-running policy manager")
|
||||
t.Error("Expected error when processing event with non-running script")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,43 +895,53 @@ func TestEdgeCasesManagerWithInvalidScript(t *testing.T) {
|
||||
t.Fatalf("Failed to create invalid script: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
manager := &PolicyManager{
|
||||
ctx: ctx,
|
||||
configDir: tempDir,
|
||||
scriptPath: scriptPath,
|
||||
enabled: true,
|
||||
responseChan: make(chan PolicyResponse, 100),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: tempDir,
|
||||
scriptPath: scriptPath,
|
||||
enabled: true,
|
||||
runners: make(map[string]*ScriptRunner),
|
||||
}
|
||||
|
||||
// Should fail to start with invalid script
|
||||
err = manager.StartPolicy()
|
||||
// Get runner and try to start with invalid script
|
||||
runner := manager.getOrCreateRunner(scriptPath)
|
||||
err = runner.Start()
|
||||
if err == nil {
|
||||
t.Error("Expected error when starting policy with invalid script")
|
||||
t.Error("Expected error when starting invalid script")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeCasesManagerDoubleStart(t *testing.T) {
|
||||
// Test double start without actually starting (simpler test)
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
manager := &PolicyManager{
|
||||
ctx: ctx,
|
||||
configDir: "/tmp",
|
||||
scriptPath: "/tmp/policy.sh",
|
||||
enabled: true,
|
||||
responseChan: make(chan PolicyResponse, 100),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
configDir: "/tmp",
|
||||
scriptPath: "/tmp/policy.sh",
|
||||
enabled: true,
|
||||
runners: make(map[string]*ScriptRunner),
|
||||
}
|
||||
|
||||
// Get runner
|
||||
runner := manager.getOrCreateRunner("/tmp/policy.sh")
|
||||
|
||||
// Try to start with non-existent script - should fail
|
||||
err := manager.StartPolicy()
|
||||
err := runner.Start()
|
||||
if err == nil {
|
||||
t.Error("Expected error when starting policy manager with non-existent script")
|
||||
t.Error("Expected error when starting script with non-existent file")
|
||||
}
|
||||
|
||||
// Try to start again - should still fail
|
||||
err = manager.StartPolicy()
|
||||
err = runner.Start()
|
||||
if err == nil {
|
||||
t.Error("Expected error when starting policy manager twice")
|
||||
t.Error("Expected error when starting script twice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1150,8 +1169,8 @@ func TestScriptPolicyDisabledFallsBackToDefault(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Manager: &PolicyManager{
|
||||
enabled: false, // Policy is disabled
|
||||
isRunning: false,
|
||||
enabled: false, // Policy is disabled
|
||||
runners: make(map[string]*ScriptRunner),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1354,8 +1373,8 @@ func TestScriptProcessingDisabledFallsBackToDefault(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Manager: &PolicyManager{
|
||||
enabled: false, // Policy is disabled
|
||||
isRunning: false,
|
||||
enabled: false, // Policy is disabled
|
||||
runners: make(map[string]*ScriptRunner),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
312
pkg/protocol/nip43/types.go
Normal file
312
pkg/protocol/nip43/types.go
Normal 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, ""
|
||||
}
|
||||
514
pkg/protocol/nip43/types_test.go
Normal file
514
pkg/protocol/nip43/types_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 +1 @@
|
||||
v0.26.4
|
||||
v0.27.1
|
||||
Reference in New Issue
Block a user