|
|
@@ -0,0 +1,183 @@
|
|
|
+// 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()
|
|
|
+}
|