@checkstack/backend-api 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,137 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 32d52c6: Bulk notifications affecting multiple systems and collapse lifecycle events into a single card.
8
+
9
+ Notifications now carry an optional `subjects` array (the entities they affect) and an optional `collapseKey` (so related notifications collapse into one row per recipient). Incidents, maintenances, anomalies, healthchecks, and dependency-impact events route through these new fields, so an incident affecting three systems produces one in-app notification + one external send per subscriber instead of three. Lifecycle updates for the same entity (created → updated → resolved) also collapse, with an expandable "+N updates" timeline.
10
+
11
+ Subject kinds are namespaced as `<pluginId>.<localKind>` and built via type-safe helpers exported from each domain's common package (`createSystemSubject`, `incidentCollapseKey`, etc.). The frontend kind registry (`registerSubjectKind`) lets plugins bind icon + label for their kinds; unknown kinds fall back to a generic chip.
12
+
13
+ All notification strategies (SMTP, Slack, Discord, Teams, Telegram, Pushover, Gotify, Webex, Backstage) render the affected subjects natively in their format (HTML cards, Slack blocks, Discord embed fields, adaptive cards, markdown lists, etc.).
14
+
15
+ - 32d52c6: feat: notification target pattern + per-spec subscriptions
16
+
17
+ Replaces the all-or-nothing catalog system/group notification model with a
18
+ platform-level target pattern. Each notification-emitting plugin declares
19
+ _subscription specs_ against typed _target_ objects exported from the
20
+ target's owning plugin (catalog ships `catalogSystemTarget` and
21
+ `catalogGroupTarget`). Notification-backend handles every per-resource
22
+ group lifecycle, parent-edge inheritance, and legacy-subscription seeding
23
+ — plugins never author groupId helpers, lifecycle hooks, or migration
24
+ code again.
25
+
26
+ **Plugin-author surface area is now ~12 lines per emitter:**
27
+
28
+ ```ts
29
+ // <plugin>-common
30
+ const { defineSubscription } = createSubscriptionFactory(pluginMetadata);
31
+ export const fooSystemSubscription = defineSubscription({
32
+ localId: "system",
33
+ target: catalogSystemTarget,
34
+ display: { title: "Foo Alerts", description: "...", iconName: "Bell" },
35
+ });
36
+
37
+ // <plugin>-backend register()
38
+ env.registerSubscriptionSpecs([fooSystemSubscription]);
39
+ // ^ feeds the plugin loader's dependency sorter — each spec's
40
+ // target.ownerPlugin becomes an implicit init-order dep, so this
41
+ // plugin automatically waits for catalog (the target owner) to
42
+ // finish init + afterPluginsReady before its own runs.
43
+
44
+ // <plugin>-backend afterPluginsReady
45
+ await notificationClient.registerSubscriptionSpec(
46
+ specToRegistration(fooSystemSubscription)
47
+ );
48
+ // dispatch
49
+ await notificationClient.notifyForSubscription({
50
+ specId: fooSystemSubscription.specId,
51
+ resourceKeys: [systemId],
52
+ title,
53
+ body,
54
+ importance,
55
+ action,
56
+ collapseKey,
57
+ subjects,
58
+ });
59
+
60
+ // <plugin>-frontend
61
+ createNotificationSubscriptionExtension({ spec: fooSystemSubscription });
62
+ ```
63
+
64
+ **Migrated plugins**: anomaly, incident, maintenance, healthcheck,
65
+ dependency. Each lost its bespoke `notification-groups.ts`,
66
+ `bootstrap*NotificationGroups`, `ensure*Group`, and inheritance walk —
67
+ all of that is now centralized in notification-backend's
68
+ `subscription-engine`.
69
+
70
+ **Plugin loader change** (`@checkstack/backend-api`,
71
+ `@checkstack/backend`): the register-time API gains
72
+ `env.registerSubscriptionSpecs([...specs])`. The dependency sorter
73
+ walks `spec.target.ownerPlugin` for every declared spec and adds the
74
+ target owner as an init-order dependency of the emitting plugin. This
75
+ guarantees that catalog (the owner of the platform's `system` and
76
+ `group` targets) completes init + afterPluginsReady before any
77
+ emitting plugin tries to register its specs against the notification
78
+ service — no string-prefix heuristics, no manual `dependsOnPlugins`
79
+ list, no stub rows. Plugins that fail to declare their specs at
80
+ register time get a clear `Target type X is not registered. Did the
81
+ emitting plugin declare this spec via env.registerSubscriptionSpecs?`
82
+ error from the dispatcher.
83
+
84
+ **Removed** (no backwards compat):
85
+
86
+ - `catalogClient.notifySystemSubscribers` and
87
+ `catalogClient.notifyManySystemSubscribers`
88
+ - `notificationClient.notifyUsers` and `notificationClient.notifyGroups`
89
+ as direct dispatch primitives — replaced by spec-bound
90
+ `notifyForSubscription`
91
+ - catalog's `bootstrapNotificationGroups` (replaced by
92
+ `bootstrapNotificationTargets`)
93
+
94
+ **Enforcement**: the dispatcher rejects calls referencing unregistered
95
+ specIds, specs owned by other plugins, or resourceKeys that haven't been
96
+ pushed via `upsertNotificationResource`. Display metadata for any
97
+ groupId is recoverable via the spec registry, so audit lists render
98
+ correct labels even when an emitter's frontend isn't loaded.
99
+
100
+ **Per-field anomaly mute** keeps working — it now lives inside the
101
+ generic SubscriptionRow's optional `SubControls` panel
102
+ (`AnomalyFieldMuteList`), exposed through the catalog system detail
103
+ page's notifications card.
104
+
105
+ The catalog system detail page renders a "Notifications" card hosting
106
+ `SystemNotificationSubscriptionsSlot`. The matching group surface is
107
+ not yet rendered — group-level subscriptions are wired end-to-end on
108
+ the backend; a follow-up will add the host UI.
109
+
110
+ **Migration of existing subscribers**: target types declare a
111
+ `legacyGroupIdTemplate`; on first registration of each spec,
112
+ notification-backend reads subscribers from the legacy
113
+ `catalog.system.<id>` / `catalog.group.<id>` groups and seeds the new
114
+ spec groups exactly once per (spec × resource) pair, tracked in
115
+ `subscription_migrations`. Anomaly stays opt-in (its target also
116
+ declares the template, but the user-explicit nature of the original
117
+ opt-in flow means the seeding produces the same set of subscribers
118
+ they already had).
119
+
120
+ ### Patch Changes
121
+
122
+ - 32d52c6: Fix and improve password reset flow + email branding:
123
+
124
+ - **Fix**: password reset emails were failing with "Malformed password reset URL: missing token parameter". Better-auth puts the reset token in the URL path (`/reset-password/{token}`), not as a `?token=` query param, so the previous URL-parsing logic always failed. Now uses the `token` argument better-auth passes to `sendResetPassword` directly.
125
+ - **UX**: the reset password page now validates the token on load via a new anonymous `validateResetToken` endpoint, so users see "Invalid Link" / "Link Expired" before typing a password rather than after submitting. Tokens are 24-char nanoid-style values (~143 bits of entropy), so exposing validity does not enable enumeration.
126
+ - **Fix**: transactional notifications were hardcoded to `importance: "critical"`, causing password reset emails to display a misleading "CRITICAL" badge. The `sendTransactional` contract now accepts an optional `importance` field that defaults to `"info"`.
127
+ - **Branding**: redesigned the email layout (`wrapInEmailLayout`) with a Checkstack-style engineering aesthetic — dark header with grid pattern, monospace importance badge, hardened CTA button (Outlook VML fallback + explicit text color), and force-light color scheme to prevent client auto-inversion from breaking text legibility.
128
+
129
+ - Updated dependencies [32d52c6]
130
+ - Updated dependencies [32d52c6]
131
+ - @checkstack/healthcheck-common@1.0.0
132
+ - @checkstack/cache-api@0.2.2
133
+ - @checkstack/queue-api@0.2.16
134
+
3
135
  ## 0.13.1
