@checkstack/incident-common 1.4.3 → 1.5.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,83 @@
1
1
  # @checkstack/incident-common
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0b6f01b: feat(incident): contribute incident signals to the backend system.issues aggregator
8
+
9
+ The incident plugin now registers a `system.issues` contributor (sourceId
10
+ `incident`) from its backend `init`, so the AI assistant surfaces open incidents
11
+ alongside SLOs, health checks, anomalies, and dependency problems.
12
+
13
+ The contributor enforces its own `incident.read` access gate (returning an empty
14
+ map - never throwing - when the principal lacks access; service users carry no
15
+ access rules and so get no signals), then reads every OPEN (not-resolved)
16
+ incident for all systems from the shared, durable `incidents` +
17
+ `incident_systems` tables via a new global `listOpenIncidentsBySystem` service
18
+ method. The answer is therefore identical on every pod, and only systems with an
19
+ open incident appear in the result.
20
+
21
+ The row->signal mapping (source/tone/label/detail/href/accessRule/since/iconName)
22
+ is extracted into a new pure `deriveIncidentSignals` deriver in
23
+ `@checkstack/incident-common`, shared by both the backend contributor and the
24
+ frontend `IncidentSignalsFiller` so the two surfaces stay in lockstep. The
25
+ frontend filler now delegates to that deriver with unchanged behavior.
26
+
27
+ ## 1.4.4
28
+
29
+ ### Patch Changes
30
+
31
+ - 56e7c75: Fix frontend access checks to use FULLY-QUALIFIED access-rule ids, and resolve
32
+ the anonymous role on the frontend.
33
+
34
+ Granted access-rule ids are stored fully-qualified as `{pluginId}.{ruleId}` (e.g.
35
+ `incident.incident.read`) so two plugins defining the same short rule id never
36
+ collide. The frontend, however, was checking the UNqualified id (`incident.read`)
37
+ via `isAccessRuleSatisfied`, so every check failed for any user without the `*`
38
+ (admin) grant - masked in development because dev-auth grants `*`. This silently
39
+ broke ALL non-admin frontend gating (route guards, sidebar entries, and
40
+ `useAccess`-based button/link gating).
41
+
42
+ - **`@checkstack/common`**: `AccessRule` now carries a REQUIRED owning `pluginId`;
43
+ `access()` / `accessPair()` require and stamp it; `isAccessRuleSatisfied`
44
+ qualifies the rule (`{pluginId}.{id}`, plus the manage->read escalation) and
45
+ matches ONLY the qualified form. There is intentionally NO unqualified fallback
46
+ - matching a bare id would let one plugin's grant satisfy another plugin's
47
+ identically-named rule (a cross-plugin privilege-escalation flaw). Every plugin
48
+ that defines access rules now passes its own `pluginId`.
49
+ - **`@checkstack/backend`**: `pluginManager.getAllAccessRules()` no longer strips
50
+ the `pluginId` field (the rule `id` is already fully-qualified for the DB sync).
51
+ - **Route guard** (`@checkstack/frontend` / `@checkstack/frontend-api`) now
52
+ checks the FULL rule object (so it qualifies and escalates), not a bare id.
53
+ - **Anonymous role on the frontend**: the `accessRules` procedure is now
54
+ `public`, returning the configurable anonymous role's grants to unauthenticated
55
+ callers; `useAccessRules` fetches them for guests instead of returning an empty
56
+ set. So anonymous UI now reflects exactly what the anonymous role is allowed -
57
+ which an admin can change (`isPublic` is only the seeded default).
58
+ - Incident / maintenance / SLO detail routes are now read-gated (their read rule
59
+ is an `isPublic` default, so the anonymous role holds it unless an admin
60
+ revokes it); their dashboard status signals carry that rule and render as a
61
+ link only when the viewer may open it.
62
+
63
+ **BREAKING (`@checkstack/common`):** `AccessRule.pluginId` is now REQUIRED, and
64
+ `access()` / `accessPair()` require a `pluginId` option. `isAccessRuleSatisfied`
65
+ matches ONLY the fully-qualified `{pluginId}.{ruleId}` form - the previous
66
+ unqualified fallback is removed, because it was a cross-plugin
67
+ privilege-escalation flaw. Any code constructing an `AccessRule` or calling
68
+ `access()`/`accessPair()` must supply the owning `pluginId`.
69
+
70
+ Verified live against an anonymous caller: read pages resolve (qualified match),
71
+ manage actions are denied, manage->read escalation and `*` still work.
72
+
73
+ - Updated dependencies [56e7c75]
74
+ - Updated dependencies [56e7c75]
75
+ - @checkstack/frontend-api@0.9.0
76
+ - @checkstack/catalog-common@2.3.4
77
+ - @checkstack/common@0.15.0
78
+ - @checkstack/notification-common@1.3.3
79
+ - @checkstack/signal-common@0.2.9
80
+
3
81
  ## 1.4.3
