// Package alert defines the Alert v1 wire shape shared by all ingest // transports (HTTP, WebSocket, MQTT, gRPC) and the internal broker // subject alerts.. // // Validation is strict: unknown fields rejected, severity enum checked, // dedupe_key length capped. See SPEC §4 + §22 layer 5. package alert import ( "errors" "fmt" "regexp" "time" ) // Severity is the alert severity taxonomy shared with the Android app. // See SPEC §4. type Severity string const ( SeverityInfo Severity = "info" SeverityWarning Severity = "warning" SeverityCritical Severity = "critical" SeverityInminentColapse Severity = "inminent_colapse" ) // ValidSeverities is the source of truth for the validation enum. var ValidSeverities = map[Severity]struct{}{ SeverityInfo: {}, SeverityWarning: {}, SeverityCritical: {}, SeverityInminentColapse: {}, } // InminentColapseBypass is the only severity that bypasses quiet hours. func (s Severity) InminentColapseBypass() bool { return s == SeverityInminentColapse } // Rank returns a numeric severity (0..3) used for `min_severity` // comparisons in subscriptions. Returns -1 for unknown severities // so the resolver can drop them defensively. func (s Severity) Rank() int { switch s { case SeverityInfo: return 0 case SeverityWarning: return 1 case SeverityCritical: return 2 case SeverityInminentColapse: return 3 } return -1 } // MinSeverityRank returns the rank of a min_severity string from // the subscriptions table. Returns 0 for empty (info-and-above). func MinSeverityRank(minSev string) int { if minSev == "" { return 0 } return Severity(minSev).Rank() } // WireCaps (SPEC §22 layer 1) — anything bigger is rejected before // validation runs. These match the per-source `max_payload_bytes` // default of 256 KB and the per-field caps below. const ( MaxAlertIDLen = 64 MaxCompanyIDLen = 64 MaxSourceIDLen = 64 MaxSeverityLen = 32 MaxCategoryLen = 64 MaxTitleLen = 256 MaxBodyLen = 4096 MaxDedupeKeyLen = 128 MaxDataKeys = 64 MaxDataKeyLen = 64 MaxDataValueLen = 1024 MaxDataTotalBytes = 8 * 1024 MaxLocaleLen = 16 MaxDataValueNested = 4 // nested depth for data{} values ) var ( // company_id and source_id must be URL-safe and DB-friendly. idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,63}$`) // dedupe_key may include colons, dots, slashes (opaque to us). dedupeRe = regexp.MustCompile(`^[A-Za-z0-9._:/+\-]{1,128}$`) // severity one of the enum. ) // Alert is the v1 wire shape. Tags are intentionally not in v1 — the // source can put structured data in `Data` if it needs key/value lookup. // FCM/Telegram routing keys off Severity + Category, not arbitrary tags. type Alert struct { // ID is server-assigned on accept; empty in inbound payloads. ID string `json:"id,omitempty"` CompanyID string `json:"company_id"` SourceID string `json:"source_id"` Severity Severity `json:"severity"` Category string `json:"category,omitempty"` // Pre-localized (SPEC: source-localized, we pass through). Title string `json:"title"` Body string `json:"body,omitempty"` // Data is an arbitrary key/value map for the recipient app // (e.g. deep links, structured context). Strictly size-bounded. Data map[string]string `json:"data,omitempty"` // DedupeKey is opaque. The dedupe window is per-source, 60s default. DedupeKey string `json:"dedupe_key,omitempty"` // ClientTsMs is the source's wall clock at send time. Server fills // ReceivedAt on accept. ClientTsMs int64 `json:"client_ts_ms,omitempty"` // Locale is BCP-47, optional. Used to pick a pre-localized copy // on the recipient side; the source can also pre-localize. Locale string `json:"locale,omitempty"` // Server-assigned. Not in inbound JSON. ReceivedAt time.Time `json:"received_at,omitempty"` DedupeCount uint32 `json:"dedupe_count,omitempty"` } // ErrInvalid is returned by Validate. errs contains one or more // human-readable reasons. type ErrInvalid struct { Reasons []string } func (e *ErrInvalid) Error() string { return fmt.Sprintf("invalid alert: %v", e.Reasons) } // IsInvalid reports whether err is an *ErrInvalid. Useful in HTTP // handlers to map to 400. func IsInvalid(err error) bool { var e *ErrInvalid return errors.As(err, &e) } // Validate enforces the SPEC §22 layer 5 + the field caps above. // It does NOT enforce the severity enum against the FCM mapping — // that's a delivery-tier concern. func (a *Alert) Validate() error { var reasons []string if !idRe.MatchString(a.CompanyID) { reasons = append(reasons, "company_id: must match [a-z0-9][a-z0-9_-]{1,63}") } if !idRe.MatchString(a.SourceID) { reasons = append(reasons, "source_id: must match [a-z0-9][a-z0-9_-]{1,63}") } if _, ok := ValidSeverities[a.Severity]; !ok { reasons = append(reasons, fmt.Sprintf("severity: must be one of info|warning|critical|inminent_colapse, got %q", a.Severity)) } if len(a.Category) > MaxCategoryLen { reasons = append(reasons, fmt.Sprintf("category: max %d chars", MaxCategoryLen)) } if a.Title == "" { reasons = append(reasons, "title: required") } if len(a.Title) > MaxTitleLen { reasons = append(reasons, fmt.Sprintf("title: max %d chars", MaxTitleLen)) } if len(a.Body) > MaxBodyLen { reasons = append(reasons, fmt.Sprintf("body: max %d chars", MaxBodyLen)) } if a.DedupeKey != "" && !dedupeRe.MatchString(a.DedupeKey) { reasons = append(reasons, "dedupe_key: must match [A-Za-z0-9._:/+-]{1,128}") } if len(a.Locale) > MaxLocaleLen { reasons = append(reasons, fmt.Sprintf("locale: max %d chars", MaxLocaleLen)) } if len(a.Data) > MaxDataKeys { reasons = append(reasons, fmt.Sprintf("data: max %d keys", MaxDataKeys)) } totalData := 0 for k, v := range a.Data { if len(k) > MaxDataKeyLen { reasons = append(reasons, fmt.Sprintf("data key %q: max %d chars", k, MaxDataKeyLen)) } if len(v) > MaxDataValueLen { reasons = append(reasons, fmt.Sprintf("data[%q]: max %d chars", k, MaxDataValueLen)) } totalData += len(k) + len(v) if totalData > MaxDataTotalBytes { reasons = append(reasons, fmt.Sprintf("data: total bytes > %d", MaxDataTotalBytes)) break } } if len(reasons) > 0 { return &ErrInvalid{reasons} } return nil } // NewID returns a new alert ID. ULID-ish: time-prefixed + random tail, // 26 chars, lexically sortable. Good for the broker subject and for // logs. Not crypto-strong. func NewID() string { return ulidLike() }