@checkstack/incident-backend 1.9.5 → 1.11.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 +152 -0
- package/drizzle/0004_flaky_malcolm_colcord.sql +2 -0
- package/drizzle/meta/0004_snapshot.json +315 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +24 -22
- package/src/ai/incident-create.test.ts +1 -0
- package/src/ai/incident-remove-link.test.ts +5 -5
- package/src/ai/incident-remove-link.ts +9 -5
- package/src/ai/incident-resolve.test.ts +1 -0
- package/src/ai/incident-update.test.ts +1 -0
- package/src/cache.test.ts +2 -0
- package/src/router.ts +13 -1
- package/src/schema.ts +12 -0
- package/src/service.it.test.ts +130 -0
- package/src/service.test.ts +89 -0
- package/src/service.ts +63 -4
- package/src/system-signals.test.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,157 @@
|
|
|
1
1
|
# @checkstack/incident-backend
|
|
2
2
|
|
|
3
|
+
## 1.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f93ee7a: Fix a class of 403s where team-scoped managers were blocked from endpoints they
|
|
8
|
+
needed. A repo-wide audit of every `instanceAccess: { global: true }` procedure
|
|
9
|
+
found more instances of the same bug behind the health-check editor fix: an
|
|
10
|
+
endpoint on a team-scopable resource type, gated so only the GLOBAL access rule
|
|
11
|
+
(never a team grant) authorizes it.
|
|
12
|
+
|
|
13
|
+
Automation: the editor utilities and catalogs (`validateDefinition`,
|
|
14
|
+
`listTriggers`, `listActions`, `listArtifactTypes`, `listAutomationGroups`,
|
|
15
|
+
`listAutomationTemplates`, `renderTemplate`, `testScript`) now use `typeScoped`
|
|
16
|
+
so a team-scoped automation manager can author without the global rule. The run
|
|
17
|
+
endpoints (`listRuns`, `getRun`, `cancelRun`, `getRunScopeForReplay`) are scoped
|
|
18
|
+
to their parent automation via `parentScope` on `automationId`; `getRun`,
|
|
19
|
+
`cancelRun`, and `getRunScopeForReplay` now take the owning `automationId`
|
|
20
|
+
(always available in the run URL/editor) and the handler filters the run fetch by
|
|
21
|
+
it, so a run id cannot be paired with a foreign automation the caller happens to
|
|
22
|
+
hold a grant on. The two migration-admin endpoints stay `global: true` (genuine
|
|
23
|
+
platform-admin actions).
|
|
24
|
+
|
|
25
|
+
Health check: `validateConfiguration` (editor deep-validate) and
|
|
26
|
+
`getPlatformNotificationDefaults` (fetched on every assignment-editor mount) move
|
|
27
|
+
to `typeScoped`. The paired WRITE `setPlatformNotificationDefaults` stays
|
|
28
|
+
`global: true` on purpose - it rewrites instance-wide defaults for every team, so
|
|
29
|
+
a single team grant must not authorize it. Because that write stays global-only,
|
|
30
|
+
the assignment editor's "Notification defaults" button is now gated on the global
|
|
31
|
+
`configuration.manage` rule (`healthcheck-frontend`), so a team-scoped manager no
|
|
32
|
+
longer sees an editor whose Save always 403'd.
|
|
33
|
+
|
|
34
|
+
Anomaly: the anomaly settings panels embedded in the health-check editor
|
|
35
|
+
(`updateAnomalyConfig` / `getAnomalyConfig` and `updateAnomalyAssignmentConfig` /
|
|
36
|
+
`getAnomalyAssignmentConfig`) were authorized against the non-team-scopable
|
|
37
|
+
`anomaly_feed` type (via `global: true` or an `idParam` that could never match a
|
|
38
|
+
team grant), so a team-scoped manager who owns the check/system saw "Save
|
|
39
|
+
Defaults" / "Save Exceptions" buttons whose Save always 403'd. They now
|
|
40
|
+
`parentScope` on the owning health-check configuration (`healthcheck.healthcheck`)
|
|
41
|
+
and catalog system (`catalog.system`) respectively, so managing the check/system
|
|
42
|
+
authorizes reading and editing its anomaly settings. The frontend needed no
|
|
43
|
+
change: those buttons were already disabled for non-managers, and the panels are
|
|
44
|
+
only reachable inside the manager-gated editor. Also, the automation "New
|
|
45
|
+
automation" template picker (`automation-frontend`) gated its page on the bare
|
|
46
|
+
global manage rule; it now uses the create capability, so a team-scoped creator
|
|
47
|
+
(whom the route already reveals the page to) is no longer shown a blocked page.
|
|
48
|
+
|
|
49
|
+
Incident & maintenance: `removeLink` was `global: true` because its input carried
|
|
50
|
+
only the link id. It now takes the owning `incidentId` / `maintenanceId`
|
|
51
|
+
(mirroring `addLink`), authorizes per-instance via `idParam`, and the service
|
|
52
|
+
scopes the delete by that parent id so a link cannot be removed by pairing its id
|
|
53
|
+
with a different incident/maintenance the caller manages. The AI `removeLink`
|
|
54
|
+
tools carry the parent id too.
|
|
55
|
+
|
|
56
|
+
BREAKING CHANGES: `automation.getRun`, `automation.cancelRun`,
|
|
57
|
+
`automation.getRunScopeForReplay`, `incident.removeLink`, and
|
|
58
|
+
`maintenance.removeLink` now require a parent id (`automationId` /
|
|
59
|
+
`incidentId` / `maintenanceId`) in their input. Endpoints previously gated by a
|
|
60
|
+
global rule alone now also accept the owning team's grant; no endpoint became
|
|
61
|
+
more permissive for a user who lacks both the global rule and a relevant team
|
|
62
|
+
grant.
|
|
63
|
+
|
|
64
|
+
Not team-scopable, so intentionally left `global: true` (verified by the audit):
|
|
65
|
+
catalog environments, anomaly config, SLO list/streak/milestone reads and
|
|
66
|
+
health-check history/stats (their read rules are public/default), and every
|
|
67
|
+
hand-rolled HTTP route (global admin/infra or already team-aware).
|
|
68
|
+
|
|
69
|
+
### Patch Changes
|
|
70
|
+
|
|
71
|
+
- Updated dependencies [f93ee7a]
|
|
72
|
+
- Updated dependencies [f93ee7a]
|
|
73
|
+
- Updated dependencies [8aae4e2]
|
|
74
|
+
- Updated dependencies [d0eddc9]
|
|
75
|
+
- Updated dependencies [d0eddc9]
|
|
76
|
+
- Updated dependencies [f93ee7a]
|
|
77
|
+
- Updated dependencies [f93ee7a]
|
|
78
|
+
- Updated dependencies [d0eddc9]
|
|
79
|
+
- Updated dependencies [d0eddc9]
|
|
80
|
+
- Updated dependencies [d0eddc9]
|
|
81
|
+
- Updated dependencies [f93ee7a]
|
|
82
|
+
- @checkstack/common@0.22.0
|
|
83
|
+
- @checkstack/catalog-common@2.6.3
|
|
84
|
+
- @checkstack/ai-backend@0.10.9
|
|
85
|
+
- @checkstack/backend-api@0.31.0
|
|
86
|
+
- @checkstack/automation-common@0.10.0
|
|
87
|
+
- @checkstack/automation-backend@0.11.0
|
|
88
|
+
- @checkstack/incident-common@1.9.0
|
|
89
|
+
- @checkstack/auth-common@0.13.0
|
|
90
|
+
- @checkstack/ai-common@0.6.6
|
|
91
|
+
- @checkstack/cache-api@0.3.19
|
|
92
|
+
- @checkstack/catalog-backend@1.6.9
|
|
93
|
+
- @checkstack/command-backend@0.2.21
|
|
94
|
+
- @checkstack/integration-backend@0.7.3
|
|
95
|
+
- @checkstack/integration-common@0.9.8
|
|
96
|
+
- @checkstack/notification-common@1.5.3
|
|
97
|
+
- @checkstack/signal-common@0.2.17
|
|
98
|
+
- @checkstack/status-page-backend@0.4.8
|
|
99
|
+
- @checkstack/status-page-common@0.5.3
|
|
100
|
+
- @checkstack/cache-utils@0.2.24
|
|
101
|
+
|
|
102
|
+
## 1.10.0
|
|
103
|
+
|
|
104
|
+
### Minor Changes
|
|
105
|
+
|
|
106
|
+
- 9d30324: Incidents can now optionally override the health status of their affected
|
|
107
|
+
systems. When creating or editing an incident you can pick "Override system
|
|
108
|
+
health" (Degraded or Unhealthy); while the incident is active (not resolved)
|
|
109
|
+
that status is folded into every affected system's derived health via
|
|
110
|
+
worst-wins, so it shows on every health surface (status pages, dashboards,
|
|
111
|
+
dependency map, catalog badges). A health check reporting a worse status still
|
|
112
|
+
wins, and the override lifts automatically when the incident resolves. This
|
|
113
|
+
covers components that no automated check can monitor (e.g. a running app whose
|
|
114
|
+
licenses were revoked so it won't open).
|
|
115
|
+
|
|
116
|
+
The override is a deliberate operator choice, independent of the incident's
|
|
117
|
+
severity. A new service-typed incident RPC `getActiveHealthOverrides` exposes
|
|
118
|
+
active overrides per system, which `@checkstack/healthcheck-backend` reads and
|
|
119
|
+
folds into `getSystemHealthStatus`. The system-health response gains an optional
|
|
120
|
+
`override` field naming the contributing incident so UIs can explain why a
|
|
121
|
+
system reads unhealthy when its checks look fine. The system health badge uses
|
|
122
|
+
it to show, on hover, when a status was forced by an incident.
|
|
123
|
+
|
|
124
|
+
The dashboard "problem system" signal attributes an override-forced status to
|
|
125
|
+
the incident ("Forced by incident: <title>") instead of misreporting
|
|
126
|
+
"0 of N checks failing", while a genuinely worse health check still drives the
|
|
127
|
+
signal and its detail. Public status pages reflect the forced status but never
|
|
128
|
+
carry the incident title (the widget DTOs project only the status), so an
|
|
129
|
+
override cannot leak the name of a hidden incident.
|
|
130
|
+
|
|
131
|
+
Behavior change: a system's derived health now reflects active incident
|
|
132
|
+
overrides in addition to its health checks. Adds a forward-only migration for
|
|
133
|
+
the new nullable `incidents.health_override` column.
|
|
134
|
+
|
|
135
|
+
Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback
|
|
136
|
+
that shaped this release.
|
|
137
|
+
|
|
138
|
+
### Patch Changes
|
|
139
|
+
|
|
140
|
+
- Updated dependencies [390d9cf]
|
|
141
|
+
- Updated dependencies [390d9cf]
|
|
142
|
+
- Updated dependencies [fc64fad]
|
|
143
|
+
- Updated dependencies [9d30324]
|
|
144
|
+
- Updated dependencies [9d30324]
|
|
145
|
+
- Updated dependencies [b218e3e]
|
|
146
|
+
- @checkstack/ai-backend@0.10.8
|
|
147
|
+
- @checkstack/backend-api@0.30.0
|
|
148
|
+
- @checkstack/incident-common@1.8.0
|
|
149
|
+
- @checkstack/automation-backend@0.10.10
|
|
150
|
+
- @checkstack/catalog-backend@1.6.8
|
|
151
|
+
- @checkstack/command-backend@0.2.20
|
|
152
|
+
- @checkstack/integration-backend@0.7.2
|
|
153
|
+
- @checkstack/status-page-backend@0.4.7
|
|
154
|
+
|
|
3
155
|
## 1.9.5
|
|
4
156
|
|
|
5
157
|
### Patch Changes
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "c158fa99-0e1b-452f-85d3-6343af3349e1",
|
|
3
|
+
"prevId": "1d8635bb-6fc2-4dfb-bc27-794bfb92b6fc",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.incident_links": {
|
|
8
|
+
"name": "incident_links",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"id": {
|
|
12
|
+
"name": "id",
|
|
13
|
+
"type": "text",
|
|
14
|
+
"primaryKey": true,
|
|
15
|
+
"notNull": true
|
|
16
|
+
},
|
|
17
|
+
"incident_id": {
|
|
18
|
+
"name": "incident_id",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true
|
|
22
|
+
},
|
|
23
|
+
"label": {
|
|
24
|
+
"name": "label",
|
|
25
|
+
"type": "text",
|
|
26
|
+
"primaryKey": false,
|
|
27
|
+
"notNull": false
|
|
28
|
+
},
|
|
29
|
+
"url": {
|
|
30
|
+
"name": "url",
|
|
31
|
+
"type": "text",
|
|
32
|
+
"primaryKey": false,
|
|
33
|
+
"notNull": true
|
|
34
|
+
},
|
|
35
|
+
"created_at": {
|
|
36
|
+
"name": "created_at",
|
|
37
|
+
"type": "timestamp",
|
|
38
|
+
"primaryKey": false,
|
|
39
|
+
"notNull": true,
|
|
40
|
+
"default": "now()"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"indexes": {
|
|
44
|
+
"incident_links_incident_url_unique": {
|
|
45
|
+
"name": "incident_links_incident_url_unique",
|
|
46
|
+
"columns": [
|
|
47
|
+
{
|
|
48
|
+
"expression": "incident_id",
|
|
49
|
+
"isExpression": false,
|
|
50
|
+
"asc": true,
|
|
51
|
+
"nulls": "last"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"expression": "url",
|
|
55
|
+
"isExpression": false,
|
|
56
|
+
"asc": true,
|
|
57
|
+
"nulls": "last"
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"isUnique": true,
|
|
61
|
+
"concurrently": false,
|
|
62
|
+
"method": "btree",
|
|
63
|
+
"with": {}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"foreignKeys": {
|
|
67
|
+
"incident_links_incident_id_incidents_id_fk": {
|
|
68
|
+
"name": "incident_links_incident_id_incidents_id_fk",
|
|
69
|
+
"tableFrom": "incident_links",
|
|
70
|
+
"tableTo": "incidents",
|
|
71
|
+
"columnsFrom": [
|
|
72
|
+
"incident_id"
|
|
73
|
+
],
|
|
74
|
+
"columnsTo": [
|
|
75
|
+
"id"
|
|
76
|
+
],
|
|
77
|
+
"onDelete": "cascade",
|
|
78
|
+
"onUpdate": "no action"
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"compositePrimaryKeys": {},
|
|
82
|
+
"uniqueConstraints": {},
|
|
83
|
+
"policies": {},
|
|
84
|
+
"checkConstraints": {},
|
|
85
|
+
"isRLSEnabled": false
|
|
86
|
+
},
|
|
87
|
+
"public.incident_systems": {
|
|
88
|
+
"name": "incident_systems",
|
|
89
|
+
"schema": "",
|
|
90
|
+
"columns": {
|
|
91
|
+
"incident_id": {
|
|
92
|
+
"name": "incident_id",
|
|
93
|
+
"type": "text",
|
|
94
|
+
"primaryKey": false,
|
|
95
|
+
"notNull": true
|
|
96
|
+
},
|
|
97
|
+
"system_id": {
|
|
98
|
+
"name": "system_id",
|
|
99
|
+
"type": "text",
|
|
100
|
+
"primaryKey": false,
|
|
101
|
+
"notNull": true
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"indexes": {},
|
|
105
|
+
"foreignKeys": {
|
|
106
|
+
"incident_systems_incident_id_incidents_id_fk": {
|
|
107
|
+
"name": "incident_systems_incident_id_incidents_id_fk",
|
|
108
|
+
"tableFrom": "incident_systems",
|
|
109
|
+
"tableTo": "incidents",
|
|
110
|
+
"columnsFrom": [
|
|
111
|
+
"incident_id"
|
|
112
|
+
],
|
|
113
|
+
"columnsTo": [
|
|
114
|
+
"id"
|
|
115
|
+
],
|
|
116
|
+
"onDelete": "cascade",
|
|
117
|
+
"onUpdate": "no action"
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"compositePrimaryKeys": {
|
|
121
|
+
"incident_systems_incident_id_system_id_pk": {
|
|
122
|
+
"name": "incident_systems_incident_id_system_id_pk",
|
|
123
|
+
"columns": [
|
|
124
|
+
"incident_id",
|
|
125
|
+
"system_id"
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"uniqueConstraints": {},
|
|
130
|
+
"policies": {},
|
|
131
|
+
"checkConstraints": {},
|
|
132
|
+
"isRLSEnabled": false
|
|
133
|
+
},
|
|
134
|
+
"public.incident_updates": {
|
|
135
|
+
"name": "incident_updates",
|
|
136
|
+
"schema": "",
|
|
137
|
+
"columns": {
|
|
138
|
+
"id": {
|
|
139
|
+
"name": "id",
|
|
140
|
+
"type": "text",
|
|
141
|
+
"primaryKey": true,
|
|
142
|
+
"notNull": true
|
|
143
|
+
},
|
|
144
|
+
"incident_id": {
|
|
145
|
+
"name": "incident_id",
|
|
146
|
+
"type": "text",
|
|
147
|
+
"primaryKey": false,
|
|
148
|
+
"notNull": true
|
|
149
|
+
},
|
|
150
|
+
"message": {
|
|
151
|
+
"name": "message",
|
|
152
|
+
"type": "text",
|
|
153
|
+
"primaryKey": false,
|
|
154
|
+
"notNull": true
|
|
155
|
+
},
|
|
156
|
+
"status_change": {
|
|
157
|
+
"name": "status_change",
|
|
158
|
+
"type": "incident_status",
|
|
159
|
+
"typeSchema": "public",
|
|
160
|
+
"primaryKey": false,
|
|
161
|
+
"notNull": false
|
|
162
|
+
},
|
|
163
|
+
"created_at": {
|
|
164
|
+
"name": "created_at",
|
|
165
|
+
"type": "timestamp",
|
|
166
|
+
"primaryKey": false,
|
|
167
|
+
"notNull": true,
|
|
168
|
+
"default": "now()"
|
|
169
|
+
},
|
|
170
|
+
"created_by": {
|
|
171
|
+
"name": "created_by",
|
|
172
|
+
"type": "text",
|
|
173
|
+
"primaryKey": false,
|
|
174
|
+
"notNull": false
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"indexes": {},
|
|
178
|
+
"foreignKeys": {
|
|
179
|
+
"incident_updates_incident_id_incidents_id_fk": {
|
|
180
|
+
"name": "incident_updates_incident_id_incidents_id_fk",
|
|
181
|
+
"tableFrom": "incident_updates",
|
|
182
|
+
"tableTo": "incidents",
|
|
183
|
+
"columnsFrom": [
|
|
184
|
+
"incident_id"
|
|
185
|
+
],
|
|
186
|
+
"columnsTo": [
|
|
187
|
+
"id"
|
|
188
|
+
],
|
|
189
|
+
"onDelete": "cascade",
|
|
190
|
+
"onUpdate": "no action"
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"compositePrimaryKeys": {},
|
|
194
|
+
"uniqueConstraints": {},
|
|
195
|
+
"policies": {},
|
|
196
|
+
"checkConstraints": {},
|
|
197
|
+
"isRLSEnabled": false
|
|
198
|
+
},
|
|
199
|
+
"public.incidents": {
|
|
200
|
+
"name": "incidents",
|
|
201
|
+
"schema": "",
|
|
202
|
+
"columns": {
|
|
203
|
+
"id": {
|
|
204
|
+
"name": "id",
|
|
205
|
+
"type": "text",
|
|
206
|
+
"primaryKey": true,
|
|
207
|
+
"notNull": true
|
|
208
|
+
},
|
|
209
|
+
"title": {
|
|
210
|
+
"name": "title",
|
|
211
|
+
"type": "text",
|
|
212
|
+
"primaryKey": false,
|
|
213
|
+
"notNull": true
|
|
214
|
+
},
|
|
215
|
+
"description": {
|
|
216
|
+
"name": "description",
|
|
217
|
+
"type": "text",
|
|
218
|
+
"primaryKey": false,
|
|
219
|
+
"notNull": false
|
|
220
|
+
},
|
|
221
|
+
"status": {
|
|
222
|
+
"name": "status",
|
|
223
|
+
"type": "incident_status",
|
|
224
|
+
"typeSchema": "public",
|
|
225
|
+
"primaryKey": false,
|
|
226
|
+
"notNull": true,
|
|
227
|
+
"default": "'investigating'"
|
|
228
|
+
},
|
|
229
|
+
"severity": {
|
|
230
|
+
"name": "severity",
|
|
231
|
+
"type": "incident_severity",
|
|
232
|
+
"typeSchema": "public",
|
|
233
|
+
"primaryKey": false,
|
|
234
|
+
"notNull": true,
|
|
235
|
+
"default": "'major'"
|
|
236
|
+
},
|
|
237
|
+
"suppress_notifications": {
|
|
238
|
+
"name": "suppress_notifications",
|
|
239
|
+
"type": "boolean",
|
|
240
|
+
"primaryKey": false,
|
|
241
|
+
"notNull": true,
|
|
242
|
+
"default": false
|
|
243
|
+
},
|
|
244
|
+
"health_override": {
|
|
245
|
+
"name": "health_override",
|
|
246
|
+
"type": "incident_health_override",
|
|
247
|
+
"typeSchema": "public",
|
|
248
|
+
"primaryKey": false,
|
|
249
|
+
"notNull": false
|
|
250
|
+
},
|
|
251
|
+
"created_at": {
|
|
252
|
+
"name": "created_at",
|
|
253
|
+
"type": "timestamp",
|
|
254
|
+
"primaryKey": false,
|
|
255
|
+
"notNull": true,
|
|
256
|
+
"default": "now()"
|
|
257
|
+
},
|
|
258
|
+
"updated_at": {
|
|
259
|
+
"name": "updated_at",
|
|
260
|
+
"type": "timestamp",
|
|
261
|
+
"primaryKey": false,
|
|
262
|
+
"notNull": true,
|
|
263
|
+
"default": "now()"
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
"indexes": {},
|
|
267
|
+
"foreignKeys": {},
|
|
268
|
+
"compositePrimaryKeys": {},
|
|
269
|
+
"uniqueConstraints": {},
|
|
270
|
+
"policies": {},
|
|
271
|
+
"checkConstraints": {},
|
|
272
|
+
"isRLSEnabled": false
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
"enums": {
|
|
276
|
+
"public.incident_health_override": {
|
|
277
|
+
"name": "incident_health_override",
|
|
278
|
+
"schema": "public",
|
|
279
|
+
"values": [
|
|
280
|
+
"degraded",
|
|
281
|
+
"unhealthy"
|
|
282
|
+
]
|
|
283
|
+
},
|
|
284
|
+
"public.incident_severity": {
|
|
285
|
+
"name": "incident_severity",
|
|
286
|
+
"schema": "public",
|
|
287
|
+
"values": [
|
|
288
|
+
"minor",
|
|
289
|
+
"major",
|
|
290
|
+
"critical"
|
|
291
|
+
]
|
|
292
|
+
},
|
|
293
|
+
"public.incident_status": {
|
|
294
|
+
"name": "incident_status",
|
|
295
|
+
"schema": "public",
|
|
296
|
+
"values": [
|
|
297
|
+
"investigating",
|
|
298
|
+
"identified",
|
|
299
|
+
"fixing",
|
|
300
|
+
"monitoring",
|
|
301
|
+
"resolved"
|
|
302
|
+
]
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
"schemas": {},
|
|
306
|
+
"sequences": {},
|
|
307
|
+
"roles": {},
|
|
308
|
+
"policies": {},
|
|
309
|
+
"views": {},
|
|
310
|
+
"_meta": {
|
|
311
|
+
"columns": {},
|
|
312
|
+
"schemas": {},
|
|
313
|
+
"tables": {}
|
|
314
|
+
}
|
|
315
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/incident-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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.10.
|
|
18
|
-
"@checkstack/ai-common": "0.6.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/cache-api": "0.3.
|
|
21
|
-
"@checkstack/cache-utils": "0.2.
|
|
22
|
-
"@checkstack/incident-common": "1.
|
|
23
|
-
"@checkstack/catalog-common": "2.6.
|
|
24
|
-
"@checkstack/catalog-backend": "1.6.
|
|
25
|
-
"@checkstack/notification-common": "1.5.
|
|
26
|
-
"@checkstack/auth-common": "0.
|
|
27
|
-
"@checkstack/command-backend": "0.2.
|
|
28
|
-
"@checkstack/signal-common": "0.2.
|
|
29
|
-
"@checkstack/status-page-backend": "0.4.
|
|
30
|
-
"@checkstack/status-page-common": "0.5.
|
|
31
|
-
"@checkstack/integration-backend": "0.7.
|
|
32
|
-
"@checkstack/integration-common": "0.9.
|
|
33
|
-
"@checkstack/automation-backend": "0.
|
|
34
|
-
"@checkstack/automation-common": "0.
|
|
35
|
-
"@checkstack/common": "0.
|
|
17
|
+
"@checkstack/ai-backend": "0.10.9",
|
|
18
|
+
"@checkstack/ai-common": "0.6.6",
|
|
19
|
+
"@checkstack/backend-api": "0.31.0",
|
|
20
|
+
"@checkstack/cache-api": "0.3.19",
|
|
21
|
+
"@checkstack/cache-utils": "0.2.24",
|
|
22
|
+
"@checkstack/incident-common": "1.9.0",
|
|
23
|
+
"@checkstack/catalog-common": "2.6.3",
|
|
24
|
+
"@checkstack/catalog-backend": "1.6.9",
|
|
25
|
+
"@checkstack/notification-common": "1.5.3",
|
|
26
|
+
"@checkstack/auth-common": "0.13.0",
|
|
27
|
+
"@checkstack/command-backend": "0.2.21",
|
|
28
|
+
"@checkstack/signal-common": "0.2.17",
|
|
29
|
+
"@checkstack/status-page-backend": "0.4.8",
|
|
30
|
+
"@checkstack/status-page-common": "0.5.3",
|
|
31
|
+
"@checkstack/integration-backend": "0.7.3",
|
|
32
|
+
"@checkstack/integration-common": "0.9.8",
|
|
33
|
+
"@checkstack/automation-backend": "0.11.0",
|
|
34
|
+
"@checkstack/automation-common": "0.10.0",
|
|
35
|
+
"@checkstack/common": "0.22.0",
|
|
36
36
|
"drizzle-orm": "^0.45.0",
|
|
37
37
|
"zod": "^4.2.1",
|
|
38
38
|
"@orpc/contract": "^1.14.4",
|
|
@@ -40,11 +40,13 @@
|
|
|
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.3",
|
|
44
|
+
"@checkstack/test-utils-backend": "0.1.55",
|
|
45
45
|
"@checkstack/tsconfig": "0.0.7",
|
|
46
46
|
"@types/bun": "^1.0.0",
|
|
47
|
+
"@types/pg": "^8.20.0",
|
|
47
48
|
"drizzle-kit": "^0.31.10",
|
|
49
|
+
"pg": "^8.21.0",
|
|
48
50
|
"typescript": "^5.0.0"
|
|
49
51
|
}
|
|
50
52
|
}
|
|
@@ -32,25 +32,25 @@ describe("incident.removeLink tool", () => {
|
|
|
32
32
|
const rpcClient = fakeRpcClient({ removeLink });
|
|
33
33
|
const tool = createIncidentRemoveLinkTool();
|
|
34
34
|
const preview = await tool.dryRun!({
|
|
35
|
-
input: { id: "lnk1" },
|
|
35
|
+
input: { id: "lnk1", incidentId: "inc1" },
|
|
36
36
|
principal,
|
|
37
37
|
rpcClient,
|
|
38
38
|
});
|
|
39
39
|
expect(removeLink).not.toHaveBeenCalled();
|
|
40
40
|
expect(preview.summary).toContain("permanent");
|
|
41
|
-
expect(preview.payload).toEqual({ id: "lnk1" });
|
|
41
|
+
expect(preview.payload).toEqual({ id: "lnk1", incidentId: "inc1" });
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
test("execute (apply) removes via removeLink with {id}", async () => {
|
|
44
|
+
test("execute (apply) removes via removeLink with {id, incidentId}", async () => {
|
|
45
45
|
const removeLink = mock(() => Promise.resolve({ success: true }));
|
|
46
46
|
const rpcClient = fakeRpcClient({ removeLink });
|
|
47
47
|
const tool = createIncidentRemoveLinkTool();
|
|
48
48
|
const result = await tool.execute({
|
|
49
|
-
input: { id: "lnk1" },
|
|
49
|
+
input: { id: "lnk1", incidentId: "inc1" },
|
|
50
50
|
principal,
|
|
51
51
|
rpcClient,
|
|
52
52
|
});
|
|
53
|
-
expect(removeLink).toHaveBeenCalledWith({ id: "lnk1" });
|
|
53
|
+
expect(removeLink).toHaveBeenCalledWith({ id: "lnk1", incidentId: "inc1" });
|
|
54
54
|
expect(result).toEqual({ id: "lnk1", removed: true });
|
|
55
55
|
});
|
|
56
56
|
});
|
|
@@ -9,9 +9,10 @@ import {
|
|
|
9
9
|
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
10
10
|
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
11
11
|
|
|
12
|
-
/** Input for `incident.removeLink`: the link id to remove. */
|
|
12
|
+
/** Input for `incident.removeLink`: the link id to remove + its owning incident. */
|
|
13
13
|
export const IncidentRemoveLinkInputSchema = z.object({
|
|
14
14
|
id: z.string(),
|
|
15
|
+
incidentId: z.string(),
|
|
15
16
|
});
|
|
16
17
|
export type IncidentRemoveLinkInput = z.infer<
|
|
17
18
|
typeof IncidentRemoveLinkInputSchema
|
|
@@ -45,15 +46,15 @@ export function createIncidentRemoveLinkTool(): RegisteredAiTool<
|
|
|
45
46
|
rpcClient: RpcClient;
|
|
46
47
|
}): Promise<AiProposalPreview<IncidentRemoveLinkInput>> => {
|
|
47
48
|
return {
|
|
48
|
-
summary: `Remove link ${input.id} from
|
|
49
|
-
payload: { id: input.id },
|
|
49
|
+
summary: `Remove link ${input.id} from incident ${input.incidentId}. This is permanent.`,
|
|
50
|
+
payload: { id: input.id, incidentId: input.incidentId },
|
|
50
51
|
};
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
return {
|
|
54
55
|
name: "incident.removeLink",
|
|
55
56
|
description:
|
|
56
|
-
"Remove a hotlink from an incident by link id. DESTRUCTIVE and irreversible. Never removes directly; a person must approve the confirmation. Find the link id by reading the incident's details first.",
|
|
57
|
+
"Remove a hotlink from an incident by link id and its owning incidentId. DESTRUCTIVE and irreversible. Never removes directly; a person must approve the confirmation. Find the link id and incidentId by reading the incident's details first.",
|
|
57
58
|
effect: "destructive",
|
|
58
59
|
input: IncidentRemoveLinkInputSchema,
|
|
59
60
|
requiredAccessRules: [
|
|
@@ -62,7 +63,10 @@ export function createIncidentRemoveLinkTool(): RegisteredAiTool<
|
|
|
62
63
|
dryRun,
|
|
63
64
|
async execute({ input, rpcClient }) {
|
|
64
65
|
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
65
|
-
await incidentClient.removeLink({
|
|
66
|
+
await incidentClient.removeLink({
|
|
67
|
+
id: input.id,
|
|
68
|
+
incidentId: input.incidentId,
|
|
69
|
+
});
|
|
66
70
|
return { id: input.id, removed: true };
|
|
67
71
|
},
|
|
68
72
|
};
|
package/src/cache.test.ts
CHANGED
|
@@ -22,6 +22,7 @@ const sampleIncident: IncidentDetail = {
|
|
|
22
22
|
status: "investigating",
|
|
23
23
|
severity: "major",
|
|
24
24
|
suppressNotifications: false,
|
|
25
|
+
healthOverride: null,
|
|
25
26
|
createdAt: new Date("2026-06-01T00:00:00Z"),
|
|
26
27
|
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
|
27
28
|
systemIds: ["s1"],
|
|
@@ -35,6 +36,7 @@ const sampleWithSystems: IncidentWithSystems = {
|
|
|
35
36
|
status: sampleIncident.status,
|
|
36
37
|
severity: sampleIncident.severity,
|
|
37
38
|
suppressNotifications: sampleIncident.suppressNotifications,
|
|
39
|
+
healthOverride: sampleIncident.healthOverride,
|
|
38
40
|
createdAt: sampleIncident.createdAt,
|
|
39
41
|
updatedAt: sampleIncident.updatedAt,
|
|
40
42
|
systemIds: sampleIncident.systemIds,
|
package/src/router.ts
CHANGED
|
@@ -260,6 +260,18 @@ export function createRouter({
|
|
|
260
260
|
},
|
|
261
261
|
),
|
|
262
262
|
|
|
263
|
+
getActiveHealthOverrides: os.getActiveHealthOverrides.handler(
|
|
264
|
+
async ({ input }) => {
|
|
265
|
+
// Deliberately un-cached: this feeds live system-health derivation, so
|
|
266
|
+
// an override must lift/apply the instant an incident is edited or
|
|
267
|
+
// resolved. It is a single indexed join (see service), not an N+1.
|
|
268
|
+
const overrides = await service.getActiveHealthOverrides(
|
|
269
|
+
input.systemIds,
|
|
270
|
+
);
|
|
271
|
+
return { overrides };
|
|
272
|
+
},
|
|
273
|
+
),
|
|
274
|
+
|
|
263
275
|
createIncident: os.createIncident.handler(async ({ input, context }) => {
|
|
264
276
|
const userId =
|
|
265
277
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
@@ -664,7 +676,7 @@ export function createRouter({
|
|
|
664
676
|
}),
|
|
665
677
|
|
|
666
678
|
removeLink: os.removeLink.handler(async ({ input }) => {
|
|
667
|
-
const incidentId = await service.removeLink(input.id);
|
|
679
|
+
const incidentId = await service.removeLink(input.id, input.incidentId);
|
|
668
680
|
if (!incidentId) {
|
|
669
681
|
return { success: false };
|
|
670
682
|
}
|
package/src/schema.ts
CHANGED
|
@@ -28,6 +28,17 @@ export const incidentSeverityEnum = pgEnum("incident_severity", [
|
|
|
28
28
|
"critical",
|
|
29
29
|
]);
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Optional health status an active incident forces onto its affected systems.
|
|
33
|
+
* A subset of the health-check status vocabulary (no "healthy" - forcing a
|
|
34
|
+
* system healthy via an incident makes no sense). Nullable column: `null` means
|
|
35
|
+
* the incident does not touch derived system health.
|
|
36
|
+
*/
|
|
37
|
+
export const incidentHealthOverrideEnum = pgEnum("incident_health_override", [
|
|
38
|
+
"degraded",
|
|
39
|
+
"unhealthy",
|
|
40
|
+
]);
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* Main incidents table
|
|
33
44
|
*/
|
|
@@ -40,6 +51,7 @@ export const incidents = pgTable("incidents", {
|
|
|
40
51
|
suppressNotifications: boolean("suppress_notifications")
|
|
41
52
|
.default(false)
|
|
42
53
|
.notNull(),
|
|
54
|
+
healthOverride: incidentHealthOverrideEnum("health_override"),
|
|
43
55
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
44
56
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
45
57
|
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for IncidentService.removeLink against a REAL Postgres,
|
|
3
|
+
* exercising the anti-spoof WHERE clause (`WHERE id AND incidentId`) that the
|
|
4
|
+
* mocked-DB unit tests cannot. removeLink is authorized with `idParam:
|
|
5
|
+
* "incidentId"`, so the caller proves a grant on the PARENT incident. The
|
|
6
|
+
* service MUST additionally scope the delete by that incidentId, or a caller
|
|
7
|
+
* could pair a link id belonging to incident A with an incident B they manage
|
|
8
|
+
* and delete A's link (an IDOR). These tests prove the scoping holds at the SQL
|
|
9
|
+
* layer: a mismatched incidentId removes nothing and returns undefined; only a
|
|
10
|
+
* matched pair deletes.
|
|
11
|
+
*
|
|
12
|
+
* Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
|
|
13
|
+
* skipped in the default `bun test` run, matching the other *.it.test.ts here.
|
|
14
|
+
*/
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
16
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
17
|
+
import { Pool } from "pg";
|
|
18
|
+
import type {
|
|
19
|
+
AdvisoryLockService,
|
|
20
|
+
SafeDatabase,
|
|
21
|
+
} from "@checkstack/backend-api";
|
|
22
|
+
import * as schema from "./schema";
|
|
23
|
+
import { IncidentService } from "./service";
|
|
24
|
+
|
|
25
|
+
const PG_URL =
|
|
26
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
27
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
28
|
+
const SCHEMA = "incident_it_removelink";
|
|
29
|
+
|
|
30
|
+
// removeLink never touches the advisory lock, so a no-op faithful stub is all
|
|
31
|
+
// the constructor needs (no cast).
|
|
32
|
+
const noopAdvisoryLock: AdvisoryLockService = {
|
|
33
|
+
tryAcquire: async () => null,
|
|
34
|
+
withXactLock: async ({ fn }) => fn(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let admin: Pool;
|
|
38
|
+
let pool: Pool;
|
|
39
|
+
let service: IncidentService;
|
|
40
|
+
|
|
41
|
+
async function insertLink(row: {
|
|
42
|
+
id: string;
|
|
43
|
+
incidentId: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}): Promise<void> {
|
|
46
|
+
await pool.query(
|
|
47
|
+
`INSERT INTO "${SCHEMA}".incident_links (id, incident_id, url)
|
|
48
|
+
VALUES ($1, $2, $3)`,
|
|
49
|
+
[row.id, row.incidentId, row.url],
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function linkExists(id: string): Promise<boolean> {
|
|
54
|
+
const res = await pool.query(
|
|
55
|
+
`SELECT 1 FROM "${SCHEMA}".incident_links WHERE id = $1`,
|
|
56
|
+
[id],
|
|
57
|
+
);
|
|
58
|
+
return res.rowCount === 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
62
|
+
"IncidentService.removeLink anti-spoof scoping (shared Postgres)",
|
|
63
|
+
() => {
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
admin = new Pool({ connectionString: PG_URL });
|
|
66
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
67
|
+
await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
|
|
68
|
+
// Only the incident_links table is exercised; no FK to incidents so the
|
|
69
|
+
// DDL stays minimal and focused on the WHERE-clause behavior.
|
|
70
|
+
await admin.query(
|
|
71
|
+
`CREATE TABLE "${SCHEMA}".incident_links (
|
|
72
|
+
id text PRIMARY KEY,
|
|
73
|
+
incident_id text NOT NULL,
|
|
74
|
+
label text,
|
|
75
|
+
url text NOT NULL,
|
|
76
|
+
created_at timestamp NOT NULL DEFAULT now()
|
|
77
|
+
)`,
|
|
78
|
+
);
|
|
79
|
+
pool = new Pool({
|
|
80
|
+
connectionString: PG_URL,
|
|
81
|
+
options: `-c search_path=${SCHEMA}`,
|
|
82
|
+
});
|
|
83
|
+
const db = drizzle(pool, {
|
|
84
|
+
schema,
|
|
85
|
+
}) as unknown as SafeDatabase<typeof schema>;
|
|
86
|
+
service = new IncidentService(db, noopAdvisoryLock);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await pool?.end();
|
|
91
|
+
await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
|
|
92
|
+
await admin.end();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await pool.query(`TRUNCATE "${SCHEMA}".incident_links`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("removes the link and returns the incidentId when the pair matches", async () => {
|
|
100
|
+
await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
|
|
101
|
+
|
|
102
|
+
expect(await service.removeLink("lnk-1", "inc-1")).toBe("inc-1");
|
|
103
|
+
expect(await linkExists("lnk-1")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("removes nothing and returns undefined when the incidentId does not own the link", async () => {
|
|
107
|
+
// Link belongs to inc-1; the caller is authorized against inc-2 (a
|
|
108
|
+
// different incident they manage). The spoofed pair must NOT delete.
|
|
109
|
+
await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
|
|
110
|
+
|
|
111
|
+
expect(await service.removeLink("lnk-1", "inc-2")).toBeUndefined();
|
|
112
|
+
// The link is untouched.
|
|
113
|
+
expect(await linkExists("lnk-1")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns undefined for a link id that does not exist", async () => {
|
|
117
|
+
expect(await service.removeLink("missing", "inc-1")).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("only deletes the link matching BOTH id and incidentId", async () => {
|
|
121
|
+
await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
|
|
122
|
+
await insertLink({ id: "lnk-2", incidentId: "inc-2", url: "https://b" });
|
|
123
|
+
|
|
124
|
+
expect(await service.removeLink("lnk-1", "inc-1")).toBe("inc-1");
|
|
125
|
+
expect(await linkExists("lnk-1")).toBe(false);
|
|
126
|
+
// The other incident's link is untouched.
|
|
127
|
+
expect(await linkExists("lnk-2")).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
);
|
package/src/service.test.ts
CHANGED
|
@@ -457,3 +457,92 @@ describe("IncidentService.createIncidentDedupedForSystem (M3)", () => {
|
|
|
457
457
|
expect([a.reused, b.reused].filter(Boolean)).toHaveLength(1);
|
|
458
458
|
});
|
|
459
459
|
});
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Mock DB for the join query in `getActiveHealthOverrides`
|
|
463
|
+
* (`select().from().innerJoin().where()` resolving to a flat joined-row list).
|
|
464
|
+
* The status/override filtering is enforced by the real query builder, so the
|
|
465
|
+
* test supplies rows as the DB would AFTER filtering and asserts the mapping +
|
|
466
|
+
* per-system grouping into the RPC shape.
|
|
467
|
+
*/
|
|
468
|
+
function createJoinDb(rows: unknown[]) {
|
|
469
|
+
const select = mock(() => {
|
|
470
|
+
const whereResult = Promise.resolve(rows);
|
|
471
|
+
const innerJoin = mock(() => ({ where: mock(() => whereResult) }));
|
|
472
|
+
const from = mock(() => ({ innerJoin }));
|
|
473
|
+
return { from };
|
|
474
|
+
});
|
|
475
|
+
return { db: { select } as unknown };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
describe("IncidentService.getActiveHealthOverrides", () => {
|
|
479
|
+
it("returns an empty record without querying when given no systems", async () => {
|
|
480
|
+
const { db } = createJoinDb([]);
|
|
481
|
+
const service = new IncidentService(db as never, makeFakeAdvisoryLock());
|
|
482
|
+
expect(await service.getActiveHealthOverrides([])).toEqual({});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("groups active overrides by system and shapes each into { status, incidentId, incidentTitle }", async () => {
|
|
486
|
+
const { db } = createJoinDb([
|
|
487
|
+
{
|
|
488
|
+
systemId: "sys-1",
|
|
489
|
+
incidentId: "inc-1",
|
|
490
|
+
incidentTitle: "License server revoked",
|
|
491
|
+
healthOverride: "unhealthy",
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
systemId: "sys-1",
|
|
495
|
+
incidentId: "inc-2",
|
|
496
|
+
incidentTitle: "Slow logins",
|
|
497
|
+
healthOverride: "degraded",
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
systemId: "sys-2",
|
|
501
|
+
incidentId: "inc-3",
|
|
502
|
+
incidentTitle: "Vendor maintenance",
|
|
503
|
+
healthOverride: "degraded",
|
|
504
|
+
},
|
|
505
|
+
]);
|
|
506
|
+
const service = new IncidentService(db as never, makeFakeAdvisoryLock());
|
|
507
|
+
|
|
508
|
+
const result = await service.getActiveHealthOverrides(["sys-1", "sys-2"]);
|
|
509
|
+
|
|
510
|
+
expect(result).toEqual({
|
|
511
|
+
"sys-1": [
|
|
512
|
+
{
|
|
513
|
+
status: "unhealthy",
|
|
514
|
+
incidentId: "inc-1",
|
|
515
|
+
incidentTitle: "License server revoked",
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
status: "degraded",
|
|
519
|
+
incidentId: "inc-2",
|
|
520
|
+
incidentTitle: "Slow logins",
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
"sys-2": [
|
|
524
|
+
{
|
|
525
|
+
status: "degraded",
|
|
526
|
+
incidentId: "inc-3",
|
|
527
|
+
incidentTitle: "Vendor maintenance",
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("omits systems with no active override", async () => {
|
|
534
|
+
const { db } = createJoinDb([
|
|
535
|
+
{
|
|
536
|
+
systemId: "sys-1",
|
|
537
|
+
incidentId: "inc-1",
|
|
538
|
+
incidentTitle: "Down",
|
|
539
|
+
healthOverride: "unhealthy",
|
|
540
|
+
},
|
|
541
|
+
]);
|
|
542
|
+
const service = new IncidentService(db as never, makeFakeAdvisoryLock());
|
|
543
|
+
|
|
544
|
+
const result = await service.getActiveHealthOverrides(["sys-1", "sys-2"]);
|
|
545
|
+
|
|
546
|
+
expect(Object.keys(result)).toEqual(["sys-1"]);
|
|
547
|
+
});
|
|
548
|
+
});
|
package/src/service.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq, and, inArray, ne } from "drizzle-orm";
|
|
1
|
+
import { eq, and, inArray, ne, isNotNull } from "drizzle-orm";
|
|
2
2
|
import type { AdvisoryLockService, SafeDatabase } from "@checkstack/backend-api";
|
|
3
3
|
import * as schema from "./schema";
|
|
4
4
|
import {
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
AddIncidentUpdateInput,
|
|
19
19
|
IncidentStatus,
|
|
20
20
|
IncidentSeverity,
|
|
21
|
+
SystemHealthOverride,
|
|
21
22
|
} from "@checkstack/incident-common";
|
|
22
23
|
|
|
23
24
|
type Db = SafeDatabase<typeof schema>;
|
|
@@ -277,6 +278,50 @@ export class IncidentService {
|
|
|
277
278
|
return result;
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
/**
|
|
282
|
+
* For each requested system, the health overrides contributed by its ACTIVE
|
|
283
|
+
* incidents (status != resolved AND `healthOverride` set). Powers the
|
|
284
|
+
* healthcheck plugin's system-health derivation (worst-wins fold). Reads the
|
|
285
|
+
* authoritative `incidents` + `incident_systems` tables, so the answer is
|
|
286
|
+
* identical on every pod (state-and-scale rule). Systems with no active
|
|
287
|
+
* override are omitted; resolved incidents never appear, so an override
|
|
288
|
+
* auto-lifts the moment its incident resolves.
|
|
289
|
+
*/
|
|
290
|
+
async getActiveHealthOverrides(
|
|
291
|
+
systemIds: string[],
|
|
292
|
+
): Promise<Record<string, SystemHealthOverride[]>> {
|
|
293
|
+
if (systemIds.length === 0) return {};
|
|
294
|
+
|
|
295
|
+
const rows = await this.db
|
|
296
|
+
.select({
|
|
297
|
+
systemId: incidentSystems.systemId,
|
|
298
|
+
incidentId: incidents.id,
|
|
299
|
+
incidentTitle: incidents.title,
|
|
300
|
+
healthOverride: incidents.healthOverride,
|
|
301
|
+
})
|
|
302
|
+
.from(incidentSystems)
|
|
303
|
+
.innerJoin(incidents, eq(incidentSystems.incidentId, incidents.id))
|
|
304
|
+
.where(
|
|
305
|
+
and(
|
|
306
|
+
inArray(incidentSystems.systemId, systemIds),
|
|
307
|
+
ne(incidents.status, "resolved"),
|
|
308
|
+
isNotNull(incidents.healthOverride),
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const result: Record<string, SystemHealthOverride[]> = {};
|
|
313
|
+
for (const row of rows) {
|
|
314
|
+
// `healthOverride` is guaranteed non-null by the `isNotNull` predicate.
|
|
315
|
+
if (row.healthOverride === null) continue;
|
|
316
|
+
(result[row.systemId] ??= []).push({
|
|
317
|
+
status: row.healthOverride,
|
|
318
|
+
incidentId: row.incidentId,
|
|
319
|
+
incidentTitle: row.incidentTitle,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
|
|
280
325
|
/**
|
|
281
326
|
* Create a new incident.
|
|
282
327
|
*
|
|
@@ -302,6 +347,7 @@ export class IncidentService {
|
|
|
302
347
|
status: "investigating",
|
|
303
348
|
severity: input.severity,
|
|
304
349
|
suppressNotifications: input.suppressNotifications ?? false,
|
|
350
|
+
healthOverride: input.healthOverride ?? null,
|
|
305
351
|
});
|
|
306
352
|
|
|
307
353
|
// Insert system associations
|
|
@@ -350,6 +396,10 @@ export class IncidentService {
|
|
|
350
396
|
if (input.severity !== undefined) updateData.severity = input.severity;
|
|
351
397
|
if (input.suppressNotifications !== undefined)
|
|
352
398
|
updateData.suppressNotifications = input.suppressNotifications;
|
|
399
|
+
// `null` clears a previously-set override; `undefined` leaves it unchanged
|
|
400
|
+
// (same nullable-optional semantics as `description`).
|
|
401
|
+
if (input.healthOverride !== undefined)
|
|
402
|
+
updateData.healthOverride = input.healthOverride;
|
|
353
403
|
|
|
354
404
|
// Atomic: the field update and the delete-then-reinsert of system links must
|
|
355
405
|
// commit together. Without the transaction a failure after the delete left
|
|
@@ -504,13 +554,22 @@ export class IncidentService {
|
|
|
504
554
|
* invalidate the right cache entry, or undefined if the link did not
|
|
505
555
|
* exist.
|
|
506
556
|
*/
|
|
507
|
-
async removeLink(id: string): Promise<string | undefined> {
|
|
557
|
+
async removeLink(id: string, incidentId: string): Promise<string | undefined> {
|
|
558
|
+
// Scope by incidentId: the caller is authorized (idParam) against THIS
|
|
559
|
+
// incident, so a link belonging to a different incident must not be
|
|
560
|
+
// removable by pairing its link id with an incident the caller manages.
|
|
508
561
|
const [existing] = await this.db
|
|
509
562
|
.select()
|
|
510
563
|
.from(incidentLinks)
|
|
511
|
-
.where(
|
|
564
|
+
.where(
|
|
565
|
+
and(eq(incidentLinks.id, id), eq(incidentLinks.incidentId, incidentId)),
|
|
566
|
+
);
|
|
512
567
|
if (!existing) return undefined;
|
|
513
|
-
await this.db
|
|
568
|
+
await this.db
|
|
569
|
+
.delete(incidentLinks)
|
|
570
|
+
.where(
|
|
571
|
+
and(eq(incidentLinks.id, id), eq(incidentLinks.incidentId, incidentId)),
|
|
572
|
+
);
|
|
514
573
|
return existing.incidentId;
|
|
515
574
|
}
|
|
516
575
|
|
|
@@ -12,6 +12,7 @@ const openIncident: IncidentWithSystems = {
|
|
|
12
12
|
status: "investigating",
|
|
13
13
|
severity: "critical",
|
|
14
14
|
suppressNotifications: false,
|
|
15
|
+
healthOverride: null,
|
|
15
16
|
createdAt: new Date("2026-06-01T10:00:00.000Z"),
|
|
16
17
|
updatedAt: new Date("2026-06-01T10:00:00.000Z"),
|
|
17
18
|
systemIds: ["sys-1"],
|