@checkstack/incident-backend 1.13.5 → 1.13.6

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,104 @@
1
1
  # @checkstack/incident-backend
2
2
 
3
+ ## 1.13.6
4
+
5
+ ### Patch Changes
6
+
7
+ - be74b01: Render markdown in incident/maintenance update messages instead of escaping it
8
+
9
+ Thanks to @stuajnht for reporting: an email notification showed the raw
10
+ markdown of a link (`[text](url)`) instead of a clickable link. The report
11
+ placed the bug in the email renderer, but the email path was fine - it already
12
+ runs `markdownToHtml`. The damage happened upstream.
13
+
14
+ The shared `sanitizeUpdateMessage` (in `notification-common`, used by both the
15
+ incident and maintenance backends to embed the latest update in a notification
16
+ body) backslash-escaped every markdown control character and forced the message
17
+ onto a single line inside a blockquote. So a `[label](href)` link arrived as
18
+ `\[label\]\(href\)` and rendered as literal text in every channel - exactly the
19
+ symptom reported.
20
+
21
+ Update messages are authored as markdown and render as markdown on the web, so
22
+ they now do the same in notifications. `sanitizeUpdateMessage` still normalizes
23
+ the text (strips non-whitespace control characters, normalizes line endings,
24
+ collapses runs of blank lines, bounds the length) but no longer escapes the
25
+ markdown, and `buildUpdateMessageSuffix` appends it as its own multi-line
26
+ markdown block rather than a single-line blockquote. Links, emphasis, code, and
27
+ lists now render.
28
+
29
+ This does not weaken safety. The only strategy that emits HTML is SMTP, via
30
+ `markdownToHtml`, whose email-safe allow-list drops `<script>`, `on*=`
31
+ handlers, and `javascript:`/`data:` URLs; every other strategy renders markdown
32
+ / mrkdwn / an adaptive card or flattens to plain text, none of which execute
33
+ HTML. Source-side escaping was redundant with that renderer sanitization for
34
+ the security goal while destroying legitimate formatting. The notification
35
+ title and the incident/maintenance descriptions were already interpolated into
36
+ the body unescaped, so this brings the update message in line with them.
37
+
38
+ - be74b01: Fix status-page detail-page content and status colouring
39
+
40
+ Thanks to @stuajnht for reporting several public status-page issues (the
41
+ announcement-block fix is a separate changeset):
42
+
43
+ - **Incident/maintenance status text was uncoloured and inline.** Update-timeline
44
+ status changes rendered in the muted grey `text-muted-foreground`, making the
45
+ status hard to tell apart from the message. The status change now sits on its
46
+ own line, coloured by its lifecycle (a new `incidentStatusTone`/`Label` mirrors
47
+ the incident status enum the way `maintenanceStatusTone` mirrors maintenance),
48
+ on both the summary block and the detail pages. The detail-page incident status
49
+ pill next to the title is now coloured too (was a neutral grey pill).
50
+
51
+ - **Detail pages showed raw markdown.** Individual incident/maintenance pages
52
+ rendered the update body as the raw source string; they now render sanitized
53
+ markdown via `<Markdown>`, like the block.
54
+
55
+ - **Detail pages showed only a few updates.** The individual pages reused the
56
+ summary block DTO, so they inherited the block's `maxUpdates` cap. Widget types
57
+ now expose an optional `resolveDetail` that returns the ONE item with ALL its
58
+ public updates (no cap) and its long-form description; the incident and
59
+ maintenance widgets implement it, and the status-page backend's
60
+ `resolvePublishedIncident`/`resolvePublishedMaintenance` call it. The detail
61
+ page is gated by the SAME anti-enumeration boundary as the block (the widget's
62
+ live scope), and the result is re-validated against the widget's item DTO, so
63
+ it fails closed exactly like the block.
64
+
65
+ - **Maintenance detail page showed no description.** The maintenance item DTO
66
+ gained an optional `description`, emitted by `resolveDetail` and rendered as
67
+ markdown on the detail page (the incident detail page already had the field
68
+ plumbed and now renders it too).
69
+
70
+ Note on maintenance/grey systems (also reported): the maintenance BLOCK already
71
+ colours scheduled windows blue. On the SYSTEM-HEALTH widget the blue
72
+ "maintenance" tone is applied only while a window is actively `in_progress`; a
73
+ future scheduled window leaves the system on its live health, and a system with
74
+ no health data reads grey "unknown". That is deliberate and left as-is.
75
+
76
+ - Updated dependencies [be74b01]
77
+ - Updated dependencies [be74b01]
78
+ - Updated dependencies [be74b01]
79
+ - Updated dependencies [be74b01]
80
+ - Updated dependencies [be74b01]
81
+ - Updated dependencies [be74b01]
82
+ - Updated dependencies [be74b01]
83
+ - Updated dependencies [be74b01]
84
+ - Updated dependencies [be74b01]
85
+ - Updated dependencies [be74b01]
86
+ - Updated dependencies [be74b01]
87
+ - Updated dependencies [be74b01]
88
+ - Updated dependencies [be74b01]
89
+ - @checkstack/ai-backend@0.11.4
90
+ - @checkstack/notification-common@1.8.0
91
+ - @checkstack/status-page-backend@0.6.6
92
+ - @checkstack/status-page-common@0.6.5
93
+ - @checkstack/auth-common@0.16.0
94
+ - @checkstack/automation-backend@0.11.8
95
+ - @checkstack/catalog-backend@1.10.1
96
+ - @checkstack/catalog-common@2.8.1
97
+ - @checkstack/incident-common@1.10.5
98
+ - @checkstack/backend-api@0.34.1
99
+ - @checkstack/command-backend@0.2.27
100
+ - @checkstack/integration-backend@0.7.9
101
+
3
102
  ## 1.13.5
