@checkstack/incident-backend 1.13.4 → 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 +136 -0
- package/package.json +22 -22
- package/src/notifications.test.ts +30 -82
- package/src/status-page-widget.test.ts +119 -3
- package/src/status-page-widget.ts +50 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,141 @@
|
|
|
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
|
+
|
|
102
|
+
## 1.13.5
|
|
103
|
+
|
|
104
|
+
### Patch Changes
|
|
105
|
+
|
|
106
|
+
- Updated dependencies [6c8b36b]
|
|
107
|
+
- Updated dependencies [6c8b36b]
|
|
108
|
+
- Updated dependencies [6c8b36b]
|
|
109
|
+
- Updated dependencies [6c8b36b]
|
|
110
|
+
- Updated dependencies [6c8b36b]
|
|
111
|
+
- Updated dependencies [6c8b36b]
|
|
112
|
+
- Updated dependencies [6c8b36b]
|
|
113
|
+
- Updated dependencies [6c8b36b]
|
|
114
|
+
- Updated dependencies [6c8b36b]
|
|
115
|
+
- Updated dependencies [6c8b36b]
|
|
116
|
+
- Updated dependencies [6c8b36b]
|
|
117
|
+
- Updated dependencies [6c8b36b]
|
|
118
|
+
- Updated dependencies [6c8b36b]
|
|
119
|
+
- @checkstack/auth-common@0.15.0
|
|
120
|
+
- @checkstack/ai-backend@0.11.3
|
|
121
|
+
- @checkstack/backend-api@0.34.0
|
|
122
|
+
- @checkstack/catalog-common@2.8.0
|
|
123
|
+
- @checkstack/common@0.23.0
|
|
124
|
+
- @checkstack/catalog-backend@1.10.0
|
|
125
|
+
- @checkstack/status-page-backend@0.6.5
|
|
126
|
+
- @checkstack/automation-backend@0.11.7
|
|
127
|
+
- @checkstack/command-backend@0.2.26
|
|
128
|
+
- @checkstack/integration-backend@0.7.8
|
|
129
|
+
- @checkstack/incident-common@1.10.4
|
|
130
|
+
- @checkstack/status-page-common@0.6.4
|
|
131
|
+
- @checkstack/ai-common@0.6.7
|
|
132
|
+
- @checkstack/automation-common@0.10.2
|
|
133
|
+
- @checkstack/cache-api@0.3.20
|
|
134
|
+
- @checkstack/integration-common@0.9.10
|
|
135
|
+
- @checkstack/notification-common@1.7.2
|
|
136
|
+
- @checkstack/signal-common@0.3.1
|
|
137
|
+
- @checkstack/cache-utils@0.3.1
|
|
138
|
+
|
|
3
139
|
## 1.13.4
|
|
4
140
|
|
|
5
141
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/incident-backend",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.6",
|
|
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.
|
|
18
|
-
"@checkstack/ai-common": "0.6.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/cache-api": "0.3.
|
|
21
|
-
"@checkstack/cache-utils": "0.3.
|
|
22
|
-
"@checkstack/incident-common": "1.10.
|
|
23
|
-
"@checkstack/catalog-common": "2.
|
|
24
|
-
"@checkstack/catalog-backend": "1.
|
|
25
|
-
"@checkstack/notification-common": "1.
|
|
26
|
-
"@checkstack/auth-common": "0.
|
|
27
|
-
"@checkstack/command-backend": "0.2.
|
|
28
|
-
"@checkstack/signal-common": "0.3.
|
|
29
|
-
"@checkstack/status-page-backend": "0.6.
|
|
30
|
-
"@checkstack/status-page-common": "0.6.
|
|
31
|
-
"@checkstack/integration-backend": "0.7.
|
|
32
|
-
"@checkstack/integration-common": "0.9.
|
|
33
|
-
"@checkstack/automation-backend": "0.11.
|
|
34
|
-
"@checkstack/automation-common": "0.10.
|
|
35
|
-
"@checkstack/common": "0.
|
|
17
|
+
"@checkstack/ai-backend": "0.11.4",
|
|
18
|
+
"@checkstack/ai-common": "0.6.7",
|
|
19
|
+
"@checkstack/backend-api": "0.34.1",
|
|
20
|
+
"@checkstack/cache-api": "0.3.20",
|
|
21
|
+
"@checkstack/cache-utils": "0.3.1",
|
|
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
|
+
"@checkstack/signal-common": "0.3.1",
|
|
29
|
+
"@checkstack/status-page-backend": "0.6.6",
|
|
30
|
+
"@checkstack/status-page-common": "0.6.5",
|
|
31
|
+
"@checkstack/integration-backend": "0.7.9",
|
|
32
|
+
"@checkstack/integration-common": "0.9.10",
|
|
33
|
+
"@checkstack/automation-backend": "0.11.8",
|
|
34
|
+
"@checkstack/automation-common": "0.10.2",
|
|
35
|
+
"@checkstack/common": "0.23.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.
|
|
44
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
43
|
+
"@checkstack/scripts": "0.7.6",
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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("
|
|
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 [
|
|
355
|
+
updateMessage: "See [the status page](https://example.com/s/1) **now**",
|
|
354
356
|
});
|
|
355
357
|
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
)[
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
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("
|
|
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: "
|
|
392
|
+
updateMessage: "- rolled back\n- monitoring",
|
|
403
393
|
});
|
|
404
394
|
|
|
405
|
-
|
|
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("<img");
|
|
412
|
-
expect(blockquoteLine).toContain("&");
|
|
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:
|
|
408
|
+
updateMessage: "a".repeat(1000),
|
|
449
409
|
});
|
|
450
410
|
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
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("
|
|
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
|
-
|
|
475
|
-
|
|
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("
|
|
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
|
-
|
|
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 {
|
|
75
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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. */
|