@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 +78 -0
- package/package.json +7 -7
- package/src/access.ts +2 -0
- package/src/index.ts +4 -0
- package/src/signals.test.ts +97 -0
- package/src/signals.ts +86 -0
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.
|
|
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.
|
|
13
|
-
"@checkstack/catalog-common": "2.3.
|
|
14
|
-
"@checkstack/frontend-api": "0.
|
|
15
|
-
"@checkstack/notification-common": "1.3.
|
|
16
|
-
"@checkstack/signal-common": "0.2.
|
|
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.
|
|
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
|
+
}
|