@checkstack/incident-backend 1.7.4 → 1.8.1

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,254 @@
1
1
  # @checkstack/incident-backend
2
2
 
3
+ ## 1.8.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [b1a5f3c]
8
+ - @checkstack/backend-api@0.24.0
9
+ - @checkstack/status-page-common@0.2.0
10
+ - @checkstack/status-page-backend@0.2.0
11
+ - @checkstack/ai-backend@0.7.1
12
+ - @checkstack/automation-backend@0.9.1
13
+ - @checkstack/catalog-backend@1.5.1
14
+ - @checkstack/command-backend@0.2.10
15
+ - @checkstack/integration-backend@0.6.3
16
+ - @checkstack/catalog-common@2.4.1
17
+ - @checkstack/incident-common@1.6.1
18
+
19
+ ## 1.8.0
20
+
21
+ ### Minor Changes
22
+
23
+ - d2077bd: Platform-wide team-scoped access control on a unified relation-tuple store.
24
+
25
+ Admins can scope any resource to teams, and the **platform** (not each plugin)
26
+ enforces it. A plugin opts in declaratively by adding `instanceAccess` to a
27
+ procedure's contract; the auth middleware does the rest, so enforcement is
28
+ consistent across catalog, health checks, incidents, maintenances, SLOs,
29
+ automations, and the dependency map, and any third-party plugin gets it for free.
30
+
31
+ Core model:
32
+
33
+ - **Teams are optional.** A resource with no team grants behaves exactly as
34
+ before.
35
+ - **Team grants are additive and restrict who can CHANGE a resource, not who can
36
+ SEE it.** Granting a team `Manage` lets its members view and change the
37
+ resource; `Read-only` lets them view it. Either level grants access to team
38
+ members **even when they lack the global permission**, and granting never
39
+ removes read from anyone who already had it (e.g. a public status page stays
40
+ readable). Privacy is a separate, explicit opt-in via the **Private** toggle,
41
+ which removes the global read path so only the resource's teams can see it.
42
+ - **Ownership at creation.** Create forms expose an **Owning team** picker. A
43
+ non-admin can create a resource for a team they belong to that holds a
44
+ create-capability grant for that type; the new resource is auto-granted to that
45
+ team. Incidents and maintenances are **parent-gated**: anyone who can manage a
46
+ system may open incidents/maintenances for it, no separate grant needed.
47
+ - **Meaningful authorization errors.** A caller with neither the global rule nor
48
+ any team grant for a resource type gets a `403` with a structured body instead
49
+ of a silently-empty `200`. Anonymous callers on public endpoints are never
50
+ `403`'d, so status pages keep rendering.
51
+
52
+ Unified relation-tuple store:
53
+
54
+ - The previously separate access primitives (`resource_team_access.canRead` /
55
+ `.canManage`, ownership, `resource_access_settings.teamOnly`, and
56
+ `resource_create_grant`) are collapsed onto ONE
57
+ `relation_tuple(object, relation, subject)` store: "a team has
58
+ `viewer`/`editor`/`owner` on an object, or `creator` on a type". Privacy is an
59
+ explicit **`private` marker** tuple — its **presence** closes the global read
60
+ path (team grants only), its **absence** is the readable-by-default state, so a
61
+ private resource with zero grants is correctly inaccessible to everyone rather
62
+ than silently globalized. The access decision is a pure, unit-tested function.
63
+ - The auth API is generic: `writeRelation` / `removeRelation` / `setObjectPublic`
64
+ / `listObjectRelations` / `listSubjectRelations` / `setCreateGrant` /
65
+ `listTeamCreateGrants` (user-facing) and `check` / `listAccessibleObjectIds` /
66
+ `hasAnyTypeGrant` / `authorizeCreate` / `setOwner` / `deleteObjectRelations`
67
+ (service-to-service). Migration `0008` backfills tuples from the legacy tables
68
+ and drops them.
69
+
70
+ Explicit per-procedure scoping:
71
+
72
+ - Access rules (`access()` / `accessPair()`) define only the rule (id, level,
73
+ defaults); every procedure declares its own `instanceAccess`. This removes a
74
+ "loaded gun" default that silently applied a shared `idParam` to any procedure
75
+ which forgot its own override.
76
+ - Modes: `idParam` (single-resource pre-check, fails **closed** if the id does
77
+ not resolve), `listKey` / `recordKey` (post-filter a list/record to the
78
+ accessible subset), `create` (authorize creation + write the owning-team
79
+ grant), `parentScope` (scope by read/manage access to a PARENT type,
80
+ cross-plugin single-hop: "you may see incidents/maintenances/SLOs/health for
81
+ system S iff you may see S"), and `global: true` (the honest "intentionally not
82
+ team-scoped" opt-out). A boot-time validator **rejects** any procedure gated on
83
+ a team-scopable resource type that declares no `instanceAccess`, turning the
84
+ previous fail-open into a boot error.
85
+
86
+ Teams administration:
87
+
88
+ - **Team managers** manage their own team's members and managers without the
89
+ global `auth.teams.manage` rule; creating, deleting, and granting a team access
90
+ remain admin-only.
91
+ - A **standalone Teams page** (gated on `auth.teams.read`) lets managers reach
92
+ team administration without the admin Auth Settings page; members are added via
93
+ a debounced directory picker.
94
+ - A **cross-plugin `ResourceResolverRegistry`** lets owning plugins register a
95
+ name/search resolver for their resource types, so the Teams page lists a team's
96
+ grants **by name** (grouped by type) and offers a resource picker — an admin can
97
+ change a grant's level, revoke it, or add one, without auth depending on every
98
+ plugin. Resolvers shipped for catalog systems, health-check configurations,
99
+ incidents, maintenances, SLO objectives, and automations.
100
+
101
+ Frontend:
102
+
103
+ - The resource-side editor is **"Who can change this"** (one Manage checkbox per
104
+ team; unticked = read-only), with an always-visible **Private** toggle
105
+ (disabled until a team that can Manage exists, so a resource can't be stranded).
106
+ - `TeamOwnershipPicker` explains _why_ there's nothing to pick (not a member of
107
+ any team, or none of your teams manage the selected parent) instead of a bare
108
+ "global resource" line.
109
+ - Read-only **"who can change this"** indicators on resource detail pages expand
110
+ to the actual people by name; bulk + per-row **Scope to team** actions in the
111
+ catalog systems list; and the team-access copy spells out that grants are
112
+ additive and that Read-only grants view (not change) even without the global
113
+ permission.
114
+
115
+ Security hardening:
116
+
117
+ - Child deletes in catalog (`removeSystemContact` / `removeSystemLink`) are scoped
118
+ to both the child id and its parent `systemId`, closing a cross-system IDOR for
119
+ team-scoped managers.
120
+ - `searchUsers` is restricted to team administrators, closing a directory/email
121
+ enumeration path opened by the default `auth.teams.read` rule.
122
+ - Grant setters reject unregistered resource types.
123
+
124
+ BREAKING CHANGES (beta; shipped as minor bumps):
125
+
126
+ - `access()` and `accessPair()` no longer accept `idParam` / `listKey` /
127
+ `recordKey`; move instance config to the procedure's `instanceAccess`.
128
+ - Boot fails if a procedure gated on a team-scopable resource type omits
129
+ `instanceAccess`. Declare a scoping mode or `instanceAccess: { global: true }`.
130
+ - The `AuthService` interface is reshaped: `check`, `listAccessibleObjectIds`,
131
+ `hasAnyTypeGrant`, `authorizeCreate` (returns `isPrivate`), `setOwner`
132
+ (`isPrivate`), and `deleteObjectRelations`. Custom `AuthService` implementations
133
+ and mocks must update.
134
+ - The auth RPC contract's per-concept resource-access endpoints are replaced by
135
+ the generic tuple API above; external callers of the old
136
+ `getResourceTeamAccess` / `setResourceTeamAccess` / `setResourceAccessSettings`
137
+ / `grantResourceCreate` / etc. must move to the new procedures.
138
+ - Several contract inputs changed from a bare `string` to an object so the
139
+ middleware can resolve the resource id: catalog `deleteSystem` (`{ id }`),
140
+ `removeSystemContact` / `removeSystemLink` (`{ id, systemId }`); health-check
141
+ `deleteConfiguration` / `pauseConfiguration` / `resumeConfiguration` (`{ id }`).
142
+ All in-tree callers are updated.
143
+ - List/record endpoints that relied on returning an empty `200` to signal "no
144
+ access" now return a `403` for categorically-unauthorized principals.
145
+ - The mis-keyed bulk endpoints `getBulkIncidentsForSystems`,
146
+ `getBulkMaintenancesForSystems`, and `getBulkObjectivesForSystems` no longer
147
+ post-filter their (systemId-keyed) result; access is already gated by
148
+ `catalog.system` upstream.
149
+ - Team membership/manager mutations (`addUserToTeam`, `removeUserFromTeam`,
150
+ `addTeamManager`, `removeTeamManager`) now require `auth.teams.read` instead of
151
+ `auth.teams.manage` at the contract level (broadened to per-team managers).
152
+ - The `resource_team_access`, `resource_access_settings`, and
153
+ `resource_create_grant` tables are dropped (data backfilled into
154
+ `relation_tuple` by migration `0008`). A previously inconsistent "team-only with
155
+ zero grants" resource is now correctly inaccessible to global-access holders.
156
+
157
+ - 9ab73c5: Status pages: configurable incident/maintenance updates + recently resolved/completed items.
158
+
159
+ The Incidents and Maintenance widgets gain four config options (in the builder):
160
+
161
+ - **Show updates** (default on) — render the per-item update timeline so visitors
162
+ can follow progress. The maintenance widget now renders its timeline too
163
+ (previously it fetched updates but didn't show them). Turning this off also
164
+ skips the per-item detail fetch (a perf win).
165
+ - **Max updates per item** (default 3) — show only the latest N updates,
166
+ most-recent first, so a chatty incident doesn't dominate the page.
167
+ - **Show recently resolved / completed** (default off) — include resolved
168
+ incidents / completed maintenances, rendered in a separate "Recently resolved"
169
+ / "Past maintenance" subsection below the active items.
170
+ - **Max age (days)** (default 7) — only include past items resolved/completed
171
+ within the window.
172
+
173
+ Scoping and isolation are unchanged: still only the systems the operator bound,
174
+ still fail-closed when none are bound, still field-allow-listed DTOs (no
175
+ `createdBy`). The active/past partition + max-age + cap is a pure, unit-tested
176
+ helper (`selectEvents`).
177
+
178
+ - 5c6393f: Add operator-built public Status Pages (phase 1: secure, extensible core).
179
+
180
+ Operators compose a public status page from widgets (status banner, system
181
+ health, group status, 90-day uptime, incidents, scheduled maintenance) plus
182
+ content blocks (text/Markdown, heading, links, image, divider), each bound to the
183
+ resources they choose, then publish it.
184
+
185
+ Security model — "only published widgets reveal data":
186
+
187
+ - A single public endpoint, `getPublishedStatusPage(slug)`, returns the layout
188
+ plus each widget's already-resolved, field-ALLOW-LISTED DTO. The public surface
189
+ has no generic data API, so it can only ever show what was placed on the page.
190
+ - Three gates: edit-time (you can only bind resources you can access), publish-time
191
+ (an audited, deliberate exposure that re-checks the editor can read every bound
192
+ resource via a user-scoped client), and render-time (resolvers run as a trusted
193
+ service but emit only DTO fields — never internal config, ids, or `createdBy`;
194
+ the service re-validates each DTO against its schema, so a resolver bug fails
195
+ closed).
196
+ - The overall banner rolls up only the bound systems; private resources are never
197
+ exposed beyond their public-safe status; per-binding label overrides avoid
198
+ internal-name leaks.
199
+
200
+ Coherence + extensibility:
201
+
202
+ - Status pages are team-scopable resources (RLAC): created via the standard
203
+ owning-team picker + create-capability flow, resolvable by name in the Teams
204
+ admin.
205
+ - Widget types come from an extension-point registry, so any plugin can contribute
206
+ a widget (config schema + public DTO + `resolvePublic`); the public renderers
207
+ are pure, prop-only components with no data access, so third-party widgets can
208
+ never leak.
209
+ - Draft vs published layouts; per-page visibility (public / authenticated-only)
210
+ and theming (brand color, logo).
211
+
212
+ Dependency direction: the status-page platform owns the widget-type registry and
213
+ the content widgets, but the DOMAIN widgets are contributed by their owning
214
+ plugins via the `statusWidgetTypeExtensionPoint` — system health / uptime /
215
+ banner / group status by `healthcheck-backend`, incidents by `incident-backend`,
216
+ scheduled maintenance by `maintenance-backend`. So `status-page-backend` depends
217
+ only on `backend-api` / `common` / `status-page-common`; the owning plugins
218
+ depend on the platform, never the reverse. `catalog-common` gains
219
+ `assertCatalogResourcesReadable` for the publish-time access check.
220
+
221
+ Phase 1 scope: the secure core, the admin builder, and the public page (served as
222
+ a no-access-rule route). A fully separate public bundle, custom domains + TLS,
223
+ drag-reorder, live-data preview, and distribution (embeds/badges/RSS/subscriptions)
224
+ are the next phases.
225
+
226
+ ### Patch Changes
227
+
228
+ - Updated dependencies [551eaa9]
229
+ - Updated dependencies [d2077bd]
230
+ - Updated dependencies [9ab73c5]
231
+ - Updated dependencies [5c6393f]
232
+ - @checkstack/ai-backend@0.7.0
233
+ - @checkstack/ai-common@0.5.0
234
+ - @checkstack/auth-common@0.10.0
235
+ - @checkstack/backend-api@0.23.0
236
+ - @checkstack/common@0.16.0
237
+ - @checkstack/automation-backend@0.9.0
238
+ - @checkstack/automation-common@0.7.0
239
+ - @checkstack/catalog-backend@1.5.0
240
+ - @checkstack/catalog-common@2.4.0
241
+ - @checkstack/incident-common@1.6.0
242
+ - @checkstack/status-page-common@0.1.0
243
+ - @checkstack/status-page-backend@0.1.0
244
+ - @checkstack/command-backend@0.2.9
245
+ - @checkstack/integration-backend@0.6.2
246
+ - @checkstack/cache-api@0.3.13
247
+ - @checkstack/integration-common@0.9.1
248
+ - @checkstack/notification-common@1.3.4
249
+ - @checkstack/signal-common@0.2.10
250
+ - @checkstack/cache-utils@0.2.18
251
+
3
252
  ## 1.7.4
4
253
 
5
254
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.7.4",
3
+ "version": "1.8.1",
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.1",
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.12",
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.1",
32
- "@checkstack/automation-common": "0.6.0",
33
- "@checkstack/common": "0.15.0",
17
+ "@checkstack/ai-backend": "0.7.1",
18
+ "@checkstack/ai-common": "0.5.0",
19
+ "@checkstack/backend-api": "0.24.0",
20
+ "@checkstack/cache-api": "0.3.13",
21
+ "@checkstack/cache-utils": "0.2.18",
22
+ "@checkstack/incident-common": "1.6.1",
23
+ "@checkstack/catalog-common": "2.4.1",
24
+ "@checkstack/catalog-backend": "1.5.1",
25
+ "@checkstack/notification-common": "1.3.4",
26
+ "@checkstack/auth-common": "0.10.0",
27
+ "@checkstack/command-backend": "0.2.10",
28
+ "@checkstack/signal-common": "0.2.10",
29
+ "@checkstack/status-page-backend": "0.2.0",
30
+ "@checkstack/status-page-common": "0.2.0",
31
+ "@checkstack/integration-backend": "0.6.3",
32
+ "@checkstack/integration-common": "0.9.1",
33
+ "@checkstack/automation-backend": "0.9.1",
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.44",
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
  }