@checkstack/incident-backend 1.13.5 → 1.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,200 @@
1
1
  # @checkstack/incident-backend
2
2
 
3
+ ## 1.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 88f4333: Resolve `#` mentions on public status pages, and check viewability in the admin UI
8
+
9
+ Cross-entity mentions previously resolved only in the admin UI, and did so
10
+ without asking whether the reader could actually open the target. Public
11
+ surfaces resolved nothing at all. Three changes, one per delivery context.
12
+
13
+ **The admin UI now checks viewability.** `useMentionResolution({ documents })`
14
+ collects the references a page is about to render and asks each owning plugin -
15
+ in ONE batched request - which of them this viewer may read. A mention to a
16
+ deleted or unreadable record now renders as plain text instead of a link to a
17
+ not-found page or an access gate. Backed by new `resolveIncidentRefs` /
18
+ `resolveMaintenanceRefs` procedures, which return ids only (so an unreadable
19
+ record is indistinguishable from a deleted one) and carry the same `listKey`
20
+ read post-filter as their list procedures. They are deliberately not a filter
21
+ over the authoring search list, which hides resolved incidents and would
22
+ silently downgrade valid references.
23
+
24
+ **Public status pages now resolve mentions.** A reference becomes a link to the
25
+ target's public detail page when - and only when - the same page publishes that
26
+ target, which is exactly the anti-enumeration gate the detail pages already
27
+ apply. So an operator writing "caused by #Database upgrade" in a public update
28
+ gets a working link, while a mention of an internal-only incident stays plain
29
+ text rather than becoming a link that confirms it exists. Widgets opt in by
30
+ declaring a `mentionType`, so the status-page packages take no dependency on any
31
+ domain plugin.
32
+
33
+ **BREAKING CHANGE (behavioural, no API change):** the in-app public status page
34
+ at `/statuspage/view/<slug>` now builds detail-page hrefs. Previously it passed
35
+ none, so incident and maintenance titles rendered as plain text there while the
36
+ same page on a custom domain linked them. Both now behave identically.
37
+
38
+ **Notification bodies no longer leak the internal scheme.** `checkstack:` is
39
+ meaningless outside a Checkstack renderer, and channels leaked it differently:
40
+ the email sanitiser stripped the href and left a dead anchor, while Slack's
41
+ mrkdwn emitted `<checkstack:maintenance/9f1c-abc|Database upgrade>` straight to
42
+ the recipient (Discord, Telegram and Teams render markdown natively and would
43
+ have passed it through too). `sanitizeUpdateMessage` now flattens every mention
44
+ to its label before the body reaches any channel, so no channel has to know the
45
+ scheme exists. Flattening also happens before the length bound, so the excerpt
46
+ budget is spent on visible text rather than on an internal URI.
47
+
48
+ ### Patch Changes
49
+
50
+ - 88f4333: Show command-palette actions to team-scoped users
51
+
52
+ The palette filtered commands against the caller's GLOBAL access rules only, so a
53
+ user whose team holds a create-capability grant - but who holds no global
54
+ `incident.incident.manage` / `maintenance.maintenance.manage` rule - never saw
55
+ "Create Incident" or "Create Maintenance", nor their keyboard shortcuts. The
56
+ palette hid the actions from exactly the people authorized to run them.
57
+
58
+ Commands can now declare a `manageCapability` (mirroring the gate routes and nav
59
+ already use). `filterByAccessRules` shows an item when the caller holds the
60
+ global rules OR can create/manage the declared type through a team grant, and the
61
+ command backend resolves that per request via `hasAnyTypeGrant` (with
62
+ `includeCreator`, so a team member who may CREATE the type qualifies before
63
+ owning an instance). It fails closed: an auth error leaves pure global gating.
64
+ The incident and maintenance commands declare their types.
65
+
66
+ `useGlobalShortcuts` no longer takes `userAccessRules` and no longer re-checks
67
+ access: the server-filtered list is authoritative. That re-check tested the
68
+ global rules only and would have dropped team-scoped users' shortcuts - both call
69
+ sites already defeated it by passing `["*"]`.
70
+
71
+ - Updated dependencies [88f4333]
72
+ - Updated dependencies [1deaac5]
73
+ - Updated dependencies [88f4333]
74
+ - Updated dependencies [88f4333]
75
+ - Updated dependencies [88f4333]
76
+ - Updated dependencies [88f4333]
77
+ - Updated dependencies [1deaac5]
78
+ - Updated dependencies [56e5375]
79
+ - @checkstack/common@0.24.0
80
+ - @checkstack/auth-common@0.17.0
81
+ - @checkstack/status-page-common@0.7.0
82
+ - @checkstack/incident-common@1.11.0
83
+ - @checkstack/automation-common@0.10.3
84
+ - @checkstack/command-backend@0.3.0
85
+ - @checkstack/status-page-backend@0.7.0
86
+ - @checkstack/notification-common@1.9.0
87
+ - @checkstack/ai-backend@0.11.5
88
+ - @checkstack/backend-api@0.35.0
89
+ - @checkstack/automation-backend@0.11.9
90
+ - @checkstack/ai-common@0.6.8
91
+ - @checkstack/cache-api@0.3.21
92
+ - @checkstack/catalog-backend@1.10.2
93
+ - @checkstack/catalog-common@2.8.2
94
+ - @checkstack/integration-backend@0.7.10
95
+ - @checkstack/integration-common@0.9.11
96
+ - @checkstack/signal-common@0.3.2
97
+ - @checkstack/cache-utils@0.3.2
98
+
99
+ ## 1.13.6
100
+
101
+ ### Patch Changes
102
+
103
+ - be74b01: Render markdown in incident/maintenance update messages instead of escaping it
104
+
105
+ Thanks to @stuajnht for reporting: an email notification showed the raw
106
+ markdown of a link (`[text](url)`) instead of a clickable link. The report
107
+ placed the bug in the email renderer, but the email path was fine - it already
108
+ runs `markdownToHtml`. The damage happened upstream.
109
+
110
+ The shared `sanitizeUpdateMessage` (in `notification-common`, used by both the
111
+ incident and maintenance backends to embed the latest update in a notification
112
+ body) backslash-escaped every markdown control character and forced the message
113
+ onto a single line inside a blockquote. So a `[label](href)` link arrived as
114
+ `\[label\]\(href\)` and rendered as literal text in every channel - exactly the
115
+ symptom reported.
116
+
117
+ Update messages are authored as markdown and render as markdown on the web, so
118
+ they now do the same in notifications. `sanitizeUpdateMessage` still normalizes
119
+ the text (strips non-whitespace control characters, normalizes line endings,
120
+ collapses runs of blank lines, bounds the length) but no longer escapes the
121
+ markdown, and `buildUpdateMessageSuffix` appends it as its own multi-line
122
+ markdown block rather than a single-line blockquote. Links, emphasis, code, and
123
+ lists now render.
124
+
125
+ This does not weaken safety. The only strategy that emits HTML is SMTP, via
126
+ `markdownToHtml`, whose email-safe allow-list drops `<script>`, `on*=`
127
+ handlers, and `javascript:`/`data:` URLs; every other strategy renders markdown
128
+ / mrkdwn / an adaptive card or flattens to plain text, none of which execute
129
+ HTML. Source-side escaping was redundant with that renderer sanitization for
130
+ the security goal while destroying legitimate formatting. The notification
131
+ title and the incident/maintenance descriptions were already interpolated into
132
+ the body unescaped, so this brings the update message in line with them.
133
+
134
+ - be74b01: Fix status-page detail-page content and status colouring
135
+
136
+ Thanks to @stuajnht for reporting several public status-page issues (the
137
+ announcement-block fix is a separate changeset):
138
+
139
+ - **Incident/maintenance status text was uncoloured and inline.** Update-timeline
140
+ status changes rendered in the muted grey `text-muted-foreground`, making the
141
+ status hard to tell apart from the message. The status change now sits on its
142
+ own line, coloured by its lifecycle (a new `incidentStatusTone`/`Label` mirrors
143
+ the incident status enum the way `maintenanceStatusTone` mirrors maintenance),
144
+ on both the summary block and the detail pages. The detail-page incident status
145
+ pill next to the title is now coloured too (was a neutral grey pill).
146
+
147
+ - **Detail pages showed raw markdown.** Individual incident/maintenance pages
148
+ rendered the update body as the raw source string; they now render sanitized
149
+ markdown via `<Markdown>`, like the block.
150
+
151
+ - **Detail pages showed only a few updates.** The individual pages reused the
152
+ summary block DTO, so they inherited the block's `maxUpdates` cap. Widget types
153
+ now expose an optional `resolveDetail` that returns the ONE item with ALL its
154
+ public updates (no cap) and its long-form description; the incident and
155
+ maintenance widgets implement it, and the status-page backend's
156
+ `resolvePublishedIncident`/`resolvePublishedMaintenance` call it. The detail
157
+ page is gated by the SAME anti-enumeration boundary as the block (the widget's
158
+ live scope), and the result is re-validated against the widget's item DTO, so
159
+ it fails closed exactly like the block.
160
+
161
+ - **Maintenance detail page showed no description.** The maintenance item DTO
162
+ gained an optional `description`, emitted by `resolveDetail` and rendered as
163
+ markdown on the detail page (the incident detail page already had the field
164
+ plumbed and now renders it too).
165
+
166
+ Note on maintenance/grey systems (also reported): the maintenance BLOCK already
167
+ colours scheduled windows blue. On the SYSTEM-HEALTH widget the blue
168
+ "maintenance" tone is applied only while a window is actively `in_progress`; a
169
+ future scheduled window leaves the system on its live health, and a system with
170
+ no health data reads grey "unknown". That is deliberate and left as-is.
171
+
172
+ - Updated dependencies [be74b01]
173
+ - Updated dependencies [be74b01]
174
+ - Updated dependencies [be74b01]
175
+ - Updated dependencies [be74b01]
176
+ - Updated dependencies [be74b01]
177
+ - Updated dependencies [be74b01]
178
+ - Updated dependencies [be74b01]
179
+ - Updated dependencies [be74b01]
180
+ - Updated dependencies [be74b01]
181
+ - Updated dependencies [be74b01]
182
+ - Updated dependencies [be74b01]
183
+ - Updated dependencies [be74b01]
184
+ - Updated dependencies [be74b01]
185
+ - @checkstack/ai-backend@0.11.4
186
+ - @checkstack/notification-common@1.8.0
187
+ - @checkstack/status-page-backend@0.6.6
188
+ - @checkstack/status-page-common@0.6.5
189
+ - @checkstack/auth-common@0.16.0
190
+ - @checkstack/automation-backend@0.11.8
191
+ - @checkstack/catalog-backend@1.10.1
192
+ - @checkstack/catalog-common@2.8.1
193
+ - @checkstack/incident-common@1.10.5
194
+ - @checkstack/backend-api@0.34.1
195
+ - @checkstack/command-backend@0.2.27
196
+ - @checkstack/integration-backend@0.7.9
197
+
3
198
  ## 1.13.5