4
136
 
5
137
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "scripts": {
@@ -10,10 +10,10 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@checkstack/common": "0.7.0",
13
- "@checkstack/healthcheck-common": "0.12.0",
14
- "@checkstack/cache-api": "0.2.0",
15
- "@checkstack/queue-api": "0.2.14",
16
- "@checkstack/signal-common": "0.1.10",
13
+ "@checkstack/healthcheck-common": "0.13.0",
14
+ "@checkstack/cache-api": "0.2.1",
15
+ "@checkstack/queue-api": "0.2.15",
16
+ "@checkstack/signal-common": "0.2.0",
17
17
  "@orpc/client": "^1.13.14",
18
18
  "@orpc/contract": "^1.13.14",
19
19
  "@orpc/openapi": "^1.13.2",
@@ -7,6 +7,18 @@
7
7
  * @module
8
8
  */
9
9
 
10
+ /**
11
+ * A notification subject as rendered by the email layout. Mirrors
12
+ * `NotificationSubject` from `@checkstack/notification-common`.
13
+ */
14
+ export interface EmailLayoutSubject {
15
+ kind: string;
16
+ id: string;
17
+ name: string;
18
+ url?: string;
19
+ status?: "healthy" | "unhealthy" | "degraded" | "unknown";
20
+ }
21
+
10
22
  /**
11
23
  * Options for the email layout wrapper.
12
24
  */
@@ -15,20 +27,26 @@ export interface EmailLayoutOptions {
15
27
  title: string;
16
28
  /** Already-converted HTML body content */
17
29
  bodyHtml: string;
18
- /** Importance level affects header/button colors */
30
+ /** Importance level affects the category badge color */
19
31
  importance: "info" | "warning" | "critical";
20
32
  /** Optional call-to-action button */
21
33
  action?: {
22
34
  label: string;
23
35
  url: string;
24
36
  };
37
+ /**
38
+ * Affected entities. Rendered as a card under the body, with each subject
39
+ * as a clickable row when `url` is present. When `action` is omitted, the
40
+ * subjects are the recipient's only navigation.
41
+ */
42
+ subjects?: EmailLayoutSubject[];
25
43
 
26
44
  // Admin-customizable options (via layoutConfig)
27
- /** Logo URL (max ~200px wide recommended) */
45
+ /** Logo URL (max ~200px wide recommended). Rendered in the dark header. */
28
46
  logoUrl?: string;
29
- /** Primary brand color (hex, e.g., "#3b82f6") */
47
+ /** Primary brand color (hex, e.g., "#7c3aed"). Used for the CTA button. */
30
48
  primaryColor?: string;
31
- /** Accent color for secondary elements */
49
+ /** Accent color (overrides primary for the CTA button if provided). */
32
50
  accentColor?: string;
33
51
  /** Footer text */
34
52
  footerText?: string;
@@ -36,15 +54,34 @@ export interface EmailLayoutOptions {
36
54
  footerLinks?: Array<{ label: string; url: string }>;
37
55
  }
38
56
 
39
- // Default importance-based colors
40
- const IMPORTANCE_COLORS = {
41
- info: "#3b82f6", // blue
42
- warning: "#f59e0b", // amber
43
- critical: "#ef4444", // red
57
+ // Importance-based badge colors (rendered as monospace pills in the header)
58
+ const IMPORTANCE_BADGES = {
59
+ info: { label: "INFO", bg: "#1e293b", fg: "#93c5fd" },
60
+ warning: { label: "WARNING", bg: "#3a2a0e", fg: "#fbbf24" },
61
+ critical: { label: "CRITICAL", bg: "#3a0e14", fg: "#fca5a5" },
44
62
  } as const;
45
63
 
64
+ // Subject status dot colors. Status is optional on subjects; when absent,
65
+ // no dot is rendered.
66
+ const SUBJECT_STATUS_COLORS: Record<
67
+ NonNullable<EmailLayoutSubject["status"]>,
68
+ string
69
+ > = {
70
+ healthy: "#22c55e",
71
+ degraded: "#f59e0b",
72
+ unhealthy: "#ef4444",
73
+ unknown: "#a1a1aa",
74
+ };
75
+
76
+ // Checkstack brand defaults — purple primary on near-black surfaces.
77
+ const BRAND_PRIMARY = "#8b5cf6"; // matches --primary in dark theme
78
+ const HEADER_BG = "#0a0a0c"; // matches --background in dark theme
79
+ const HEADER_BORDER = "#27272a"; // matches --border in dark theme
80
+ const HEADER_FG = "#fafafa";
81
+ const HEADER_MUTED = "#a1a1aa";
82
+
46
83
  /**
47
- * Simple HTML escaping for security.
84
+ * HTML escaping for security.
48
85
  */
49
86
  function escapeHtml(text: string): string {
50
87
  return text
@@ -56,20 +93,29 @@ function escapeHtml(text: string): string {
56
93
  }
57
94
 
58
95
  /**
59
- * Wrap HTML content in a responsive email template.
96
+ * Subtle grid pattern (matches the `ambient-grid` aesthetic in the app UI).
97
+ * Embedded as an inline SVG data URI — supported in Apple Mail and most modern clients.
98
+ * Email clients that strip backgrounds (Gmail web) gracefully fall back to the solid header color.
99
+ */
100
+ const GRID_PATTERN_SVG =
101
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCI+PHBhdGggZD0iTSA0OCAwIEwgMCAwIDAgNDgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzNmM2Y0NiIgc3Ryb2tlLXdpZHRoPSIxIiBvcGFjaXR5PSIwLjQiLz48L3N2Zz4=";
102
+
103
+ /**
104
+ * Wrap HTML content in a Checkstack-branded responsive email template.
60
105
  *
61
- * This template is designed to render consistently across major
62
- * email clients (Gmail, Outlook, Apple Mail, etc.).
106
+ * The design pairs a dark "engineering" header (with optional grid pattern
107
+ * and monospace importance badge) with a clean light body for readability,
108
+ * and a purple-accented CTA button.
63
109
  *
64
110
  * @example
65
111
  * ```typescript
66
112
  * const html = wrapInEmailLayout({
67
113
  * title: "Password Reset",
68
114
  * bodyHtml: "<p>Click the button below to reset your password.</p>",
69
- * importance: "warning",
115
+ * importance: "info",
70
116
  * action: { label: "Reset Password", url: "https://..." },
71
- * primaryColor: "#10b981",
72
- * footerText: "Sent by Acme Corp",
117
+ * primaryColor: "#7c3aed",
118
+ * footerText: "Sent by Checkstack",
73
119
  * });
74
120
  * ```
75
121
  */
@@ -79,27 +125,49 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
79
125
  bodyHtml,
80
126
  importance,
81
127
  action,
128
+ subjects,
82
129
  logoUrl,
83
130
  primaryColor,
84
- footerText = "This is an automated notification.",
131
+ footerText = "This is an automated notification from Checkstack.",
85
132
  footerLinks = [],
86
133
  } = options;
87
134
 
88
- // Use custom color or importance-based default
89
- const headerColor = primaryColor ?? IMPORTANCE_COLORS[importance];
90
- const buttonColor = options.accentColor ?? headerColor;
135
+ const buttonColor = options.accentColor ?? primaryColor ?? BRAND_PRIMARY;
136
+ const badge = IMPORTANCE_BADGES[importance];
137
+
138
+ const subjectsHtml =
139
+ subjects && subjects.length > 0
140
+ ? `
141
+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-top: 20px; background-color: #fafafa; border: 1px solid #e4e4e7; border-radius: 8px;">
142
+ <tr>
143
+ <td style="padding: 12px 16px;">
144
+ <p class="mono" style="margin: 0 0 8px 0; color: #71717a; font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;">
145
+ Affected
146
+ </p>
147
+ ${subjects
148
+ .map((subject) => {
149
+ const statusDot = subject.status
150
+ ? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background-color:${SUBJECT_STATUS_COLORS[subject.status]};margin-right:8px;vertical-align:middle;"></span>`
151
+ : "";
152
+ const namePart = `<span style="color:#18181b;font-size:14px;font-weight:500;vertical-align:middle;">${escapeHtml(subject.name)}</span>`;
153
+ const inner = subject.url
154
+ ? `<a href="${escapeHtml(subject.url)}" style="color:#18181b;text-decoration:none;">${statusDot}${namePart}</a>`
155
+ : `${statusDot}${namePart}`;
156
+ return `<div style="padding:6px 0;border-bottom:1px solid #e4e4e7;">${inner}</div>`;
157
+ })
158
+ .join("")}
159
+ </td>
160
+ </tr>
161
+ </table>
162
+ `
163
+ : "";
91
164
 
92
- // Build footer links HTML
93
165
  const footerLinksHtml =
94
166
  footerLinks.length > 0
95
167
  ? footerLinks
96
168
  .map(
97
169
  (link) =>
98
- `<a href="${escapeHtml(
99
- link.url
100
- )}" style="color: #6b7280; text-decoration: underline;">${escapeHtml(
101
- link.label
102
- )}</a>`
170
+ `<a href="${escapeHtml(link.url)}" style="color: #71717a; text-decoration: underline;">${escapeHtml(link.label)}</a>`,
103
171
  )
104
172
  .join(" · ")
105
173
  : "";
@@ -110,7 +178,8 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
110
178
  <head>
111
179
  <meta charset="utf-8">
112
180
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
113
- <meta name="color-scheme" content="light">
181
+ <!-- Force light scheme so clients don't auto-invert our intentionally dark header -->
182
+ <meta name="color-scheme" content="only light">
114
183
  <meta name="supported-color-schemes" content="light">
115
184
  <title>${escapeHtml(title)}</title>
116
185
  <!--[if mso]>
@@ -123,7 +192,6 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
123
192
  </noscript>
124
193
  <![endif]-->
125
194
  <style>
126
- /* Reset styles */
127
195
  body, table, td, p, a, li, blockquote {
128
196
  -webkit-text-size-adjust: 100%;
129
197
  -ms-text-size-adjust: 100%;
@@ -144,76 +212,84 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
144
212
  margin: 0 !important;
145
213
  padding: 0 !important;
146
214
  width: 100% !important;
147
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
215
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
148
216
  background-color: #f4f4f5;
149
217
  }
150
- /* Link styling */
151
- a {
152
- color: ${buttonColor};
153
- }
154
- /* Button styling */
155
- .button {
156
- display: inline-block;
157
- padding: 12px 24px;
158
- background-color: ${buttonColor};
159
- color: #ffffff !important;
160
- text-decoration: none;
161
- border-radius: 6px;
162
- font-weight: 600;
163
- font-size: 14px;
164
- line-height: 1.5;
218
+ .mono {
219
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
165
220
  }
221
+ a { color: ${buttonColor}; }
222
+ /* Force-light-mode hardening: prevent Outlook/Apple Mail dark-mode auto-inversion. */
223
+ [data-ogsc] .email-body, [data-ogsb] .email-body { color: #27272a !important; background-color: #ffffff !important; }
224
+ [data-ogsc] .email-card, [data-ogsb] .email-card { background-color: #ffffff !important; }
225
+ [data-ogsc] .email-footer, [data-ogsb] .email-footer { background-color: #fafafa !important; }
226
+ [data-ogsc] .button-label, [data-ogsb] .button-label { color: #ffffff !important; }
166
227
  </style>
167
228
  </head>
168
229
  <body style="margin: 0; padding: 0; background-color: #f4f4f5;">
230
+ <span style="display:none !important; visibility:hidden; mso-hide:all; font-size:1px; color:#f4f4f5; line-height:1px; max-height:0; max-width:0; opacity:0; overflow:hidden;">${escapeHtml(title)}</span>
169
231
  <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f4f4f5;">
170
232
  <tr>
171
- <td align="center" style="padding: 24px 16px;">
233
+ <td align="center" style="padding: 32px 16px;">
172
234
  <!-- Main container -->
173
- <table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
174
- ${
175
- logoUrl
176
- ? `
177
- <!-- Logo -->
178
- <tr>
179
- <td align="center" style="padding: 24px 24px 0 24px;">
180
- <img src="${escapeHtml(
181
- logoUrl
182
- )}" alt="Logo" style="max-width: 200px; height: auto;">
183
- </td>
184
- </tr>
185
- `
186
- : ""
187
- }
188
- <!-- Header -->
235
+ <table role="presentation" cellpadding="0" cellspacing="0" width="600" class="email-card" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.04);">
236
+ <!-- Header (dark, engineering aesthetic) -->
189
237
  <tr>
190
- <td style="background-color: ${headerColor}; padding: 20px 24px;">
191
- <h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600; line-height: 1.4;">
192
- ${escapeHtml(title)}
193
- </h1>
238
+ <td style="background-color: ${HEADER_BG}; background-image: url('${GRID_PATTERN_SVG}'); background-repeat: repeat; background-size: 48px 48px; padding: 28px 28px 24px 28px; border-bottom: 1px solid ${HEADER_BORDER};">
239
+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%">
240
+ <tr>
241
+ <td>
242
+ ${
243
+ logoUrl
244
+ ? `<img src="${escapeHtml(logoUrl)}" alt="Logo" style="max-width: 160px; height: auto; display: block; margin-bottom: 14px;">`
245
+ : `<div class="mono" style="color: ${HEADER_MUTED}; font-size: 11px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 14px;">— checkstack</div>`
246
+ }
247
+ <table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom: 10px;">
248
+ <tr>
249
+ <td class="mono" style="background-color: ${badge.bg}; color: ${badge.fg}; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: 0.12em;">
250
+ ${badge.label}
251
+ </td>
252
+ </tr>
253
+ </table>
254
+ <h1 style="margin: 0; color: ${HEADER_FG}; font-size: 22px; font-weight: 600; line-height: 1.35; letter-spacing: -0.01em;">
255
+ ${escapeHtml(title)}
256
+ </h1>
257
+ </td>
258
+ </tr>
259
+ </table>
194
260
  </td>
195
261
  </tr>
196
262
  <!-- Body -->
197
263
  <tr>
198
- <td style="padding: 24px;">
199
- <div style="color: #374151; font-size: 16px; line-height: 1.6;">
264
+ <td class="email-body" bgcolor="#ffffff" style="padding: 28px 28px 24px 28px; background-color: #ffffff; color: #18181b;">
265
+ <div style="color: #18181b; font-size: 15px; line-height: 1.65;">
200
266
  ${bodyHtml}
201
267
  </div>
268
+ ${subjectsHtml}
202
269
  ${
203
270
  action
204
271
  ? `
