@checkstack/incident-backend 1.7.3 → 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 CHANGED
@@ -1,5 +1,247 @@
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
+
236
+ ## 1.7.4
237
+
238
+ ### Patch Changes
239
+
240
+ - Updated dependencies [bb6f0fe]
241
+ - @checkstack/ai-backend@0.6.1
242
+ - @checkstack/automation-backend@0.8.1
243
+ - @checkstack/catalog-backend@1.4.12
244
+
3
245
  ## 1.7.3
4
246
 
5
247
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.7.3",
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.6.0",
18
- "@checkstack/ai-common": "0.4.0",
19
- "@checkstack/backend-api": "0.22.0",
20
- "@checkstack/cache-api": "0.3.12",
21
- "@checkstack/cache-utils": "0.2.17",
22
- "@checkstack/incident-common": "1.5.2",
23
- "@checkstack/catalog-common": "2.3.6",
24
- "@checkstack/catalog-backend": "1.4.11",
25
- "@checkstack/notification-common": "1.3.3",
26
- "@checkstack/auth-common": "0.9.1",
27
- "@checkstack/command-backend": "0.2.8",
28
- "@checkstack/signal-common": "0.2.9",
29
- "@checkstack/integration-backend": "0.6.1",
30
- "@checkstack/integration-common": "0.9.0",
31
- "@checkstack/automation-backend": "0.8.0",
32
- "@checkstack/automation-common": "0.6.0",
33
- "@checkstack/common": "0.15.0",
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.1",
42
- "@checkstack/test-utils-backend": "0.1.42",
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 service = new IncidentService(
157
- database as SafeDatabase<typeof schema>,
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(input, userId, incidentId);
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
+ }
package/tsconfig.json CHANGED
@@ -58,6 +58,12 @@
58
58
  {
59
59
  "path": "../signal-common"
60
60
  },
61
+ {
62
+ "path": "../status-page-backend"
63
+ },
64
+ {
65
+ "path": "../status-page-common"
66
+ },
61
67
  {
62
68
  "path": "../test-utils-backend"
63
69
  }