4
199
 
5
200
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.13.5",
3
+ "version": "1.14.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,25 +14,25 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.11.3",
18
- "@checkstack/ai-common": "0.6.7",
19
- "@checkstack/backend-api": "0.34.0",
20
- "@checkstack/cache-api": "0.3.20",
21
- "@checkstack/cache-utils": "0.3.1",
22
- "@checkstack/incident-common": "1.10.4",
23
- "@checkstack/catalog-common": "2.8.0",
24
- "@checkstack/catalog-backend": "1.10.0",
25
- "@checkstack/notification-common": "1.7.2",
26
- "@checkstack/auth-common": "0.15.0",
27
- "@checkstack/command-backend": "0.2.26",
28
- "@checkstack/signal-common": "0.3.1",
29
- "@checkstack/status-page-backend": "0.6.5",
30
- "@checkstack/status-page-common": "0.6.4",
31
- "@checkstack/integration-backend": "0.7.8",
32
- "@checkstack/integration-common": "0.9.10",
33
- "@checkstack/automation-backend": "0.11.7",
34
- "@checkstack/automation-common": "0.10.2",
35
- "@checkstack/common": "0.23.0",
17
+ "@checkstack/ai-backend": "0.11.5",
18
+ "@checkstack/ai-common": "0.6.8",
19
+ "@checkstack/backend-api": "0.35.0",
20
+ "@checkstack/cache-api": "0.3.21",
21
+ "@checkstack/cache-utils": "0.3.2",
22
+ "@checkstack/incident-common": "1.11.0",
23
+ "@checkstack/catalog-common": "2.8.2",
24
+ "@checkstack/catalog-backend": "1.10.2",
25
+ "@checkstack/notification-common": "1.9.0",
26
+ "@checkstack/auth-common": "0.17.0",
27
+ "@checkstack/command-backend": "0.3.0",
28
+ "@checkstack/signal-common": "0.3.2",
29
+ "@checkstack/status-page-backend": "0.7.0",
30
+ "@checkstack/status-page-common": "0.7.0",
31
+ "@checkstack/integration-backend": "0.7.10",
32
+ "@checkstack/integration-common": "0.9.11",
33
+ "@checkstack/automation-backend": "0.11.9",
34
+ "@checkstack/automation-common": "0.10.3",
35
+ "@checkstack/common": "0.24.0",
36
36
  "drizzle-orm": "^0.45.0",
