@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 +195 -0
- package/package.json +22 -22
- package/src/index.ts +7 -0
- package/src/notifications.test.ts +91 -82
- package/src/router.test.ts +109 -0
- package/src/router.ts +9 -0
- package/src/service-reads.it.test.ts +78 -0
- package/src/service.ts +24 -0
- package/src/status-page-widget.test.ts +119 -3
- package/src/status-page-widget.ts +58 -7
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.
|
|
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.
|
|
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.
|
|
23
|
-
"@checkstack/catalog-common": "2.8.
|
|
24
|
-
"@checkstack/catalog-backend": "1.10.
|
|
25
|
-
"@checkstack/notification-common": "1.
|
|
26
|
-
"@checkstack/auth-common": "0.
|
|
27
|
-
"@checkstack/command-backend": "0.
|
|
28
|
-
"@checkstack/signal-common": "0.3.
|
|
29
|
-
"@checkstack/status-page-backend": "0.
|
|
30
|
-
"@checkstack/status-page-common": "0.
|
|
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.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.
|
|
44
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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
|
-
|
|
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,42 +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
|
-
);
|
|
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("<img");
|
|
412
|
-
expect(blockquoteLine).toContain("&");
|
|
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("
|
|
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: "
|
|
392
|
+
updateMessage: "- rolled back\n- monitoring",
|
|
426
393
|
});
|
|
427
394
|
|
|
428
|
-
|
|
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
|
|
|
@@ -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
|
+
});
|
package/src/router.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { CatalogApi, assertCatalogResourcesReadable } from "@checkstack/catalog-common";
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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. */
|