refactor: unify alarm destination types behind one shared registry

Closed
FI
1d ago
+594-201

Addresses part of #957

Problem

Every alarm destination type is spelled out by hand in four places, so a new channel means editing all four and hoping they agree:

LayerWhereWhat it hardcodes
DBpackages/db/src/drizzle/schema/billing.ts (alarmDestinationTypeValues)["slack", "email", "webhook"]
RPCpackages/rpc/src/routers/alarms.ts (destinationSchema, redactDestination)per-type zod schema, host checks, which fields are secrets
Deliverypackages/notifications/src/alarm-config.ts (buildAlarmNotificationTargets)per-type if chain, a second host check, header sanitising
Dashboardalarm-sheet.tsxa third copy of the identifier validation, labels

Two of these had already drifted: the RPC layer accepted and persisted custom webhook headers named Content-Type or X-Original-URL, but alarm-config.ts silently dropped both at send time — a saved alarm didn't do what the form showed.

Fix

One destination registry, packages/shared/src/alarm-destinations.ts, used by every layer above:

  • ALARM_DESTINATION_TYPES — single source of truth for the type list, re-exported by the DB schema.
  • Per-type identifier/config zod schema, secretFields, and masking, replacing three separate hand-rolled copies.
  • Canonical forbidden-header set (now including Content-Type and X-Original-URL, plus rejecting CRLF in header names, not just values) shared by the RPC write path and the delivery-layer filter.

Placed in @databuddy/shared rather than @databuddy/notifications since all four consumers (db, rpc, notifications, dashboard) already depend on shared, while notifications pulls in resend and provider/fetch code a dashboard client component shouldn't bundle.

Header bug: chose reject-at-save over silently-send-what-was-saved. alarms.create/alarms.update now reject a Content-Type/X-Original-URL header (or CRLF in any header) with a clear validation error at save time. The delivery-layer filter stays as a defense-in-depth net for rows written before this fix.

Zero behavior change for Slack/email/webhook: the pre-existing alarm-config.test.ts passes unmodified, confirming the dispatch-table refactor is behavior-identical.

Drift test: packages/rpc/src/routers/alarm-destinations.test.ts asserts the DB's allowed types, the RPC discriminated union, and the delivery builder's handled types are exactly the same set, so a future addition to only one of the three fails CI instead of shipping silently broken.

Explicitly out of scope

No Discord/Teams/Telegram/Google Chat destination types — that's step 2+ in #957's delivery plan, gated on open questions only the maintainer can answer (plan-gating, where a Telegram bot token lives). No changes to BaseProvider.withRetry/Retry-After handling either, since it's unrelated to this registry slice.

Note for reviewers

PR #988 (issue #958's slice) also touches packages/rpc/src/routers/alarms.ts and originally would have collided with this PR's own new test file at the same path — renamed this PR's file to alarm-destinations.test.ts to avoid that conflict. The two PRs touch different regions of alarms.ts (trigger conditions vs. destinations) so should merge cleanly in either order, but flagging it since both are open at once.

Verification

  • bun run check-types (36 packages), bunx biome check on every touched file, bun run lint:policies: all clean.
  • Per-package test suites: packages/shared 346 pass, packages/db 115 pass, packages/notifications 31 pass (unmodified alarm-config.test.ts still green), packages/rpc 232 pass. 0 fail across all four.
  • Full monorepo pre-push suite: 1101 pass, 0 fail.

Disclosure: implemented with AI assistance (Claude Code); verified and tested by hand — read all four touched layers against current code before designing the registry shape, confirmed the header-drift bug was real before fixing it, and confirmed the existing alarm-config tests pass unmodified before submitting.


Summary by cubic

Unifies alarm destination types (slack, email, webhook) behind one registry in packages/shared/src/alarm-destinations.ts, replacing three hand-rolled validation copies plus a fourth label/masking copy in the dashboard alarm sheet. Slack, email, and webhook destinations behave identically; alarm-config.test.ts passes unmodified.

Bug Fixes

  • Webhook headers named Content-Type or X-Original-URL (or containing CRLF) were previously accepted and saved but silently dropped at send time; the RPC schema now filters them out at save, and both layers share one forbidden-header set.
  • Slack/email config is now locked to an empty object, so no value can be stored that redaction would never mask on read.
  • Delivery now skips malformed destination types instead of throwing on an inherited object property.

Refactors

  • Adds a drift test asserting the DB, RPC, and delivery type lists are identical, so adding a destination type in just one layer fails CI.