@checkstack/incident-backend 1.13.6 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,101 @@
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
+
3
99
  ## 1.13.6
4
100
 
5
101
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.13.6",
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.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",
17
+ "@checkstack/ai-backend": "0.11.5",
18
+ "@checkstack/ai-common": "0.6.8",
19
+ "@checkstack/backend-api": "0.35.0",
20
+ "@checkstack/cache-api": "0.3.21",
21
+ "@checkstack/cache-utils": "0.3.2",
22
+ "@checkstack/incident-common": "1.11.0",
23
+ "@checkstack/catalog-common": "2.8.2",
24
+ "@checkstack/catalog-backend": "1.10.2",
25
+ "@checkstack/notification-common": "1.9.0",
26
+ "@checkstack/auth-common": "0.17.0",
27
+ "@checkstack/command-backend": "0.3.0",
28
+ "@checkstack/signal-common": "0.3.2",
29
+ "@checkstack/status-page-backend": "0.7.0",
30
+ "@checkstack/status-page-common": "0.7.0",
31
+ "@checkstack/integration-backend": "0.7.10",
32
+ "@checkstack/integration-common": "0.9.11",
33
+ "@checkstack/automation-backend": "0.11.9",
34
+ "@checkstack/automation-common": "0.10.3",
35
+ "@checkstack/common": "0.24.0",
36
36
  "drizzle-orm": "^0.45.0",
37
37
  "zod": "^4.2.1",
38
38
  "@orpc/contract": "^1.14.4",
@@ -40,8 +40,8 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@checkstack/drizzle-helper": "0.0.6",
43
- "@checkstack/scripts": "0.7.6",
44
- "@checkstack/test-utils-backend": "0.1.61",
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
  });
@@ -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
+ });
@@ -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 { IncidentApi } from "@checkstack/incident-common";
2
+ import {
3
+ IncidentApi,
4
+ INCIDENT_MENTION_TYPE,
5
+ } from "@checkstack/incident-common";
3
6
  import {
4
7
  pluginMetadata as statusPagePluginMetadata,
5
8
  IncidentsConfigSchema,
@@ -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",