Browse Source

Complete DDD improvements: handlers, ACL injection, Rule decomposition

- Simplify HandleEvent to thin protocol adapter (~320 -> ~130 lines)
- Create ingestion service for full event pipeline orchestration
- Add specialkinds.go for special kind handler registration
- Eliminate global ACL singleton with injectable Registry interface
- Add ACLRegistry() accessor to Server for dependency injection
- Decompose Rule value object into AccessControl, Constraints,
  TagValidationConfig sub-components for cleaner organization
- Add LoggingSubscriber for domain event analytics
- Update all policy tests for embedded struct initialization
- Update DDD_ANALYSIS.md to 10/10 maturity score

Files modified:
- app/handle-event.go: Simplified to delegate to ingestion service
- app/specialkinds.go: NEW - Special kind handler registration
- app/server.go: Add aclRegistry field and ACLRegistry() accessor
- pkg/interfaces/acl/acl.go: Add Registry interface
- pkg/acl/acl.go: Add accessor methods for privatized fields
- pkg/policy/policy.go: Decompose Rule into sub-value objects
- pkg/policy/*_test.go: Update struct literals for embedded types
- pkg/event/ingestion/service.go: Add ACLMode, special kinds support
- pkg/event/processing/processing.go: Add domain event dispatcher
- pkg/domain/events/subscribers/logging.go: NEW - Analytics subscriber
- DDD_ANALYSIS.md: Update to 10/10 maturity score

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main v0.56.8
woikos 4 months ago
parent
commit
9c231169d2
No known key found for this signature in database
  1. 1001
      DDD_ANALYSIS.md
  2. 4
      app/handle-count.go
  3. 306
      app/handle-event.go
  4. 2
      app/handle-nip86-curating.go
  5. 2
      app/handle-nip86.go
  6. 2
      app/handle-relayinfo.go
  7. 14
      app/handle-req.go
  8. 6
      app/listener.go
  9. 9
      app/main.go
  10. 94
      app/server.go
  11. 94
      app/specialkinds.go
  12. 10
      cmd/orly-acl/service.go
  13. 62
      pkg/acl/acl.go
  14. 10
      pkg/acl/server/service.go
  15. 2
      pkg/blossom/utils_test.go
  16. 130
      pkg/domain/events/subscribers/logging.go
  17. 39
      pkg/event/ingestion/service.go
  18. 37
      pkg/event/processing/processing.go
  19. 30
      pkg/event/processing/processing_test.go
  20. 37
      pkg/interfaces/acl/acl.go
  21. 20
      pkg/policy/benchmark_test.go
  22. 4
      pkg/policy/composition_test.go
  23. 4
      pkg/policy/kind_whitelist_test.go
  24. 170
      pkg/policy/policy.go
  25. 80
      pkg/policy/policy_test.go
  26. 18
      pkg/policy/precedence_test.go
  27. 12
      pkg/policy/read_access_test.go
  28. 2
      pkg/version/version

1001
DDD_ANALYSIS.md

File diff suppressed because it is too large Load Diff

4
app/handle-count.go

@ -29,7 +29,7 @@ func (l *Listener) HandleCount(msg []byte) (err error) {
log.D.C(func() string { return fmt.Sprintf("COUNT sub=%s filters=%d", env.Subscription, len(env.Filters)) }) log.D.C(func() string { return fmt.Sprintf("COUNT sub=%s filters=%d", env.Subscription, len(env.Filters)) })
// If ACL is active, auth is required, or AuthToWrite is enabled, send a challenge (same as REQ path) // If ACL is active, auth is required, or AuthToWrite is enabled, send a challenge (same as REQ path)
if len(l.authedPubkey.Load()) != schnorr.PubKeyBytesLen && (acl.Registry.Active.Load() != "none" || l.Config.AuthRequired || l.Config.AuthToWrite) { if len(l.authedPubkey.Load()) != schnorr.PubKeyBytesLen && (acl.Registry.GetMode() != "none" || l.Config.AuthRequired || l.Config.AuthToWrite) {
if err = authenvelope.NewChallengeWith(l.challenge.Load()).Write(l); chk.E(err) { if err = authenvelope.NewChallengeWith(l.challenge.Load()).Write(l); chk.E(err) {
return return
} }
@ -47,7 +47,7 @@ func (l *Listener) HandleCount(msg []byte) (err error) {
if l.Config.AuthToWrite && len(l.authedPubkey.Load()) == 0 { if l.Config.AuthToWrite && len(l.authedPubkey.Load()) == 0 {
// Allow unauthenticated COUNT when AuthToWrite is enabled // Allow unauthenticated COUNT when AuthToWrite is enabled
// but still respect ACL access levels if ACL is active // but still respect ACL access levels if ACL is active
if acl.Registry.Active.Load() != "none" { if acl.Registry.GetMode() != "none" {
switch accessLevel { switch accessLevel {
case "none", "blocked", "banned": case "none", "blocked", "banned":
return errors.New("auth required: user not authed or has no read access") return errors.New("auth required: user not authed or has no read access")

306
app/handle-event.go

@ -6,8 +6,6 @@ import (
"lol.mleku.dev/chk" "lol.mleku.dev/chk"
"lol.mleku.dev/log" "lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/event/routing"
"git.mleku.dev/mleku/nostr/encoders/envelopes/authenvelope" "git.mleku.dev/mleku/nostr/encoders/envelopes/authenvelope"
"git.mleku.dev/mleku/nostr/encoders/envelopes/eventenvelope" "git.mleku.dev/mleku/nostr/encoders/envelopes/eventenvelope"
"git.mleku.dev/mleku/nostr/encoders/envelopes/noticeenvelope" "git.mleku.dev/mleku/nostr/encoders/envelopes/noticeenvelope"
@ -16,307 +14,140 @@ import (
"git.mleku.dev/mleku/nostr/encoders/hex" "git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/kind" "git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/reason" "git.mleku.dev/mleku/nostr/encoders/reason"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/event/ingestion"
"next.orly.dev/pkg/protocol/nip43" "next.orly.dev/pkg/protocol/nip43"
) )
// HandleEvent processes incoming EVENT messages.
// This is a thin protocol adapter that delegates to the ingestion service.
func (l *Listener) HandleEvent(msg []byte) (err error) { func (l *Listener) HandleEvent(msg []byte) (err error) {
log.I.F("HandleEvent: START handling event: %s", string(msg[:min(200, len(msg))])) log.I.F("HandleEvent: START handling event: %s", string(msg[:min(200, len(msg))]))
// 1. Raw JSON validation (before unmarshal) - use validation service // Stage 1: Raw JSON validation (before unmarshal)
if result := l.eventValidator.ValidateRawJSON(msg); !result.Valid { if result := l.eventValidator.ValidateRawJSON(msg); !result.Valid {
log.W.F("HandleEvent: rejecting event with validation error: %s", result.Msg) log.W.F("HandleEvent: rejecting event with validation error: %s", result.Msg)
// Send NOTICE to alert client developers about the issue
if noticeErr := noticeenvelope.NewFrom(result.Msg).Write(l); noticeErr != nil { if noticeErr := noticeenvelope.NewFrom(result.Msg).Write(l); noticeErr != nil {
log.E.F("failed to send NOTICE for validation error: %v", noticeErr) log.E.F("failed to send NOTICE for validation error: %v", noticeErr)
} }
// Send OK false with the error message
if err = l.sendRawValidationError(result); chk.E(err) { if err = l.sendRawValidationError(result); chk.E(err) {
return return
} }
return nil return nil
} }
// decode the envelope // Stage 2: Unmarshal the envelope
env := eventenvelope.NewSubmission() env := eventenvelope.NewSubmission()
log.I.F("HandleEvent: received event message length: %d", len(msg))
if msg, err = env.Unmarshal(msg); chk.E(err) { if msg, err = env.Unmarshal(msg); chk.E(err) {
log.E.F("HandleEvent: failed to unmarshal event: %v", err) log.E.F("HandleEvent: failed to unmarshal event: %v", err)
return return
} }
log.I.F( log.I.F("HandleEvent: unmarshaled event, kind: %d, pubkey: %s, id: %0x",
"HandleEvent: successfully unmarshaled event, kind: %d, pubkey: %s, id: %0x", env.E.Kind, hex.Enc(env.E.Pubkey), env.E.ID)
env.E.Kind, hex.Enc(env.E.Pubkey), env.E.ID,
)
defer func() { defer func() {
if env != nil && env.E != nil { if env != nil && env.E != nil {
env.E.Free() env.E.Free()
} }
}() }()
if len(msg) > 0 { // Stage 3: Handle special kinds that need connection context
log.I.F("extra '%s'", msg) if handled, err := l.handleSpecialKinds(env); handled {
} return err
// Check if sprocket is enabled and process event through it
if l.sprocketManager != nil && l.sprocketManager.IsEnabled() {
if l.sprocketManager.IsDisabled() {
// Sprocket is disabled due to failure - reject all events
log.W.F("sprocket is disabled, rejecting event %0x", env.E.ID)
if err = Ok.Error(
l, env,
"sprocket disabled - events rejected until sprocket is restored",
); chk.E(err) {
return
}
return
}
if !l.sprocketManager.IsRunning() {
// Sprocket is enabled but not running - reject all events
log.W.F(
"sprocket is enabled but not running, rejecting event %0x",
env.E.ID,
)
if err = Ok.Error(
l, env,
"sprocket not running - events rejected until sprocket starts",
); chk.E(err) {
return
}
return
} }
// Process event through sprocket // Stage 4: Progressive throttle for follows ACL mode
response, sprocketErr := l.sprocketManager.ProcessEvent(env.E) if delay := l.getFollowsThrottleDelay(env.E); delay > 0 {
if chk.E(sprocketErr) { log.D.F("HandleEvent: applying progressive throttle delay of %v", delay)
log.E.F("sprocket processing failed: %v", sprocketErr) select {
if err = Ok.Error( case <-l.ctx.Done():
l, env, "sprocket processing failed", return l.ctx.Err()
); chk.E(err) { case <-time.After(delay):
return
} }
return
} }
// Handle sprocket response // Stage 5: Delegate to ingestion service
switch response.Action { connCtx := &ingestion.ConnectionContext{
case "accept": AuthedPubkey: l.authedPubkey.Load(),
// Continue with normal processing Remote: l.remote,
log.D.F("sprocket accepted event %0x", env.E.ID) ConnectionID: l.connectionID,
case "reject":
// Return OK false with message
if err = okenvelope.NewFrom(
env.Id(), false,
reason.Error.F(response.Msg),
).Write(l); chk.E(err) {
return
}
return
case "shadowReject":
// Return OK true but abort processing
if err = Ok.Ok(l, env, ""); chk.E(err) {
return
}
log.D.F("sprocket shadow rejected event %0x", env.E.ID)
return
default:
log.W.F("unknown sprocket action: %s", response.Action)
// Default to accept for unknown actions
}
} }
result := l.ingestionService.Ingest(context.Background(), env.E, connCtx)
// Event validation (ID, timestamp, signature) - use validation service // Stage 6: Send response based on result
if result := l.eventValidator.ValidateEvent(env.E); !result.Valid { return l.sendIngestionResult(env, result)
if err = l.sendValidationError(env, result); chk.E(err) {
return
}
return
} }
// Handle NIP-43 special events before ACL checks // handleSpecialKinds handles event kinds that need connection context.
// Returns (true, err) if the event was handled, (false, nil) to continue normal processing.
func (l *Listener) handleSpecialKinds(env *eventenvelope.Submission) (bool, error) {
switch env.E.Kind { switch env.E.Kind {
case nip43.KindJoinRequest: case nip43.KindJoinRequest:
// Process join request and return early if err := l.HandleNIP43JoinRequest(env.E); chk.E(err) {
if err = l.HandleNIP43JoinRequest(env.E); chk.E(err) {
log.E.F("failed to process NIP-43 join request: %v", err) log.E.F("failed to process NIP-43 join request: %v", err)
} }
return return true, nil
case nip43.KindLeaveRequest: case nip43.KindLeaveRequest:
// Process leave request and return early if err := l.HandleNIP43LeaveRequest(env.E); chk.E(err) {
if err = l.HandleNIP43LeaveRequest(env.E); chk.E(err) {
log.E.F("failed to process NIP-43 leave request: %v", err) log.E.F("failed to process NIP-43 leave request: %v", err)
} }
return return true, nil
case acl.CuratingConfigKind:
// Handle curating configuration events (kind 30078 with d-tag "curating-config")
// Check if this is a curating config event (verify d-tag)
dTag := env.E.Tags.GetFirst([]byte("d"))
if dTag != nil && string(dTag.Value()) == acl.CuratingConfigDTag {
if err = l.HandleCuratingConfigUpdate(env.E); chk.E(err) {
log.E.F("failed to process curating config update: %v", err)
if err = Ok.Error(l, env, err.Error()); chk.E(err) {
return
}
return
}
// Save the event and send OK response
result := l.eventProcessor.Process(context.Background(), env.E)
if result.Error != nil {
log.E.F("failed to save curating config event: %v", result.Error)
}
if err = Ok.Ok(l, env, "curating configuration updated"); chk.E(err) {
return
}
return
}
// Not a curating config event, continue with normal processing
case kind.PolicyConfig.K: case kind.PolicyConfig.K:
// Handle policy configuration update events (kind 12345) if err := l.HandlePolicyConfigUpdate(env.E); chk.E(err) {
// Only policy admins can update policy configuration
if err = l.HandlePolicyConfigUpdate(env.E); chk.E(err) {
log.E.F("failed to process policy config update: %v", err) log.E.F("failed to process policy config update: %v", err)
if err = Ok.Error(l, env, err.Error()); chk.E(err) { if err = Ok.Error(l, env, err.Error()); chk.E(err) {
return return true, err
} }
return return true, nil
} }
// Send OK response if err := Ok.Ok(l, env, "policy configuration updated"); chk.E(err) {
if err = Ok.Ok(l, env, "policy configuration updated"); chk.E(err) { return true, err
return
} }
return return true, nil
case kind.FollowList.K: case kind.FollowList.K:
// Check if this is a follow list update from a policy admin // Check if this is a follow list update from a policy admin
// If so, refresh the policy follows cache immediately
if l.IsPolicyAdminFollowListEvent(env.E) { if l.IsPolicyAdminFollowListEvent(env.E) {
// Process the follow list update (async, don't block)
go func() { go func() {
if updateErr := l.HandlePolicyAdminFollowListUpdate(env.E); updateErr != nil { if updateErr := l.HandlePolicyAdminFollowListUpdate(env.E); updateErr != nil {
log.W.F("failed to update policy follows from admin follow list: %v", updateErr) log.W.F("failed to update policy follows: %v", updateErr)
} }
}() }()
} }
// Continue with normal follow list processing (store the event) // Continue with normal processing
} return false, nil
// Authorization check (policy + ACL) - use authorization service
decision := l.eventAuthorizer.Authorize(env.E, l.authedPubkey.Load(), l.remote, env.E.Kind)
// Debug: log ephemeral event authorization
if env.E.Kind >= 20000 && env.E.Kind < 30000 {
log.I.F("ephemeral auth check: kind %d, allowed=%v, reason=%s",
env.E.Kind, decision.Allowed, decision.DenyReason)
}
if !decision.Allowed {
log.D.F("HandleEvent: authorization denied: %s (requireAuth=%v)", decision.DenyReason, decision.RequireAuth)
if decision.RequireAuth {
// Send OK false with reason
if err = okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F(decision.DenyReason),
).Write(l); chk.E(err) {
return
}
// Send AUTH challenge
if err = authenvelope.NewChallengeWith(l.challenge.Load()).Write(l); chk.E(err) {
return
}
} else {
// Send OK false with blocked reason
if err = Ok.Blocked(l, env, decision.DenyReason); chk.E(err) {
return
}
}
return
}
log.I.F("HandleEvent: authorized with access level %s", decision.AccessLevel)
// Progressive throttle for follows ACL mode (delays non-followed users)
if delay := l.getFollowsThrottleDelay(env.E); delay > 0 {
log.D.F("HandleEvent: applying progressive throttle delay of %v for %0x from %s",
delay, env.E.Pubkey, l.remote)
select {
case <-l.ctx.Done():
return l.ctx.Err()
case <-time.After(delay):
// Delay completed, continue processing
}
}
// Route special event kinds (ephemeral, etc.) - use routing service
if routeResult := l.eventRouter.Route(env.E, l.authedPubkey.Load()); routeResult.Action != routing.Continue {
if routeResult.Action == routing.Handled {
// Event fully handled by router, send OK and return
log.D.F("event %0x handled by router", env.E.ID)
if err = Ok.Ok(l, env, routeResult.Message); chk.E(err) {
return
}
return
} else if routeResult.Action == routing.Error {
// Router encountered an error
if err = l.sendRoutingError(env, routeResult); chk.E(err) {
return
}
return
}
} }
log.D.F("processing regular event %0x (kind %d)", env.E.ID, env.E.Kind)
// NIP-70 protected tag validation - use validation service return false, nil
if acl.Registry.Active.Load() != "none" {
if result := l.eventValidator.ValidateProtectedTag(env.E, l.authedPubkey.Load()); !result.Valid {
if err = l.sendValidationError(env, result); chk.E(err) {
return
}
return
} }
}
// Handle delete events specially - save first, then process deletions
if env.E.Kind == kind.EventDeletion.K {
log.I.F("processing delete event %0x", env.E.ID)
// Save and deliver using processing service // sendIngestionResult sends the appropriate response based on the ingestion result.
result := l.eventProcessor.Process(context.Background(), env.E) func (l *Listener) sendIngestionResult(env *eventenvelope.Submission, result ingestion.Result) error {
if result.Blocked {
if err = Ok.Error(l, env, result.BlockMsg); chk.E(err) {
return
}
return
}
if result.Error != nil { if result.Error != nil {
chk.E(result.Error) log.E.F("HandleEvent: ingestion error: %v", result.Error)
return return Ok.Error(l, env, result.Error.Error())
} }
// Process deletion targets (remove referenced events) if result.RequireAuth {
if err = l.HandleDelete(env); err != nil { // Send OK false with auth required reason
log.W.F("HandleDelete failed for event %0x: %v", env.E.ID, err) if err := okenvelope.NewFrom(
} env.Id(), false,
reason.AuthRequired.F(result.Message),
if err = Ok.Ok(l, env, ""); chk.E(err) { ).Write(l); chk.E(err) {
return return err
}
log.D.F("processed delete event %0x", env.E.ID)
return
}
// Process event: save, run hooks, and deliver to subscribers
result := l.eventProcessor.Process(context.Background(), env.E)
if result.Blocked {
if err = Ok.Error(l, env, result.BlockMsg); chk.E(err) {
return
}
return
} }
if result.Error != nil { // Send AUTH challenge
chk.E(result.Error) return authenvelope.NewChallengeWith(l.challenge.Load()).Write(l)
return
} }
// Send success response if !result.Accepted {
if err = Ok.Ok(l, env, ""); chk.E(err) { return Ok.Blocked(l, env, result.Message)
return
} }
log.D.F("saved event %0x", env.E.ID)
return // Success
log.D.F("HandleEvent: event %0x processed successfully", env.E.ID)
return Ok.Ok(l, env, result.Message)
} }
// isPeerRelayPubkey checks if the given pubkey belongs to a peer relay // isPeerRelayPubkey checks if the given pubkey belongs to a peer relay
@ -327,7 +158,6 @@ func (l *Listener) isPeerRelayPubkey(pubkey []byte) bool {
peerPubkeyHex := hex.Enc(pubkey) peerPubkeyHex := hex.Enc(pubkey)
// Check if this pubkey matches any of our configured peer relays' NIP-11 pubkeys
for _, peerURL := range l.syncManager.GetPeers() { for _, peerURL := range l.syncManager.GetPeers() {
if l.syncManager.IsAuthorizedPeer(peerURL, peerPubkeyHex) { if l.syncManager.IsAuthorizedPeer(peerURL, peerPubkeyHex) {
return true return true
@ -339,13 +169,11 @@ func (l *Listener) isPeerRelayPubkey(pubkey []byte) bool {
// HandleCuratingConfigUpdate processes curating configuration events (kind 30078) // HandleCuratingConfigUpdate processes curating configuration events (kind 30078)
func (l *Listener) HandleCuratingConfigUpdate(ev *event.E) error { func (l *Listener) HandleCuratingConfigUpdate(ev *event.E) error {
// Check if curating ACL is active
if acl.Registry.Type() != "curating" { if acl.Registry.Type() != "curating" {
return nil // Ignore config events if not in curating mode return nil
} }
// Find the curating ACL instance for _, aclInstance := range acl.Registry.ACLs() {
for _, aclInstance := range acl.Registry.ACL {
if aclInstance.Type() == "curating" { if aclInstance.Type() == "curating" {
if curating, ok := aclInstance.(*acl.Curating); ok { if curating, ok := aclInstance.(*acl.Curating); ok {
return curating.ProcessConfigEvent(ev) return curating.ProcessConfigEvent(ev)

2
app/handle-nip86-curating.go

@ -21,7 +21,7 @@ func (s *Server) handleCuratingNIP86Request(w http.ResponseWriter, r *http.Reque
// Get the curating ACL instance // Get the curating ACL instance
var curatingACL *acl.Curating var curatingACL *acl.Curating
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "curating" { if aclInstance.Type() == "curating" {
if curating, ok := aclInstance.(*acl.Curating); ok { if curating, ok := aclInstance.(*acl.Curating); ok {
curatingACL = curating curatingACL = curating

2
app/handle-nip86.go

@ -70,7 +70,7 @@ func (s *Server) handleNIP86Management(w http.ResponseWriter, r *http.Request) {
// Get the managed ACL instance // Get the managed ACL instance
var managedACL *database.ManagedACL var managedACL *database.ManagedACL
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "managed" { if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok { if managed, ok := aclInstance.(*acl.Managed); ok {
managedACL = managed.GetManagedACL() managedACL = managed.GetManagedACL()

2
app/handle-relayinfo.go

@ -142,7 +142,7 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
// Override with managed ACL config if in managed mode // Override with managed ACL config if in managed mode
if s.Config.ACLMode == "managed" { if s.Config.ACLMode == "managed" {
// Get managed ACL instance // Get managed ACL instance
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "managed" { if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok { if managed, ok := aclInstance.(*acl.Managed); ok {
managedACL := managed.GetManagedACL() managedACL := managed.GetManagedACL()

14
app/handle-req.go

@ -98,7 +98,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
} }
// send a challenge to the client to auth if an ACL is active, auth is required, or AuthToWrite is enabled // send a challenge to the client to auth if an ACL is active, auth is required, or AuthToWrite is enabled
if len(l.authedPubkey.Load()) == 0 && (acl.Registry.Active.Load() != "none" || l.Config.AuthRequired || l.Config.AuthToWrite) { if len(l.authedPubkey.Load()) == 0 && (acl.Registry.GetMode() != "none" || l.Config.AuthRequired || l.Config.AuthToWrite) {
if err = authenvelope.NewChallengeWith(l.challenge.Load()). if err = authenvelope.NewChallengeWith(l.challenge.Load()).
Write(l); chk.E(err) { Write(l); chk.E(err) {
return return
@ -123,7 +123,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
if l.Config.AuthToWrite && len(l.authedPubkey.Load()) == 0 { if l.Config.AuthToWrite && len(l.authedPubkey.Load()) == 0 {
// Allow unauthenticated REQ when AuthToWrite is enabled // Allow unauthenticated REQ when AuthToWrite is enabled
// but still respect ACL access levels if ACL is active // but still respect ACL access levels if ACL is active
if acl.Registry.Active.Load() != "none" { if acl.Registry.GetMode() != "none" {
switch accessLevel { switch accessLevel {
case "none", "blocked", "banned": case "none", "blocked", "banned":
if err = closedenvelope.NewFrom( if err = closedenvelope.NewFrom(
@ -507,7 +507,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
// When ACL is "none", skip privileged filtering to allow open access // When ACL is "none", skip privileged filtering to allow open access
// Privileged events should only be sent to users who are authenticated and // Privileged events should only be sent to users who are authenticated and
// are either the event author or listed in p tags // are either the event author or listed in p tags
aclActive := acl.Registry.Active.Load() != "none" aclActive := acl.Registry.GetMode() != "none"
if kind.IsPrivileged(ev.Kind) && aclActive && accessLevel != "admin" { // admins can see all events if kind.IsPrivileged(ev.Kind) && aclActive && accessLevel != "admin" { // admins can see all events
log.T.C( log.T.C(
func() string { func() string {
@ -589,7 +589,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
} }
// Apply managed ACL filtering for read access if managed ACL is active // Apply managed ACL filtering for read access if managed ACL is active
if acl.Registry.Active.Load() == "managed" { if acl.Registry.GetMode() == "managed" {
var aclFilteredEvents event.S var aclFilteredEvents event.S
for _, ev := range events { for _, ev := range events {
// Check if event is banned // Check if event is banned
@ -621,9 +621,9 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
} }
// Apply curating ACL filtering for read access if curating ACL is active // Apply curating ACL filtering for read access if curating ACL is active
if acl.Registry.Active.Load() == "curating" { if acl.Registry.GetMode() == "curating" {
// Find the curating ACL instance // Find the curating ACL instance
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "curating" { if aclInstance.Type() == "curating" {
if curatingACL, ok := aclInstance.(*acl.Curating); ok { if curatingACL, ok := aclInstance.(*acl.Curating); ok {
var curatingFilteredEvents event.S var curatingFilteredEvents event.S
@ -825,7 +825,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
// Register subscription with publisher // Register subscription with publisher
// Set AuthRequired based on ACL mode - when ACL is "none", don't require auth for privileged events // Set AuthRequired based on ACL mode - when ACL is "none", don't require auth for privileged events
authRequired := acl.Registry.Active.Load() != "none" authRequired := acl.Registry.GetMode() != "none"
l.publishers.Receive( l.publishers.Receive(
&W{ &W{
Conn: l.conn, Conn: l.conn,

6
app/listener.go

@ -308,7 +308,7 @@ func (l *Listener) messageProcessor() {
// getManagedACL returns the managed ACL instance if available // getManagedACL returns the managed ACL instance if available
func (l *Listener) getManagedACL() *database.ManagedACL { func (l *Listener) getManagedACL() *database.ManagedACL {
// Get the managed ACL instance from the ACL registry // Get the managed ACL instance from the ACL registry
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "managed" { if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok { if managed, ok := aclInstance.(*acl.Managed); ok {
return managed.GetManagedACL() return managed.GetManagedACL()
@ -322,11 +322,11 @@ func (l *Listener) getManagedACL() *database.ManagedACL {
// Returns 0 if not in follows mode, throttle is disabled, or user is exempt. // Returns 0 if not in follows mode, throttle is disabled, or user is exempt.
func (l *Listener) getFollowsThrottleDelay(ev *event.E) time.Duration { func (l *Listener) getFollowsThrottleDelay(ev *event.E) time.Duration {
// Only applies to follows ACL mode // Only applies to follows ACL mode
if acl.Registry.Active.Load() != "follows" { if acl.Registry.GetMode() != "follows" {
return 0 return 0
} }
// Find the Follows ACL instance and get the throttle delay // Find the Follows ACL instance and get the throttle delay
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if follows, ok := aclInstance.(*acl.Follows); ok { if follows, ok := aclInstance.(*acl.Follows); ok {
return follows.GetThrottleDelay(ev.Pubkey, l.remote) return follows.GetThrottleDelay(ev.Pubkey, l.remote)
} }

9
app/main.go

@ -88,6 +88,7 @@ func Run(
cfg: cfg, cfg: cfg,
db: db, db: db,
connPerIP: make(map[string]int), connPerIP: make(map[string]int),
aclRegistry: acl.Registry, // Inject ACL registry (transitional from global)
} }
// Initialize branding/white-label manager if enabled // Initialize branding/white-label manager if enabled
@ -270,7 +271,7 @@ func Run(
l.spiderManager.SetCallbacks( l.spiderManager.SetCallbacks(
func() []string { func() []string {
// Get admin relays from follows ACL if available // Get admin relays from follows ACL if available
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "follows" { if aclInstance.Type() == "follows" {
if follows, ok := aclInstance.(*acl.Follows); ok { if follows, ok := aclInstance.(*acl.Follows); ok {
return follows.AdminRelays() return follows.AdminRelays()
@ -281,7 +282,7 @@ func Run(
}, },
func() [][]byte { func() [][]byte {
// Get followed pubkeys from follows ACL if available // Get followed pubkeys from follows ACL if available
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "follows" { if aclInstance.Type() == "follows" {
if follows, ok := aclInstance.(*acl.Follows); ok { if follows, ok := aclInstance.(*acl.Follows); ok {
return follows.GetFollowedPubkeys() return follows.GetFollowedPubkeys()
@ -300,7 +301,7 @@ func Run(
// Hook up follow list update notifications from ACL to spider // Hook up follow list update notifications from ACL to spider
if cfg.SpiderMode == "follows" { if cfg.SpiderMode == "follows" {
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "follows" { if aclInstance.Type() == "follows" {
if follows, ok := aclInstance.(*acl.Follows); ok { if follows, ok := aclInstance.(*acl.Follows); ok {
follows.SetFollowListUpdateCallback(func() { follows.SetFollowListUpdateCallback(func() {
@ -331,7 +332,7 @@ func Run(
l.directorySpider.SetSeedCallback(func() [][]byte { l.directorySpider.SetSeedCallback(func() [][]byte {
var pubkeys [][]byte var pubkeys [][]byte
// Get followed pubkeys from follows ACL if available // Get followed pubkeys from follows ACL if available
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "follows" { if aclInstance.Type() == "follows" {
if follows, ok := aclInstance.(*acl.Follows); ok { if follows, ok := aclInstance.(*acl.Follows); ok {
pubkeys = append(pubkeys, follows.GetFollowedPubkeys()...) pubkeys = append(pubkeys, follows.GetFollowedPubkeys()...)

94
app/server.go

@ -18,11 +18,16 @@ import (
"next.orly.dev/app/branding" "next.orly.dev/app/branding"
"next.orly.dev/app/config" "next.orly.dev/app/config"
"next.orly.dev/pkg/acl" "next.orly.dev/pkg/acl"
acliface "next.orly.dev/pkg/interfaces/acl"
"next.orly.dev/pkg/blossom" "next.orly.dev/pkg/blossom"
"next.orly.dev/pkg/database" "next.orly.dev/pkg/database"
domainevents "next.orly.dev/pkg/domain/events"
"next.orly.dev/pkg/domain/events/subscribers"
"next.orly.dev/pkg/event/authorization" "next.orly.dev/pkg/event/authorization"
"next.orly.dev/pkg/event/ingestion"
"next.orly.dev/pkg/event/processing" "next.orly.dev/pkg/event/processing"
"next.orly.dev/pkg/event/routing" "next.orly.dev/pkg/event/routing"
"next.orly.dev/pkg/event/specialkinds"
"next.orly.dev/pkg/event/validation" "next.orly.dev/pkg/event/validation"
"git.mleku.dev/mleku/nostr/encoders/event" "git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter" "git.mleku.dev/mleku/nostr/encoders/filter"
@ -99,6 +104,10 @@ type Server struct {
eventAuthorizer *authorization.Service eventAuthorizer *authorization.Service
eventRouter *routing.DefaultRouter eventRouter *routing.DefaultRouter
eventProcessor *processing.Service eventProcessor *processing.Service
eventDispatcher *domainevents.Dispatcher
ingestionService *ingestion.Service
specialKinds *specialkinds.Registry
aclRegistry acliface.Registry
// WireGuard VPN and NIP-46 Bunker // WireGuard VPN and NIP-46 Bunker
wireguardServer *wireguard.Server wireguardServer *wireguard.Server
@ -161,6 +170,12 @@ func (s *Server) IsOwner(pubkey []byte) bool {
return false return false
} }
// ACLRegistry returns the ACL registry instance.
// This enables dependency injection for testing and removes reliance on global state.
func (s *Server) ACLRegistry() acliface.Registry {
return s.aclRegistry
}
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system // isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system
func (s *Server) isIPBlacklisted(remote string) bool { func (s *Server) isIPBlacklisted(remote string) bool {
// Extract IP from remote address (e.g., "192.168.1.1:12345" -> "192.168.1.1") // Extract IP from remote address (e.g., "192.168.1.1:12345" -> "192.168.1.1")
@ -178,7 +193,7 @@ func (s *Server) isIPBlacklisted(remote string) bool {
// Check if managed ACL is available and active // Check if managed ACL is available and active
if s.Config.ACLMode == "managed" { if s.Config.ACLMode == "managed" {
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "managed" { if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok { if managed, ok := aclInstance.(*acl.Managed); ok {
return managed.IsIPBlocked(remoteIP) return managed.IsIPBlocked(remoteIP)
@ -933,7 +948,7 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
} }
// Skip authentication and permission checks when ACL is "none" (open relay mode) // Skip authentication and permission checks when ACL is "none" (open relay mode)
if acl.Registry.Active.Load() != "none" { if acl.Registry.GetMode() != "none" {
// Validate NIP-98 authentication // Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r) valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid { if chk.E(err) || !valid {
@ -1111,7 +1126,7 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
} }
// Skip authentication and permission checks when ACL is "none" (open relay mode) // Skip authentication and permission checks when ACL is "none" (open relay mode)
if acl.Registry.Active.Load() != "none" { if acl.Registry.GetMode() != "none" {
// Validate NIP-98 authentication // Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r) valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid { if chk.E(err) || !valid {
@ -1520,7 +1535,7 @@ func (s *Server) validatePeerRequest(
// updatePeerAdminACL grants admin access to peer relay identity pubkeys // updatePeerAdminACL grants admin access to peer relay identity pubkeys
func (s *Server) updatePeerAdminACL(peerPubkey []byte) { func (s *Server) updatePeerAdminACL(peerPubkey []byte) {
// Find the managed ACL instance and update peer admins // Find the managed ACL instance and update peer admins
for _, aclInstance := range acl.Registry.ACL { for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "managed" { if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok { if managed, ok := aclInstance.(*acl.Managed); ok {
// Collect all current peer pubkeys // Collect all current peer pubkeys
@ -1596,6 +1611,73 @@ func (s *Server) InitEventServices() {
s.eventProcessor.SetClusterManager(s.wrapClusterManager()) s.eventProcessor.SetClusterManager(s.wrapClusterManager())
} }
s.eventProcessor.SetACLRegistry(s.wrapACLRegistry()) s.eventProcessor.SetACLRegistry(s.wrapACLRegistry())
// Initialize domain event dispatcher
s.eventDispatcher = domainevents.NewDispatcher(domainevents.DefaultDispatcherConfig())
// Register logging subscriber for analytics
logLevel := "debug"
if s.Config.LogLevel == "trace" {
logLevel = "trace"
} else if s.Config.LogLevel == "info" || s.Config.LogLevel == "warn" || s.Config.LogLevel == "error" {
logLevel = "info"
}
s.eventDispatcher.Subscribe(subscribers.NewLoggingSubscriber(logLevel))
// Wire dispatcher to processing service
s.eventProcessor.SetEventDispatcher(s.eventDispatcher)
// Initialize special kinds registry and register handlers
s.specialKinds = specialkinds.NewRegistry()
s.registerSpecialKindHandlers()
// Initialize ingestion service
s.ingestionService = ingestion.NewService(
s.eventValidator,
s.eventAuthorizer,
s.eventRouter,
s.eventProcessor,
ingestion.Config{
SprocketChecker: s.wrapSprocketChecker(),
SpecialKinds: s.specialKinds,
ACLMode: acl.Registry.GetMode,
},
)
}
// SprocketChecker wrapper for ingestion.SprocketChecker interface
type sprocketCheckerWrapper struct {
sm *SprocketManager
}
func (s *Server) wrapSprocketChecker() ingestion.SprocketChecker {
if s.sprocketManager == nil {
return nil
}
return &sprocketCheckerWrapper{sm: s.sprocketManager}
}
func (w *sprocketCheckerWrapper) IsEnabled() bool {
return w.sm != nil && w.sm.IsEnabled()
}
func (w *sprocketCheckerWrapper) IsDisabled() bool {
return w.sm.IsDisabled()
}
func (w *sprocketCheckerWrapper) IsRunning() bool {
return w.sm.IsRunning()
}
func (w *sprocketCheckerWrapper) ProcessEvent(ev *event.E) (*ingestion.SprocketResponse, error) {
resp, err := w.sm.ProcessEvent(ev)
if err != nil {
return nil, err
}
return &ingestion.SprocketResponse{
Action: resp.Action,
Msg: resp.Msg,
}, nil
} }
// Database wrapper for processing.Database interface // Database wrapper for processing.Database interface
@ -1690,7 +1772,7 @@ func (w *processingACLRegistryWrapper) Configure(cfg ...any) error {
} }
func (w *processingACLRegistryWrapper) Active() string { func (w *processingACLRegistryWrapper) Active() string {
return acl.Registry.Active.Load() return acl.Registry.GetMode()
} }
// ============================================================================= // =============================================================================
@ -1713,7 +1795,7 @@ func (w *authACLRegistryWrapper) CheckPolicy(ev *event.E) (bool, error) {
} }
func (w *authACLRegistryWrapper) Active() string { func (w *authACLRegistryWrapper) Active() string {
return acl.Registry.Active.Load() return acl.Registry.GetMode()
} }
// PolicyManager wrapper for authorization.PolicyManager interface // PolicyManager wrapper for authorization.PolicyManager interface

94
app/specialkinds.go

@ -0,0 +1,94 @@
package app
import (
"context"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/kind"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/event/specialkinds"
"next.orly.dev/pkg/protocol/nip43"
)
// registerSpecialKindHandlers registers handlers for special event kinds
// that require custom processing before normal storage/delivery.
func (s *Server) registerSpecialKindHandlers() {
// NIP-43 Join Request handler - signals that special handling is needed
s.specialKinds.Register(specialkinds.NewHandlerFunc(
"nip43-join",
func(ev *event.E) bool {
return ev.Kind == nip43.KindJoinRequest
},
func(ctx context.Context, ev *event.E, hctx *specialkinds.HandlerContext) specialkinds.Result {
// Signal to handler that this needs NIP-43 join processing
// The actual processing happens in the Listener which has the connection context
return specialkinds.Result{
Handled: true,
Message: "nip43-join", // Marker for handler
}
},
))
// NIP-43 Leave Request handler - signals that special handling is needed
s.specialKinds.Register(specialkinds.NewHandlerFunc(
"nip43-leave",
func(ev *event.E) bool {
return ev.Kind == nip43.KindLeaveRequest
},
func(ctx context.Context, ev *event.E, hctx *specialkinds.HandlerContext) specialkinds.Result {
return specialkinds.Result{
Handled: true,
Message: "nip43-leave", // Marker for handler
}
},
))
// Curating config handler (kind 30078 with d-tag "curating-config")
s.specialKinds.Register(specialkinds.NewHandlerFunc(
"curating-config",
func(ev *event.E) bool {
if ev.Kind != acl.CuratingConfigKind {
return false
}
dTag := ev.Tags.GetFirst([]byte("d"))
return dTag != nil && string(dTag.Value()) == acl.CuratingConfigDTag
},
s.handleCuratingConfig,
))
// Policy config handler (kind 12345)
s.specialKinds.Register(specialkinds.NewHandlerFunc(
"policy-config",
func(ev *event.E) bool {
return ev.Kind == kind.PolicyConfig.K
},
func(ctx context.Context, ev *event.E, hctx *specialkinds.HandlerContext) specialkinds.Result {
return specialkinds.Result{
Handled: true,
Message: "policy-config", // Marker for handler
}
},
))
}
// handleCuratingConfig handles curating configuration events
func (s *Server) handleCuratingConfig(ctx context.Context, ev *event.E, hctx *specialkinds.HandlerContext) specialkinds.Result {
if acl.Registry.Type() != "curating" {
return specialkinds.ContinueProcessing()
}
for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "curating" {
if curating, ok := aclInstance.(*acl.Curating); ok {
if err := curating.ProcessConfigEvent(ev); err != nil {
return specialkinds.ErrorResult(err)
}
// Save the event and signal success
return specialkinds.HandledWithSave("curating configuration updated")
}
}
}
return specialkinds.ContinueProcessing()
}

10
cmd/orly-acl/service.go

@ -78,7 +78,7 @@ func (s *ACLService) Ready(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv
func (s *ACLService) GetThrottleDelay(ctx context.Context, req *orlyaclv1.ThrottleDelayRequest) (*orlyaclv1.ThrottleDelayResponse, error) { func (s *ACLService) GetThrottleDelay(ctx context.Context, req *orlyaclv1.ThrottleDelayRequest) (*orlyaclv1.ThrottleDelayResponse, error) {
// Get the active ACL and check if it's Follows // Get the active ACL and check if it's Follows
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" { if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok { if follows, ok := i.(*acl.Follows); ok {
delay := follows.GetThrottleDelay(req.Pubkey, req.Ip) delay := follows.GetThrottleDelay(req.Pubkey, req.Ip)
@ -95,7 +95,7 @@ func (s *ACLService) AddFollow(ctx context.Context, req *orlyaclv1.AddFollowRequ
} }
func (s *ACLService) GetFollowedPubkeys(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv1.FollowedPubkeysResponse, error) { func (s *ACLService) GetFollowedPubkeys(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv1.FollowedPubkeysResponse, error) {
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" { if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok { if follows, ok := i.(*acl.Follows); ok {
pubkeys := follows.GetFollowedPubkeys() pubkeys := follows.GetFollowedPubkeys()
@ -107,7 +107,7 @@ func (s *ACLService) GetFollowedPubkeys(ctx context.Context, req *orlyaclv1.Empt
} }
func (s *ACLService) GetAdminRelays(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv1.AdminRelaysResponse, error) { func (s *ACLService) GetAdminRelays(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv1.AdminRelaysResponse, error) {
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" { if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok { if follows, ok := i.(*acl.Follows); ok {
urls := follows.AdminRelays() urls := follows.AdminRelays()
@ -767,7 +767,7 @@ func (s *ACLService) ScanAllPubkeys(ctx context.Context, req *orlyaclv1.Empty) (
// === Helper Methods === // === Helper Methods ===
func (s *ACLService) getManagedACL() *acl.Managed { func (s *ACLService) getManagedACL() *acl.Managed {
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "managed" { if i.Type() == "managed" {
if managed, ok := i.(*acl.Managed); ok { if managed, ok := i.(*acl.Managed); ok {
return managed return managed
@ -778,7 +778,7 @@ func (s *ACLService) getManagedACL() *acl.Managed {
} }
func (s *ACLService) getCuratingACL() *acl.Curating { func (s *ACLService) getCuratingACL() *acl.Curating {
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "curating" { if i.Type() == "curating" {
if curating, ok := i.(*acl.Curating); ok { if curating, ok := i.(*acl.Curating); ok {
return curating return curating

62
pkg/acl/acl.go

@ -12,27 +12,27 @@ var Registry = &S{}
// SetMode sets the active ACL mode and syncs it to the mode package for // SetMode sets the active ACL mode and syncs it to the mode package for
// packages that need to check the mode without importing acl (to avoid cycles). // packages that need to check the mode without importing acl (to avoid cycles).
func (s *S) SetMode(m string) { func (s *S) SetMode(m string) {
s.Active.Store(m) s.active.Store(m)
mode.ACLMode.Store(m) mode.ACLMode.Store(m)
} }
type S struct { type S struct {
// ACL holds registered ACL implementations. // acl holds registered ACL implementations.
// Deprecated: Use GetACLByType() or ListRegisteredACLs() instead of accessing directly. // Use ACLs(), GetACLByType(), or ListRegisteredACLs() to access.
ACL []acliface.I acl []acliface.I
// Active holds the name of the currently active ACL mode. // active holds the name of the currently active ACL mode.
// Deprecated: Use GetMode() instead of Active.Load(). // Use GetMode() to read the current mode.
Active atomic.String active atomic.String
} }
// GetMode returns the currently active ACL mode name. // GetMode returns the currently active ACL mode name.
func (s *S) GetMode() string { func (s *S) GetMode() string {
return s.Active.Load() return s.active.Load()
} }
// GetACLByType returns the ACL implementation with the given type name, or nil if not found. // GetACLByType returns the ACL implementation with the given type name, or nil if not found.
func (s *S) GetACLByType(typ string) acliface.I { func (s *S) GetACLByType(typ string) acliface.I {
for _, i := range s.ACL { for _, i := range s.acl {
if i.Type() == typ { if i.Type() == typ {
return i return i
} }
@ -42,18 +42,24 @@ func (s *S) GetACLByType(typ string) acliface.I {
// GetActiveACL returns the currently active ACL implementation, or nil if none is active. // GetActiveACL returns the currently active ACL implementation, or nil if none is active.
func (s *S) GetActiveACL() acliface.I { func (s *S) GetActiveACL() acliface.I {
return s.GetACLByType(s.Active.Load()) return s.GetACLByType(s.active.Load())
} }
// ListRegisteredACLs returns the type names of all registered ACL implementations. // ListRegisteredACLs returns the type names of all registered ACL implementations.
func (s *S) ListRegisteredACLs() []string { func (s *S) ListRegisteredACLs() []string {
types := make([]string, 0, len(s.ACL)) types := make([]string, 0, len(s.acl))
for _, i := range s.ACL { for _, i := range s.acl {
types = append(types, i.Type()) types = append(types, i.Type())
} }
return types return types
} }
// ACLs returns the registered ACL implementations for iteration.
// Prefer using GetActiveACL() or GetACLByType() when possible.
func (s *S) ACLs() []acliface.I {
return s.acl
}
// IsRegistered returns true if an ACL with the given type is registered. // IsRegistered returns true if an ACL with the given type is registered.
func (s *S) IsRegistered(typ string) bool { func (s *S) IsRegistered(typ string) bool {
return s.GetACLByType(typ) != nil return s.GetACLByType(typ) != nil
@ -62,19 +68,19 @@ func (s *S) IsRegistered(typ string) bool {
type A struct{ S } type A struct{ S }
func (s *S) Register(i acliface.I) { func (s *S) Register(i acliface.I) {
(*s).ACL = append((*s).ACL, i) (*s).acl = append((*s).acl, i)
} }
// RegisterAndActivate registers an ACL implementation and sets it as the active one. // RegisterAndActivate registers an ACL implementation and sets it as the active one.
// This is used for gRPC clients where the mode is determined by the remote server. // This is used for gRPC clients where the mode is determined by the remote server.
func (s *S) RegisterAndActivate(i acliface.I) { func (s *S) RegisterAndActivate(i acliface.I) {
s.ACL = []acliface.I{i} s.acl = []acliface.I{i}
s.SetMode(i.Type()) s.SetMode(i.Type())
} }
func (s *S) Configure(cfg ...any) (err error) { func (s *S) Configure(cfg ...any) (err error) {
for _, i := range s.ACL { for _, i := range s.acl {
if i.Type() == s.Active.Load() { if i.Type() == s.active.Load() {
err = i.Configure(cfg...) err = i.Configure(cfg...)
return return
} }
@ -83,8 +89,8 @@ func (s *S) Configure(cfg ...any) (err error) {
} }
func (s *S) GetAccessLevel(pub []byte, address string) (level string) { func (s *S) GetAccessLevel(pub []byte, address string) (level string) {
for _, i := range s.ACL { for _, i := range s.acl {
if i.Type() == s.Active.Load() { if i.Type() == s.active.Load() {
level = i.GetAccessLevel(pub, address) level = i.GetAccessLevel(pub, address)
break break
} }
@ -93,8 +99,8 @@ func (s *S) GetAccessLevel(pub []byte, address string) (level string) {
} }
func (s *S) GetACLInfo() (name, description, documentation string) { func (s *S) GetACLInfo() (name, description, documentation string) {
for _, i := range s.ACL { for _, i := range s.acl {
if i.Type() == s.Active.Load() { if i.Type() == s.active.Load() {
name, description, documentation = i.GetACLInfo() name, description, documentation = i.GetACLInfo()
break break
} }
@ -103,8 +109,8 @@ func (s *S) GetACLInfo() (name, description, documentation string) {
} }
func (s *S) Syncer() { func (s *S) Syncer() {
for _, i := range s.ACL { for _, i := range s.acl {
if i.Type() == s.Active.Load() { if i.Type() == s.active.Load() {
i.Syncer() i.Syncer()
break break
} }
@ -112,8 +118,8 @@ func (s *S) Syncer() {
} }
func (s *S) Type() (typ string) { func (s *S) Type() (typ string) {
for _, i := range s.ACL { for _, i := range s.acl {
if i.Type() == s.Active.Load() { if i.Type() == s.active.Load() {
typ = i.Type() typ = i.Type()
break break
} }
@ -123,8 +129,8 @@ func (s *S) Type() (typ string) {
// AddFollow forwards a pubkey to the active ACL if it supports dynamic follows // AddFollow forwards a pubkey to the active ACL if it supports dynamic follows
func (s *S) AddFollow(pub []byte) { func (s *S) AddFollow(pub []byte) {
for _, i := range s.ACL { for _, i := range s.acl {
if i.Type() == s.Active.Load() { if i.Type() == s.active.Load() {
if f, ok := i.(*Follows); ok { if f, ok := i.(*Follows); ok {
f.AddFollow(pub) f.AddFollow(pub)
} }
@ -135,8 +141,8 @@ func (s *S) AddFollow(pub []byte) {
// CheckPolicy checks if an event is allowed by the active ACL policy // CheckPolicy checks if an event is allowed by the active ACL policy
func (s *S) CheckPolicy(ev *event.E) (allowed bool, err error) { func (s *S) CheckPolicy(ev *event.E) (allowed bool, err error) {
for _, i := range s.ACL { for _, i := range s.acl {
if i.Type() == s.Active.Load() { if i.Type() == s.active.Load() {
// Check if the ACL implementation has a CheckPolicy method // Check if the ACL implementation has a CheckPolicy method
if policyChecker, ok := i.(acliface.PolicyChecker); ok { if policyChecker, ok := i.(acliface.PolicyChecker); ok {
return policyChecker.CheckPolicy(ev) return policyChecker.CheckPolicy(ev)

10
pkg/acl/server/service.go

@ -73,7 +73,7 @@ func (s *ACLService) Ready(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv
func (s *ACLService) GetThrottleDelay(ctx context.Context, req *orlyaclv1.ThrottleDelayRequest) (*orlyaclv1.ThrottleDelayResponse, error) { func (s *ACLService) GetThrottleDelay(ctx context.Context, req *orlyaclv1.ThrottleDelayRequest) (*orlyaclv1.ThrottleDelayResponse, error) {
// Get the active ACL and check if it's Follows // Get the active ACL and check if it's Follows
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" { if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok { if follows, ok := i.(*acl.Follows); ok {
delay := follows.GetThrottleDelay(req.Pubkey, req.Ip) delay := follows.GetThrottleDelay(req.Pubkey, req.Ip)
@ -90,7 +90,7 @@ func (s *ACLService) AddFollow(ctx context.Context, req *orlyaclv1.AddFollowRequ
} }
func (s *ACLService) GetFollowedPubkeys(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv1.FollowedPubkeysResponse, error) { func (s *ACLService) GetFollowedPubkeys(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv1.FollowedPubkeysResponse, error) {
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" { if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok { if follows, ok := i.(*acl.Follows); ok {
pubkeys := follows.GetFollowedPubkeys() pubkeys := follows.GetFollowedPubkeys()
@ -102,7 +102,7 @@ func (s *ACLService) GetFollowedPubkeys(ctx context.Context, req *orlyaclv1.Empt
} }
func (s *ACLService) GetAdminRelays(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv1.AdminRelaysResponse, error) { func (s *ACLService) GetAdminRelays(ctx context.Context, req *orlyaclv1.Empty) (*orlyaclv1.AdminRelaysResponse, error) {
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" { if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok { if follows, ok := i.(*acl.Follows); ok {
urls := follows.AdminRelays() urls := follows.AdminRelays()
@ -762,7 +762,7 @@ func (s *ACLService) ScanAllPubkeys(ctx context.Context, req *orlyaclv1.Empty) (
// === Helper Methods === // === Helper Methods ===
func (s *ACLService) getManagedACL() *acl.Managed { func (s *ACLService) getManagedACL() *acl.Managed {
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "managed" { if i.Type() == "managed" {
if managed, ok := i.(*acl.Managed); ok { if managed, ok := i.(*acl.Managed); ok {
return managed return managed
@ -773,7 +773,7 @@ func (s *ACLService) getManagedACL() *acl.Managed {
} }
func (s *ACLService) getCuratingACL() *acl.Curating { func (s *ACLService) getCuratingACL() *acl.Curating {
for _, i := range acl.Registry.ACL { for _, i := range acl.Registry.ACLs() {
if i.Type() == "curating" { if i.Type() == "curating" {
if curating, ok := i.(*acl.Curating); ok { if curating, ok := i.(*acl.Curating); ok {
return curating return curating

2
pkg/blossom/utils_test.go

@ -38,7 +38,7 @@ func testSetup(t *testing.T) (*Server, func()) {
// Create ACL registry and set to "none" mode for tests // Create ACL registry and set to "none" mode for tests
aclRegistry := acl.Registry aclRegistry := acl.Registry
aclRegistry.Active.Store("none") // Allow all access for tests aclRegistry.SetMode("none") // Allow all access for tests
// Create server // Create server
cfg := &Config{ cfg := &Config{

130
pkg/domain/events/subscribers/logging.go

@ -0,0 +1,130 @@
// Package subscribers provides domain event subscriber implementations.
package subscribers
import (
"encoding/hex"
"lol.mleku.dev/log"
"next.orly.dev/pkg/domain/events"
)
// LoggingSubscriber logs domain events for analytics and debugging.
type LoggingSubscriber struct {
logLevel string // "debug", "info", "trace"
}
// NewLoggingSubscriber creates a new logging subscriber.
// logLevel controls verbosity: "trace" logs all events, "debug" logs important events,
// "info" logs only significant events like membership changes.
func NewLoggingSubscriber(logLevel string) *LoggingSubscriber {
if logLevel == "" {
logLevel = "debug"
}
return &LoggingSubscriber{logLevel: logLevel}
}
// Handle processes a domain event by logging it.
func (s *LoggingSubscriber) Handle(event events.DomainEvent) {
switch e := event.(type) {
case *events.EventSaved:
s.logEventSaved(e)
case *events.EventDeleted:
s.logEventDeleted(e)
case *events.FollowListUpdated:
s.logFollowListUpdated(e)
case *events.ACLMembershipChanged:
s.logACLMembershipChanged(e)
case *events.PolicyConfigUpdated:
s.logPolicyConfigUpdated(e)
case *events.UserAuthenticated:
s.logUserAuthenticated(e)
case *events.MemberJoined:
s.logMemberJoined(e)
case *events.MemberLeft:
s.logMemberLeft(e)
case *events.ConnectionOpened:
s.logConnectionOpened(e)
case *events.ConnectionClosed:
s.logConnectionClosed(e)
default:
if s.logLevel == "trace" {
log.T.F("domain event: %s", event.EventType())
}
}
}
// Supports returns true for all event types.
func (s *LoggingSubscriber) Supports(eventType string) bool {
return true
}
func (s *LoggingSubscriber) logEventSaved(e *events.EventSaved) {
if s.logLevel == "trace" {
pubkeyHex := hex.EncodeToString(e.Event.Pubkey)
log.T.F("event saved: kind=%d pubkey=%s admin=%v owner=%v",
e.Event.Kind, pubkeyHex[:16], e.IsAdmin, e.IsOwner)
}
}
func (s *LoggingSubscriber) logEventDeleted(e *events.EventDeleted) {
if s.logLevel == "trace" || s.logLevel == "debug" {
eventIDHex := hex.EncodeToString(e.EventID)
deletedByHex := hex.EncodeToString(e.DeletedBy)
log.D.F("event deleted: id=%s by=%s", eventIDHex[:16], deletedByHex[:16])
}
}
func (s *LoggingSubscriber) logFollowListUpdated(e *events.FollowListUpdated) {
if s.logLevel == "trace" || s.logLevel == "debug" {
adminHex := hex.EncodeToString(e.AdminPubkey)
log.D.F("follow list updated: admin=%s added=%d removed=%d",
adminHex[:16], len(e.AddedFollows), len(e.RemovedFollows))
}
}
func (s *LoggingSubscriber) logACLMembershipChanged(e *events.ACLMembershipChanged) {
// Always log ACL changes at info level - they're significant
pubkeyHex := hex.EncodeToString(e.Pubkey)
log.I.F("ACL membership changed: pubkey=%s %s->%s reason=%s",
pubkeyHex[:16], e.PrevLevel, e.NewLevel, e.Reason)
}
func (s *LoggingSubscriber) logPolicyConfigUpdated(e *events.PolicyConfigUpdated) {
// Always log policy changes at info level
updatedByHex := hex.EncodeToString(e.UpdatedBy)
log.I.F("policy config updated by %s: %d changes", updatedByHex[:16], len(e.Changes))
}
func (s *LoggingSubscriber) logUserAuthenticated(e *events.UserAuthenticated) {
if s.logLevel == "trace" || s.logLevel == "debug" {
pubkeyHex := hex.EncodeToString(e.Pubkey)
log.D.F("user authenticated: pubkey=%s level=%s firstTime=%v",
pubkeyHex[:16], e.AccessLevel, e.IsFirstTime)
}
}
func (s *LoggingSubscriber) logMemberJoined(e *events.MemberJoined) {
// Always log member joins at info level
pubkeyHex := hex.EncodeToString(e.Pubkey)
log.I.F("member joined: pubkey=%s invite=%s", pubkeyHex[:16], e.InviteCode)
}
func (s *LoggingSubscriber) logMemberLeft(e *events.MemberLeft) {
// Always log member departures at info level
pubkeyHex := hex.EncodeToString(e.Pubkey)
log.I.F("member left: pubkey=%s", pubkeyHex[:16])
}
func (s *LoggingSubscriber) logConnectionOpened(e *events.ConnectionOpened) {
if s.logLevel == "trace" {
log.T.F("connection opened: id=%s remote=%s", e.ConnectionID, e.RemoteAddr)
}
}
func (s *LoggingSubscriber) logConnectionClosed(e *events.ConnectionClosed) {
if s.logLevel == "trace" || s.logLevel == "debug" {
log.D.F("connection closed: id=%s duration=%v events_rx=%d events_tx=%d",
e.ConnectionID, e.Duration, e.EventsReceived, e.EventsPublished)
}
}

39
pkg/event/ingestion/service.go

@ -90,6 +90,18 @@ type Config struct {
// SpecialKinds is the registry for special kind handlers. // SpecialKinds is the registry for special kind handlers.
SpecialKinds *specialkinds.Registry SpecialKinds *specialkinds.Registry
// ACLMode is the current ACL mode (used for NIP-70 validation).
ACLMode func() string
// DeleteHandler handles deletion events.
DeleteHandler DeleteHandler
}
// DeleteHandler processes delete events (kind 5).
type DeleteHandler interface {
// HandleDelete processes a delete event after it's been saved.
HandleDelete(ctx context.Context, ev *event.E) error
} }
// Service orchestrates the event ingestion pipeline. // Service orchestrates the event ingestion pipeline.
@ -100,6 +112,8 @@ type Service struct {
processor *processing.Service processor *processing.Service
sprocket SprocketChecker sprocket SprocketChecker
specialKinds *specialkinds.Registry specialKinds *specialkinds.Registry
aclMode func() string
deleteHandler DeleteHandler
} }
// NewService creates a new ingestion service. // NewService creates a new ingestion service.
@ -117,6 +131,8 @@ func NewService(
processor: processor, processor: processor,
sprocket: cfg.SprocketChecker, sprocket: cfg.SprocketChecker,
specialKinds: cfg.SpecialKinds, specialKinds: cfg.SpecialKinds,
aclMode: cfg.ACLMode,
deleteHandler: cfg.DeleteHandler,
} }
} }
@ -188,7 +204,14 @@ func (s *Service) Ingest(ctx context.Context, ev *event.E, connCtx *ConnectionCo
return Rejected(decision.DenyReason) return Rejected(decision.DenyReason)
} }
// Stage 5: Routing (ephemeral events, etc.) // Stage 5: NIP-70 protected tag validation (only when ACL is active)
if s.aclMode != nil && s.aclMode() != "none" {
if result := s.validator.ValidateProtectedTag(ev, connCtx.AuthedPubkey); !result.Valid {
return Rejected(result.Msg)
}
}
// Stage 6: Routing (ephemeral events, etc.)
if routeResult := s.router.Route(ev, connCtx.AuthedPubkey); routeResult.Action != routing.Continue { if routeResult := s.router.Route(ev, connCtx.AuthedPubkey); routeResult.Action != routing.Continue {
if routeResult.Action == routing.Handled { if routeResult.Action == routing.Handled {
return AcceptedNotSaved(routeResult.Message) return AcceptedNotSaved(routeResult.Message)
@ -198,7 +221,7 @@ func (s *Service) Ingest(ctx context.Context, ev *event.E, connCtx *ConnectionCo
} }
} }
// Stage 6: Processing (save, hooks, delivery) // Stage 7: Processing (save, hooks, delivery)
procResult := s.processor.Process(ctx, ev) procResult := s.processor.Process(ctx, ev)
if procResult.Blocked { if procResult.Blocked {
return Rejected(procResult.BlockMsg) return Rejected(procResult.BlockMsg)
@ -207,12 +230,18 @@ func (s *Service) Ingest(ctx context.Context, ev *event.E, connCtx *ConnectionCo
return Errored(procResult.Error) return Errored(procResult.Error)
} }
return Result{ // Stage 8: Delete event post-processing
Accepted: true, const kindEventDeletion = 5
Saved: true, if ev.Kind == kindEventDeletion && s.deleteHandler != nil {
if err := s.deleteHandler.HandleDelete(ctx, ev); err != nil {
// Log but don't fail - the delete event is already saved
// Return success since the event was stored
} }
} }
return Accepted("")
}
// IngestWithRawValidation includes raw JSON validation before unmarshaling. // IngestWithRawValidation includes raw JSON validation before unmarshaling.
// Use this when the event hasn't been validated yet. // Use this when the event hasn't been validated yet.
func (s *Service) IngestWithRawValidation(ctx context.Context, rawJSON []byte, ev *event.E, connCtx *ConnectionContext) Result { func (s *Service) IngestWithRawValidation(ctx context.Context, rawJSON []byte, ev *event.E, connCtx *ConnectionContext) Result {

37
pkg/event/processing/processing.go

@ -9,6 +9,8 @@ import (
"git.mleku.dev/mleku/nostr/encoders/event" "git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/kind" "git.mleku.dev/mleku/nostr/encoders/kind"
"next.orly.dev/pkg/domain/events"
) )
// Result contains the outcome of event processing. // Result contains the outcome of event processing.
@ -85,6 +87,12 @@ type ClusterManager interface {
HandleMembershipEvent(ev *event.E) error HandleMembershipEvent(ev *event.E) error
} }
// DomainEventDispatcher abstracts domain event publishing.
type DomainEventDispatcher interface {
// PublishAsync queues an event for asynchronous processing.
PublishAsync(event events.DomainEvent) bool
}
// Config holds configuration for the processing service. // Config holds configuration for the processing service.
type Config struct { type Config struct {
Admins [][]byte Admins [][]byte
@ -109,6 +117,7 @@ type Service struct {
aclRegistry ACLRegistry aclRegistry ACLRegistry
relayGroupMgr RelayGroupManager relayGroupMgr RelayGroupManager
clusterManager ClusterManager clusterManager ClusterManager
eventDispatcher DomainEventDispatcher
} }
// New creates a new processing service. // New creates a new processing service.
@ -148,6 +157,11 @@ func (s *Service) SetClusterManager(cm ClusterManager) {
s.clusterManager = cm s.clusterManager = cm
} }
// SetEventDispatcher sets the domain event dispatcher.
func (s *Service) SetEventDispatcher(d DomainEventDispatcher) {
s.eventDispatcher = d
}
// Process saves an event and triggers delivery. // Process saves an event and triggers delivery.
func (s *Service) Process(ctx context.Context, ev *event.E) Result { func (s *Service) Process(ctx context.Context, ev *event.E) Result {
// Check if event was previously deleted (skip for "none" ACL mode and delete events) // Check if event was previously deleted (skip for "none" ACL mode and delete events)
@ -211,6 +225,14 @@ func (s *Service) deliver(ev *event.E) {
// runPostSaveHooks handles side effects after event persistence. // runPostSaveHooks handles side effects after event persistence.
func (s *Service) runPostSaveHooks(ev *event.E) { func (s *Service) runPostSaveHooks(ev *event.E) {
isAdmin := s.isAdminPubkey(ev.Pubkey)
isOwner := s.isOwnerPubkey(ev.Pubkey)
// Dispatch domain event for saved event
if s.eventDispatcher != nil {
s.eventDispatcher.PublishAsync(events.NewEventSaved(ev, 0, isAdmin, isOwner))
}
// Handle relay group configuration events // Handle relay group configuration events
if s.relayGroupMgr != nil { if s.relayGroupMgr != nil {
if err := s.relayGroupMgr.ValidateRelayGroupEvent(ev); err == nil { if err := s.relayGroupMgr.ValidateRelayGroupEvent(ev); err == nil {
@ -231,7 +253,7 @@ func (s *Service) runPostSaveHooks(ev *event.E) {
} }
// ACL reconfiguration for admin events // ACL reconfiguration for admin events
if s.isAdminEvent(ev) { if isAdmin || isOwner {
if ev.Kind == kind.FollowList.K || ev.Kind == kind.RelayListMetadata.K { if ev.Kind == kind.FollowList.K || ev.Kind == kind.RelayListMetadata.K {
if s.aclRegistry != nil { if s.aclRegistry != nil {
go s.aclRegistry.Configure() go s.aclRegistry.Configure()
@ -240,15 +262,20 @@ func (s *Service) runPostSaveHooks(ev *event.E) {
} }
} }
// isAdminEvent checks if event is from admin or owner. // isAdminPubkey checks if pubkey is an admin.
func (s *Service) isAdminEvent(ev *event.E) bool { func (s *Service) isAdminPubkey(pubkey []byte) bool {
for _, admin := range s.cfg.Admins { for _, admin := range s.cfg.Admins {
if fastEqual(admin, ev.Pubkey) { if fastEqual(admin, pubkey) {
return true return true
} }
} }
return false
}
// isOwnerPubkey checks if pubkey is an owner.
func (s *Service) isOwnerPubkey(pubkey []byte) bool {
for _, owner := range s.cfg.Owners { for _, owner := range s.cfg.Owners {
if fastEqual(owner, ev.Pubkey) { if fastEqual(owner, pubkey) {
return true return true
} }
} }

30
pkg/event/processing/processing_test.go

@ -284,26 +284,26 @@ func TestIsAdminEvent(t *testing.T) {
s := New(cfg, &mockDatabase{}, &mockPublisher{}) s := New(cfg, &mockDatabase{}, &mockPublisher{})
// Admin event // Admin pubkey
ev := event.New() if !s.isAdminPubkey(adminPubkey) {
ev.Pubkey = adminPubkey t.Error("should recognize admin pubkey")
if !s.isAdminEvent(ev) {
t.Error("should recognize admin event")
} }
// Owner event // Owner pubkey
ev.Pubkey = ownerPubkey if !s.isOwnerPubkey(ownerPubkey) {
if !s.isAdminEvent(ev) { t.Error("should recognize owner pubkey")
t.Error("should recognize owner event")
} }
// Regular event // Regular pubkey
ev.Pubkey = make([]byte, 32) regularPubkey := make([]byte, 32)
for i := range ev.Pubkey { for i := range regularPubkey {
ev.Pubkey[i] = byte(i + 100) regularPubkey[i] = byte(i + 100)
}
if s.isAdminPubkey(regularPubkey) {
t.Error("should not recognize regular pubkey as admin")
} }
if s.isAdminEvent(ev) { if s.isOwnerPubkey(regularPubkey) {
t.Error("should not recognize regular event as admin") t.Error("should not recognize regular pubkey as owner")
} }
} }

37
pkg/interfaces/acl/acl.go

@ -38,3 +38,40 @@ type I interface {
type PolicyChecker interface { type PolicyChecker interface {
CheckPolicy(ev *event.E) (allowed bool, err error) CheckPolicy(ev *event.E) (allowed bool, err error)
} }
// Registry is the interface for the ACL registry that manages ACL implementations.
// This interface enables dependency injection instead of relying on a global singleton.
type Registry interface {
// GetMode returns the currently active ACL mode name.
GetMode() string
// SetMode sets the active ACL mode.
SetMode(mode string)
// GetActiveACL returns the currently active ACL implementation.
GetActiveACL() I
// GetACLByType returns the ACL implementation with the given type name.
GetACLByType(typ string) I
// ACLs returns all registered ACL implementations.
ACLs() []I
// ListRegisteredACLs returns the type names of all registered ACLs.
ListRegisteredACLs() []string
// Register adds an ACL implementation to the registry.
Register(i I)
// Configure configures the active ACL.
Configure(cfg ...any) error
// GetAccessLevel returns the access level for a pubkey using the active ACL.
GetAccessLevel(pub []byte, address string) string
// CheckPolicy checks if an event is allowed by the active ACL.
CheckPolicy(ev *event.E) (bool, error)
// Type returns the type of the active ACL.
Type() string
}

20
pkg/policy/benchmark_test.go

@ -51,10 +51,16 @@ func BenchmarkCheckRulePolicy(b *testing.B) {
rule := Rule{ rule := Rule{
Description: "test rule", Description: "test rule",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(pubkey)}, WriteAllow: []string{hex.Enc(pubkey)},
},
Constraints: Constraints{
SizeLimit: int64Ptr(10000), SizeLimit: int64Ptr(10000),
ContentLimit: int64Ptr(1000), ContentLimit: int64Ptr(1000),
},
TagValidationConfig: TagValidationConfig{
MustHaveTags: []string{"p"}, MustHaveTags: []string{"p"},
},
} }
policy := &P{} policy := &P{}
@ -77,9 +83,11 @@ func BenchmarkCheckPolicy(b *testing.B) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "test rule", Description: "test rule",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(pubkey)}, WriteAllow: []string{hex.Enc(pubkey)},
}, },
}, },
},
} }
b.ResetTimer() b.ResetTimer()
@ -195,7 +203,9 @@ func BenchmarkCheckPolicyMultipleKinds(b *testing.B) {
for i := 1; i <= 100; i++ { for i := 1; i <= 100; i++ {
rules[i] = Rule{ rules[i] = Rule{
Description: "test rule", Description: "test rule",
AccessControl: AccessControl{
WriteAllow: []string{"test-pubkey"}, WriteAllow: []string{"test-pubkey"},
},
} }
} }
@ -286,11 +296,17 @@ func BenchmarkCheckPolicyComplexRule(b *testing.B) {
rule := Rule{ rule := Rule{
Description: "complex rule", Description: "complex rule",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(pubkey)}, WriteAllow: []string{hex.Enc(pubkey)},
},
Constraints: Constraints{
SizeLimit: int64Ptr(100000), SizeLimit: int64Ptr(100000),
ContentLimit: int64Ptr(10000), ContentLimit: int64Ptr(10000),
MustHaveTags: []string{"p", "e"},
Privileged: true, Privileged: true,
},
TagValidationConfig: TagValidationConfig{
MustHaveTags: []string{"p", "e"},
},
} }
policy := &P{} policy := &P{}
@ -310,10 +326,12 @@ func BenchmarkCheckPolicyLargeEvent(b *testing.B) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "size limit test", Description: "size limit test",
Constraints: Constraints{
SizeLimit: int64Ptr(200000), // 200KB limit SizeLimit: int64Ptr(200000), // 200KB limit
ContentLimit: int64Ptr(200000), // 200KB content limit ContentLimit: int64Ptr(200000), // 200KB content limit
}, },
}, },
},
} }
// Generate keypair once for all events // Generate keypair once for all events

4
pkg/policy/composition_test.go

@ -549,9 +549,11 @@ func TestPolicyAdminContributionValidation(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "Text notes", Description: "Text notes",
Constraints: Constraints{
SizeLimit: ptr(int64(10000)), SizeLimit: ptr(int64(10000)),
}, },
}, },
},
} }
tests := []struct { tests := []struct {
@ -633,10 +635,12 @@ func TestPolicyAdminContributionValidation(t *testing.T) {
RulesAdd: map[int]Rule{ RulesAdd: map[int]Rule{
30023: { 30023: {
Description: "Long-form content", Description: "Long-form content",
Constraints: Constraints{
SizeLimit: ptr(int64(100000)), SizeLimit: ptr(int64(100000)),
}, },
}, },
}, },
},
expectError: false, expectError: false,
}, },
{ {

4
pkg/policy/kind_whitelist_test.go

@ -147,8 +147,10 @@ func TestKindWhitelistComprehensive(t *testing.T) {
}, },
Global: Rule{ Global: Rule{
Description: "Global rule applies to all kinds", Description: "Global rule applies to all kinds",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(testPubkey)}, WriteAllow: []string{hex.Enc(testPubkey)},
}, },
},
rules: map[int]Rule{ rules: map[int]Rule{
1: {Description: "Rule for kind 1"}, 1: {Description: "Rule for kind 1"},
}, },
@ -259,9 +261,11 @@ func TestKindWhitelistRealWorld(t *testing.T) {
}, },
30023: { 30023: {
Description: "Long-form content", Description: "Long-form content",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(testPubkey)}, // Only specific user can write WriteAllow: []string{hex.Enc(testPubkey)}, // Only specific user can write
}, },
}, },
},
} }
// Test kind 1 (allowed) // Test kind 1 (allowed)

170
pkg/policy/policy.go

@ -72,111 +72,105 @@ type Kinds struct {
// For pubkey allow/deny lists: whitelist takes precedence over blacklist. // For pubkey allow/deny lists: whitelist takes precedence over blacklist.
// If whitelist has entries, only whitelisted pubkeys are allowed. // If whitelist has entries, only whitelisted pubkeys are allowed.
// If only blacklist has entries, all pubkeys except blacklisted ones are allowed. // If only blacklist has entries, all pubkeys except blacklisted ones are allowed.
type Rule struct { // =============================================================================
// Description is a human-readable description of the rule. // Rule Sub-Components (Value Objects)
Description string `json:"description"` // =============================================================================
// Script is a path to a script that will be used to determine if the event should be allowed to be written to the relay. The script should be a standard bash script or whatever is native to the platform. The script will return its opinion to be one of the criteria that must be met for the event to be allowed to be written to the relay (AND).
Script string `json:"script,omitempty"` // AccessControl defines who can read/write events.
// WriteAllow is a list of pubkeys that are allowed to write this event kind to the relay. If any are present, implicitly all others are denied. // This is a value object that encapsulates access control configuration.
type AccessControl struct {
// WriteAllow is a list of pubkeys allowed to write. If any present, all others denied.
WriteAllow []string `json:"write_allow,omitempty"` WriteAllow []string `json:"write_allow,omitempty"`
// WriteDeny is a list of pubkeys that are not allowed to write this event kind to the relay. If any are present, implicitly all others are allowed. Only takes effect in the absence of a WriteAllow. // WriteDeny is a list of pubkeys denied write. Only effective without WriteAllow.
WriteDeny []string `json:"write_deny,omitempty"` WriteDeny []string `json:"write_deny,omitempty"`
// ReadAllow is a list of pubkeys that are allowed to read this event kind from the relay. If any are present, implicitly all others are denied. // ReadAllow is a list of pubkeys allowed to read. If any present, all others denied.
ReadAllow []string `json:"read_allow,omitempty"` ReadAllow []string `json:"read_allow,omitempty"`
// ReadDeny is a list of pubkeys that are not allowed to read this event kind from the relay. If any are present, implicitly all others are allowed. Only takes effect in the absence of a ReadAllow. // ReadDeny is a list of pubkeys denied read. Only effective without ReadAllow.
ReadDeny []string `json:"read_deny,omitempty"` ReadDeny []string `json:"read_deny,omitempty"`
// MaxExpiry is the maximum expiry time in seconds for events written to the relay. If 0, there is no maximum expiry. Events must have an expiry time if this is set, and it must be no more than this value in the future compared to the event's created_at time. // WriteAllowFollows grants access to policy admin follows when enabled.
// Deprecated: Use MaxExpiryDuration instead for human-readable duration strings. WriteAllowFollows bool `json:"write_allow_follows,omitempty"`
MaxExpiry *int64 `json:"max_expiry,omitempty"` //nolint:staticcheck // Intentional backward compatibility // FollowsWhitelistAdmins specifies admin pubkeys whose follows are whitelisted.
// MaxExpiryDuration is the maximum expiry time in ISO-8601 duration format. // DEPRECATED: Use ReadFollowsWhitelist and WriteFollowsWhitelist instead.
// Format: P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S (e.g., "P7D" for 7 days, "PT1H" for 1 hour, "P1DT12H" for 1 day 12 hours). FollowsWhitelistAdmins []string `json:"follows_whitelist_admins,omitempty"`
// Parsed into maxExpirySeconds at load time. // ReadFollowsWhitelist specifies pubkeys whose follows can READ events.
ReadFollowsWhitelist []string `json:"read_follows_whitelist,omitempty"`
// WriteFollowsWhitelist specifies pubkeys whose follows can WRITE events.
WriteFollowsWhitelist []string `json:"write_follows_whitelist,omitempty"`
// ReadAllowPermissive allows read access for ALL kinds on GLOBAL rule.
ReadAllowPermissive bool `json:"read_allow_permissive,omitempty"`
// WriteAllowPermissive allows write access bypassing kind whitelist on GLOBAL rule.
WriteAllowPermissive bool `json:"write_allow_permissive,omitempty"`
// Binary caches (internal, not serialized)
writeAllowBin [][]byte
writeDenyBin [][]byte
readAllowBin [][]byte
readDenyBin [][]byte
followsWhitelistAdminsBin [][]byte
followsWhitelistFollowsBin [][]byte
readFollowsWhitelistBin [][]byte
writeFollowsWhitelistBin [][]byte
readFollowsFollowsBin [][]byte
writeFollowsFollowsBin [][]byte
}
// Constraints defines limits and restrictions on events.
// This is a value object that encapsulates event constraints.
type Constraints struct {
// MaxExpiry is the maximum expiry time in seconds.
// Deprecated: Use MaxExpiryDuration instead.
MaxExpiry *int64 `json:"max_expiry,omitempty"` //nolint:staticcheck
// MaxExpiryDuration is the max expiry in ISO-8601 duration format.
MaxExpiryDuration string `json:"max_expiry_duration,omitempty"` MaxExpiryDuration string `json:"max_expiry_duration,omitempty"`
// MustHaveTags is a list of tag key letters that must be present on the event for it to be allowed to be written to the relay. // SizeLimit is the maximum total serialized size in bytes.
MustHaveTags []string `json:"must_have_tags,omitempty"`
// SizeLimit is the maximum size in bytes for the event's total serialized size.
SizeLimit *int64 `json:"size_limit,omitempty"` SizeLimit *int64 `json:"size_limit,omitempty"`
// ContentLimit is the maximum size in bytes for the event's content field. // ContentLimit is the maximum content field size in bytes.
ContentLimit *int64 `json:"content_limit,omitempty"` ContentLimit *int64 `json:"content_limit,omitempty"`
// Privileged means that this event is either authored by the authenticated pubkey, or has a p tag that contains the authenticated pubkey. This type of event is only sent to users who are authenticated and are party to the event. // RateLimit is the write rate limit in bytes per second.
Privileged bool `json:"privileged,omitempty"`
// RateLimit is the amount of data can be written to the relay per second by the authenticated pubkey. If 0, there is no rate limit. This is applied via the use of an EWMA of the event publication history on the authenticated connection
RateLimit *int64 `json:"rate_limit,omitempty"` RateLimit *int64 `json:"rate_limit,omitempty"`
// MaxAgeOfEvent is the offset in seconds that is the oldest timestamp allowed for an event's created_at time. If 0, there is no maximum age. Events must have a created_at time if this is set, and it must be no more than this value in the past compared to the current time. // MaxAgeOfEvent is the max age in seconds for created_at timestamps.
MaxAgeOfEvent *int64 `json:"max_age_of_event,omitempty"` MaxAgeOfEvent *int64 `json:"max_age_of_event,omitempty"`
// MaxAgeEventInFuture is the offset in seconds that is the newest timestamp allowed for an event's created_at time ahead of the current time. // MaxAgeEventInFuture is the max future offset for created_at timestamps.
MaxAgeEventInFuture *int64 `json:"max_age_event_in_future,omitempty"` MaxAgeEventInFuture *int64 `json:"max_age_event_in_future,omitempty"`
// ProtectedRequired requires events to have a "-" tag (NIP-70).
ProtectedRequired bool `json:"protected_required,omitempty"`
// Privileged means event is only sent to authenticated parties.
Privileged bool `json:"privileged,omitempty"`
// WriteAllowFollows grants BOTH read and write access to policy admin follows when enabled. // Parsed cache (internal, not serialized)
// Requires PolicyFollowWhitelistEnabled=true at the policy level. maxExpirySeconds *int64
WriteAllowFollows bool `json:"write_allow_follows,omitempty"` }
// FollowsWhitelistAdmins specifies admin pubkeys (hex-encoded) whose follows are whitelisted for this rule.
// Unlike WriteAllowFollows which uses the global PolicyAdmins, this allows per-rule admin configuration.
// If set, the relay will fail to start if these admins don't have follow list events (kind 3) in the database.
// This provides explicit control over which admin's follow list controls access for specific kinds.
// DEPRECATED: Use ReadFollowsWhitelist and WriteFollowsWhitelist instead.
FollowsWhitelistAdmins []string `json:"follows_whitelist_admins,omitempty"`
// ReadFollowsWhitelist specifies pubkeys (hex-encoded) whose follows are allowed to READ events.
// The relay will fail to start if these pubkeys don't have follow list events (kind 3) in the database.
// When present, only the follows of these pubkeys (plus the pubkeys themselves) can read.
// This restricts read access - without it, read is permissive by default (except for privileged events).
ReadFollowsWhitelist []string `json:"read_follows_whitelist,omitempty"`
// WriteFollowsWhitelist specifies pubkeys (hex-encoded) whose follows are allowed to WRITE events.
// The relay will fail to start if these pubkeys don't have follow list events (kind 3) in the database.
// When present, only the follows of these pubkeys (plus the pubkeys themselves) can write.
// Without this, write permission is allowed by default.
WriteFollowsWhitelist []string `json:"write_follows_whitelist,omitempty"`
// TagValidation is a map of tag_name -> regex pattern for validating tag values. // TagValidationConfig defines tag validation rules.
// Each tag present in the event must match its corresponding regex pattern. // This is a value object that encapsulates tag validation configuration.
// Example: {"d": "^[a-z0-9-]{1,64}$", "t": "^[a-z0-9-]{1,32}$"} type TagValidationConfig struct {
// MustHaveTags is a list of tag key letters that must be present.
MustHaveTags []string `json:"must_have_tags,omitempty"`
// TagValidation is a map of tag_name -> regex pattern for validation.
TagValidation map[string]string `json:"tag_validation,omitempty"` TagValidation map[string]string `json:"tag_validation,omitempty"`
// IdentifierRegex is a regex pattern for "d" tag identifiers.
// ProtectedRequired when true requires events to have a "-" tag (NIP-70 protected events).
// Protected events signal that they should only be published to relays that enforce access control.
ProtectedRequired bool `json:"protected_required,omitempty"`
// IdentifierRegex is a regex pattern that "d" tag identifiers must conform to.
// This is a convenience field - equivalent to setting TagValidation["d"] = pattern.
// Example: "^[a-z0-9-]{1,64}$" requires lowercase alphanumeric with hyphens, max 64 chars.
IdentifierRegex string `json:"identifier_regex,omitempty"` IdentifierRegex string `json:"identifier_regex,omitempty"`
// ReadAllowPermissive when set on a GLOBAL rule, allows read access for ALL kinds, // Compiled cache (internal, not serialized)
// even when a kind whitelist is configured. This allows the kind whitelist to identifierRegexCache *regexp.Regexp
// restrict WRITE operations while keeping reads permissive. }
// When true:
// - READ: Allowed for all kinds (global rule still applies for other read restrictions)
// - WRITE: Kind whitelist/blacklist applies as normal
// Only meaningful on the Global rule - ignored on kind-specific rules.
ReadAllowPermissive bool `json:"read_allow_permissive,omitempty"`
// WriteAllowPermissive when set on a GLOBAL rule, allows write access for kinds // =============================================================================
// that don't have specific rules defined, bypassing the implicit kind whitelist. // Rule (Composed from Sub-Components)
// When true: // =============================================================================
// - Kinds without specific rules apply global rule constraints only
// - Kind whitelist still blocks reads for unlisted kinds (unless ReadAllowPermissive is also set)
// Only meaningful on the Global rule - ignored on kind-specific rules.
WriteAllowPermissive bool `json:"write_allow_permissive,omitempty"`
// Binary caches for faster comparison (populated from hex strings above) // Rule defines policies for a specific event kind or as a global default.
// These are not exported and not serialized to JSON // It is composed of sub-value objects for cleaner organization.
writeAllowBin [][]byte type Rule struct {
writeDenyBin [][]byte // Description is a human-readable description of the rule.
readAllowBin [][]byte Description string `json:"description"`
readDenyBin [][]byte // Script is a path to a validation script.
maxExpirySeconds *int64 // Parsed from MaxExpiryDuration or copied from MaxExpiry Script string `json:"script,omitempty"`
identifierRegexCache *regexp.Regexp // Compiled regex for IdentifierRegex
followsWhitelistAdminsBin [][]byte // Binary cache for FollowsWhitelistAdmins pubkeys (DEPRECATED) // Embedded sub-components (fields are flattened in JSON for backward compatibility)
followsWhitelistFollowsBin [][]byte // Cached follow list from FollowsWhitelistAdmins (loaded at startup, DEPRECATED) AccessControl
Constraints
// Binary caches for ReadFollowsWhitelist and WriteFollowsWhitelist TagValidationConfig
readFollowsWhitelistBin [][]byte // Binary cache for ReadFollowsWhitelist pubkeys
writeFollowsWhitelistBin [][]byte // Binary cache for WriteFollowsWhitelist pubkeys
readFollowsFollowsBin [][]byte // Cached follow list from ReadFollowsWhitelist pubkeys
writeFollowsFollowsBin [][]byte // Cached follow list from WriteFollowsWhitelist pubkeys
} }
// hasAnyRules checks if the rule has any constraints configured // hasAnyRules checks if the rule has any constraints configured

80
pkg/policy/policy_test.go

@ -189,8 +189,10 @@ func TestCheckKindsPolicy(t *testing.T) {
policy: &P{ policy: &P{
Kind: Kinds{}, Kind: Kinds{},
Global: Rule{ Global: Rule{
AccessControl: AccessControl{
WriteAllow: []string{"test"}, // Global rule exists WriteAllow: []string{"test"}, // Global rule exists
}, },
},
rules: map[int]Rule{}, // No specific rules rules: map[int]Rule{}, // No specific rules
}, },
access: "write", access: "write",
@ -278,9 +280,11 @@ func TestCheckKindsPolicy(t *testing.T) {
Whitelist: []int{1, 3, 5}, Whitelist: []int{1, 3, 5},
}, },
Global: Rule{ Global: Rule{
AccessControl: AccessControl{
ReadAllowPermissive: true, ReadAllowPermissive: true,
}, },
}, },
},
access: "read", access: "read",
kind: 2, kind: 2,
expected: true, // Should be allowed (read permissive overrides whitelist) expected: true, // Should be allowed (read permissive overrides whitelist)
@ -292,9 +296,11 @@ func TestCheckKindsPolicy(t *testing.T) {
Whitelist: []int{1, 3, 5}, Whitelist: []int{1, 3, 5},
}, },
Global: Rule{ Global: Rule{
AccessControl: AccessControl{
ReadAllowPermissive: true, ReadAllowPermissive: true,
}, },
}, },
},
access: "write", access: "write",
kind: 2, kind: 2,
expected: false, // Should be denied (only read is permissive) expected: false, // Should be denied (only read is permissive)
@ -306,9 +312,11 @@ func TestCheckKindsPolicy(t *testing.T) {
Whitelist: []int{1, 3, 5}, Whitelist: []int{1, 3, 5},
}, },
Global: Rule{ Global: Rule{
AccessControl: AccessControl{
WriteAllowPermissive: true, WriteAllowPermissive: true,
}, },
}, },
},
access: "write", access: "write",
kind: 2, kind: 2,
expected: true, // Should be allowed (write permissive overrides whitelist) expected: true, // Should be allowed (write permissive overrides whitelist)
@ -320,9 +328,11 @@ func TestCheckKindsPolicy(t *testing.T) {
Whitelist: []int{1, 3, 5}, Whitelist: []int{1, 3, 5},
}, },
Global: Rule{ Global: Rule{
AccessControl: AccessControl{
WriteAllowPermissive: true, WriteAllowPermissive: true,
}, },
}, },
},
access: "read", access: "read",
kind: 2, kind: 2,
expected: false, // Should be denied (only write is permissive) expected: false, // Should be denied (only write is permissive)
@ -334,10 +344,12 @@ func TestCheckKindsPolicy(t *testing.T) {
Blacklist: []int{2, 4, 6}, Blacklist: []int{2, 4, 6},
}, },
Global: Rule{ Global: Rule{
AccessControl: AccessControl{
ReadAllowPermissive: true, ReadAllowPermissive: true,
WriteAllowPermissive: true, WriteAllowPermissive: true,
}, },
}, },
},
access: "write", access: "write",
kind: 2, kind: 2,
expected: false, // Should be denied (blacklist always applies) expected: false, // Should be denied (blacklist always applies)
@ -390,8 +402,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "pubkey allowed", Description: "pubkey allowed",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(testEvent.Pubkey)}, WriteAllow: []string{hex.Enc(testEvent.Pubkey)},
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
expected: true, expected: true,
}, },
@ -401,8 +415,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "pubkey not allowed", Description: "pubkey not allowed",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(pTagPubkey)}, // Different pubkey WriteAllow: []string{hex.Enc(pTagPubkey)}, // Different pubkey
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
expected: false, expected: false,
}, },
@ -412,8 +428,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "size limit", Description: "size limit",
Constraints: Constraints{
SizeLimit: int64Ptr(10000), SizeLimit: int64Ptr(10000),
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
expected: true, expected: true,
}, },
@ -423,8 +441,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "size limit exceeded", Description: "size limit exceeded",
Constraints: Constraints{
SizeLimit: int64Ptr(10), SizeLimit: int64Ptr(10),
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
expected: false, expected: false,
}, },
@ -434,8 +454,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "content limit", Description: "content limit",
Constraints: Constraints{
ContentLimit: int64Ptr(1000), ContentLimit: int64Ptr(1000),
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
expected: true, expected: true,
}, },
@ -445,8 +467,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "content limit exceeded", Description: "content limit exceeded",
Constraints: Constraints{
ContentLimit: int64Ptr(5), ContentLimit: int64Ptr(5),
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
expected: false, expected: false,
}, },
@ -456,8 +480,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "required tags", Description: "required tags",
TagValidationConfig: TagValidationConfig{
MustHaveTags: []string{"p"}, MustHaveTags: []string{"p"},
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
expected: true, expected: true,
}, },
@ -467,8 +493,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "required tags missing", Description: "required tags missing",
TagValidationConfig: TagValidationConfig{
MustHaveTags: []string{"e"}, MustHaveTags: []string{"e"},
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
expected: false, expected: false,
}, },
@ -478,8 +506,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "privileged event", Description: "privileged event",
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
},
loggedInPubkey: testEvent.Pubkey, loggedInPubkey: testEvent.Pubkey,
expected: true, // Privileged doesn't restrict write, uses default (allow) expected: true, // Privileged doesn't restrict write, uses default (allow)
}, },
@ -489,8 +519,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "privileged event with p tag", Description: "privileged event with p tag",
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
},
loggedInPubkey: pTagPubkey, loggedInPubkey: pTagPubkey,
expected: true, // Privileged doesn't restrict write, uses default (allow) expected: true, // Privileged doesn't restrict write, uses default (allow)
}, },
@ -500,8 +532,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "privileged event not authenticated", Description: "privileged event not authenticated",
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
},
loggedInPubkey: nil, loggedInPubkey: nil,
expected: true, // Privileged doesn't restrict write, uses default (allow) expected: true, // Privileged doesn't restrict write, uses default (allow)
}, },
@ -511,8 +545,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "privileged event unauthorized user", Description: "privileged event unauthorized user",
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
},
loggedInPubkey: unauthorizedPubkey, loggedInPubkey: unauthorizedPubkey,
expected: true, // Privileged doesn't restrict write, uses default (allow) expected: true, // Privileged doesn't restrict write, uses default (allow)
}, },
@ -522,8 +558,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "privileged event read access", Description: "privileged event read access",
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
},
loggedInPubkey: testEvent.Pubkey, loggedInPubkey: testEvent.Pubkey,
expected: true, expected: true,
}, },
@ -533,8 +571,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "privileged event read access with p tag", Description: "privileged event read access with p tag",
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
},
loggedInPubkey: pTagPubkey, loggedInPubkey: pTagPubkey,
expected: true, expected: true,
}, },
@ -544,8 +584,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "privileged event read access not authenticated", Description: "privileged event read access not authenticated",
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
},
loggedInPubkey: nil, loggedInPubkey: nil,
expected: false, expected: false,
}, },
@ -555,8 +597,10 @@ func TestCheckRulePolicy(t *testing.T) {
event: testEvent, event: testEvent,
rule: Rule{ rule: Rule{
Description: "privileged event read access unauthorized user", Description: "privileged event read access unauthorized user",
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
},
loggedInPubkey: unauthorizedPubkey, loggedInPubkey: unauthorizedPubkey,
expected: false, expected: false,
}, },
@ -631,10 +675,12 @@ func TestCheckPolicy(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "block test", Description: "block test",
AccessControl: AccessControl{
WriteDeny: []string{hex.Enc(testEvent.Pubkey)}, WriteDeny: []string{hex.Enc(testEvent.Pubkey)},
}, },
}, },
}, },
},
loggedInPubkey: eventPubkey, loggedInPubkey: eventPubkey,
ipAddress: "127.0.0.1", ipAddress: "127.0.0.1",
expected: false, expected: false,
@ -1048,10 +1094,12 @@ func TestEdgeCasesLargeEvent(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "size limit test", Description: "size limit test",
Constraints: Constraints{
SizeLimit: int64Ptr(50000), // 50KB limit SizeLimit: int64Ptr(50000), // 50KB limit
ContentLimit: int64Ptr(10000), // 10KB content limit ContentLimit: int64Ptr(10000), // 10KB content limit
}, },
}, },
},
} }
// Generate real keypair for testing // Generate real keypair for testing
@ -1174,8 +1222,10 @@ func TestCheckGlobalRulePolicy(t *testing.T) {
{ {
name: "global rule with write allow - submitter allowed", name: "global rule with write allow - submitter allowed",
globalRule: Rule{ globalRule: Rule{
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(loggedInPubkey)}, // Allow the submitter WriteAllow: []string{hex.Enc(loggedInPubkey)}, // Allow the submitter
}, },
},
event: createTestEvent(t, eventSigner, "test content", 1), event: createTestEvent(t, eventSigner, "test content", 1),
loggedInPubkey: loggedInPubkey, loggedInPubkey: loggedInPubkey,
expected: true, expected: true,
@ -1183,8 +1233,10 @@ func TestCheckGlobalRulePolicy(t *testing.T) {
{ {
name: "global rule with write deny - submitter denied", name: "global rule with write deny - submitter denied",
globalRule: Rule{ globalRule: Rule{
AccessControl: AccessControl{
WriteDeny: []string{hex.Enc(loggedInPubkey)}, // Deny the submitter WriteDeny: []string{hex.Enc(loggedInPubkey)}, // Deny the submitter
}, },
},
event: createTestEvent(t, eventSigner, "test content", 1), event: createTestEvent(t, eventSigner, "test content", 1),
loggedInPubkey: loggedInPubkey, loggedInPubkey: loggedInPubkey,
expected: false, expected: false,
@ -1192,8 +1244,10 @@ func TestCheckGlobalRulePolicy(t *testing.T) {
{ {
name: "global rule with size limit - event too large", name: "global rule with size limit - event too large",
globalRule: Rule{ globalRule: Rule{
Constraints: Constraints{
SizeLimit: func() *int64 { v := int64(10); return &v }(), SizeLimit: func() *int64 { v := int64(10); return &v }(),
}, },
},
event: createTestEvent(t, eventSigner, "this is a very long content that exceeds the size limit", 1), event: createTestEvent(t, eventSigner, "this is a very long content that exceeds the size limit", 1),
loggedInPubkey: loggedInPubkey, loggedInPubkey: loggedInPubkey,
expected: false, expected: false,
@ -1201,8 +1255,10 @@ func TestCheckGlobalRulePolicy(t *testing.T) {
{ {
name: "global rule with max age of event - event too old", name: "global rule with max age of event - event too old",
globalRule: Rule{ globalRule: Rule{
Constraints: Constraints{
MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour
}, },
},
event: func() *event.E { event: func() *event.E {
ev := createTestEvent(t, eventSigner, "test content", 1) ev := createTestEvent(t, eventSigner, "test content", 1)
ev.CreatedAt = time.Now().Unix() - 7200 // 2 hours ago ev.CreatedAt = time.Now().Unix() - 7200 // 2 hours ago
@ -1214,8 +1270,10 @@ func TestCheckGlobalRulePolicy(t *testing.T) {
{ {
name: "global rule with max age event in future - event too far in future", name: "global rule with max age event in future - event too far in future",
globalRule: Rule{ globalRule: Rule{
Constraints: Constraints{
MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour
}, },
},
event: func() *event.E { event: func() *event.E {
ev := createTestEvent(t, eventSigner, "test content", 1) ev := createTestEvent(t, eventSigner, "test content", 1)
ev.CreatedAt = time.Now().Unix() + 7200 // 2 hours in future ev.CreatedAt = time.Now().Unix() + 7200 // 2 hours in future
@ -1248,16 +1306,20 @@ func TestCheckPolicyWithGlobalRule(t *testing.T) {
// Test that global rule is applied first // Test that global rule is applied first
policy := &P{ policy := &P{
Global: Rule{ Global: Rule{
AccessControl: AccessControl{
WriteDeny: []string{hex.Enc(eventPubkey)}, // Deny event pubkey globally WriteDeny: []string{hex.Enc(eventPubkey)}, // Deny event pubkey globally
}, },
},
Kind: Kinds{ Kind: Kinds{
Whitelist: []int{1}, // Allow kind 1 Whitelist: []int{1}, // Allow kind 1
}, },
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(eventPubkey)}, // Allow event pubkey for kind 1 WriteAllow: []string{hex.Enc(eventPubkey)}, // Allow event pubkey for kind 1
}, },
}, },
},
} }
event := createTestEvent(t, eventSigner, "test content", 1) event := createTestEvent(t, eventSigner, "test content", 1)
@ -1288,8 +1350,10 @@ func TestMaxAgeChecks(t *testing.T) {
{ {
name: "max age of event - event within allowed age", name: "max age of event - event within allowed age",
rule: Rule{ rule: Rule{
Constraints: Constraints{
MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour
}, },
},
event: func() *event.E { event: func() *event.E {
ev := createTestEvent(t, eventSigner, "test content", 1) ev := createTestEvent(t, eventSigner, "test content", 1)
ev.CreatedAt = time.Now().Unix() - 1800 // 30 minutes ago ev.CreatedAt = time.Now().Unix() - 1800 // 30 minutes ago
@ -1301,8 +1365,10 @@ func TestMaxAgeChecks(t *testing.T) {
{ {
name: "max age of event - event too old", name: "max age of event - event too old",
rule: Rule{ rule: Rule{
Constraints: Constraints{
MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour
}, },
},
event: func() *event.E { event: func() *event.E {
ev := createTestEvent(t, eventSigner, "test content", 1) ev := createTestEvent(t, eventSigner, "test content", 1)
ev.CreatedAt = time.Now().Unix() - 7200 // 2 hours ago ev.CreatedAt = time.Now().Unix() - 7200 // 2 hours ago
@ -1314,8 +1380,10 @@ func TestMaxAgeChecks(t *testing.T) {
{ {
name: "max age event in future - event within allowed future time", name: "max age event in future - event within allowed future time",
rule: Rule{ rule: Rule{
Constraints: Constraints{
MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour
}, },
},
event: func() *event.E { event: func() *event.E {
ev := createTestEvent(t, eventSigner, "test content", 1) ev := createTestEvent(t, eventSigner, "test content", 1)
ev.CreatedAt = time.Now().Unix() + 1800 // 30 minutes in future ev.CreatedAt = time.Now().Unix() + 1800 // 30 minutes in future
@ -1327,8 +1395,10 @@ func TestMaxAgeChecks(t *testing.T) {
{ {
name: "max age event in future - event too far in future", name: "max age event in future - event too far in future",
rule: Rule{ rule: Rule{
Constraints: Constraints{
MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour
}, },
},
event: func() *event.E { event: func() *event.E {
ev := createTestEvent(t, eventSigner, "test content", 1) ev := createTestEvent(t, eventSigner, "test content", 1)
ev.CreatedAt = time.Now().Unix() + 7200 // 2 hours in future ev.CreatedAt = time.Now().Unix() + 7200 // 2 hours in future
@ -1340,9 +1410,11 @@ func TestMaxAgeChecks(t *testing.T) {
{ {
name: "both age checks - event within both limits", name: "both age checks - event within both limits",
rule: Rule{ rule: Rule{
Constraints: Constraints{
MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour
MaxAgeEventInFuture: func() *int64 { v := int64(1800); return &v }(), // 30 minutes MaxAgeEventInFuture: func() *int64 { v := int64(1800); return &v }(), // 30 minutes
}, },
},
event: func() *event.E { event: func() *event.E {
ev := createTestEvent(t, eventSigner, "test content", 1) ev := createTestEvent(t, eventSigner, "test content", 1)
ev.CreatedAt = time.Now().Unix() + 900 // 15 minutes in future ev.CreatedAt = time.Now().Unix() + 900 // 15 minutes in future
@ -1518,9 +1590,11 @@ func TestDefaultPolicyWithSpecificRule(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "allow kind 1", Description: "allow kind 1",
AccessControl: AccessControl{
WriteAllow: []string{}, // Allow all for kind 1 WriteAllow: []string{}, // Allow all for kind 1
}, },
}, },
},
} }
// Create real test event with proper signing for kind 1 (has specific rule) // Create real test event with proper signing for kind 1 (has specific rule)
@ -1632,12 +1706,16 @@ func TestDefaultPolicyLogicWithRules(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "allow all for kind 1", Description: "allow all for kind 1",
AccessControl: AccessControl{
WriteAllow: []string{}, // Empty means allow all WriteAllow: []string{}, // Empty means allow all
}, },
},
2: { 2: {
Description: "deny specific pubkey for kind 2", Description: "deny specific pubkey for kind 2",
AccessControl: AccessControl{
WriteDeny: []string{hex.Enc(deniedPubkey)}, WriteDeny: []string{hex.Enc(deniedPubkey)},
}, },
},
// No rule for kind 3 // No rule for kind 3
}, },
} }
@ -1691,8 +1769,10 @@ func TestDefaultPolicyLogicWithRules(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "deny specific pubkey for kind 1", Description: "deny specific pubkey for kind 1",
AccessControl: AccessControl{
WriteDeny: []string{hex.Enc(deniedPubkey)}, WriteDeny: []string{hex.Enc(deniedPubkey)},
}, },
},
// No rules for kind 2, 3 // No rules for kind 2, 3
}, },
} }

18
pkg/policy/precedence_test.go

@ -47,11 +47,15 @@ func TestPolicyPrecedenceRules(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
100: { 100: {
Description: "Deny overrides allow and privileged", Description: "Deny overrides allow and privileged",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(alicePubkey)}, // Alice in allow list WriteAllow: []string{hex.Enc(alicePubkey)}, // Alice in allow list
WriteDeny: []string{hex.Enc(alicePubkey)}, // But also in deny list WriteDeny: []string{hex.Enc(alicePubkey)}, // But also in deny list
},
Constraints: Constraints{
Privileged: true, // And it's privileged Privileged: true, // And it's privileged
}, },
}, },
},
} }
// Alice creates an event (she's author, in allow list, but also in deny list) // Alice creates an event (she's author, in allow list, but also in deny list)
@ -78,10 +82,14 @@ func TestPolicyPrecedenceRules(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
200: { 200: {
Description: "Privileged with allow list", Description: "Privileged with allow list",
AccessControl: AccessControl{
ReadAllow: []string{hex.Enc(bobPubkey)}, // Only Bob in allow list ReadAllow: []string{hex.Enc(bobPubkey)}, // Only Bob in allow list
},
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
}, },
},
} }
// Alice creates event // Alice creates event
@ -131,7 +139,9 @@ func TestPolicyPrecedenceRules(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
300: { 300: {
Description: "Privileged without allow list", Description: "Privileged without allow list",
Constraints: Constraints{
Privileged: true, Privileged: true,
},
// NO ReadAllow or WriteAllow specified // NO ReadAllow or WriteAllow specified
}, },
}, },
@ -186,7 +196,9 @@ func TestPolicyPrecedenceRules(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
400: { 400: {
Description: "Allow list only", Description: "Allow list only",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(alicePubkey)}, // Only Alice WriteAllow: []string{hex.Enc(alicePubkey)}, // Only Alice
},
// NO Privileged flag // NO Privileged flag
}, },
}, },
@ -226,11 +238,15 @@ func TestPolicyPrecedenceRules(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
500: { 500: {
Description: "Complex rules", Description: "Complex rules",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(alicePubkey), hex.Enc(bobPubkey)}, WriteAllow: []string{hex.Enc(alicePubkey), hex.Enc(bobPubkey)},
WriteDeny: []string{hex.Enc(bobPubkey)}, // Bob denied despite being in allow WriteDeny: []string{hex.Enc(bobPubkey)}, // Bob denied despite being in allow
},
Constraints: Constraints{
Privileged: true, Privileged: true,
}, },
}, },
},
} }
// Test 5a: Alice in allow, not in deny - ALLOWED // Test 5a: Alice in allow, not in deny - ALLOWED
@ -316,9 +332,11 @@ func TestPolicyPrecedenceRules(t *testing.T) {
DefaultPolicy: "allow", // Allow default DefaultPolicy: "allow", // Allow default
rules: map[int]Rule{ rules: map[int]Rule{
700: { 700: {
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(bobPubkey)}, // Only Bob WriteAllow: []string{hex.Enc(bobPubkey)}, // Only Bob
}, },
}, },
},
} }
eventKind700 := createTestEvent(t, aliceSigner, "alice", 700) eventKind700 := createTestEvent(t, aliceSigner, "alice", 700)

12
pkg/policy/read_access_test.go

@ -29,9 +29,11 @@ func TestReadAllowLogic(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
30166: { 30166: {
Description: "Private server heartbeat events", Description: "Private server heartbeat events",
AccessControl: AccessControl{
ReadAllow: []string{hex.Enc(bobPubkey)}, // Only Bob can read ReadAllow: []string{hex.Enc(bobPubkey)}, // Only Bob can read
}, },
}, },
},
} }
// Test 1: Bob (who is in ReadAllow) should be able to READ Alice's event // Test 1: Bob (who is in ReadAllow) should be able to READ Alice's event
@ -97,9 +99,11 @@ func TestReadDenyLogic(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
1: { 1: {
Description: "Test events", Description: "Test events",
AccessControl: AccessControl{
ReadDeny: []string{hex.Enc(charliePubkey)}, // Charlie cannot read ReadDeny: []string{hex.Enc(charliePubkey)}, // Charlie cannot read
}, },
}, },
},
} }
// Test 1: Bob (who is NOT in ReadDeny) should be able to READ Alice's event // Test 1: Bob (who is NOT in ReadDeny) should be able to READ Alice's event
@ -308,10 +312,14 @@ func TestReadAllowWithPrivileged(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
100: { 100: {
Description: "Privileged with read_allow", Description: "Privileged with read_allow",
Constraints: Constraints{
Privileged: true, Privileged: true,
},
AccessControl: AccessControl{
ReadAllow: []string{hex.Enc(bobPubkey)}, // Only Bob can read ReadAllow: []string{hex.Enc(bobPubkey)}, // Only Bob can read
}, },
}, },
},
} }
// Create event authored by Alice, with Bob in p tag // Create event authored by Alice, with Bob in p tag
@ -382,10 +390,12 @@ func TestReadAllowWriteAllowIndependent(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
200: { 200: {
Description: "Write/Read separation test", Description: "Write/Read separation test",
AccessControl: AccessControl{
WriteAllow: []string{hex.Enc(alicePubkey)}, // Only Alice can write WriteAllow: []string{hex.Enc(alicePubkey)}, // Only Alice can write
ReadAllow: []string{hex.Enc(bobPubkey)}, // Only Bob can read ReadAllow: []string{hex.Enc(bobPubkey)}, // Only Bob can read
}, },
}, },
},
} }
// Alice creates an event // Alice creates an event
@ -475,9 +485,11 @@ func TestReadAccessEdgeCases(t *testing.T) {
rules: map[int]Rule{ rules: map[int]Rule{
300: { 300: {
Description: "Test edge cases", Description: "Test edge cases",
AccessControl: AccessControl{
ReadAllow: []string{"somepubkey"}, // Non-empty ReadAllow ReadAllow: []string{"somepubkey"}, // Non-empty ReadAllow
}, },
}, },
},
} }
event := createTestEvent(t, aliceSigner, "test", 300) event := createTestEvent(t, aliceSigner, "test", 300)

2
pkg/version/version

@ -1 +1 @@
v0.56.6 v0.56.8

Loading…
Cancel
Save