205
- <!-- CTA Button -->
272
+ <!-- CTA button: bulletproof Outlook VML fallback + standard anchor for everyone else -->
206
273
  <table role="presentation" cellpadding="0" cellspacing="0" style="margin-top: 24px;">
207
274
  <tr>
208
- <td>
209
- <a href="${escapeHtml(
210
- action.url
211
- )}" class="button" style="display: inline-block; padding: 12px 24px; background-color: ${buttonColor}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">
212
- ${escapeHtml(action.label)}
275
+ <td bgcolor="${buttonColor}" style="background-color: ${buttonColor}; border-radius: 6px;">
276
+ <!--[if mso]>
277
+ <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${escapeHtml(action.url)}" style="height:44px;v-text-anchor:middle;width:220px;" arcsize="14%" stroke="f" fillcolor="${buttonColor}">
278
+ <w:anchorlock/>
279
+ <center style="color:#ffffff;font-family:'Segoe UI',Arial,sans-serif;font-size:14px;font-weight:600;">${escapeHtml(action.label)}</center>
280
+ </v:roundrect>
281
+ <![endif]-->
282
+ <!--[if !mso]><!-- -->
283
+ <a href="${escapeHtml(action.url)}" class="button" style="display: inline-block; padding: 12px 22px; background-color: ${buttonColor}; border-radius: 6px; text-decoration: none; mso-hide: all;">
284
+ <span class="button-label" style="color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, Arial, sans-serif; font-size: 14px; font-weight: 600; line-height: 1.5; letter-spacing: 0.01em;">${escapeHtml(action.label)}</span>
213
285
  </a>
