Decompose handle-event.go into DDD domain services (v0.36.15)
Some checks failed
Go / build-and-release (push) Has been cancelled

Major refactoring of event handling into clean, testable domain services:

- Add pkg/event/validation: JSON hex validation, signature verification,
  timestamp bounds, NIP-70 protected tag validation
- Add pkg/event/authorization: Policy and ACL authorization decisions,
  auth challenge handling, access level determination
- Add pkg/event/routing: Event router registry with ephemeral and delete
  handlers, kind-based dispatch
- Add pkg/event/processing: Event persistence, delivery to subscribers,
  and post-save hooks (ACL reconfig, sync, relay groups)
- Reduce handle-event.go from 783 to 296 lines (62% reduction)
- Add comprehensive unit tests for all new domain services
- Refactor database tests to use shared TestMain setup
- Fix blossom URL test expectations (missing "/" separator)
- Add go-memory-optimization skill and analysis documentation
- Update DDD_ANALYSIS.md to reflect completed decomposition

Files modified:
- app/handle-event.go: Slim orchestrator using domain services
- app/server.go: Service initialization and interface wrappers
- app/handle-event-types.go: Shared types (OkHelper, result types)
- pkg/event/validation/*: New validation service package
- pkg/event/authorization/*: New authorization service package
- pkg/event/routing/*: New routing service package
- pkg/event/processing/*: New processing service package
- pkg/database/*_test.go: Refactored to shared TestMain
- pkg/blossom/http_test.go: Fixed URL format expectations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-25 05:30:07 +01:00
parent 3e0a94a053
commit 24383ef1f4
42 changed files with 4791 additions and 2118 deletions

View File

@@ -0,0 +1,236 @@
// Package authorization provides event authorization services for the ORLY relay.
// It handles ACL checks, policy evaluation, and access level decisions.
package authorization
import (
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/hex"
)
// Decision carries authorization context through the event processing pipeline.
type Decision struct {
Allowed bool
AccessLevel string // none/read/write/admin/owner/blocked/banned
IsAdmin bool
IsOwner bool
IsPeerRelay bool
SkipACLCheck bool // For admin/owner deletes
DenyReason string // Human-readable reason for denial
RequireAuth bool // Should send AUTH challenge
}
// Allow returns an allowed decision with the given access level.
func Allow(accessLevel string) Decision {
return Decision{
Allowed: true,
AccessLevel: accessLevel,
}
}
// Deny returns a denied decision with the given reason.
func Deny(reason string, requireAuth bool) Decision {
return Decision{
Allowed: false,
DenyReason: reason,
RequireAuth: requireAuth,
}
}
// Authorizer makes authorization decisions for events.
type Authorizer interface {
// Authorize checks if event is allowed based on ACL and policy.
Authorize(ev *event.E, authedPubkey []byte, remote string, eventKind uint16) Decision
}
// ACLRegistry abstracts the ACL registry for authorization checks.
type ACLRegistry interface {
// GetAccessLevel returns the access level for a pubkey and remote address.
GetAccessLevel(pub []byte, address string) string
// CheckPolicy checks if an event passes ACL policy.
CheckPolicy(ev *event.E) (bool, error)
// Active returns the active ACL mode name.
Active() string
}
// PolicyManager abstracts the policy manager for authorization checks.
type PolicyManager interface {
// IsEnabled returns whether policy is enabled.
IsEnabled() bool
// CheckPolicy checks if an action is allowed by policy.
CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error)
}
// SyncManager abstracts the sync manager for peer relay checking.
type SyncManager interface {
// GetPeers returns the list of peer relay URLs.
GetPeers() []string
// IsAuthorizedPeer checks if a pubkey is an authorized peer.
IsAuthorizedPeer(url, pubkey string) bool
}
// Config holds configuration for the authorization service.
type Config struct {
AuthRequired bool // Whether auth is required for all operations
AuthToWrite bool // Whether auth is required for write operations
Admins [][]byte // Admin pubkeys
Owners [][]byte // Owner pubkeys
}
// Service implements the Authorizer interface.
type Service struct {
cfg *Config
acl ACLRegistry
policy PolicyManager
sync SyncManager
}
// New creates a new authorization service.
func New(cfg *Config, acl ACLRegistry, policy PolicyManager, sync SyncManager) *Service {
return &Service{
cfg: cfg,
acl: acl,
policy: policy,
sync: sync,
}
}
// Authorize checks if event is allowed based on ACL and policy.
func (s *Service) Authorize(ev *event.E, authedPubkey []byte, remote string, eventKind uint16) Decision {
// Check if peer relay - they get special treatment
if s.isPeerRelayPubkey(authedPubkey) {
return Decision{
Allowed: true,
AccessLevel: "admin",
IsPeerRelay: true,
}
}
// Check policy if enabled
if s.policy != nil && s.policy.IsEnabled() {
allowed, err := s.policy.CheckPolicy("write", ev, authedPubkey, remote)
if err != nil {
return Deny("policy check failed", false)
}
if !allowed {
return Deny("event blocked by policy", false)
}
// Check ACL policy for managed ACL mode
if s.acl != nil && s.acl.Active() == "managed" {
allowed, err := s.acl.CheckPolicy(ev)
if err != nil {
return Deny("ACL policy check failed", false)
}
if !allowed {
return Deny("event blocked by ACL policy", false)
}
}
}
// Determine pubkey for ACL check
pubkeyForACL := authedPubkey
if len(authedPubkey) == 0 && s.acl != nil && s.acl.Active() == "none" &&
!s.cfg.AuthRequired && !s.cfg.AuthToWrite {
pubkeyForACL = ev.Pubkey
}
// Check if auth is required but user not authenticated
if (s.cfg.AuthRequired || s.cfg.AuthToWrite) && len(authedPubkey) == 0 {
return Deny("authentication required for write operations", true)
}
// Get access level
accessLevel := "write" // Default for none mode
if s.acl != nil {
accessLevel = s.acl.GetAccessLevel(pubkeyForACL, remote)
}
// Check if admin/owner for delete events (skip ACL check)
isAdmin := s.isAdmin(ev.Pubkey)
isOwner := s.isOwner(ev.Pubkey)
skipACL := (isAdmin || isOwner) && eventKind == 5 // kind 5 = deletion
decision := Decision{
AccessLevel: accessLevel,
IsAdmin: isAdmin,
IsOwner: isOwner,
SkipACLCheck: skipACL,
}
// Handle access levels
if !skipACL {
switch accessLevel {
case "none":
decision.Allowed = false
decision.DenyReason = "auth required for write access"
decision.RequireAuth = true
case "read":
decision.Allowed = false
decision.DenyReason = "auth required for write access"
decision.RequireAuth = true
case "blocked":
decision.Allowed = false
decision.DenyReason = "IP address blocked"
case "banned":
decision.Allowed = false
decision.DenyReason = "pubkey banned"
default:
// write/admin/owner - allowed
decision.Allowed = true
}
} else {
decision.Allowed = true
}
return decision
}
// isPeerRelayPubkey checks if the given pubkey belongs to a peer relay.
func (s *Service) isPeerRelayPubkey(pubkey []byte) bool {
if s.sync == nil || len(pubkey) == 0 {
return false
}
peerPubkeyHex := hex.Enc(pubkey)
for _, peerURL := range s.sync.GetPeers() {
if s.sync.IsAuthorizedPeer(peerURL, peerPubkeyHex) {
return true
}
}
return false
}
// isAdmin checks if a pubkey is an admin.
func (s *Service) isAdmin(pubkey []byte) bool {
for _, admin := range s.cfg.Admins {
if fastEqual(admin, pubkey) {
return true
}
}
return false
}
// isOwner checks if a pubkey is an owner.
func (s *Service) isOwner(pubkey []byte) bool {
for _, owner := range s.cfg.Owners {
if fastEqual(owner, pubkey) {
return true
}
}
return false
}
// fastEqual compares two byte slices for equality.
func fastEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}