alert.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. // Package alert defines the Alert v1 wire shape shared by all ingest
  2. // transports (HTTP, WebSocket, MQTT, gRPC) and the internal broker
  3. // subject alerts.<company_id>.
  4. //
  5. // Validation is strict: unknown fields rejected, severity enum checked,
  6. // dedupe_key length capped. See SPEC §4 + §22 layer 5.
  7. package alert
  8. import (
  9. "errors"
  10. "fmt"
  11. "regexp"
  12. "time"
  13. )
  14. // Severity is the alert severity taxonomy shared with the Android app.
  15. // See SPEC §4.
  16. type Severity string
  17. const (
  18. SeverityInfo Severity = "info"
  19. SeverityWarning Severity = "warning"
  20. SeverityCritical Severity = "critical"
  21. SeverityInminentColapse Severity = "inminent_colapse"
  22. )
  23. // ValidSeverities is the source of truth for the validation enum.
  24. var ValidSeverities = map[Severity]struct{}{
  25. SeverityInfo: {},
  26. SeverityWarning: {},
  27. SeverityCritical: {},
  28. SeverityInminentColapse: {},
  29. }
  30. // InminentColapseBypass is the only severity that bypasses quiet hours.
  31. func (s Severity) InminentColapseBypass() bool {
  32. return s == SeverityInminentColapse
  33. }
  34. // WireCaps (SPEC §22 layer 1) — anything bigger is rejected before
  35. // validation runs. These match the per-source `max_payload_bytes`
  36. // default of 256 KB and the per-field caps below.
  37. const (
  38. MaxAlertIDLen = 64
  39. MaxCompanyIDLen = 64
  40. MaxSourceIDLen = 64
  41. MaxSeverityLen = 32
  42. MaxCategoryLen = 64
  43. MaxTitleLen = 256
  44. MaxBodyLen = 4096
  45. MaxDedupeKeyLen = 128
  46. MaxDataKeys = 64
  47. MaxDataKeyLen = 64
  48. MaxDataValueLen = 1024
  49. MaxDataTotalBytes = 8 * 1024
  50. MaxLocaleLen = 16
  51. MaxDataValueNested = 4 // nested depth for data{} values
  52. )
  53. var (
  54. // company_id and source_id must be URL-safe and DB-friendly.
  55. idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,63}$`)
  56. // dedupe_key may include colons, dots, slashes (opaque to us).
  57. dedupeRe = regexp.MustCompile(`^[A-Za-z0-9._:/+\-]{1,128}$`)
  58. // severity one of the enum.
  59. )
  60. // Alert is the v1 wire shape. Tags are intentionally not in v1 — the
  61. // source can put structured data in `Data` if it needs key/value lookup.
  62. // FCM/Telegram routing keys off Severity + Category, not arbitrary tags.
  63. type Alert struct {
  64. // ID is server-assigned on accept; empty in inbound payloads.
  65. ID string `json:"id,omitempty"`
  66. CompanyID string `json:"company_id"`
  67. SourceID string `json:"source_id"`
  68. Severity Severity `json:"severity"`
  69. Category string `json:"category,omitempty"`
  70. // Pre-localized (SPEC: source-localized, we pass through).
  71. Title string `json:"title"`
  72. Body string `json:"body,omitempty"`
  73. // Data is an arbitrary key/value map for the recipient app
  74. // (e.g. deep links, structured context). Strictly size-bounded.
  75. Data map[string]string `json:"data,omitempty"`
  76. // DedupeKey is opaque. The dedupe window is per-source, 60s default.
  77. DedupeKey string `json:"dedupe_key,omitempty"`
  78. // ClientTsMs is the source's wall clock at send time. Server fills
  79. // ReceivedAt on accept.
  80. ClientTsMs int64 `json:"client_ts_ms,omitempty"`
  81. // Locale is BCP-47, optional. Used to pick a pre-localized copy
  82. // on the recipient side; the source can also pre-localize.
  83. Locale string `json:"locale,omitempty"`
  84. // Server-assigned. Not in inbound JSON.
  85. ReceivedAt time.Time `json:"received_at,omitempty"`
  86. DedupeCount uint32 `json:"dedupe_count,omitempty"`
  87. }
  88. // ErrInvalid is returned by Validate. errs contains one or more
  89. // human-readable reasons.
  90. type ErrInvalid struct {
  91. Reasons []string
  92. }
  93. func (e *ErrInvalid) Error() string {
  94. return fmt.Sprintf("invalid alert: %v", e.Reasons)
  95. }
  96. // IsInvalid reports whether err is an *ErrInvalid. Useful in HTTP
  97. // handlers to map to 400.
  98. func IsInvalid(err error) bool {
  99. var e *ErrInvalid
  100. return errors.As(err, &e)
  101. }
  102. // Validate enforces the SPEC §22 layer 5 + the field caps above.
  103. // It does NOT enforce the severity enum against the FCM mapping —
  104. // that's a delivery-tier concern.
  105. func (a *Alert) Validate() error {
  106. var reasons []string
  107. if !idRe.MatchString(a.CompanyID) {
  108. reasons = append(reasons, "company_id: must match [a-z0-9][a-z0-9_-]{1,63}")
  109. }
  110. if !idRe.MatchString(a.SourceID) {
  111. reasons = append(reasons, "source_id: must match [a-z0-9][a-z0-9_-]{1,63}")
  112. }
  113. if _, ok := ValidSeverities[a.Severity]; !ok {
  114. reasons = append(reasons, fmt.Sprintf("severity: must be one of info|warning|critical|inminent_colapse, got %q", a.Severity))
  115. }
  116. if len(a.Category) > MaxCategoryLen {
  117. reasons = append(reasons, fmt.Sprintf("category: max %d chars", MaxCategoryLen))
  118. }
  119. if a.Title == "" {
  120. reasons = append(reasons, "title: required")
  121. }
  122. if len(a.Title) > MaxTitleLen {
  123. reasons = append(reasons, fmt.Sprintf("title: max %d chars", MaxTitleLen))
  124. }
  125. if len(a.Body) > MaxBodyLen {
  126. reasons = append(reasons, fmt.Sprintf("body: max %d chars", MaxBodyLen))
  127. }
  128. if a.DedupeKey != "" && !dedupeRe.MatchString(a.DedupeKey) {
  129. reasons = append(reasons, "dedupe_key: must match [A-Za-z0-9._:/+-]{1,128}")
  130. }
  131. if len(a.Locale) > MaxLocaleLen {
  132. reasons = append(reasons, fmt.Sprintf("locale: max %d chars", MaxLocaleLen))
  133. }
  134. if len(a.Data) > MaxDataKeys {
  135. reasons = append(reasons, fmt.Sprintf("data: max %d keys", MaxDataKeys))
  136. }
  137. totalData := 0
  138. for k, v := range a.Data {
  139. if len(k) > MaxDataKeyLen {
  140. reasons = append(reasons, fmt.Sprintf("data key %q: max %d chars", k, MaxDataKeyLen))
  141. }
  142. if len(v) > MaxDataValueLen {
  143. reasons = append(reasons, fmt.Sprintf("data[%q]: max %d chars", k, MaxDataValueLen))
  144. }
  145. totalData += len(k) + len(v)
  146. if totalData > MaxDataTotalBytes {
  147. reasons = append(reasons, fmt.Sprintf("data: total bytes > %d", MaxDataTotalBytes))
  148. break
  149. }
  150. }
  151. if len(reasons) > 0 {
  152. return &ErrInvalid{reasons}
  153. }
  154. return nil
  155. }
  156. // NewID returns a new alert ID. ULID-ish: time-prefixed + random tail,
  157. // 26 chars, lexically sortable. Good for the broker subject and for
  158. // logs. Not crypto-strong.
  159. func NewID() string {
  160. return ulidLike()
  161. }