286
+ <!--<![endif]-->
214
287
  </td>
215
288
  </tr>
216
289
  </table>
290
+ <p class="mono" style="margin: 16px 0 0 0; color: #a1a1aa; font-size: 11px; line-height: 1.5; word-break: break-all;">
291
+ ${escapeHtml(action.url)}
292
+ </p>
217
293
  `
218
294
  : ""
219
295
  }
@@ -221,14 +297,14 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
221
297
  </tr>
222
298
  <!-- Footer -->
223
299
  <tr>
224
- <td style="background-color: #f9fafb; padding: 16px 24px; border-top: 1px solid #e5e7eb;">
225
- <p style="margin: 0; color: #6b7280; font-size: 12px; line-height: 1.5; text-align: center;">
300
+ <td class="email-footer" style="background-color: #fafafa; padding: 16px 28px; border-top: 1px solid #e4e4e7;">
301
+ <p class="mono" style="margin: 0; color: #71717a; font-size: 11px; line-height: 1.5; text-align: center; letter-spacing: 0.02em;">
226
302
  ${escapeHtml(footerText)}
227
303
  </p>
228
304
  ${
229
305
  footerLinksHtml
230
306
  ? `
231
- <p style="margin: 8px 0 0 0; color: #6b7280; font-size: 12px; line-height: 1.5; text-align: center;">
307
+ <p style="margin: 8px 0 0 0; color: #71717a; font-size: 11px; line-height: 1.5; text-align: center;">
232
308
  ${footerLinksHtml}
