alert.go 6.4 KB

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