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) { @@ -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)) })
// 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) {
return
}
@ -47,7 +47,7 @@ func (l *Listener) HandleCount(msg []byte) (err error) { @@ -47,7 +47,7 @@ func (l *Listener) HandleCount(msg []byte) (err error) {
if l.Config.AuthToWrite && len(l.authedPubkey.Load()) == 0 {
// Allow unauthenticated COUNT when AuthToWrite is enabled
// but still respect ACL access levels if ACL is active
if acl.Registry.Active.Load() != "none" {
if acl.Registry.GetMode() != "none" {
switch accessLevel {
case "none", "blocked", "banned":
return errors.New("auth required: user not authed or has no read access")

306
app/handle-event.go

@ -6,8 +6,6 @@ import ( @@ -6,8 +6,6 @@ import (
"lol.mleku.dev/chk"
"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/eventenvelope"
"git.mleku.dev/mleku/nostr/encoders/envelopes/noticeenvelope"
@ -16,307 +14,140 @@ import ( @@ -16,307 +14,140 @@ import (
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/reason"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/event/ingestion"
"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) {
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 {
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 {
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) {
return
}
return nil
}
// decode the envelope
// Stage 2: Unmarshal the envelope
env := eventenvelope.NewSubmission()
log.I.F("HandleEvent: received event message length: %d", len(msg))
if msg, err = env.Unmarshal(msg); chk.E(err) {
log.E.F("HandleEvent: failed to unmarshal event: %v", err)
return
}
log.I.F(
"HandleEvent: successfully unmarshaled event, kind: %d, pubkey: %s, id: %0x",
env.E.Kind, hex.Enc(env.E.Pubkey), env.E.ID,
)
log.I.F("HandleEvent: unmarshaled event, kind: %d, pubkey: %s, id: %0x",
env.E.Kind, hex.Enc(env.E.Pubkey), env.E.ID)
defer func() {
if env != nil && env.E != nil {
env.E.Free()
}
}()
if len(msg) > 0 {
log.I.F("extra '%s'", msg)
}
// 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
// Stage 3: Handle special kinds that need connection context
if handled, err := l.handleSpecialKinds(env); handled {
return err
}
// Process event through sprocket
response, sprocketErr := l.sprocketManager.ProcessEvent(env.E)
if chk.E(sprocketErr) {
log.E.F("sprocket processing failed: %v", sprocketErr)
if err = Ok.Error(
l, env, "sprocket processing failed",
); chk.E(err) {
return
// Stage 4: Progressive throttle for follows ACL mode
if delay := l.getFollowsThrottleDelay(env.E); delay > 0 {
log.D.F("HandleEvent: applying progressive throttle delay of %v", delay)
select {
case <-l.ctx.Done():
return l.ctx.Err()
case <-time.After(delay):
}
return
}
// Handle sprocket response
switch response.Action {
case "accept":
// Continue with normal processing
log.D.F("sprocket accepted event %0x", env.E.ID)
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
}
// Stage 5: Delegate to ingestion service
connCtx := &ingestion.ConnectionContext{
AuthedPubkey: l.authedPubkey.Load(),
Remote: l.remote,
ConnectionID: l.connectionID,
}
result := l.ingestionService.Ingest(context.Background(), env.E, connCtx)
// Event validation (ID, timestamp, signature) - use validation service
if result := l.eventValidator.ValidateEvent(env.E); !result.Valid {
if err = l.sendValidationError(env, result); chk.E(err) {
return
}
return
// Stage 6: Send response based on result
return l.sendIngestionResult(env, result)
}
// 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 {
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)
}
return
return true, nil
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)
}
return
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
return true, nil
case kind.PolicyConfig.K:
// Handle policy configuration update events (kind 12345)
// Only policy admins can update policy configuration
if err = l.HandlePolicyConfigUpdate(env.E); chk.E(err) {
if err := l.HandlePolicyConfigUpdate(env.E); chk.E(err) {
log.E.F("failed to process policy config update: %v", 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) {
return
if err := Ok.Ok(l, env, "policy configuration updated"); chk.E(err) {
return true, err
}
return
return true, nil
case kind.FollowList.K:
// 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) {
// Process the follow list update (async, don't block)
go func() {
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)
}
// 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
}
// Continue with normal processing
return false, nil
}
log.D.F("processing regular event %0x (kind %d)", env.E.ID, env.E.Kind)
// NIP-70 protected tag validation - use validation service
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
return false, nil
}
}
// 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
result := l.eventProcessor.Process(context.Background(), env.E)
if result.Blocked {
if err = Ok.Error(l, env, result.BlockMsg); chk.E(err) {
return
}
return
}
// sendIngestionResult sends the appropriate response based on the ingestion result.
func (l *Listener) sendIngestionResult(env *eventenvelope.Submission, result ingestion.Result) error {
if result.Error != nil {
chk.E(result.Error)
return
log.E.F("HandleEvent: ingestion error: %v", result.Error)
return Ok.Error(l, env, result.Error.Error())
}
// Process deletion targets (remove referenced events)
if err = l.HandleDelete(env); err != nil {
log.W.F("HandleDelete failed for event %0x: %v", env.E.ID, err)
}
if err = Ok.Ok(l, env, ""); chk.E(err) {
return
}
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.RequireAuth {
// Send OK false with auth required reason
if err := okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F(result.Message),
).Write(l); chk.E(err) {
return err
}
if result.Error != nil {
chk.E(result.Error)
return
// Send AUTH challenge
return authenvelope.NewChallengeWith(l.challenge.Load()).Write(l)
}
// Send success response
if err = Ok.Ok(l, env, ""); chk.E(err) {
return
if !result.Accepted {
return Ok.Blocked(l, env, result.Message)
}
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
@ -327,7 +158,6 @@ func (l *Listener) isPeerRelayPubkey(pubkey []byte) bool { @@ -327,7 +158,6 @@ func (l *Listener) isPeerRelayPubkey(pubkey []byte) bool {
peerPubkeyHex := hex.Enc(pubkey)
// Check if this pubkey matches any of our configured peer relays' NIP-11 pubkeys
for _, peerURL := range l.syncManager.GetPeers() {
if l.syncManager.IsAuthorizedPeer(peerURL, peerPubkeyHex) {
return true
@ -339,13 +169,11 @@ func (l *Listener) isPeerRelayPubkey(pubkey []byte) bool { @@ -339,13 +169,11 @@ func (l *Listener) isPeerRelayPubkey(pubkey []byte) bool {
// HandleCuratingConfigUpdate processes curating configuration events (kind 30078)
func (l *Listener) HandleCuratingConfigUpdate(ev *event.E) error {
// Check if curating ACL is active
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.ACL {
for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "curating" {
if curating, ok := aclInstance.(*acl.Curating); ok {
return curating.ProcessConfigEvent(ev)

2
app/handle-nip86-curating.go

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

2
app/handle-nip86.go

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

2
app/handle-relayinfo.go

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

14
app/handle-req.go

@ -98,7 +98,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -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
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()).
Write(l); chk.E(err) {
return
@ -123,7 +123,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -123,7 +123,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
if l.Config.AuthToWrite && len(l.authedPubkey.Load()) == 0 {
// Allow unauthenticated REQ when AuthToWrite is enabled
// but still respect ACL access levels if ACL is active
if acl.Registry.Active.Load() != "none" {
if acl.Registry.GetMode() != "none" {
switch accessLevel {
case "none", "blocked", "banned":
if err = closedenvelope.NewFrom(
@ -507,7 +507,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -507,7 +507,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
// When ACL is "none", skip privileged filtering to allow open access
// Privileged events should only be sent to users who are authenticated and
// 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
log.T.C(
func() string {
@ -589,7 +589,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -589,7 +589,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
}
// 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
for _, ev := range events {
// Check if event is banned
@ -621,9 +621,9 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -621,9 +621,9 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
}
// 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
for _, aclInstance := range acl.Registry.ACL {
for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "curating" {
if curatingACL, ok := aclInstance.(*acl.Curating); ok {
var curatingFilteredEvents event.S
@ -825,7 +825,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -825,7 +825,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
// Register subscription with publisher
// 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(
&W{
Conn: l.conn,

6
app/listener.go

@ -308,7 +308,7 @@ func (l *Listener) messageProcessor() { @@ -308,7 +308,7 @@ func (l *Listener) messageProcessor() {
// getManagedACL returns the managed ACL instance if available
func (l *Listener) getManagedACL() *database.ManagedACL {
// 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 managed, ok := aclInstance.(*acl.Managed); ok {
return managed.GetManagedACL()
@ -322,11 +322,11 @@ func (l *Listener) getManagedACL() *database.ManagedACL { @@ -322,11 +322,11 @@ func (l *Listener) getManagedACL() *database.ManagedACL {
// Returns 0 if not in follows mode, throttle is disabled, or user is exempt.
func (l *Listener) getFollowsThrottleDelay(ev *event.E) time.Duration {
// Only applies to follows ACL mode
if acl.Registry.Active.Load() != "follows" {
if acl.Registry.GetMode() != "follows" {
return 0
}
// 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 {
return follows.GetThrottleDelay(ev.Pubkey, l.remote)
}

9
app/main.go

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

94
app/server.go

@ -18,11 +18,16 @@ import ( @@ -18,11 +18,16 @@ import (
"next.orly.dev/app/branding"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
acliface "next.orly.dev/pkg/interfaces/acl"
"next.orly.dev/pkg/blossom"
"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/ingestion"
"next.orly.dev/pkg/event/processing"
"next.orly.dev/pkg/event/routing"
"next.orly.dev/pkg/event/specialkinds"
"next.orly.dev/pkg/event/validation"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
@ -99,6 +104,10 @@ type Server struct { @@ -99,6 +104,10 @@ type Server struct {
eventAuthorizer *authorization.Service
eventRouter *routing.DefaultRouter
eventProcessor *processing.Service
eventDispatcher *domainevents.Dispatcher
ingestionService *ingestion.Service
specialKinds *specialkinds.Registry
aclRegistry acliface.Registry
// WireGuard VPN and NIP-46 Bunker
wireguardServer *wireguard.Server
@ -161,6 +170,12 @@ func (s *Server) IsOwner(pubkey []byte) bool { @@ -161,6 +170,12 @@ func (s *Server) IsOwner(pubkey []byte) bool {
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
func (s *Server) isIPBlacklisted(remote string) bool {
// 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 { @@ -178,7 +193,7 @@ func (s *Server) isIPBlacklisted(remote string) bool {
// Check if managed ACL is available and active
if s.Config.ACLMode == "managed" {
for _, aclInstance := range acl.Registry.ACL {
for _, aclInstance := range acl.Registry.ACLs() {
if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok {
return managed.IsIPBlocked(remoteIP)
@ -933,7 +948,7 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { @@ -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)
if acl.Registry.Active.Load() != "none" {
if acl.Registry.GetMode() != "none" {
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
@ -1111,7 +1126,7 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { @@ -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)
if acl.Registry.Active.Load() != "none" {
if acl.Registry.GetMode() != "none" {
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
@ -1520,7 +1535,7 @@ func (s *Server) validatePeerRequest( @@ -1520,7 +1535,7 @@ func (s *Server) validatePeerRequest(
// updatePeerAdminACL grants admin access to peer relay identity pubkeys
func (s *Server) updatePeerAdminACL(peerPubkey []byte) {
// 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 managed, ok := aclInstance.(*acl.Managed); ok {
// Collect all current peer pubkeys
@ -1596,6 +1611,73 @@ func (s *Server) InitEventServices() { @@ -1596,6 +1611,73 @@ func (s *Server) InitEventServices() {
s.eventProcessor.SetClusterManager(s.wrapClusterManager())
}
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
@ -1690,7 +1772,7 @@ func (w *processingACLRegistryWrapper) Configure(cfg ...any) error { @@ -1690,7 +1772,7 @@ func (w *processingACLRegistryWrapper) Configure(cfg ...any) error {
}
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) { @@ -1713,7 +1795,7 @@ func (w *authACLRegistryWrapper) CheckPolicy(ev *event.E) (bool, error) {
}
func (w *authACLRegistryWrapper) Active() string {
return acl.Registry.Active.Load()
return acl.Registry.GetMode()
}
// PolicyManager wrapper for authorization.PolicyManager interface

94
app/specialkinds.go

@ -0,0 +1,94 @@ @@ -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 @@ -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) {
// 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 follows, ok := i.(*acl.Follows); ok {
delay := follows.GetThrottleDelay(req.Pubkey, req.Ip)
@ -95,7 +95,7 @@ func (s *ACLService) AddFollow(ctx context.Context, req *orlyaclv1.AddFollowRequ @@ -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) {
for _, i := range acl.Registry.ACL {
for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok {
pubkeys := follows.GetFollowedPubkeys()
@ -107,7 +107,7 @@ func (s *ACLService) GetFollowedPubkeys(ctx context.Context, req *orlyaclv1.Empt @@ -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) {
for _, i := range acl.Registry.ACL {
for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok {
urls := follows.AdminRelays()
@ -767,7 +767,7 @@ func (s *ACLService) ScanAllPubkeys(ctx context.Context, req *orlyaclv1.Empty) ( @@ -767,7 +767,7 @@ func (s *ACLService) ScanAllPubkeys(ctx context.Context, req *orlyaclv1.Empty) (
// === Helper Methods ===
func (s *ACLService) getManagedACL() *acl.Managed {
for _, i := range acl.Registry.ACL {
for _, i := range acl.Registry.ACLs() {
if i.Type() == "managed" {
if managed, ok := i.(*acl.Managed); ok {
return managed
@ -778,7 +778,7 @@ func (s *ACLService) getManagedACL() *acl.Managed { @@ -778,7 +778,7 @@ func (s *ACLService) getManagedACL() *acl.Managed {
}
func (s *ACLService) getCuratingACL() *acl.Curating {
for _, i := range acl.Registry.ACL {
for _, i := range acl.Registry.ACLs() {
if i.Type() == "curating" {
if curating, ok := i.(*acl.Curating); ok {
return curating

62
pkg/acl/acl.go

@ -12,27 +12,27 @@ var Registry = &S{} @@ -12,27 +12,27 @@ var Registry = &S{}
// 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).
func (s *S) SetMode(m string) {
s.Active.Store(m)
s.active.Store(m)
mode.ACLMode.Store(m)
}
type S struct {
// ACL holds registered ACL implementations.
// Deprecated: Use GetACLByType() or ListRegisteredACLs() instead of accessing directly.
ACL []acliface.I
// Active holds the name of the currently active ACL mode.
// Deprecated: Use GetMode() instead of Active.Load().
Active atomic.String
// acl holds registered ACL implementations.
// Use ACLs(), GetACLByType(), or ListRegisteredACLs() to access.
acl []acliface.I
// active holds the name of the currently active ACL mode.
// Use GetMode() to read the current mode.
active atomic.String
}
// GetMode returns the currently active ACL mode name.
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.
func (s *S) GetACLByType(typ string) acliface.I {
for _, i := range s.ACL {
for _, i := range s.acl {
if i.Type() == typ {
return i
}
@ -42,18 +42,24 @@ func (s *S) GetACLByType(typ string) acliface.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.
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.
func (s *S) ListRegisteredACLs() []string {
types := make([]string, 0, len(s.ACL))
for _, i := range s.ACL {
types := make([]string, 0, len(s.acl))
for _, i := range s.acl {
types = append(types, i.Type())
}
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.
func (s *S) IsRegistered(typ string) bool {
return s.GetACLByType(typ) != nil
@ -62,19 +68,19 @@ func (s *S) IsRegistered(typ string) bool { @@ -62,19 +68,19 @@ func (s *S) IsRegistered(typ string) bool {
type A struct{ S }
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.
// This is used for gRPC clients where the mode is determined by the remote server.
func (s *S) RegisterAndActivate(i acliface.I) {
s.ACL = []acliface.I{i}
s.acl = []acliface.I{i}
s.SetMode(i.Type())
}
func (s *S) Configure(cfg ...any) (err error) {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
for _, i := range s.acl {
if i.Type() == s.active.Load() {
err = i.Configure(cfg...)
return
}
@ -83,8 +89,8 @@ func (s *S) Configure(cfg ...any) (err error) { @@ -83,8 +89,8 @@ func (s *S) Configure(cfg ...any) (err error) {
}
func (s *S) GetAccessLevel(pub []byte, address string) (level string) {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
for _, i := range s.acl {
if i.Type() == s.active.Load() {
level = i.GetAccessLevel(pub, address)
break
}
@ -93,8 +99,8 @@ func (s *S) GetAccessLevel(pub []byte, address string) (level string) { @@ -93,8 +99,8 @@ func (s *S) GetAccessLevel(pub []byte, address string) (level string) {
}
func (s *S) GetACLInfo() (name, description, documentation string) {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
for _, i := range s.acl {
if i.Type() == s.active.Load() {
name, description, documentation = i.GetACLInfo()
break
}
@ -103,8 +109,8 @@ func (s *S) GetACLInfo() (name, description, documentation string) { @@ -103,8 +109,8 @@ func (s *S) GetACLInfo() (name, description, documentation string) {
}
func (s *S) Syncer() {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
for _, i := range s.acl {
if i.Type() == s.active.Load() {
i.Syncer()
break
}
@ -112,8 +118,8 @@ func (s *S) Syncer() { @@ -112,8 +118,8 @@ func (s *S) Syncer() {
}
func (s *S) Type() (typ string) {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
for _, i := range s.acl {
if i.Type() == s.active.Load() {
typ = i.Type()
break
}
@ -123,8 +129,8 @@ func (s *S) Type() (typ string) { @@ -123,8 +129,8 @@ func (s *S) Type() (typ string) {
// AddFollow forwards a pubkey to the active ACL if it supports dynamic follows
func (s *S) AddFollow(pub []byte) {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
for _, i := range s.acl {
if i.Type() == s.active.Load() {
if f, ok := i.(*Follows); ok {
f.AddFollow(pub)
}
@ -135,8 +141,8 @@ func (s *S) AddFollow(pub []byte) { @@ -135,8 +141,8 @@ func (s *S) AddFollow(pub []byte) {
// CheckPolicy checks if an event is allowed by the active ACL policy
func (s *S) CheckPolicy(ev *event.E) (allowed bool, err error) {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
for _, i := range s.acl {
if i.Type() == s.active.Load() {
// Check if the ACL implementation has a CheckPolicy method
if policyChecker, ok := i.(acliface.PolicyChecker); ok {
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 @@ -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) {
// 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 follows, ok := i.(*acl.Follows); ok {
delay := follows.GetThrottleDelay(req.Pubkey, req.Ip)
@ -90,7 +90,7 @@ func (s *ACLService) AddFollow(ctx context.Context, req *orlyaclv1.AddFollowRequ @@ -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) {
for _, i := range acl.Registry.ACL {
for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok {
pubkeys := follows.GetFollowedPubkeys()
@ -102,7 +102,7 @@ func (s *ACLService) GetFollowedPubkeys(ctx context.Context, req *orlyaclv1.Empt @@ -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) {
for _, i := range acl.Registry.ACL {
for _, i := range acl.Registry.ACLs() {
if i.Type() == "follows" {
if follows, ok := i.(*acl.Follows); ok {
urls := follows.AdminRelays()
@ -762,7 +762,7 @@ func (s *ACLService) ScanAllPubkeys(ctx context.Context, req *orlyaclv1.Empty) ( @@ -762,7 +762,7 @@ func (s *ACLService) ScanAllPubkeys(ctx context.Context, req *orlyaclv1.Empty) (
// === Helper Methods ===
func (s *ACLService) getManagedACL() *acl.Managed {
for _, i := range acl.Registry.ACL {
for _, i := range acl.Registry.ACLs() {
if i.Type() == "managed" {
if managed, ok := i.(*acl.Managed); ok {
return managed
@ -773,7 +773,7 @@ func (s *ACLService) getManagedACL() *acl.Managed { @@ -773,7 +773,7 @@ func (s *ACLService) getManagedACL() *acl.Managed {
}
func (s *ACLService) getCuratingACL() *acl.Curating {
for _, i := range acl.Registry.ACL {
for _, i := range acl.Registry.ACLs() {
if i.Type() == "curating" {
if curating, ok := i.(*acl.Curating); ok {
return curating

2
pkg/blossom/utils_test.go

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

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

@ -0,0 +1,130 @@ @@ -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 { @@ -90,6 +90,18 @@ type Config struct {
// SpecialKinds is the registry for special kind handlers.
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.
@ -100,6 +112,8 @@ type Service struct { @@ -100,6 +112,8 @@ type Service struct {
processor *processing.Service
sprocket SprocketChecker
specialKinds *specialkinds.Registry
aclMode func() string
deleteHandler DeleteHandler
}
// NewService creates a new ingestion service.
@ -117,6 +131,8 @@ func NewService( @@ -117,6 +131,8 @@ func NewService(
processor: processor,
sprocket: cfg.SprocketChecker,
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 @@ -188,7 +204,14 @@ func (s *Service) Ingest(ctx context.Context, ev *event.E, connCtx *ConnectionCo
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.Action == routing.Handled {
return AcceptedNotSaved(routeResult.Message)
@ -198,7 +221,7 @@ func (s *Service) Ingest(ctx context.Context, ev *event.E, connCtx *ConnectionCo @@ -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)
if procResult.Blocked {
return Rejected(procResult.BlockMsg)
@ -207,12 +230,18 @@ func (s *Service) Ingest(ctx context.Context, ev *event.E, connCtx *ConnectionCo @@ -207,12 +230,18 @@ func (s *Service) Ingest(ctx context.Context, ev *event.E, connCtx *ConnectionCo
return Errored(procResult.Error)
}
return Result{
Accepted: true,
Saved: true,
// Stage 8: Delete event post-processing
const kindEventDeletion = 5
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.
// 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 {

37
pkg/event/processing/processing.go

@ -9,6 +9,8 @@ import ( @@ -9,6 +9,8 @@ import (
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/kind"
"next.orly.dev/pkg/domain/events"
)
// Result contains the outcome of event processing.
@ -85,6 +87,12 @@ type ClusterManager interface { @@ -85,6 +87,12 @@ type ClusterManager interface {
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.
type Config struct {
Admins [][]byte
@ -109,6 +117,7 @@ type Service struct { @@ -109,6 +117,7 @@ type Service struct {
aclRegistry ACLRegistry
relayGroupMgr RelayGroupManager
clusterManager ClusterManager
eventDispatcher DomainEventDispatcher
}
// New creates a new processing service.
@ -148,6 +157,11 @@ func (s *Service) SetClusterManager(cm ClusterManager) { @@ -148,6 +157,11 @@ func (s *Service) SetClusterManager(cm ClusterManager) {
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.
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)
@ -211,6 +225,14 @@ func (s *Service) deliver(ev *event.E) { @@ -211,6 +225,14 @@ func (s *Service) deliver(ev *event.E) {
// runPostSaveHooks handles side effects after event persistence.
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
if s.relayGroupMgr != nil {
if err := s.relayGroupMgr.ValidateRelayGroupEvent(ev); err == nil {
@ -231,7 +253,7 @@ func (s *Service) runPostSaveHooks(ev *event.E) { @@ -231,7 +253,7 @@ func (s *Service) runPostSaveHooks(ev *event.E) {
}
// ACL reconfiguration for admin events
if s.isAdminEvent(ev) {
if isAdmin || isOwner {
if ev.Kind == kind.FollowList.K || ev.Kind == kind.RelayListMetadata.K {
if s.aclRegistry != nil {
go s.aclRegistry.Configure()
@ -240,15 +262,20 @@ func (s *Service) runPostSaveHooks(ev *event.E) { @@ -240,15 +262,20 @@ func (s *Service) runPostSaveHooks(ev *event.E) {
}
}
// isAdminEvent checks if event is from admin or owner.
func (s *Service) isAdminEvent(ev *event.E) bool {
// isAdminPubkey checks if pubkey is an admin.
func (s *Service) isAdminPubkey(pubkey []byte) bool {
for _, admin := range s.cfg.Admins {
if fastEqual(admin, ev.Pubkey) {
if fastEqual(admin, pubkey) {
return true
}
}
return false
}
// isOwnerPubkey checks if pubkey is an owner.
func (s *Service) isOwnerPubkey(pubkey []byte) bool {
for _, owner := range s.cfg.Owners {
if fastEqual(owner, ev.Pubkey) {
if fastEqual(owner, pubkey) {
return true
}
}

30
pkg/event/processing/processing_test.go

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

37
pkg/interfaces/acl/acl.go

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

4
pkg/policy/composition_test.go

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

4
pkg/policy/kind_whitelist_test.go

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

170
pkg/policy/policy.go

@ -72,111 +72,105 @@ type Kinds struct { @@ -72,111 +72,105 @@ type Kinds struct {
// For pubkey allow/deny lists: whitelist takes precedence over blacklist.
// If whitelist has entries, only whitelisted pubkeys 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.
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"`
// 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.
// =============================================================================
// Rule Sub-Components (Value Objects)
// =============================================================================
// AccessControl defines who can read/write events.
// 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"`
// 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"`
// 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"`
// 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"`
// 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.
// Deprecated: Use MaxExpiryDuration instead for human-readable duration strings.
MaxExpiry *int64 `json:"max_expiry,omitempty"` //nolint:staticcheck // Intentional backward compatibility
// MaxExpiryDuration is the maximum expiry time in ISO-8601 duration format.
// 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).
// Parsed into maxExpirySeconds at load time.
// WriteAllowFollows grants access to policy admin follows when enabled.
WriteAllowFollows bool `json:"write_allow_follows,omitempty"`
// FollowsWhitelistAdmins specifies admin pubkeys whose follows are whitelisted.
// DEPRECATED: Use ReadFollowsWhitelist and WriteFollowsWhitelist instead.
FollowsWhitelistAdmins []string `json:"follows_whitelist_admins,omitempty"`
// 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"`
// 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.
MustHaveTags []string `json:"must_have_tags,omitempty"`
// SizeLimit is the maximum size in bytes for the event's total serialized size.
// SizeLimit is the maximum total serialized size in bytes.
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"`
// 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.
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 is the write rate limit in bytes per second.
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"`
// 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"`
// 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.
// Requires PolicyFollowWhitelistEnabled=true at the policy level.
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"`
// Parsed cache (internal, not serialized)
maxExpirySeconds *int64
}
// TagValidation is a map of tag_name -> regex pattern for validating tag values.
// Each tag present in the event must match its corresponding regex pattern.
// Example: {"d": "^[a-z0-9-]{1,64}$", "t": "^[a-z0-9-]{1,32}$"}
// TagValidationConfig defines tag validation rules.
// This is a value object that encapsulates tag validation configuration.
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"`
// 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 is a regex pattern for "d" tag identifiers.
IdentifierRegex string `json:"identifier_regex,omitempty"`
// ReadAllowPermissive when set on a GLOBAL rule, allows read access for ALL kinds,
// even when a kind whitelist is configured. This allows the kind whitelist to
// 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"`
// Compiled cache (internal, not serialized)
identifierRegexCache *regexp.Regexp
}
// WriteAllowPermissive when set on a GLOBAL rule, allows write access for kinds
// that don't have specific rules defined, bypassing the implicit kind whitelist.
// 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"`
// =============================================================================
// Rule (Composed from Sub-Components)
// =============================================================================
// Binary caches for faster comparison (populated from hex strings above)
// These are not exported and not serialized to JSON
writeAllowBin [][]byte
writeDenyBin [][]byte
readAllowBin [][]byte
readDenyBin [][]byte
maxExpirySeconds *int64 // Parsed from MaxExpiryDuration or copied from MaxExpiry
identifierRegexCache *regexp.Regexp // Compiled regex for IdentifierRegex
followsWhitelistAdminsBin [][]byte // Binary cache for FollowsWhitelistAdmins pubkeys (DEPRECATED)
followsWhitelistFollowsBin [][]byte // Cached follow list from FollowsWhitelistAdmins (loaded at startup, DEPRECATED)
// Binary caches for ReadFollowsWhitelist and WriteFollowsWhitelist
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
// Rule defines policies for a specific event kind or as a global default.
// It is composed of sub-value objects for cleaner organization.
type Rule struct {
// Description is a human-readable description of the rule.
Description string `json:"description"`
// Script is a path to a validation script.
Script string `json:"script,omitempty"`
// Embedded sub-components (fields are flattened in JSON for backward compatibility)
AccessControl
Constraints
TagValidationConfig
}
// hasAnyRules checks if the rule has any constraints configured

80
pkg/policy/policy_test.go

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

18
pkg/policy/precedence_test.go

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

12
pkg/policy/read_access_test.go

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

2
pkg/version/version

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

Loading…
Cancel
Save