4
103
 
5
104
  ### 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.13.6",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,23 +14,23 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.11.3",
17
+ "@checkstack/ai-backend": "0.11.4",
18
18
  "@checkstack/ai-common": "0.6.7",
19
- "@checkstack/backend-api": "0.34.0",
19
+ "@checkstack/backend-api": "0.34.1",
20
20
  "@checkstack/cache-api": "0.3.20",
21
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",
22
+ "@checkstack/incident-common": "1.10.5",
23
+ "@checkstack/catalog-common": "2.8.1",
24
+ "@checkstack/catalog-backend": "1.10.1",
25
+ "@checkstack/notification-common": "1.8.0",
26
+ "@checkstack/auth-common": "0.16.0",
27
+ "@checkstack/command-backend": "0.2.27",
28
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",
29
+ "@checkstack/status-page-backend": "0.6.6",
30
+ "@checkstack/status-page-common": "0.6.5",
31
+ "@checkstack/integration-backend": "0.7.9",
32
32
  "@checkstack/integration-common": "0.9.10",
33
- "@checkstack/automation-backend": "0.11.7",
33
+ "@checkstack/automation-backend": "0.11.8",
34
34
  "@checkstack/automation-common": "0.10.2",
35
35
  "@checkstack/common": "0.23.0",
36
36
  "drizzle-orm": "^0.45.0",
@@ -41,7 +41,7 @@
41
41
  "devDependencies": {
42
42
  "@checkstack/drizzle-helper": "0.0.6",
43
43
  "@checkstack/scripts": "0.7.6",
44
- "@checkstack/test-utils-backend": "0.1.60",
44
+ "@checkstack/test-utils-backend": "0.1.61",
45
45
  "@checkstack/tsconfig": "0.0.7",
46
46
  "@types/bun": "^1.0.0",
47
47
  "@types/pg": "^8.20.0",
@@ -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,19 +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
- );
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);
390
380
  });
391
381
 
