package domain import ( "errors" "net" "time" "github.com/google/uuid" ) // Tier 2 Block G — per-event thresholds, allowlists, feedback. // Default band boundaries — matches the previous hardcoded constants in // the fraud engine so existing events behave identically until a host // tweaks them. Mirrored on the events table as columns so a host can // dial them up/down without touching code. const ( DefaultFraudMediumThreshold = 30 DefaultFraudHighThreshold = 60 DefaultFraudBlockThreshold = 85 ) // FraudThresholds bundles the trio that controls band assignment for one // event. Sent to the fraud engine on every Score call so the engine can // apply the host's preference without a separate DB lookup. type FraudThresholds struct { Medium int `json:"medium"` High int `json:"high"` Block int `json:"block"` } // Valid sanity-checks the ordering. The frontend slider keeps these in // order; this is a belt-and-braces server-side check. func (t FraudThresholds) Valid() error { if t.Medium < 0 || t.High > 100 || t.Block > 100 { return ErrInvalidThresholds } if !(t.Medium <= t.High && t.High <= t.Block) { return ErrInvalidThresholds } return nil } // DefaultThresholds returns the package defaults — useful when an event // row hasn't been loaded yet (e.g. fallback in the scoring path). func DefaultThresholds() FraudThresholds { return FraudThresholds{ Medium: DefaultFraudMediumThreshold, High: DefaultFraudHighThreshold, Block: DefaultFraudBlockThreshold, } } // Band maps a score to one of low/medium/high/block per the configured // thresholds. Below medium → low; everything else picks the highest band // the score crosses. func (t FraudThresholds) Band(score int) string { switch { case score >= t.Block: return "block" case score >= t.High: return "high" case score >= t.Medium: return "medium" default: return "low" } } // Allowlist is one CIDR range that bypasses scoring entirely for the // event. Hosts use this for known-good networks (office Wi-Fi, the // venue's guest network, family routers). Score = 0, band = low, // short-circuited before the fraud engine is even called. type Allowlist struct { EventID uuid.UUID `json:"event_id"` CIDR string `json:"cidr"` Label string `json:"label,omitempty"` CreatedBy *uuid.UUID `json:"created_by,omitempty"` CreatedAt time.Time `json:"created_at"` } // ParseAllowlistCIDR validates and normalises a CIDR string. Single IPs // are accepted and widened to /32 (IPv4) or /128 (IPv6). Returns the // canonical string + the parsed *net.IPNet so the API layer can use both. func ParseAllowlistCIDR(input string) (string, *net.IPNet, error) { // Bare IP without /mask? Treat as a host route. if ip := net.ParseIP(input); ip != nil { if ip.To4() != nil { input += "/32" } else { input += "/128" } } _, ipnet, err := net.ParseCIDR(input) if err != nil { return "", nil, ErrInvalidCIDR } return ipnet.String(), ipnet, nil } // FraudFeedback is one host-back annotation on an access log: "this was // fine despite the score" or "this really was suspicious". Seeds the // future labelled-data ML model and lets hosts hide repeat false // positives from their live monitor. type FraudFeedback struct { AccessLogID uuid.UUID `json:"access_log_id"` Verdict string `json:"verdict"` // "legitimate" | "suspicious" MarkedBy *uuid.UUID `json:"marked_by,omitempty"` Note string `json:"note,omitempty"` CreatedAt time.Time `json:"created_at"` } func (f FraudFeedback) Valid() error { if f.Verdict != "legitimate" && f.Verdict != "suspicious" { return ErrInvalidVerdict } return nil } var ( ErrInvalidThresholds = errors.New("invalid thresholds (must satisfy 0 <= medium <= high <= block <= 100)") ErrInvalidCIDR = errors.New("invalid CIDR — expected e.g. 203.0.113.0/24 or 2001:db8::/32") ErrInvalidVerdict = errors.New("verdict must be 'legitimate' or 'suspicious'") ErrAllowlistNotFound = errors.New("allowlist entry not found") )