37
37
  "zod": "^4.2.1",
38
38
  "@orpc/contract": "^1.14.4",
@@ -40,8 +40,8 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@checkstack/drizzle-helper": "0.0.6",
43
- "@checkstack/scripts": "0.7.6",
44
- "@checkstack/test-utils-backend": "0.1.60",
43
+ "@checkstack/scripts": "0.7.7",
44
+ "@checkstack/test-utils-backend": "0.1.62",
45
45
  "@checkstack/tsconfig": "0.0.7",
46
46
  "@types/bun": "^1.0.0",
47
47
  "@types/pg": "^8.20.0",
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import {
12
12
  incidentAccessRules,
13
13
  incidentAccess,
14
+ incidentResourceTypes,
14
15
  pluginMetadata,
15
16
  incidentContract,
16
17
  incidentRoutes,
@@ -289,6 +290,9 @@ export default createBackendPlugin({
289
290
  route:
290
291
  resolveRoute(incidentRoutes.routes.config) + "?action=create",
291
292
  requiredAccessRules: [incidentAccess.incident.manage],
293
+ // Team-scoped: a team that may create/manage incidents sees this
294
+ // even without the global rule (see command-common filterByAccessRules).
295
+ manageCapability: { objectType: incidentResourceTypes.incident },
292
296
  },
293
297
  {
294
298
  id: "manage",
@@ -298,6 +302,9 @@ export default createBackendPlugin({
298
302
  shortcuts: ["meta+shift+i", "ctrl+shift+i"],
299
303
  route: resolveRoute(incidentRoutes.routes.config),
300
304
  requiredAccessRules: [incidentAccess.incident.manage],
305
+ // Team-scoped: a team that may create/manage incidents sees this
306
+ // even without the global rule (see command-common filterByAccessRules).
307
+ manageCapability: { objectType: incidentResourceTypes.incident },
301
308
  },
302
309
  ],
303
310
  });
@@ -318,7 +318,13 @@ describe("notifyAffectedSystems", () => {
318
318
  });
319
319
 
320
320
  describe("update message in body", () => {
321
- it("appends the escaped update message as a blockquote", async () => {
321
+ const bodyOf = () =>
322
+ (
323
+ mockNotificationClient.notifyForSubscription.mock
324
+ .calls[0] as unknown as [{ body?: string }]
325
+ )[0]?.body ?? "";
326
+
327
+ it("appends the update message as its own markdown block", async () => {
322
328
  await notifyAffectedSystems({
323
329
  catalogClient: mockCatalogClient as never,
324
330
  notificationClient: mockNotificationClient as never,
@@ -331,16 +337,12 @@ describe("notifyAffectedSystems", () => {
331
337
  updateMessage: "Rolled back the bad deploy, monitoring recovery.",
332
338
  });
333
339
 
334
- const call = (
335
- mockNotificationClient.notifyForSubscription.mock
336
- .calls[0] as unknown as [{ body?: string }]
337
- )[0];
338
- expect(call?.body).toContain(
339
- "\n\n> Rolled back the bad deploy, monitoring recovery",
340
+ expect(bodyOf()).toContain(
341
+ "\n\nRolled back the bad deploy, monitoring recovery.",
340
342
  );
341
343
  });
342
344
 
343
- it("escapes markdown control characters in the message", async () => {
345
+ it("PRESERVES authored markdown so a link renders (the reported bug)", async () => {
344
346
  await notifyAffectedSystems({
345
347
  catalogClient: mockCatalogClient as never,
346
348
  notificationClient: mockNotificationClient as never,
@@ -350,18 +352,13 @@ describe("notifyAffectedSystems", () => {
350
352
  systemIds: ["sys-1"],
351
353
  action: "updated",
352
354
  severity: "minor",
353
- updateMessage: "See [here](http://evil) **now** `code`",
355
+ updateMessage: "See [the status page](https://example.com/s/1) **now**",
354
356
  });
355
357
 
356
- const call = (
357
- mockNotificationClient.notifyForSubscription.mock
358
- .calls[0] as unknown as [{ body?: string }]
359
- )[0];
360
- // No unescaped link/bold/code syntax survives into the body.
361
- expect(call?.body).not.toContain("[here](http://evil)");
362
- expect(call?.body).not.toContain("**now**");
363
- expect(call?.body).toContain("\\[here\\]");
364
- expect(call?.body).toContain("\\*\\*now\\*\\*");
358
+ const body = bodyOf();
359
+ expect(body).toContain("[the status page](https://example.com/s/1)");
360
+ expect(body).toContain("**now**");
361
+ expect(body).not.toContain("\\[the status page\\]");
365
362
  });
366
363
 
367
364
  it("strips non-whitespace control characters (ESC/NUL/BEL/DEL)", async () => {
@@ -377,42 +374,12 @@ describe("notifyAffectedSystems", () => {
377
374
  updateMessage: "before\u001B\u0000\u0007\u007Fafter",
378
375
  });
379
376
 
380
- const call = (
381
- mockNotificationClient.notifyForSubscription.mock
382
- .calls[0] as unknown as [{ body?: string }]
383
- )[0];
384
- const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
385
- expect(blockquoteLine).toBe("beforeafter");
386
- // No control characters survive into the excerpt.
387
- expect(/[\u0000-\u001F\u007F-\u009F]/u.test(blockquoteLine)).toBe(
388
- false,
389
- );
390
- });
391
-
392
- it("escapes HTML-significant < and & so markup cannot be injected", async () => {
393
- await notifyAffectedSystems({
394
- catalogClient: mockCatalogClient as never,
395
- notificationClient: mockNotificationClient as never,
396
- logger: mockLogger as never,
397
- incidentId: "inc-1",
398
- incidentTitle: "API Outage",
399
- systemIds: ["sys-1"],
400
- action: "updated",
401
- severity: "minor",
402
- updateMessage: "watch <img onerror=x> & <script>",
403
- });
404
-
405
- const call = (
406
- mockNotificationClient.notifyForSubscription.mock
407
- .calls[0] as unknown as [{ body?: string }]
408
- )[0];
409
- const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
410
- expect(blockquoteLine).not.toContain("<");
411
- expect(blockquoteLine).toContain("&lt;img");
412
- expect(blockquoteLine).toContain("&amp;");
377
+ const body = bodyOf();
378
+ expect(body).toContain("\n\nbeforeafter");
379
+ expect(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/u.test(body.split("\n\n").at(-1) ?? "")).toBe(false);
413
380
  });
414
381
 
415
- it("collapses newlines so the message cannot break out of the blockquote", async () => {
382
+ it("preserves multi-line structure instead of collapsing to one line", async () => {
416
383
  await notifyAffectedSystems({
417
384
  catalogClient: mockCatalogClient as never,
418
385
  notificationClient: mockNotificationClient as never,
@@ -422,20 +389,13 @@ describe("notifyAffectedSystems", () => {
422
389
  systemIds: ["sys-1"],
423
390
  action: "updated",
424
391
  severity: "minor",
425
- updateMessage: "line one\n\nline two\ninjected",
392
+ updateMessage: "- rolled back\n- monitoring",
426
393
  });
427
394
 
428
- const call = (
429
- mockNotificationClient.notifyForSubscription.mock
430
- .calls[0] as unknown as [{ body?: string }]
431
- )[0];
432
- const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
433
- expect(blockquoteLine).not.toContain("\n");
434
- expect(blockquoteLine).toBe("line one line two injected");
395
+ expect(bodyOf()).toContain("\n\n- rolled back\n- monitoring");
435
396
  });
436
397
 
437
398
  it("truncates an over-long message to a bounded length", async () => {
438
- const longMessage = "a".repeat(1000);
439
399
  await notifyAffectedSystems({
440
400
  catalogClient: mockCatalogClient as never,
441
401
  notificationClient: mockNotificationClient as never,
@@ -445,20 +405,15 @@ describe("notifyAffectedSystems", () => {
445
405
  systemIds: ["sys-1"],
446
406
  action: "updated",
447
407
  severity: "minor",
448
- updateMessage: longMessage,
408
+ updateMessage: "a".repeat(1000),
449
409
  });
450
410
 
451
- const call = (
452
- mockNotificationClient.notifyForSubscription.mock
453
- .calls[0] as unknown as [{ body?: string }]
454
- )[0];
455
- const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
456
- expect(blockquoteLine.endsWith("...")).toBe(true);
457
- // 500 chars + the "..." indicator.
458
- expect(blockquoteLine.length).toBeLessThanOrEqual(503);
411
+ const block = bodyOf().split("\n\n").at(-1) ?? "";
412
+ expect(block.endsWith("...")).toBe(true);
413
+ expect(block.length).toBeLessThanOrEqual(503);
459
414
  });
460
415
 
461
- it("omits the blockquote entirely for a blank/whitespace message", async () => {
416
+ it("appends no extra block for a blank/whitespace message", async () => {
462
417
  await notifyAffectedSystems({
463
418
  catalogClient: mockCatalogClient as never,
464
419
  notificationClient: mockNotificationClient as never,
@@ -471,14 +426,11 @@ describe("notifyAffectedSystems", () => {
471
426
  updateMessage: " \n ",
472
427
  });
473
428
 
474
- const call = (
475
- mockNotificationClient.notifyForSubscription.mock
476
- .calls[0] as unknown as [{ body?: string }]
477
- )[0];
478
- expect(call?.body).not.toContain("\n\n>");
429
+ // No suffix -> the body is a single paragraph with no block separator.
430
+ expect(bodyOf().includes("\n\n")).toBe(false);
479
431
  });
480
432
 
481
- it("omits the blockquote when no message is provided", async () => {
433
+ it("appends nothing when no message is provided", async () => {
482
434
  await notifyAffectedSystems({
483
435
  catalogClient: mockCatalogClient as never,
484
436
  notificationClient: mockNotificationClient as never,
@@ -490,11 +442,7 @@ describe("notifyAffectedSystems", () => {
490
442
  severity: "minor",
491
443
  });
492
444
 
493
- const call = (
494
- mockNotificationClient.notifyForSubscription.mock
495
- .calls[0] as unknown as [{ body?: string }]
496
- )[0];
497
- expect(call?.body).not.toContain("\n\n>");
445
+ expect(bodyOf().includes("\n\n")).toBe(false);
498
446
  });
499
447
  });
500
448
 
@@ -519,3 +467,64 @@ describe("notifyAffectedSystems", () => {
519
467
  });
520
468
  });
521
469
  });
470
+
471
+ /**
472
+ * The REAL notification body build, not just the converter.
473
+ *
474
+ * `update-message.test.ts` proves the sanitizer strips the mention scheme and
475
+ * `mention-leak.test.ts` proves each channel converter is faithful. Neither
476
+ * proves that THIS function actually routes the update message through the
477
+ * sanitizer - a caller that interpolated `updateMessage` directly would pass
478
+ * both of those suites and still ship the internal URI to every subscriber.
479
+ */
480
+ describe("notifyAffectedSystems does not leak the mention scheme", () => {
481
+ const bodyOf = (client: ReturnType<typeof createMockNotificationClient>) => {
482
+ const payload = (
483
+ client.notifyForSubscription.mock.calls[0] as unknown[] | undefined
484
+ )?.[0] as { body?: string } | undefined;
485
+ return payload?.body ?? "";
486
+ };
487
+
488
+ it("flattens a mention in the update message to its label", async () => {
489
+ const mockCatalog = createMockCatalogClient();
490
+ const mockNotify = createMockNotificationClient();
491
+ const logger = createMockLogger();
492
+
493
+ await notifyAffectedSystems({
494
+ catalogClient: mockCatalog as never,
495
+ notificationClient: mockNotify as never,
496
+ logger: logger as never,
497
+ incidentId: "inc-1",
498
+ incidentTitle: "Checkout errors",
499
+ systemIds: ["sys-1"],
500
+ action: "updated",
501
+ severity: "major",
502
+ updateMessage:
503
+ "Rolled back. See [Database upgrade](checkstack:maintenance/9f1c-abc).",
504
+ });
505
+
506
+ const body = bodyOf(mockNotify);
507
+ expect(body).not.toContain("checkstack:");
508
+ expect(body).toContain("Database upgrade");
509
+ });
510
+
511
+ it("keeps an ordinary link in the update message intact", async () => {
512
+ const mockCatalog = createMockCatalogClient();
513
+ const mockNotify = createMockNotificationClient();
514
+ const logger = createMockLogger();
515
+
516
+ await notifyAffectedSystems({
517
+ catalogClient: mockCatalog as never,
518
+ notificationClient: mockNotify as never,
519
+ logger: logger as never,
520
+ incidentId: "inc-1",
521
+ incidentTitle: "Checkout errors",
522
+ systemIds: ["sys-1"],
523
+ action: "updated",
524
+ severity: "major",
525
+ updateMessage: "See [the runbook](https://example.com/rb).",
526
+ });
527
+
528
+ expect(bodyOf(mockNotify)).toContain("https://example.com/rb");
529
+ });
530
+ });
@@ -71,11 +71,18 @@ function buildRouter() {
71
71
  PRESENT.has(input.id) ? makeIncident(input.id) : undefined,
72
72
  );
73
73
 
74
+ // Existence only - the READ post-filter is the platform middleware's job,
75
+ // which is exactly what the resolveIncidentRefs tests below prove.
76
+ const findExistingIncidentIds = mock(async (ids: string[]) =>
77
+ ids.filter((id) => PRESENT.has(id)),
78
+ );
79
+
74
80
  const service = {
75
81
  getIncident,
76
82
  deleteIncident,
77
83
  resolveIncident,
78
84
  updateIncident,
85
+ findExistingIncidentIds,
79
86
  } as unknown as Parameters<typeof createRouter>[0]["service"];
80
87
 
81
88
  const invalidateForMutation = mock(async () => {});
@@ -114,6 +121,7 @@ function buildRouter() {
114
121
  deleteIncident,
115
122
  resolveIncident,
116
123
  updateIncident,
124
+ findExistingIncidentIds,
117
125
  emit,
118
126
  invalidateForMutation,
119
127
  notifyForSubscription,
@@ -247,3 +255,104 @@ describe("incident router bulkResolveIncidents", () => {
247
255
  expect(resolvedIds).toContain("inc-ok");
248
256
  });
249
257
  });
258
+
259
+ /**
260
+ * `resolveIncidentRefs` backs viewability-aware mention rendering: a `#`
261
+ * reference becomes a link only when the reader may actually open the target.
262
+ *
263
+ * These drive the REAL procedure through `call`, so the contract's
264
+ * `listKey: "incidents"` post-filter runs. That matters more than the handler
265
+ * body - the handler only establishes existence, and it is the middleware that
266
+ * turns "exists" into "and you may read it". A regression that dropped the
267
+ * `listKey` declaration would leave the handler passing and silently link every
268
+ * incident in the estate to a team-scoped reader.
269
+ */
270
+ describe("incident router resolveIncidentRefs", () => {
271
+ it("returns only ids the team-scoped caller may READ", async () => {
272
+ const { router } = buildRouter();
273
+ const ctx = teamScopedContext(["inc-ok"]);
274
+
275
+ const { incidents } = await call(
276
+ router.resolveIncidentRefs,
277
+ { ids: ["inc-ok", "inc-throw"] },
278
+ { context: ctx },
279
+ );
280
+
281
+ // Both exist; only "inc-ok" is granted.
282
+ expect(incidents).toEqual([{ id: "inc-ok" }]);
283
+ });
284
+
285
+ it("omits an id that does not exist, even when granted", async () => {
286
+ // A reference to a DELETED incident must not become a link either.
287
+ const { router } = buildRouter();
288
+ const ctx = teamScopedContext(["inc-ok", "inc-missing"]);
289
+
290
+ const { incidents } = await call(
291
+ router.resolveIncidentRefs,
292
+ { ids: ["inc-ok", "inc-missing"] },
293
+ { context: ctx },
294
+ );
295
+
296
+ expect(incidents).toEqual([{ id: "inc-ok" }]);
297
+ });
298
+
299
+ it("makes an unreadable incident indistinguishable from a missing one", async () => {
300
+ // Both come back as simple absence - no error, no title, nothing that
301
+ // confirms the unreadable incident exists.
302
+ const { router } = buildRouter();
303
+ const ctx = teamScopedContext([]);
304
+
305
+ const { incidents } = await call(
306
+ router.resolveIncidentRefs,
307
+ { ids: ["inc-ok", "inc-missing"] },
308
+ { context: ctx },
309
+ );
310
+
311
+ expect(incidents).toEqual([]);
312
+ });
313
+
314
+ it("discloses nothing beyond the id", async () => {
315
+ // The label already lives in the authored markdown, so the response has no
316
+ // reason to carry titles - and must not, since it is reached by anyone who
317
+ // can read ANY incident.
318
+ const { router } = buildRouter();
319
+ const ctx = teamScopedContext(["inc-ok"]);
320
+
321
+ const { incidents } = await call(
322
+ router.resolveIncidentRefs,
323
+ { ids: ["inc-ok"] },
324
+ { context: ctx },
325
+ );
326
+
327
+ expect(Object.keys(incidents[0] ?? {})).toEqual(["id"]);
328
+ });
329
+
330
+ it("accepts an empty id list without querying", async () => {
331
+ const { router, findExistingIncidentIds } = buildRouter();
332
+ const ctx = teamScopedContext(["inc-ok"]);
333
+
334
+ const { incidents } = await call(
335
+ router.resolveIncidentRefs,
336
+ { ids: [] },
337
+ { context: ctx },
338
+ );
339
+
340
+ expect(incidents).toEqual([]);
341
+ expect(findExistingIncidentIds).toHaveBeenCalledWith([]);
342
+ });
343
+
344
+ it("rejects an id list beyond the contract's bound", async () => {
345
+ // The input is reachable by any reader, so the batch size is capped rather
346
+ // than letting one request probe the whole estate.
347
+ const { router } = buildRouter();
348
+ const ctx = teamScopedContext(["inc-ok"]);
349
+
350
+ await expect(
351
+ call(
352
+ router.resolveIncidentRefs,
353
+ { ids: Array.from({ length: 201 }, (_, i) => `inc-${i}`) },
354
+ { context: ctx },
355
+ ),
356
+ ).rejects.toThrow();
357
+ });
358
+ });
package/src/router.ts CHANGED
@@ -249,6 +249,15 @@ export function createRouter({
249
249
  };
250
250
  }),
251
251
 
252
+ resolveIncidentRefs: os.resolveIncidentRefs.handler(async ({ input }) => {
253
+ // Existence is checked here; READ permission is applied by the
254
+ // contract's `listKey: "incidents"` post-filter on the way out. An id
255
+ // that fails either test is simply absent from the response, so a
256
+ // deleted incident and an unreadable one look identical to the caller.
257
+ const ids = await service.findExistingIncidentIds(input.ids);
258
+ return { incidents: ids.map((id) => ({ id })) };
259
+ }),
260
+
252
261
  getIncident: os.getIncident.handler(async ({ input, context }) => {
253
262
  const result = await cache.wrapIncident(input.id, () =>
254
263
  service.getIncident(input.id),
@@ -349,5 +349,83 @@ describe.skipIf(!process.env.CHECKSTACK_IT)(
349
349
  forA.find((i) => i.id === "inc-1")?.systemIds.toSorted(),
350
350
  ).toEqual(["sys-a", "sys-b"]);
351
351
  });
352
+
353
+ /**
354
+ * `findExistingIncidentIds` backs viewability-aware mention rendering. It
355
+ * is a real set-based `inArray` read, so the SQL is worth proving against
356
+ * a database - the mocked-db unit tests only cover the router around it.
357
+ */
358
+ describe("findExistingIncidentIds", () => {
359
+ it("returns only the ids that exist", async () => {
360
+ await insertIncident({
361
+ id: "inc-1",
362
+ status: "investigating",
363
+ systemIds: ["sys-a"],
364
+ });
365
+
366
+ const found = await service.findExistingIncidentIds([
367
+ "inc-1",
368
+ "inc-deleted",
369
+ ]);
370
+
371
+ expect(found).toEqual(["inc-1"]);
372
+ });
373
+
374
+ it("returns nothing for an empty input WITHOUT hitting the database", async () => {
375
+ // `inArray(col, [])` is invalid SQL in Postgres, so the empty case has
376
+ // to short-circuit rather than build a query.
377
+ expect(await service.findExistingIncidentIds([])).toEqual([]);
378
+ });
379
+
380
+ it("returns nothing when no id exists", async () => {
381
+ expect(
382
+ await service.findExistingIncidentIds(["nope-1", "nope-2"]),
383
+ ).toEqual([]);
384
+ });
385
+
386
+ it("de-duplicates a repeated id", async () => {
387
+ await insertIncident({
388
+ id: "inc-1",
389
+ status: "investigating",
390
+ systemIds: ["sys-a"],
391
+ });
392
+
393
+ expect(
394
+ await service.findExistingIncidentIds(["inc-1", "inc-1", "inc-1"]),
395
+ ).toEqual(["inc-1"]);
396
+ });
397
+
398
+ it("finds a RESOLVED incident, which the browse list hides", async () => {
399
+ // The whole reason this is not a filter over `listIncidents`: that list
400
+ // excludes resolved incidents by default, so deriving viewability from
401
+ // it would silently downgrade a valid reference to plain text.
402
+ await insertIncident({
403
+ id: "inc-resolved",
404
+ status: "resolved",
405
+ systemIds: ["sys-a"],
406
+ });
407
+
408
+ expect(
409
+ await service.findExistingIncidentIds(["inc-resolved"]),
410
+ ).toEqual(["inc-resolved"]);
411
+ });
412
+
413
+ it("handles a large batch in one round trip", async () => {
414
+ for (let i = 0; i < 50; i++) {
415
+ await insertIncident({
416
+ id: `bulk-${i}`,
417
+ status: "investigating",
418
+ systemIds: ["sys-a"],
419
+ });
420
+ }
421
+
422
+ const asked = Array.from({ length: 200 }, (_, i) => `bulk-${i}`);
423
+ const found = await service.findExistingIncidentIds(asked);
424
+
425
+ expect(found.toSorted()).toEqual(
426
+ Array.from({ length: 50 }, (_, i) => `bulk-${i}`).toSorted(),
427
+ );
428
+ });
429
+ });
352
430
  },
353
431
  );
package/src/service.ts CHANGED
@@ -155,6 +155,30 @@ export class IncidentService {
155
155
  return byIncident;
156
156
  }
157
157
 
158
+ /**
159
+ * Which of these incident ids EXIST?
160
+ *
161
+ * The read-permission half of the answer is applied by the contract's
162
+ * `listKey` post-filter, so this only has to establish existence - a
163
+ * reference to a deleted incident must not become a link either.
164
+ *
165
+ * Selects the id column alone: the caller (viewability-aware mention
166
+ * rendering) needs nothing else, and fetching titles here would put the
167
+ * titles of not-yet-filtered incidents into memory for no reason.
168
+ */
169
+ async findExistingIncidentIds(ids: string[]): Promise<string[]> {
170
+ if (ids.length === 0) return [];
171
+
172
+ const rows = await withScopedTransaction(this.db, (tx) =>
173
+ tx
174
+ .select({ id: incidents.id })
175
+ .from(incidents)
176
+ .where(inArray(incidents.id, [...new Set(ids)])),
177
+ );
178
+
179
+ return rows.map((row) => row.id);
180
+ }
181
+
158
182
  /**
159
183
  * Get single incident with full details
160
184
  */
@@ -62,17 +62,36 @@ interface IncidentFixture {
62
62
  systemIds: string[];
63
63
  createdAt: string;
64
64
  updatedAt: string;
65
+ description?: string;
66
+ }
67
+
68
+ interface UpdateFixture {
69
+ message: string;
70
+ statusChange?: string;
71
+ createdAt: string;
72
+ /** "public" | "logged_in" | "internal" — only public reaches the page. */
73
+ visibility: string;
74
+ /** Internal author identity that must NEVER reach the public detail page. */
75
+ createdBy?: string;
76
+ createdByName?: string;
65
77
  }
66
78
 
67
79
  function makeCtx(args: {
68
80
  publishedEnvironmentIds?: string[];
69
81
  incidents?: IncidentFixture[];
82
+ /** incidentId -> its full internal update timeline (the bulk-fetch source). */
83
+ updatesById?: Record<string, UpdateFixture[]>;
70
84
  /** environmentId -> member system ids (the catalog env->systems mapping). */
71
85
  envSystems?: Record<string, string[]>;
72
86
  systems?: Array<{ id: string; name: string }>;
73
87
  }): WidgetResolveContext {
74
- const { publishedEnvironmentIds, incidents = [], envSystems = {}, systems = [] } =
75
- args;
88
+ const {
89
+ publishedEnvironmentIds,
90
+ incidents = [],
91
+ updatesById = {},
92
+ envSystems = {},
93
+ systems = [],
94
+ } = args;
76
95
  const memo = new Map<string, Promise<unknown>>();
77
96
  const api = {
78
97
  resolveEnvironments: async ({
@@ -92,7 +111,15 @@ function makeCtx(args: {
92
111
  getGroups: async () => [],
93
112
  getSystems: async () => ({ systems }),
94
113
  listIncidents: async () => ({ incidents }),
95
- getBulkIncidentUpdates: async () => ({ updates: {} }),
114
+ getBulkIncidentUpdates: async ({
115
+ incidentIds,
116
+ }: {
117
+ incidentIds: string[];
118
+ }) => {
119
+ const updates: Record<string, UpdateFixture[]> = {};
120
+ for (const id of incidentIds) updates[id] = updatesById[id] ?? [];
121
+ return { updates };
122
+ },
96
123
  };
97
124
  return {
98
125
  rpcClient: {
@@ -196,3 +223,92 @@ describe("incidents widget — environment filtering", () => {
196
223
  expect(result.incidents[0]?.systems).toEqual(["Prod System"]);
197
224
  });
198
225
  });
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // resolveDetail — the individual incident page. Unlike the summary block, it
229
+ // returns ALL public updates (never capped by `maxUpdates`) and the incident's
230
+ // description (Items 4/5/6), while keeping the same scope gate + createdBy strip.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ describe("incidents widget — resolveDetail (full detail page)", () => {
234
+ const systems = [{ id: "s1", name: "System One" }];
235
+ const incident: IncidentFixture = {
236
+ id: "inc-1",
237
+ title: "API outage",
238
+ status: "monitoring",
239
+ severity: "major",
240
+ systemIds: ["s1"],
241
+ createdAt: "2026-07-01T00:00:00Z",
242
+ updatedAt: "2026-07-02T00:00:00Z",
243
+ description: "Full **markdown** postmortem body.",
244
+ };
245
+ // Five public updates + one internal one. The block caps to maxUpdates (1);
246
+ // the detail page must return every PUBLIC update and drop the internal one.
247
+ const updatesById = {
248
+ "inc-1": [
249
+ { message: "u1 public", statusChange: "investigating", createdAt: "2026-07-01T01:00:00Z", visibility: "public", createdBy: "op-1", createdByName: "Alice" },
250
+ { message: "u2 public", statusChange: "identified", createdAt: "2026-07-01T02:00:00Z", visibility: "public" },
251
+ { message: "u3 internal only", createdAt: "2026-07-01T02:30:00Z", visibility: "internal" },
252
+ { message: "u4 public", statusChange: "fixing", createdAt: "2026-07-01T03:00:00Z", visibility: "public" },
253
+ { message: "u5 public", createdAt: "2026-07-01T04:00:00Z", visibility: "public" },
254
+ { message: "u6 public", statusChange: "monitoring", createdAt: "2026-07-01T05:00:00Z", visibility: "public" },
255
+ ],
256
+ };
257
+
258
+ test("returns ALL public updates (ignoring the block's maxUpdates cap) + description", async () => {
259
+ const widget = capture();
260
+ const detail = (await widget.resolveDetail!({
261
+ id: "inc-1",
262
+ // maxUpdates: 1 would cap the BLOCK to one update; the detail page ignores it.
263
+ config: { systemIds: ["s1"], maxUpdates: 1 },
264
+ ctx: makeCtx({ incidents: [incident], updatesById, systems }),
265
+ })) as {
266
+ description?: string;
267
+ updates: Array<{ message: string; statusChange?: string }>;
268
+ } | null;
269
+ if (!detail) throw new Error("expected detail");
270
+ // All FIVE public updates, most-recent first; the internal one is dropped.
271
+ expect(detail.updates.map((u) => u.message)).toEqual([
272
+ "u6 public",
273
+ "u5 public",
274
+ "u4 public",
275
+ "u2 public",
276
+ "u1 public",
277
+ ]);
278
+ // createdBy / createdByName never reach the public DTO.
279
+ expect(JSON.stringify(detail.updates)).not.toContain("Alice");
280
+ expect(detail.description).toBe("Full **markdown** postmortem body.");
281
+ });
282
+
283
+ test("returns null for an id the page does not surface (anti-enumeration gate)", async () => {
284
+ const widget = capture();
285
+ const detail = await widget.resolveDetail!({
286
+ id: "some-other-incident",
287
+ config: { systemIds: ["s1"] },
288
+ ctx: makeCtx({ incidents: [incident], updatesById, systems }),
289
+ });
290
+ expect(detail).toBeNull();
291
+ });
292
+
293
+ test("returns null when nothing is bound (fail closed, no read)", async () => {
294
+ const widget = capture();
295
+ expect(
296
+ await widget.resolveDetail!({ id: "inc-1", config: {}, ctx: noReadCtx }),
297
+ ).toBeNull();
298
+ });
299
+
300
+ test("omits description when the incident has none", async () => {
301
+ const widget = capture();
302
+ const detail = (await widget.resolveDetail!({
303
+ id: "inc-1",
304
+ config: { systemIds: ["s1"] },
305
+ ctx: makeCtx({
306
+ incidents: [{ ...incident, description: undefined }],
307
+ updatesById,
308
+ systems,
309
+ }),
310
+ })) as { description?: string } | null;
311
+ if (!detail) throw new Error("expected detail");
312
+ expect(detail.description).toBeUndefined();
313
+ });
314
+ });
@@ -1,9 +1,13 @@
1
1
  import { CatalogApi, assertCatalogResourcesReadable } from "@checkstack/catalog-common";
2
- import { IncidentApi } from "@checkstack/incident-common";
2
+ import {
3
+ IncidentApi,
4
+ INCIDENT_MENTION_TYPE,
5
+ } from "@checkstack/incident-common";
3
6
  import {
4
7
  pluginMetadata as statusPagePluginMetadata,
5
8
  IncidentsConfigSchema,
6
9
  IncidentsDtoSchema,
10
+ IncidentDtoItemSchema,
7
11
  toPublicUpdate,
8
12
  selectEvents,
9
13
  resolveEventFeedScope,
@@ -84,18 +88,26 @@ function iso(value: string | Date): string {
84
88
  return value instanceof Date ? value.toISOString() : String(value);
85
89
  }
86
90
 
87
- /** Newest `max` updates, most-recent first (the current progress at the top). */
88
- function latestUpdates(updates: InternalUpdate[], max: number): PublicUpdate[] {
89
- return updates
91
+ /**
92
+ * Public updates, most-recent first (the current progress at the top). `max`
93
+ * caps the list for the summary BLOCK; omit it (the detail page) to return ALL
94
+ * updates.
95
+ */
96
+ function latestUpdates(
97
+ updates: InternalUpdate[],
98
+ max?: number,
99
+ ): PublicUpdate[] {
100
+ const sorted = updates
90
101
  // The public status page is anonymous: only `public`-visibility updates may
91
102
  // appear. `logged_in` / `internal` updates are filtered out here so they
92
103
  // never reach the unauthenticated projection (Item 3/5).
93
104
  .filter((u) => u.visibility === "public")
94
105
  .toSorted(
95
106
  (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
96
- )
97
- .slice(0, max)
98
- .map((u) => toPublicUpdate(u));
107
+ );
108
+ return (max === undefined ? sorted : sorted.slice(0, max)).map((u) =>
109
+ toPublicUpdate(u),
110
+ );
99
111
  }
100
112
 
101
113
  async function labelsFor(
@@ -118,6 +130,10 @@ async function labelsFor(
118
130
  const incidents: WidgetTypeDefinition = {
119
131
  id: "incidents",
120
132
  displayName: "Incidents",
133
+ // Lets a public page turn a `#` reference to an incident into a link to its
134
+ // public detail page - but only for an incident THIS page surfaces, which is
135
+ // the same gate `resolveDetail` below enforces.
136
+ mentionType: INCIDENT_MENTION_TYPE,
121
137
  description: "Recent unresolved incidents with their update timeline.",
122
138
  category: "Events",
123
139
  binding: "systems",
@@ -219,6 +235,41 @@ const incidents: WidgetTypeDefinition = {
219
235
  });
220
236
  return IncidentsDtoSchema.parse({ incidents: items });
221
237
  },
238
+ // Full detail for the individual incident page: the ONE incident's ALL public
239
+ // updates (no `maxUpdates` cap) + its description. Scope-checked the same way
240
+ // as resolvePublic; returns null for an out-of-scope / unknown id.
241
+ async resolveDetail({ id, config, ctx }) {
242
+ const c = IncidentsConfigSchema.parse(config);
243
+ const bound = await effectiveScope(c, ctx);
244
+ if (bound.size === 0) return null;
245
+ const inc = ctx.rpcClient.forPlugin(IncidentApi);
246
+ // includeResolved so a resolved incident's page still loads; the status-page
247
+ // gate has already confirmed this id is surfaced by the page.
248
+ const { incidents: all } = await inc.listIncidents({ includeResolved: true });
249
+ const found = all.find(
250
+ (i) => i.id === id && i.systemIds.some((s) => bound.has(s)),
251
+ );
252
+ if (!found) return null;
253
+ const names = await labelsFor(ctx, [...bound]);
254
+ const labelOf = (sid: string): string | undefined =>
255
+ bound.has(sid) ? (c.systemLabels[sid] ?? names.get(sid) ?? sid) : undefined;
256
+ const bulk = await inc.getBulkIncidentUpdates({ incidentIds: [id] });
257
+ const updates = latestUpdates(bulk.updates?.[id] ?? []);
258
+ const resolved = found.status === "resolved";
259
+ return IncidentDtoItemSchema.parse({
260
+ id: found.id,
261
+ title: found.title,
262
+ status: found.status,
263
+ severity: found.severity,
264
+ systems: found.systemIds
265
+ .map((sid) => labelOf(sid))
266
+ .filter((l): l is string => l !== undefined),
267
+ startedAt: iso(found.createdAt),
268
+ ...(resolved ? { resolvedAt: iso(found.updatedAt) } : {}),
269
+ ...(found.description ? { description: found.description } : {}),
270
+ updates,
271
+ });
272
+ },
222
273
  };
223
274
 
224
275
  /** Register the incident-owned status-page widget under the `statuspage.*` namespace. */