392
- it("escapes HTML-significant < and & so markup cannot be injected", async () => {
382
+ it("preserves multi-line structure instead of collapsing to one line", async () => {
393
383
  await notifyAffectedSystems({
394
384
  catalogClient: mockCatalogClient as never,
395
385
  notificationClient: mockNotificationClient as never,
@@ -399,43 +389,13 @@ describe("notifyAffectedSystems", () => {
399
389
  systemIds: ["sys-1"],
400
390
  action: "updated",
401
391
  severity: "minor",
402
- updateMessage: "watch <img onerror=x> & <script>",
392
+ updateMessage: "- rolled back\n- monitoring",
403
393
  });
404
394
 
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;");
413
- });
414
-
415
- it("collapses newlines so the message cannot break out of the blockquote", async () => {
416
- await notifyAffectedSystems({
417
- catalogClient: mockCatalogClient as never,
418
- notificationClient: mockNotificationClient as never,
419
- logger: mockLogger as never,
420
- incidentId: "inc-1",
421
- incidentTitle: "API Outage",
422
- systemIds: ["sys-1"],
423
- action: "updated",
424
- severity: "minor",
425
- updateMessage: "line one\n\nline two\ninjected",
426
- });
427
-
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
 
@@ -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
+ });
@@ -4,6 +4,7 @@ import {
4
4
  pluginMetadata as statusPagePluginMetadata,
5
5
  IncidentsConfigSchema,
6
6
  IncidentsDtoSchema,
7
+ IncidentDtoItemSchema,
7
8
  toPublicUpdate,
8
9
  selectEvents,
9
10
  resolveEventFeedScope,
@@ -84,18 +85,26 @@ function iso(value: string | Date): string {
84
85
  return value instanceof Date ? value.toISOString() : String(value);
85
86
  }
86
87
 
87
- /** Newest `max` updates, most-recent first (the current progress at the top). */
88
- function latestUpdates(updates: InternalUpdate[], max: number): PublicUpdate[] {
89
- return updates
88
+ /**
89
+ * Public updates, most-recent first (the current progress at the top). `max`
90
+ * caps the list for the summary BLOCK; omit it (the detail page) to return ALL
91
+ * updates.
92
+ */
93
+ function latestUpdates(
94
+ updates: InternalUpdate[],
95
+ max?: number,
96
+ ): PublicUpdate[] {
97
+ const sorted = updates
90
98
  // The public status page is anonymous: only `public`-visibility updates may
91
99
  // appear. `logged_in` / `internal` updates are filtered out here so they
92
100
  // never reach the unauthenticated projection (Item 3/5).
93
101
  .filter((u) => u.visibility === "public")
94
102
  .toSorted(
95
103
  (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
96
- )
97
- .slice(0, max)
98
- .map((u) => toPublicUpdate(u));
104
+ );
105
+ return (max === undefined ? sorted : sorted.slice(0, max)).map((u) =>
106
+ toPublicUpdate(u),
107
+ );
99
108
  }
100
109
 
101
110
  async function labelsFor(
@@ -219,6 +228,41 @@ const incidents: WidgetTypeDefinition = {
219
228
  });
220
229
  return IncidentsDtoSchema.parse({ incidents: items });
221
230
  },
231
+ // Full detail for the individual incident page: the ONE incident's ALL public
232
+ // updates (no `maxUpdates` cap) + its description. Scope-checked the same way
233
+ // as resolvePublic; returns null for an out-of-scope / unknown id.
234
+ async resolveDetail({ id, config, ctx }) {
235
+ const c = IncidentsConfigSchema.parse(config);
236
+ const bound = await effectiveScope(c, ctx);
237
+ if (bound.size === 0) return null;
238
+ const inc = ctx.rpcClient.forPlugin(IncidentApi);
239
+ // includeResolved so a resolved incident's page still loads; the status-page
240
+ // gate has already confirmed this id is surfaced by the page.
241
+ const { incidents: all } = await inc.listIncidents({ includeResolved: true });
242
+ const found = all.find(
243
+ (i) => i.id === id && i.systemIds.some((s) => bound.has(s)),
244
+ );
245
+ if (!found) return null;
246
+ const names = await labelsFor(ctx, [...bound]);
247
+ const labelOf = (sid: string): string | undefined =>
248
+ bound.has(sid) ? (c.systemLabels[sid] ?? names.get(sid) ?? sid) : undefined;
249
+ const bulk = await inc.getBulkIncidentUpdates({ incidentIds: [id] });
250
+ const updates = latestUpdates(bulk.updates?.[id] ?? []);
251
+ const resolved = found.status === "resolved";
252
+ return IncidentDtoItemSchema.parse({
253
+ id: found.id,
254
+ title: found.title,
255
+ status: found.status,
256
+ severity: found.severity,
257
+ systems: found.systemIds
258
+ .map((sid) => labelOf(sid))
259
+ .filter((l): l is string => l !== undefined),
260
+ startedAt: iso(found.createdAt),
261
+ ...(resolved ? { resolvedAt: iso(found.updatedAt) } : {}),
262
+ ...(found.description ? { description: found.description } : {}),
263
+ updates,
264
+ });
265
+ },
222
266
  };
223
267
 
224
268
  /** Register the incident-owned status-page widget under the `statuspage.*` namespace. */