@checkstack/incident-backend 1.13.6 → 1.14.1
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 +112 -0
- package/package.json +22 -22
- package/src/index.ts +7 -0
- package/src/notifications.test.ts +61 -0
- 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.ts +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,117 @@
|
|
|
1
1
|
# @checkstack/incident-backend
|
|
2
2
|
|
|
3
|
+
## 1.14.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [c38551f]
|
|
8
|
+
- @checkstack/ai-backend@0.11.6
|
|
9
|
+
- @checkstack/automation-backend@0.11.10
|
|
10
|
+
- @checkstack/catalog-backend@1.10.3
|
|
11
|
+
- @checkstack/catalog-common@2.8.3
|
|
12
|
+
- @checkstack/incident-common@1.11.1
|
|
13
|
+
- @checkstack/status-page-common@0.7.1
|
|
14
|
+
- @checkstack/backend-api@0.35.1
|
|
15
|
+
- @checkstack/status-page-backend@0.7.1
|
|
16
|
+
- @checkstack/command-backend@0.3.1
|
|
17
|
+
- @checkstack/integration-backend@0.7.11
|
|
18
|
+
|
|
19
|
+
## 1.14.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- 88f4333: Resolve `#` mentions on public status pages, and check viewability in the admin UI
|
|
24
|
+
|
|
25
|
+
Cross-entity mentions previously resolved only in the admin UI, and did so
|
|
26
|
+
without asking whether the reader could actually open the target. Public
|
|
27
|
+
surfaces resolved nothing at all. Three changes, one per delivery context.
|
|
28
|
+
|
|
29
|
+
**The admin UI now checks viewability.** `useMentionResolution({ documents })`
|
|
30
|
+
collects the references a page is about to render and asks each owning plugin -
|
|
31
|
+
in ONE batched request - which of them this viewer may read. A mention to a
|
|
32
|
+
deleted or unreadable record now renders as plain text instead of a link to a
|
|
33
|
+
not-found page or an access gate. Backed by new `resolveIncidentRefs` /
|
|
34
|
+
`resolveMaintenanceRefs` procedures, which return ids only (so an unreadable
|
|
35
|
+
record is indistinguishable from a deleted one) and carry the same `listKey`
|
|
36
|
+
read post-filter as their list procedures. They are deliberately not a filter
|
|
37
|
+
over the authoring search list, which hides resolved incidents and would
|
|
38
|
+
silently downgrade valid references.
|
|
39
|
+
|
|
40
|
+
**Public status pages now resolve mentions.** A reference becomes a link to the
|
|
41
|
+
target's public detail page when - and only when - the same page publishes that
|
|
42
|
+
target, which is exactly the anti-enumeration gate the detail pages already
|
|
43
|
+
apply. So an operator writing "caused by #Database upgrade" in a public update
|
|
44
|
+
gets a working link, while a mention of an internal-only incident stays plain
|
|
45
|
+
text rather than becoming a link that confirms it exists. Widgets opt in by
|
|
46
|
+
declaring a `mentionType`, so the status-page packages take no dependency on any
|
|
47
|
+
domain plugin.
|
|
48
|
+
|
|
49
|
+
**BREAKING CHANGE (behavioural, no API change):** the in-app public status page
|
|
50
|
+
at `/statuspage/view/<slug>` now builds detail-page hrefs. Previously it passed
|
|
51
|
+
none, so incident and maintenance titles rendered as plain text there while the
|
|
52
|
+
same page on a custom domain linked them. Both now behave identically.
|
|
53
|
+
|
|
54
|
+
**Notification bodies no longer leak the internal scheme.** `checkstack:` is
|
|
55
|
+
meaningless outside a Checkstack renderer, and channels leaked it differently:
|
|
56
|
+
the email sanitiser stripped the href and left a dead anchor, while Slack's
|
|
57
|
+
mrkdwn emitted `<checkstack:maintenance/9f1c-abc|Database upgrade>` straight to
|
|
58
|
+
the recipient (Discord, Telegram and Teams render markdown natively and would
|
|
59
|
+
have passed it through too). `sanitizeUpdateMessage` now flattens every mention
|
|
60
|
+
to its label before the body reaches any channel, so no channel has to know the
|
|
61
|
+
scheme exists. Flattening also happens before the length bound, so the excerpt
|
|
62
|
+
budget is spent on visible text rather than on an internal URI.
|
|
63
|
+
|
|
64
|
+
### Patch Changes
|
|
65
|
+
|
|
66
|
+
- 88f4333: Show command-palette actions to team-scoped users
|
|
67
|
+
|
|
68
|
+
The palette filtered commands against the caller's GLOBAL access rules only, so a
|
|
69
|
+
user whose team holds a create-capability grant - but who holds no global
|
|
70
|
+
`incident.incident.manage` / `maintenance.maintenance.manage` rule - never saw
|
|
71
|
+
"Create Incident" or "Create Maintenance", nor their keyboard shortcuts. The
|
|
72
|
+
palette hid the actions from exactly the people authorized to run them.
|
|
73
|
+
|
|
74
|
+
Commands can now declare a `manageCapability` (mirroring the gate routes and nav
|
|
75
|
+
already use). `filterByAccessRules` shows an item when the caller holds the
|
|
76
|
+
global rules OR can create/manage the declared type through a team grant, and the
|
|
77
|
+
command backend resolves that per request via `hasAnyTypeGrant` (with
|
|
78
|
+
`includeCreator`, so a team member who may CREATE the type qualifies before
|
|
79
|
+
owning an instance). It fails closed: an auth error leaves pure global gating.
|
|
80
|
+
The incident and maintenance commands declare their types.
|
|
81
|
+
|
|
82
|
+
`useGlobalShortcuts` no longer takes `userAccessRules` and no longer re-checks
|
|
83
|
+
access: the server-filtered list is authoritative. That re-check tested the
|
|
84
|
+
global rules only and would have dropped team-scoped users' shortcuts - both call
|
|
85
|
+
sites already defeated it by passing `["*"]`.
|
|
86
|
+
|
|
87
|
+
- Updated dependencies [88f4333]
|
|
88
|
+
- Updated dependencies [1deaac5]
|
|
89
|
+
- Updated dependencies [88f4333]
|
|
90
|
+
- Updated dependencies [88f4333]
|
|
91
|
+
- Updated dependencies [88f4333]
|
|
92
|
+
- Updated dependencies [88f4333]
|
|
93
|
+
- Updated dependencies [1deaac5]
|
|
94
|
+
- Updated dependencies [56e5375]
|
|
95
|
+
- @checkstack/common@0.24.0
|
|
96
|
+
- @checkstack/auth-common@0.17.0
|
|
97
|
+
- @checkstack/status-page-common@0.7.0
|
|
98
|
+
- @checkstack/incident-common@1.11.0
|
|
99
|
+
- @checkstack/automation-common@0.10.3
|
|
100
|
+
- @checkstack/command-backend@0.3.0
|
|
101
|
+
- @checkstack/status-page-backend@0.7.0
|
|
102
|
+
- @checkstack/notification-common@1.9.0
|
|
103
|
+
- @checkstack/ai-backend@0.11.5
|
|
104
|
+
- @checkstack/backend-api@0.35.0
|
|
105
|
+
- @checkstack/automation-backend@0.11.9
|
|
106
|
+
- @checkstack/ai-common@0.6.8
|
|
107
|
+
- @checkstack/cache-api@0.3.21
|
|
108
|
+
- @checkstack/catalog-backend@1.10.2
|
|
109
|
+
- @checkstack/catalog-common@2.8.2
|
|
110
|
+
- @checkstack/integration-backend@0.7.10
|
|
111
|
+
- @checkstack/integration-common@0.9.11
|
|
112
|
+
- @checkstack/signal-common@0.3.2
|
|
113
|
+
- @checkstack/cache-utils@0.3.2
|
|
114
|
+
|
|
3
115
|
## 1.13.6
|
|
4
116
|
|
|
5
117
|
### 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.1",
|
|
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.6",
|
|
18
|
+
"@checkstack/ai-common": "0.6.8",
|
|
19
|
+
"@checkstack/backend-api": "0.35.1",
|
|
20
|
+
"@checkstack/cache-api": "0.3.21",
|
|
21
|
+
"@checkstack/cache-utils": "0.3.2",
|
|
22
|
+
"@checkstack/incident-common": "1.11.1",
|
|
23
|
+
"@checkstack/catalog-common": "2.8.3",
|
|
24
|
+
"@checkstack/catalog-backend": "1.10.3",
|
|
25
|
+
"@checkstack/notification-common": "1.9.0",
|
|
26
|
+
"@checkstack/auth-common": "0.17.0",
|
|
27
|
+
"@checkstack/command-backend": "0.3.1",
|
|
28
|
+
"@checkstack/signal-common": "0.3.2",
|
|
29
|
+
"@checkstack/status-page-backend": "0.7.1",
|
|
30
|
+
"@checkstack/status-page-common": "0.7.1",
|
|
31
|
+
"@checkstack/integration-backend": "0.7.11",
|
|
32
|
+
"@checkstack/integration-common": "0.9.11",
|
|
33
|
+
"@checkstack/automation-backend": "0.11.10",
|
|
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.63",
|
|
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
|
});
|
|
@@ -467,3 +467,64 @@ describe("notifyAffectedSystems", () => {
|
|
|
467
467
|
});
|
|
468
468
|
});
|
|
469
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
|
*/
|
|
@@ -1,5 +1,8 @@
|
|
|
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,
|
|
@@ -127,6 +130,10 @@ async function labelsFor(
|
|
|
127
130
|
const incidents: WidgetTypeDefinition = {
|
|
128
131
|
id: "incidents",
|
|
129
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,
|
|
130
137
|
description: "Recent unresolved incidents with their update timeline.",
|
|
131
138
|
category: "Events",
|
|
132
139
|
binding: "systems",
|