| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- // Package alert defines the Alert v1 wire shape shared by all ingest
- // transports (HTTP, WebSocket, MQTT, gRPC) and the internal broker
- // subject alerts.<company_id>.
- //
- // 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
- }
- // 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()
- }
|