4
82
 
5
83
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-common",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,18 +9,18 @@
9
9
  }
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/common": "0.14.1",
13
- "@checkstack/catalog-common": "2.3.3",
14
- "@checkstack/frontend-api": "0.8.0",
15
- "@checkstack/notification-common": "1.3.2",
16
- "@checkstack/signal-common": "0.2.8",
12
+ "@checkstack/common": "0.15.0",
13
+ "@checkstack/catalog-common": "2.3.4",
14
+ "@checkstack/frontend-api": "0.9.0",
15
+ "@checkstack/notification-common": "1.3.3",
16
+ "@checkstack/signal-common": "0.2.9",
17
17
  "@orpc/contract": "^1.14.4",
18
18
  "zod": "^4.2.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^5.7.2",
22
22
  "@checkstack/tsconfig": "0.0.7",
23
- "@checkstack/scripts": "0.6.0"
23
+ "@checkstack/scripts": "0.6.1"
24
24
  },
25
25
  "scripts": {
26
26
  "typecheck": "tsgo -b",
package/src/access.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { accessPair } from "@checkstack/common";
2
+ import { pluginMetadata } from "./plugin-metadata";
2
3
 
3
4
  /**
4
5
  * Access rules for the Incident plugin.
@@ -26,6 +27,7 @@ export const incidentAccess = {
26
27
  },
27
28
  {
28
29
  idParam: "systemId",
30
+ pluginId: pluginMetadata.pluginId,
29
31
  },
30
32
  ),
31
33
  };
package/src/index.ts CHANGED
@@ -29,6 +29,10 @@ export {
29
29
  type AddIncidentUpdateInput,
30
30
  } from "./schemas";
31
31
  export { IncidentDetailsSlot, IncidentStatusSlot } from "./slots";
32
+ export {
33
+ INCIDENT_SIGNAL_SOURCE_ID,
34
+ deriveIncidentSignals,
35
+ } from "./signals";
32
36
  export * from "./plugin-metadata";
33
37
  export * from "./notifications";
34
38
  export { incidentRoutes } from "./routes";
@@ -0,0 +1,97 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ INCIDENT_SIGNAL_SOURCE_ID,
4
+ deriveIncidentSignals,
5
+ } from "./signals";
6
+ import { incidentAccess } from "./access";
7
+ import type { IncidentWithSystems } from "./schemas";
8
+
9
+ const baseIncident = (
10
+ overrides: Partial<IncidentWithSystems>,
11
+ ): IncidentWithSystems => ({
12
+ id: "inc-1",
13
+ title: "Database down",
14
+ description: undefined,
15
+ status: "investigating",
16
+ severity: "critical",
17
+ suppressNotifications: false,
18
+ createdAt: new Date("2026-06-01T10:00:00.000Z"),
19
+ updatedAt: new Date("2026-06-01T10:00:00.000Z"),
20
+ systemIds: ["sys-1"],
21
+ ...overrides,
22
+ });
23
+
24
+ describe("deriveIncidentSignals", () => {
25
+ test("emits one error signal per open critical incident, keyed by system", () => {
26
+ const result = deriveIncidentSignals({
27
+ incidentsBySystem: {
28
+ "sys-1": [baseIncident({ id: "inc-1", severity: "critical" })],
29
+ },
30
+ systemIds: ["sys-1"],
31
+ });
32
+
33
+ expect(result["sys-1"]).toHaveLength(1);
34
+ expect(result["sys-1"][0]).toEqual({
35
+ source: INCIDENT_SIGNAL_SOURCE_ID,
36
+ tone: "error",
37
+ label: "Critical incident",
38
+ detail: "Database down",
39
+ href: "/incident/inc-1",
40
+ accessRule: incidentAccess.incident.read,
41
+ since: "2026-06-01T10:00:00.000Z",
42
+ iconName: "TriangleAlert",
43
+ });
44
+ });
45
+
46
+ test("maps severity to tone/label (major->warn, minor->info)", () => {
47
+ const result = deriveIncidentSignals({
48
+ incidentsBySystem: {
49
+ "sys-1": [
50
+ baseIncident({ id: "inc-major", severity: "major", title: "Slow" }),
51
+ ],
52
+ "sys-2": [
53
+ baseIncident({
54
+ id: "inc-minor",
55
+ severity: "minor",
56
+ title: "Cosmetic",
57
+ systemIds: ["sys-2"],
58
+ }),
59
+ ],
60
+ },
61
+ systemIds: ["sys-1", "sys-2"],
62
+ });
63
+
64
+ expect(result["sys-1"][0].tone).toBe("warn");
65
+ expect(result["sys-1"][0].label).toBe("Major incident");
66
+ expect(result["sys-2"][0].tone).toBe("info");
67
+ expect(result["sys-2"][0].label).toBe("Incident");
68
+ });
69
+
70
+ test("skips resolved incidents and systems with no open problem", () => {
71
+ const result = deriveIncidentSignals({
72
+ incidentsBySystem: {
73
+ "sys-1": [baseIncident({ id: "inc-1", status: "resolved" })],
74
+ "sys-2": [],
75
+ },
76
+ systemIds: ["sys-1", "sys-2", "sys-3"],
77
+ });
78
+
79
+ expect(result).toEqual({});
80
+ });
81
+
82
+ test("emits multiple signals for a system with multiple open incidents", () => {
83
+ const result = deriveIncidentSignals({
84
+ incidentsBySystem: {
85
+ "sys-1": [
86
+ baseIncident({ id: "inc-1", severity: "critical" }),
87
+ baseIncident({ id: "inc-2", severity: "minor", title: "Other" }),
88
+ ],
89
+ },
90
+ systemIds: ["sys-1"],
91
+ });
92
+
93
+ expect(result["sys-1"]).toHaveLength(2);
94
+ expect(result["sys-1"][0].tone).toBe("error");
95
+ expect(result["sys-1"][1].tone).toBe("info");
96
+ });
97
+ });
package/src/signals.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { resolveRoute } from "@checkstack/common";
2
+ import type {
3
+ SystemSignal,
4
+ SystemSignalsMap,
5
+ } from "@checkstack/catalog-common";
6
+ import { incidentAccess } from "./access";
7
+ import { incidentRoutes } from "./routes";
8
+ import type { IncidentWithSystems } from "./schemas";
9
+
10
+ /**
11
+ * Stable source id for incident-originated dashboard/system signals. Surfaced as
12
+ * `source` on every emitted {@link SystemSignal} and used as the contributor's
13
+ * `sourceId` in the backend `system.issues` aggregator. MUST stay in sync with
14
+ * both the frontend `SystemSignalsSlot` filler and the backend contributor.
15
+ */
16
+ export const INCIDENT_SIGNAL_SOURCE_ID = "incident";
17
+
18
+ /**
19
+ * Incident severity -> signal tone. "critical" is an active breach (`error`),
20
+ * "major" is at-risk/degraded (`warn`), "minor" is informational (`info`).
21
+ */
22
+ const severityToTone = {
23
+ critical: "error",
24
+ major: "warn",
25
+ minor: "info",
26
+ } as const;
27
+
28
+ /**
29
+ * Incident severity -> signal label shown in the dashboard row.
30
+ */
31
+ const severityToLabel = {
32
+ critical: "Critical incident",
33
+ major: "Major incident",
34
+ minor: "Incident",
35
+ } as const;
36
+
37
+ /**
38
+ * Pure, dependency-free deriver shared by the frontend `SystemSignalsSlot`
39
+ * filler and the backend `system.issues` contributor. Given the active
40
+ * incidents grouped by systemId, emit one {@link SystemSignal} per unresolved
41
+ * incident, keyed by systemId. Systems with no unresolved incident are omitted
42
+ * from the returned map (healthy as far as this source is concerned).
43
+ *
44
+ * Both callers run this SAME function so frontend and backend always produce
45
+ * identical signals (source/tone/label/detail/href/accessRule/since/iconName).
46
+ * The backend aggregator passes signals through and drops the frontend-only
47
+ * fields (href/accessRule/iconName); keeping them here lets the frontend render
48
+ * the deep-linking, permission-gated row unchanged.
49
+ */
50
+ export function deriveIncidentSignals({
51
+ incidentsBySystem,
52
+ systemIds,
53
+ }: {
54
+ /** Active incidents grouped by systemId (the bulk RPC / global query shape). */
55
+ incidentsBySystem: Record<string, IncidentWithSystems[]>;
56
+ /** Systems to consider, in iteration order. */
57
+ systemIds: readonly string[];
58
+ }): SystemSignalsMap {
59
+ const result: SystemSignalsMap = {};
60
+
61
+ for (const systemId of systemIds) {
62
+ const incidents = (incidentsBySystem[systemId] ?? []).filter(
63
+ (incident) => incident.status !== "resolved",
64
+ );
65
+ if (incidents.length === 0) continue;
66
+
67
+ result[systemId] = incidents.map((incident) => {
68
+ const signal: SystemSignal = {
69
+ source: INCIDENT_SIGNAL_SOURCE_ID,
70
+ tone: severityToTone[incident.severity],
71
+ label: severityToLabel[incident.severity],
72
+ detail: incident.title,
73
+ href: resolveRoute(incidentRoutes.routes.detail, {
74
+ incidentId: incident.id,
75
+ }),
76
+ // Detail page is read-gated; render as text for users without it.
77
+ accessRule: incidentAccess.incident.read,
78
+ since: new Date(incident.createdAt).toISOString(),
79
+ iconName: "TriangleAlert",
80
+ };
81
+ return signal;
82
+ });
83
+ }
84
+
85
+ return result;
86
+ }