233
309
  </p>
234
310
  `
@@ -19,6 +19,22 @@ export type NotificationContactResolution =
19
19
  // Payload and Result Types
20
20
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21
21
 
22
+ /**
23
+ * An entity affected by a notification, surfaced to the recipient as a
24
+ * chip/link/native element. Mirrors `NotificationSubject` from
25
+ * `@checkstack/notification-common` — duplicated here to avoid a runtime
26
+ * dependency from backend-api on notification-common.
27
+ *
28
+ * `kind` is namespaced as `<pluginId>.<localKind>` (e.g., `catalog.system`).
29
+ */
30
+ export interface NotificationSubject {
31
+ kind: string;
32
+ id: string;
33
+ name: string;
34
+ url?: string;
35
+ status?: "healthy" | "unhealthy" | "degraded" | "unknown";
36
+ }
37
+
22
38
  /**
23
39
  * The notification content to send via external channel.
24
40
  */
@@ -41,6 +57,15 @@ export interface NotificationPayload {
41
57
  label: string;
42
58
  url: string;
43
59
  };
60
+ /**
61
+ * Affected entities. Each strategy renders these in its native format
62
+ * (Slack section, Discord embed field, SMTP card, etc.). For text-only
63
+ * channels, render as a bulleted list with optional URLs.
64
+ *
65
+ * When `action` is null and `subjects` are present, the subject URLs are
66
+ * the recipient's only navigation paths.
67
+ */
68
+ subjects?: NotificationSubject[];
44
69
  /**
45
70
  * Source type identifier for filtering and templates.
46
71
  * Examples: "password-reset", "healthcheck.alert", "maintenance.reminder"
@@ -99,6 +99,25 @@ export type BackendPluginRegistry = {
99
99
  * Multiple cleanup handlers can be registered; they run in LIFO order.
100
100
  */
101
101
  registerCleanup: (cleanup: () => Promise<void>) => void;
102
+ /**
103
+ * Declare notification subscription specs this plugin owns. Called
104
+ * synchronously during `register()` so the plugin loader can derive
105
+ * init-order dependencies from each spec's target: an emitter
106
+ * targeting `catalogSystemTarget` automatically waits for catalog's
107
+ * init + afterPluginsReady before its own. Plugins still call the
108
+ * notification-backend RPC `registerSubscriptionSpec` from
109
+ * afterPluginsReady — this declaration is purely for ordering.
110
+ *
111
+ * Each entry must carry an `ownerPlugin` that matches the calling
112
+ * plugin id, and a `target.ownerPlugin` describing the plugin that
113
+ * owns the target type the spec is bound to.
114
+ */
115
+ registerSubscriptionSpecs: (
116
+ specs: ReadonlyArray<{
117
+ readonly ownerPlugin: string;
118
+ readonly target: { readonly ownerPlugin: string };
119
+ }>,
120
+ ) => void;
102
121
  pluginManager: {
103
122
  getAllAccessRules: () => { id: string; description?: string }[];
104
123
  };