@checkstack/incident-backend 1.7.4 → 1.8.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 +233 -0
- package/package.json +22 -20
- package/src/index.ts +37 -4
- package/src/router.ts +6 -1
- package/src/status-page-widget.test.ts +49 -0
- package/src/status-page-widget.ts +130 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,238 @@
|
|
|
1
1
|
# @checkstack/incident-backend
|
|
2
2
|
|
|
3
|
+
## 1.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d2077bd: Platform-wide team-scoped access control on a unified relation-tuple store.
|
|
8
|
+
|
|
9
|
+
Admins can scope any resource to teams, and the **platform** (not each plugin)
|
|
10
|
+
enforces it. A plugin opts in declaratively by adding `instanceAccess` to a
|
|
11
|
+
procedure's contract; the auth middleware does the rest, so enforcement is
|
|
12
|
+
consistent across catalog, health checks, incidents, maintenances, SLOs,
|
|
13
|
+
automations, and the dependency map, and any third-party plugin gets it for free.
|
|
14
|
+
|
|
15
|
+
Core model:
|
|
16
|
+
|
|
17
|
+
- **Teams are optional.** A resource with no team grants behaves exactly as
|
|
18
|
+
before.
|
|
19
|
+
- **Team grants are additive and restrict who can CHANGE a resource, not who can
|
|
20
|
+
SEE it.** Granting a team `Manage` lets its members view and change the
|
|
21
|
+
resource; `Read-only` lets them view it. Either level grants access to team
|
|
22
|
+
members **even when they lack the global permission**, and granting never
|
|
23
|
+
removes read from anyone who already had it (e.g. a public status page stays
|
|
24
|
+
readable). Privacy is a separate, explicit opt-in via the **Private** toggle,
|
|
25
|
+
which removes the global read path so only the resource's teams can see it.
|
|
26
|
+
- **Ownership at creation.** Create forms expose an **Owning team** picker. A
|
|
27
|
+
non-admin can create a resource for a team they belong to that holds a
|
|
28
|
+
create-capability grant for that type; the new resource is auto-granted to that
|
|
29
|
+
team. Incidents and maintenances are **parent-gated**: anyone who can manage a
|
|
30
|
+
system may open incidents/maintenances for it, no separate grant needed.
|
|
31
|
+
- **Meaningful authorization errors.** A caller with neither the global rule nor
|
|
32
|
+
any team grant for a resource type gets a `403` with a structured body instead
|
|
33
|
+
of a silently-empty `200`. Anonymous callers on public endpoints are never
|
|
34
|
+
`403`'d, so status pages keep rendering.
|
|
35
|
+
|
|
36
|
+
Unified relation-tuple store:
|
|
37
|
+
|
|
38
|
+
- The previously separate access primitives (`resource_team_access.canRead` /
|
|
39
|
+
`.canManage`, ownership, `resource_access_settings.teamOnly`, and
|
|
40
|
+
`resource_create_grant`) are collapsed onto ONE
|
|
41
|
+
`relation_tuple(object, relation, subject)` store: "a team has
|
|
42
|
+
`viewer`/`editor`/`owner` on an object, or `creator` on a type". Privacy is an
|
|
43
|
+
explicit **`private` marker** tuple — its **presence** closes the global read
|
|
44
|
+
path (team grants only), its **absence** is the readable-by-default state, so a
|
|
45
|
+
private resource with zero grants is correctly inaccessible to everyone rather
|
|
46
|
+
than silently globalized. The access decision is a pure, unit-tested function.
|
|
47
|
+
- The auth API is generic: `writeRelation` / `removeRelation` / `setObjectPublic`
|
|
48
|
+
/ `listObjectRelations` / `listSubjectRelations` / `setCreateGrant` /
|
|
49
|
+
`listTeamCreateGrants` (user-facing) and `check` / `listAccessibleObjectIds` /
|
|
50
|
+
`hasAnyTypeGrant` / `authorizeCreate` / `setOwner` / `deleteObjectRelations`
|
|
51
|
+
(service-to-service). Migration `0008` backfills tuples from the legacy tables
|
|
52
|
+
and drops them.
|
|
53
|
+
|
|
54
|
+
Explicit per-procedure scoping:
|
|
55
|
+
|
|
56
|
+
- Access rules (`access()` / `accessPair()`) define only the rule (id, level,
|
|
57
|
+
defaults); every procedure declares its own `instanceAccess`. This removes a
|
|
58
|
+
"loaded gun" default that silently applied a shared `idParam` to any procedure
|
|
59
|
+
which forgot its own override.
|
|
60
|
+
- Modes: `idParam` (single-resource pre-check, fails **closed** if the id does
|
|
61
|
+
not resolve), `listKey` / `recordKey` (post-filter a list/record to the
|
|
62
|
+
accessible subset), `create` (authorize creation + write the owning-team
|
|
63
|
+
grant), `parentScope` (scope by read/manage access to a PARENT type,
|
|
64
|
+
cross-plugin single-hop: "you may see incidents/maintenances/SLOs/health for
|
|
65
|
+
system S iff you may see S"), and `global: true` (the honest "intentionally not
|
|
66
|
+
team-scoped" opt-out). A boot-time validator **rejects** any procedure gated on
|
|
67
|
+
a team-scopable resource type that declares no `instanceAccess`, turning the
|
|
68
|
+
previous fail-open into a boot error.
|
|
69
|
+
|
|
70
|
+
Teams administration:
|
|
71
|
+
|
|
72
|
+
- **Team managers** manage their own team's members and managers without the
|
|
73
|
+
global `auth.teams.manage` rule; creating, deleting, and granting a team access
|
|
74
|
+
remain admin-only.
|
|
75
|
+
- A **standalone Teams page** (gated on `auth.teams.read`) lets managers reach
|
|
76
|
+
team administration without the admin Auth Settings page; members are added via
|
|
77
|
+
a debounced directory picker.
|
|
78
|
+
- A **cross-plugin `ResourceResolverRegistry`** lets owning plugins register a
|
|
79
|
+
name/search resolver for their resource types, so the Teams page lists a team's
|
|
80
|
+
grants **by name** (grouped by type) and offers a resource picker — an admin can
|
|
81
|
+
change a grant's level, revoke it, or add one, without auth depending on every
|
|
82
|
+
plugin. Resolvers shipped for catalog systems, health-check configurations,
|
|
83
|
+
incidents, maintenances, SLO objectives, and automations.
|
|
84
|
+
|
|
85
|
+
Frontend:
|
|
86
|
+
|
|
87
|
+
- The resource-side editor is **"Who can change this"** (one Manage checkbox per
|
|
88
|
+
team; unticked = read-only), with an always-visible **Private** toggle
|
|
89
|
+
(disabled until a team that can Manage exists, so a resource can't be stranded).
|
|
90
|
+
- `TeamOwnershipPicker` explains _why_ there's nothing to pick (not a member of
|
|
91
|
+
any team, or none of your teams manage the selected parent) instead of a bare
|
|
92
|
+
"global resource" line.
|
|
93
|
+
- Read-only **"who can change this"** indicators on resource detail pages expand
|
|
94
|
+
to the actual people by name; bulk + per-row **Scope to team** actions in the
|
|
95
|
+
catalog systems list; and the team-access copy spells out that grants are
|
|
96
|
+
additive and that Read-only grants view (not change) even without the global
|
|
97
|
+
permission.
|
|
98
|
+
|
|
99
|
+
Security hardening:
|
|
100
|
+
|
|
101
|
+
- Child deletes in catalog (`removeSystemContact` / `removeSystemLink`) are scoped
|
|
102
|
+
to both the child id and its parent `systemId`, closing a cross-system IDOR for
|
|
103
|
+
team-scoped managers.
|
|
104
|
+
- `searchUsers` is restricted to team administrators, closing a directory/email
|
|
105
|
+
enumeration path opened by the default `auth.teams.read` rule.
|
|
106
|
+
- Grant setters reject unregistered resource types.
|
|
107
|
+
|
|
108
|
+
BREAKING CHANGES (beta; shipped as minor bumps):
|
|
109
|
+
|
|
110
|
+
- `access()` and `accessPair()` no longer accept `idParam` / `listKey` /
|
|
111
|
+
`recordKey`; move instance config to the procedure's `instanceAccess`.
|
|
112
|
+
- Boot fails if a procedure gated on a team-scopable resource type omits
|
|
113
|
+
`instanceAccess`. Declare a scoping mode or `instanceAccess: { global: true }`.
|
|
114
|
+
- The `AuthService` interface is reshaped: `check`, `listAccessibleObjectIds`,
|
|
115
|
+
`hasAnyTypeGrant`, `authorizeCreate` (returns `isPrivate`), `setOwner`
|
|
116
|
+
(`isPrivate`), and `deleteObjectRelations`. Custom `AuthService` implementations
|
|
117
|
+
and mocks must update.
|
|
118
|
+
- The auth RPC contract's per-concept resource-access endpoints are replaced by
|
|
119
|
+
the generic tuple API above; external callers of the old
|
|
120
|
+
`getResourceTeamAccess` / `setResourceTeamAccess` / `setResourceAccessSettings`
|
|
121
|
+
/ `grantResourceCreate` / etc. must move to the new procedures.
|
|
122
|
+
- Several contract inputs changed from a bare `string` to an object so the
|
|
123
|
+
middleware can resolve the resource id: catalog `deleteSystem` (`{ id }`),
|
|
124
|
+
`removeSystemContact` / `removeSystemLink` (`{ id, systemId }`); health-check
|
|
125
|
+
`deleteConfiguration` / `pauseConfiguration` / `resumeConfiguration` (`{ id }`).
|
|
126
|
+
All in-tree callers are updated.
|
|
127
|
+
- List/record endpoints that relied on returning an empty `200` to signal "no
|
|
128
|
+
access" now return a `403` for categorically-unauthorized principals.
|
|
129
|
+
- The mis-keyed bulk endpoints `getBulkIncidentsForSystems`,
|
|
130
|
+
`getBulkMaintenancesForSystems`, and `getBulkObjectivesForSystems` no longer
|
|
131
|
+
post-filter their (systemId-keyed) result; access is already gated by
|
|
132
|
+
`catalog.system` upstream.
|
|
133
|
+
- Team membership/manager mutations (`addUserToTeam`, `removeUserFromTeam`,
|
|
134
|
+
`addTeamManager`, `removeTeamManager`) now require `auth.teams.read` instead of
|
|
135
|
+
`auth.teams.manage` at the contract level (broadened to per-team managers).
|
|
136
|
+
- The `resource_team_access`, `resource_access_settings`, and
|
|
137
|
+
`resource_create_grant` tables are dropped (data backfilled into
|
|
138
|
+
`relation_tuple` by migration `0008`). A previously inconsistent "team-only with
|
|
139
|
+
zero grants" resource is now correctly inaccessible to global-access holders.
|
|
140
|
+
|
|
141
|
+
- 9ab73c5: Status pages: configurable incident/maintenance updates + recently resolved/completed items.
|
|
142
|
+
|
|
143
|
+
The Incidents and Maintenance widgets gain four config options (in the builder):
|
|
144
|
+
|
|
145
|
+
- **Show updates** (default on) — render the per-item update timeline so visitors
|
|
146
|
+
can follow progress. The maintenance widget now renders its timeline too
|
|
147
|
+
(previously it fetched updates but didn't show them). Turning this off also
|
|
148
|
+
skips the per-item detail fetch (a perf win).
|
|
149
|
+
- **Max updates per item** (default 3) — show only the latest N updates,
|
|
150
|
+
most-recent first, so a chatty incident doesn't dominate the page.
|
|
151
|
+
- **Show recently resolved / completed** (default off) — include resolved
|
|
152
|
+
incidents / completed maintenances, rendered in a separate "Recently resolved"
|
|
153
|
+
/ "Past maintenance" subsection below the active items.
|
|
154
|
+
- **Max age (days)** (default 7) — only include past items resolved/completed
|
|
155
|
+
within the window.
|
|
156
|
+
|
|
157
|
+
Scoping and isolation are unchanged: still only the systems the operator bound,
|
|
158
|
+
still fail-closed when none are bound, still field-allow-listed DTOs (no
|
|
159
|
+
`createdBy`). The active/past partition + max-age + cap is a pure, unit-tested
|
|
160
|
+
helper (`selectEvents`).
|
|
161
|
+
|
|
162
|
+
- 5c6393f: Add operator-built public Status Pages (phase 1: secure, extensible core).
|
|
163
|
+
|
|
164
|
+
Operators compose a public status page from widgets (status banner, system
|
|
165
|
+
health, group status, 90-day uptime, incidents, scheduled maintenance) plus
|
|
166
|
+
content blocks (text/Markdown, heading, links, image, divider), each bound to the
|
|
167
|
+
resources they choose, then publish it.
|
|
168
|
+
|
|
169
|
+
Security model — "only published widgets reveal data":
|
|
170
|
+
|
|
171
|
+
- A single public endpoint, `getPublishedStatusPage(slug)`, returns the layout
|
|
172
|
+
plus each widget's already-resolved, field-ALLOW-LISTED DTO. The public surface
|
|
173
|
+
has no generic data API, so it can only ever show what was placed on the page.
|
|
174
|
+
- Three gates: edit-time (you can only bind resources you can access), publish-time
|
|
175
|
+
(an audited, deliberate exposure that re-checks the editor can read every bound
|
|
176
|
+
resource via a user-scoped client), and render-time (resolvers run as a trusted
|
|
177
|
+
service but emit only DTO fields — never internal config, ids, or `createdBy`;
|
|
178
|
+
the service re-validates each DTO against its schema, so a resolver bug fails
|
|
179
|
+
closed).
|
|
180
|
+
- The overall banner rolls up only the bound systems; private resources are never
|
|
181
|
+
exposed beyond their public-safe status; per-binding label overrides avoid
|
|
182
|
+
internal-name leaks.
|
|
183
|
+
|
|
184
|
+
Coherence + extensibility:
|
|
185
|
+
|
|
186
|
+
- Status pages are team-scopable resources (RLAC): created via the standard
|
|
187
|
+
owning-team picker + create-capability flow, resolvable by name in the Teams
|
|
188
|
+
admin.
|
|
189
|
+
- Widget types come from an extension-point registry, so any plugin can contribute
|
|
190
|
+
a widget (config schema + public DTO + `resolvePublic`); the public renderers
|
|
191
|
+
are pure, prop-only components with no data access, so third-party widgets can
|
|
192
|
+
never leak.
|
|
193
|
+
- Draft vs published layouts; per-page visibility (public / authenticated-only)
|
|
194
|
+
and theming (brand color, logo).
|
|
195
|
+
|
|
196
|
+
Dependency direction: the status-page platform owns the widget-type registry and
|
|
197
|
+
the content widgets, but the DOMAIN widgets are contributed by their owning
|
|
198
|
+
plugins via the `statusWidgetTypeExtensionPoint` — system health / uptime /
|
|
199
|
+
banner / group status by `healthcheck-backend`, incidents by `incident-backend`,
|
|
200
|
+
scheduled maintenance by `maintenance-backend`. So `status-page-backend` depends
|
|
201
|
+
only on `backend-api` / `common` / `status-page-common`; the owning plugins
|
|
202
|
+
depend on the platform, never the reverse. `catalog-common` gains
|
|
203
|
+
`assertCatalogResourcesReadable` for the publish-time access check.
|
|
204
|
+
|
|
205
|
+
Phase 1 scope: the secure core, the admin builder, and the public page (served as
|
|
206
|
+
a no-access-rule route). A fully separate public bundle, custom domains + TLS,
|
|
207
|
+
drag-reorder, live-data preview, and distribution (embeds/badges/RSS/subscriptions)
|
|
208
|
+
are the next phases.
|
|
209
|
+
|
|
210
|
+
### Patch Changes
|
|
211
|
+
|
|
212
|
+
- Updated dependencies [551eaa9]
|
|
213
|
+
- Updated dependencies [d2077bd]
|
|
214
|
+
- Updated dependencies [9ab73c5]
|
|
215
|
+
- Updated dependencies [5c6393f]
|
|
216
|
+
- @checkstack/ai-backend@0.7.0
|
|
217
|
+
- @checkstack/ai-common@0.5.0
|
|
218
|
+
- @checkstack/auth-common@0.10.0
|
|
219
|
+
- @checkstack/backend-api@0.23.0
|
|
220
|
+
- @checkstack/common@0.16.0
|
|
221
|
+
- @checkstack/automation-backend@0.9.0
|
|
222
|
+
- @checkstack/automation-common@0.7.0
|
|
223
|
+
- @checkstack/catalog-backend@1.5.0
|
|
224
|
+
- @checkstack/catalog-common@2.4.0
|
|
225
|
+
- @checkstack/incident-common@1.6.0
|
|
226
|
+
- @checkstack/status-page-common@0.1.0
|
|
227
|
+
- @checkstack/status-page-backend@0.1.0
|
|
228
|
+
- @checkstack/command-backend@0.2.9
|
|
229
|
+
- @checkstack/integration-backend@0.6.2
|
|
230
|
+
- @checkstack/cache-api@0.3.13
|
|
231
|
+
- @checkstack/integration-common@0.9.1
|
|
232
|
+
- @checkstack/notification-common@1.3.4
|
|
233
|
+
- @checkstack/signal-common@0.2.10
|
|
234
|
+
- @checkstack/cache-utils@0.2.18
|
|
235
|
+
|
|
3
236
|
## 1.7.4
|
|
4
237
|
|
|
5
238
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/incident-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,23 +14,25 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/ai-backend": "0.
|
|
18
|
-
"@checkstack/ai-common": "0.
|
|
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.
|
|
24
|
-
"@checkstack/catalog-backend": "1.
|
|
25
|
-
"@checkstack/notification-common": "1.3.
|
|
26
|
-
"@checkstack/auth-common": "0.
|
|
27
|
-
"@checkstack/command-backend": "0.2.
|
|
28
|
-
"@checkstack/signal-common": "0.2.
|
|
29
|
-
"@checkstack/
|
|
30
|
-
"@checkstack/
|
|
31
|
-
"@checkstack/
|
|
32
|
-
"@checkstack/
|
|
33
|
-
"@checkstack/
|
|
17
|
+
"@checkstack/ai-backend": "0.7.0",
|
|
18
|
+
"@checkstack/ai-common": "0.5.0",
|
|
19
|
+
"@checkstack/backend-api": "0.23.0",
|
|
20
|
+
"@checkstack/cache-api": "0.3.13",
|
|
21
|
+
"@checkstack/cache-utils": "0.2.18",
|
|
22
|
+
"@checkstack/incident-common": "1.6.0",
|
|
23
|
+
"@checkstack/catalog-common": "2.4.0",
|
|
24
|
+
"@checkstack/catalog-backend": "1.5.0",
|
|
25
|
+
"@checkstack/notification-common": "1.3.4",
|
|
26
|
+
"@checkstack/auth-common": "0.10.0",
|
|
27
|
+
"@checkstack/command-backend": "0.2.9",
|
|
28
|
+
"@checkstack/signal-common": "0.2.10",
|
|
29
|
+
"@checkstack/status-page-backend": "0.1.0",
|
|
30
|
+
"@checkstack/status-page-common": "0.1.0",
|
|
31
|
+
"@checkstack/integration-backend": "0.6.2",
|
|
32
|
+
"@checkstack/integration-common": "0.9.1",
|
|
33
|
+
"@checkstack/automation-backend": "0.9.0",
|
|
34
|
+
"@checkstack/automation-common": "0.7.0",
|
|
35
|
+
"@checkstack/common": "0.16.0",
|
|
34
36
|
"drizzle-orm": "^0.45.0",
|
|
35
37
|
"zod": "^4.2.1",
|
|
36
38
|
"@orpc/contract": "^1.14.4",
|
|
@@ -38,8 +40,8 @@
|
|
|
38
40
|
},
|
|
39
41
|
"devDependencies": {
|
|
40
42
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
41
|
-
"@checkstack/scripts": "0.6.
|
|
42
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
43
|
+
"@checkstack/scripts": "0.6.2",
|
|
44
|
+
"@checkstack/test-utils-backend": "0.1.43",
|
|
43
45
|
"@checkstack/tsconfig": "0.0.7",
|
|
44
46
|
"@types/bun": "^1.0.0",
|
|
45
47
|
"drizzle-kit": "^0.31.10",
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as schema from "./schema";
|
|
2
2
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import { inArray, ilike } from "drizzle-orm";
|
|
3
4
|
import {
|
|
4
5
|
aiToolExtensionPoint,
|
|
5
6
|
aiToolProjectionExtensionPoint,
|
|
@@ -17,6 +18,8 @@ import {
|
|
|
17
18
|
incidentGroupSubscription,
|
|
18
19
|
} from "@checkstack/incident-common";
|
|
19
20
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
21
|
+
import { statusWidgetTypeExtensionPoint } from "@checkstack/status-page-backend";
|
|
22
|
+
import { registerIncidentStatusWidgets } from "./status-page-widget";
|
|
20
23
|
import {
|
|
21
24
|
automationActionExtensionPoint,
|
|
22
25
|
automationArtifactTypeExtensionPoint,
|
|
@@ -76,6 +79,13 @@ export default createBackendPlugin({
|
|
|
76
79
|
incidentGroupSubscription,
|
|
77
80
|
]);
|
|
78
81
|
|
|
82
|
+
// Status-page "Incidents" widget, owned by incident-backend (it owns
|
|
83
|
+
// incidents + their public-safe projection). Buffered behind the
|
|
84
|
+
// status-page extension point — status-page never depends on incident.
|
|
85
|
+
registerIncidentStatusWidgets(
|
|
86
|
+
env.getExtensionPoint(statusWidgetTypeExtensionPoint),
|
|
87
|
+
);
|
|
88
|
+
|
|
79
89
|
// Register triggers — buffered until the automation plugin's
|
|
80
90
|
// `register()` runs and the extension point resolves. Triggers expose
|
|
81
91
|
// `contextKey` so wait_for_trigger can match resume events back to the
|
|
@@ -137,6 +147,7 @@ export default createBackendPlugin({
|
|
|
137
147
|
signalService: coreServices.signalService,
|
|
138
148
|
cacheManager: coreServices.cacheManager,
|
|
139
149
|
advisoryLock: coreServices.advisoryLock,
|
|
150
|
+
resourceResolverRegistry: coreServices.resourceResolverRegistry,
|
|
140
151
|
},
|
|
141
152
|
init: async ({
|
|
142
153
|
logger,
|
|
@@ -146,6 +157,7 @@ export default createBackendPlugin({
|
|
|
146
157
|
signalService,
|
|
147
158
|
cacheManager,
|
|
148
159
|
advisoryLock,
|
|
160
|
+
resourceResolverRegistry,
|
|
149
161
|
}) => {
|
|
150
162
|
logger.debug("🔧 Initializing Incident Backend...");
|
|
151
163
|
|
|
@@ -153,13 +165,34 @@ export default createBackendPlugin({
|
|
|
153
165
|
const authClient = rpcClient.forPlugin(AuthApi);
|
|
154
166
|
const notificationClient = rpcClient.forPlugin(NotificationApi);
|
|
155
167
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
advisoryLock,
|
|
159
|
-
);
|
|
168
|
+
const typedDb = database as SafeDatabase<typeof schema>;
|
|
169
|
+
const service = new IncidentService(typedDb, advisoryLock);
|
|
160
170
|
// Publish the service for the PLUGIN-BACKED entity `read` accessor
|
|
161
171
|
// (defined in register()). Mutations only run from here onward.
|
|
162
172
|
incidentServiceRef = service;
|
|
173
|
+
|
|
174
|
+
// Resolve/search incidents by name for the Teams admin UI (team grants
|
|
175
|
+
// are stored as opaque incident.incident:<id> rows). Lets the auth
|
|
176
|
+
// backend render grants by name and power the grant picker.
|
|
177
|
+
resourceResolverRegistry.register("incident.incident", {
|
|
178
|
+
resolveNames: async (ids) => {
|
|
179
|
+
if (ids.length === 0) return new Map();
|
|
180
|
+
const rows = await typedDb
|
|
181
|
+
.select({ id: schema.incidents.id, title: schema.incidents.title })
|
|
182
|
+
.from(schema.incidents)
|
|
183
|
+
.where(inArray(schema.incidents.id, ids));
|
|
184
|
+
return new Map(rows.map((r) => [r.id, r.title]));
|
|
185
|
+
},
|
|
186
|
+
search: async (query, limit) => {
|
|
187
|
+
const rows = await typedDb
|
|
188
|
+
.select({ id: schema.incidents.id, title: schema.incidents.title })
|
|
189
|
+
.from(schema.incidents)
|
|
190
|
+
.where(ilike(schema.incidents.title, `%${query}%`))
|
|
191
|
+
.limit(limit);
|
|
192
|
+
return rows.map((r) => ({ id: r.id, name: r.title }));
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
163
196
|
const cache = createIncidentCache({ cacheManager, logger });
|
|
164
197
|
incidentCache = cache;
|
|
165
198
|
const router = createRouter(
|
package/src/router.ts
CHANGED
|
@@ -160,6 +160,11 @@ export function createRouter(
|
|
|
160
160
|
const userId =
|
|
161
161
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
162
162
|
|
|
163
|
+
// `teamId` is consumed exclusively by autoAuthMiddleware (create-mode
|
|
164
|
+
// ownership grant). Strip it here so it is never forwarded to the
|
|
165
|
+
// service layer or written into the incident row.
|
|
166
|
+
const { teamId: _teamId, ...serviceInput } = input;
|
|
167
|
+
|
|
163
168
|
// Drive the create through the reactive `incident` entity (§10.1):
|
|
164
169
|
// `apply` performs the REAL `incidents`/junction write (the plugin's own
|
|
165
170
|
// db/tx) and returns the new reactive state; the deriver fires
|
|
@@ -172,7 +177,7 @@ export function createRouter(
|
|
|
172
177
|
handle: getIncidentEntity?.(),
|
|
173
178
|
incidentId,
|
|
174
179
|
apply: async () => {
|
|
175
|
-
result = await service.createIncident(
|
|
180
|
+
result = await service.createIncident(serviceInput, userId, incidentId);
|
|
176
181
|
return toIncidentEntityState(result);
|
|
177
182
|
},
|
|
178
183
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import type {
|
|
4
|
+
WidgetResolveContext,
|
|
5
|
+
WidgetTypeDefinition,
|
|
6
|
+
} from "@checkstack/status-page-backend";
|
|
7
|
+
import { registerIncidentStatusWidgets } from "./status-page-widget";
|
|
8
|
+
|
|
9
|
+
/** Capture the single widget the plugin registers, to exercise it directly. */
|
|
10
|
+
function capture(): WidgetTypeDefinition {
|
|
11
|
+
let captured: WidgetTypeDefinition | undefined;
|
|
12
|
+
registerIncidentStatusWidgets({
|
|
13
|
+
registerWidgetType: (def) => {
|
|
14
|
+
captured = def;
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
if (!captured) throw new Error("no widget registered");
|
|
18
|
+
return captured;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A context that throws on ANY read — proves the resolver touched no data. */
|
|
22
|
+
const noReadCtx: WidgetResolveContext = {
|
|
23
|
+
rpcClient: {
|
|
24
|
+
forPlugin: () => {
|
|
25
|
+
throw new Error("must not read");
|
|
26
|
+
},
|
|
27
|
+
} as unknown as RpcClient,
|
|
28
|
+
cache: () => {
|
|
29
|
+
throw new Error("must not read");
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("incidents widget — fail closed (S1)", () => {
|
|
34
|
+
test("no bound systems resolves to empty without reading anything", async () => {
|
|
35
|
+
const widget = capture();
|
|
36
|
+
expect(
|
|
37
|
+
await widget.resolvePublic({ config: {}, ctx: noReadCtx }),
|
|
38
|
+
).toEqual({ incidents: [] });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("binds exactly the configured systems (publish-gate input)", () => {
|
|
42
|
+
expect(
|
|
43
|
+
capture().boundResources({ systemIds: ["s1", "s2"], limit: 5 }),
|
|
44
|
+
).toEqual([
|
|
45
|
+
{ resourceType: "catalog.system", resourceId: "s1" },
|
|
46
|
+
{ resourceType: "catalog.system", resourceId: "s2" },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { CatalogApi, assertCatalogResourcesReadable } from "@checkstack/catalog-common";
|
|
2
|
+
import { IncidentApi } from "@checkstack/incident-common";
|
|
3
|
+
import {
|
|
4
|
+
pluginMetadata as statusPagePluginMetadata,
|
|
5
|
+
IncidentsConfigSchema,
|
|
6
|
+
IncidentsDtoSchema,
|
|
7
|
+
toPublicUpdate,
|
|
8
|
+
selectEvents,
|
|
9
|
+
type InternalUpdate,
|
|
10
|
+
type PublicUpdate,
|
|
11
|
+
} from "@checkstack/status-page-common";
|
|
12
|
+
import type {
|
|
13
|
+
WidgetResolveContext,
|
|
14
|
+
WidgetTypeDefinition,
|
|
15
|
+
StatusWidgetTypeExtensionPoint,
|
|
16
|
+
} from "@checkstack/status-page-backend";
|
|
17
|
+
|
|
18
|
+
const SYSTEM_TYPE = "catalog.system";
|
|
19
|
+
|
|
20
|
+
function iso(value: string | Date): string {
|
|
21
|
+
return value instanceof Date ? value.toISOString() : String(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Newest `max` updates, most-recent first (the current progress at the top). */
|
|
25
|
+
function latestUpdates(updates: InternalUpdate[], max: number): PublicUpdate[] {
|
|
26
|
+
return updates
|
|
27
|
+
.toSorted(
|
|
28
|
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
29
|
+
)
|
|
30
|
+
.slice(0, max)
|
|
31
|
+
.map((u) => toPublicUpdate(u));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function labelsFor(
|
|
35
|
+
ctx: WidgetResolveContext,
|
|
36
|
+
ids: string[],
|
|
37
|
+
): Promise<Map<string, string>> {
|
|
38
|
+
if (ids.length === 0) return new Map();
|
|
39
|
+
const all = await ctx.cache("catalog.systemNames", async () => {
|
|
40
|
+
const { systems } = await ctx.rpcClient.forPlugin(CatalogApi).getSystems();
|
|
41
|
+
return new Map(systems.map((s) => [s.id, s.name] as const));
|
|
42
|
+
});
|
|
43
|
+
const out = new Map<string, string>();
|
|
44
|
+
for (const id of ids) {
|
|
45
|
+
const name = all.get(id);
|
|
46
|
+
if (name !== undefined) out.set(id, name);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const incidents: WidgetTypeDefinition = {
|
|
52
|
+
id: "incidents",
|
|
53
|
+
displayName: "Incidents",
|
|
54
|
+
description: "Recent unresolved incidents with their update timeline.",
|
|
55
|
+
category: "Events",
|
|
56
|
+
binding: "systems",
|
|
57
|
+
configSchema: IncidentsConfigSchema,
|
|
58
|
+
dtoSchema: IncidentsDtoSchema,
|
|
59
|
+
boundResources: (config) =>
|
|
60
|
+
IncidentsConfigSchema.parse(config).systemIds.map((id) => ({
|
|
61
|
+
resourceType: SYSTEM_TYPE,
|
|
62
|
+
resourceId: id,
|
|
63
|
+
})),
|
|
64
|
+
assertBindingsReadable: async ({ userClient, config }) => {
|
|
65
|
+
await assertCatalogResourcesReadable({
|
|
66
|
+
client: userClient.forPlugin(CatalogApi),
|
|
67
|
+
systemIds: IncidentsConfigSchema.parse(config).systemIds,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
async resolvePublic({ config, ctx }) {
|
|
71
|
+
const c = IncidentsConfigSchema.parse(config);
|
|
72
|
+
const bound = new Set(c.systemIds);
|
|
73
|
+
// FAIL CLOSED: no systems bound -> nothing the operator chose to expose.
|
|
74
|
+
// Never fall back to "all incidents" (that would be a trusted-service read
|
|
75
|
+
// of every incident on the platform).
|
|
76
|
+
if (bound.size === 0) return IncidentsDtoSchema.parse({ incidents: [] });
|
|
77
|
+
const inc = ctx.rpcClient.forPlugin(IncidentApi);
|
|
78
|
+
const { incidents: all } = await inc.listIncidents({
|
|
79
|
+
includeResolved: c.includePast,
|
|
80
|
+
});
|
|
81
|
+
const inScope = all.filter((i) => i.systemIds.some((s) => bound.has(s)));
|
|
82
|
+
// Active first, then recently-resolved within the configured max age.
|
|
83
|
+
const { active, past } = selectEvents({
|
|
84
|
+
items: inScope,
|
|
85
|
+
isPast: (i) => i.status === "resolved",
|
|
86
|
+
timestampOf: (i) => i.updatedAt,
|
|
87
|
+
includePast: c.includePast,
|
|
88
|
+
pastMaxAgeDays: c.pastMaxAgeDays,
|
|
89
|
+
limit: c.limit,
|
|
90
|
+
now: Date.now(),
|
|
91
|
+
});
|
|
92
|
+
// Only label BOUND systems; an unbound co-affected system must not leak.
|
|
93
|
+
const names = await labelsFor(ctx, [...bound]);
|
|
94
|
+
const items = await Promise.allSettled(
|
|
95
|
+
[...active, ...past].map(async (i) => {
|
|
96
|
+
// showUpdates=false also skips the per-item detail fetch (perf).
|
|
97
|
+
const detail = c.showUpdates ? await inc.getIncident({ id: i.id }) : null;
|
|
98
|
+
const updates = latestUpdates(
|
|
99
|
+
(detail?.updates ?? []) as InternalUpdate[],
|
|
100
|
+
c.maxUpdates,
|
|
101
|
+
);
|
|
102
|
+
const resolved = i.status === "resolved";
|
|
103
|
+
return {
|
|
104
|
+
id: i.id,
|
|
105
|
+
title: i.title,
|
|
106
|
+
status: i.status,
|
|
107
|
+
severity: i.severity,
|
|
108
|
+
systems: i.systemIds
|
|
109
|
+
.map((id) => names.get(id))
|
|
110
|
+
.filter((l): l is string => l !== undefined),
|
|
111
|
+
startedAt: iso(i.createdAt),
|
|
112
|
+
...(resolved ? { resolvedAt: iso(i.updatedAt) } : {}),
|
|
113
|
+
updates,
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
return IncidentsDtoSchema.parse({
|
|
118
|
+
incidents: items
|
|
119
|
+
.filter((r) => r.status === "fulfilled")
|
|
120
|
+
.map((r) => r.value),
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Register the incident-owned status-page widget under the `statuspage.*` namespace. */
|
|
126
|
+
export function registerIncidentStatusWidgets(
|
|
127
|
+
ext: StatusWidgetTypeExtensionPoint,
|
|
128
|
+
): void {
|
|
129
|
+
ext.registerWidgetType(incidents, statusPagePluginMetadata);
|
|
130
|
+
}
|