@checkstack/incident-backend 1.11.0 → 1.13.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.
@@ -47,3 +47,152 @@ describe("incidents widget — fail closed (S1)", () => {
47
47
  ]);
48
48
  });
49
49
  });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Environment filtering: when a page publishes a specific environment set, the
53
+ // widget omits systems (and thus incidents whose only affected system is)
54
+ // outside those environments - across resolvePublic AND resolveScopedSystems.
55
+ // ---------------------------------------------------------------------------
56
+
57
+ interface IncidentFixture {
58
+ id: string;
59
+ title: string;
60
+ status: string;
61
+ severity: string;
62
+ systemIds: string[];
63
+ createdAt: string;
64
+ updatedAt: string;
65
+ }
66
+
67
+ function makeCtx(args: {
68
+ publishedEnvironmentIds?: string[];
69
+ incidents?: IncidentFixture[];
70
+ /** environmentId -> member system ids (the catalog env->systems mapping). */
71
+ envSystems?: Record<string, string[]>;
72
+ systems?: Array<{ id: string; name: string }>;
73
+ }): WidgetResolveContext {
74
+ const { publishedEnvironmentIds, incidents = [], envSystems = {}, systems = [] } =
75
+ args;
76
+ const memo = new Map<string, Promise<unknown>>();
77
+ const api = {
78
+ resolveEnvironments: async ({
79
+ environmentIds,
80
+ }: {
81
+ environmentIds: string[];
82
+ }) =>
83
+ environmentIds.map((id) => ({
84
+ id,
85
+ name: id,
86
+ description: null,
87
+ systemIds: envSystems[id] ?? [],
88
+ metadata: null,
89
+ createdAt: new Date(),
90
+ updatedAt: new Date(),
91
+ })),
92
+ getGroups: async () => [],
93
+ getSystems: async () => ({ systems }),
94
+ listIncidents: async () => ({ incidents }),
95
+ getBulkIncidentUpdates: async () => ({ updates: {} }),
96
+ };
97
+ return {
98
+ rpcClient: {
99
+ forPlugin: () => api,
100
+ } as unknown as RpcClient,
101
+ cache: <T,>(key: string, loader: () => Promise<T>): Promise<T> => {
102
+ const existing = memo.get(key);
103
+ if (existing) return existing as Promise<T>;
104
+ const created = loader();
105
+ memo.set(key, created);
106
+ return created;
107
+ },
108
+ publishedEnvironmentIds,
109
+ };
110
+ }
111
+
112
+ describe("incidents widget — environment filtering", () => {
113
+ const envSystems = { prod: ["prod-sys"], stage: ["stage-sys"] };
114
+ const systems = [
115
+ { id: "prod-sys", name: "Prod System" },
116
+ { id: "stage-sys", name: "Stage System" },
117
+ ];
118
+
119
+ test("resolveScopedSystems keeps only systems in the published env", async () => {
120
+ const widget = capture();
121
+ const scope = await widget.resolveScopedSystems!({
122
+ config: { systemIds: ["prod-sys", "stage-sys"] },
123
+ ctx: makeCtx({ publishedEnvironmentIds: ["prod"], envSystems, systems }),
124
+ });
125
+ expect([...scope].toSorted()).toEqual(["prod-sys"]);
126
+ });
127
+
128
+ test("no env filter keeps every bound system", async () => {
129
+ const widget = capture();
130
+ const scope = await widget.resolveScopedSystems!({
131
+ config: { systemIds: ["prod-sys", "stage-sys"] },
132
+ ctx: makeCtx({ envSystems, systems }),
133
+ });
134
+ expect([...scope].toSorted()).toEqual(["prod-sys", "stage-sys"]);
135
+ });
136
+
137
+ test("resolvePublic drops a staging-only incident when publishing prod", async () => {
138
+ const incidents: IncidentFixture[] = [
139
+ {
140
+ id: "stage-only",
141
+ title: "Stage outage",
142
+ status: "investigating",
143
+ severity: "minor",
144
+ systemIds: ["stage-sys"],
145
+ createdAt: "2026-07-01T00:00:00Z",
146
+ updatedAt: "2026-07-01T00:00:00Z",
147
+ },
148
+ {
149
+ id: "prod-inc",
150
+ title: "Prod outage",
151
+ status: "investigating",
152
+ severity: "major",
153
+ systemIds: ["prod-sys"],
154
+ createdAt: "2026-07-01T00:00:00Z",
155
+ updatedAt: "2026-07-01T00:00:00Z",
156
+ },
157
+ ];
158
+ const widget = capture();
159
+ const result = (await widget.resolvePublic({
160
+ config: { systemIds: ["prod-sys", "stage-sys"], showUpdates: false },
161
+ ctx: makeCtx({
162
+ publishedEnvironmentIds: ["prod"],
163
+ incidents,
164
+ envSystems,
165
+ systems,
166
+ }),
167
+ })) as { incidents: Array<{ id: string; systems: string[] }> };
168
+ expect(result.incidents.map((i) => i.id)).toEqual(["prod-inc"]);
169
+ });
170
+
171
+ test("multi-env incident is kept but labels only its published-env systems", async () => {
172
+ const incidents: IncidentFixture[] = [
173
+ {
174
+ id: "both",
175
+ title: "Cross-env outage",
176
+ status: "investigating",
177
+ severity: "major",
178
+ systemIds: ["prod-sys", "stage-sys"],
179
+ createdAt: "2026-07-01T00:00:00Z",
180
+ updatedAt: "2026-07-01T00:00:00Z",
181
+ },
182
+ ];
183
+ const widget = capture();
184
+ const result = (await widget.resolvePublic({
185
+ config: { systemIds: ["prod-sys", "stage-sys"], showUpdates: false },
186
+ ctx: makeCtx({
187
+ publishedEnvironmentIds: ["prod"],
188
+ incidents,
189
+ envSystems,
190
+ systems,
191
+ }),
192
+ })) as { incidents: Array<{ id: string; systems: string[] }> };
193
+ expect(result.incidents).toHaveLength(1);
194
+ // Only the published-env (prod) system is labelled; the staging system is
195
+ // not leaked even though the incident also affects it (multi-env caveat).
196
+ expect(result.incidents[0]?.systems).toEqual(["Prod System"]);
197
+ });
198
+ });
@@ -6,6 +6,7 @@ import {
6
6
  IncidentsDtoSchema,
7
7
  toPublicUpdate,
8
8
  selectEvents,
9
+ resolveEventFeedScope,
9
10
  type InternalUpdate,
10
11
  type PublicUpdate,
11
12
  } from "@checkstack/status-page-common";
@@ -16,6 +17,68 @@ import type {
16
17
  } from "@checkstack/status-page-backend";
17
18
 
18
19
  const SYSTEM_TYPE = "catalog.system";
20
+ const GROUP_TYPE = "catalog.group";
21
+
22
+ /** Current membership of every catalog group, fetched once per page resolve. */
23
+ async function groupMembers(
24
+ ctx: WidgetResolveContext,
25
+ ): Promise<Map<string, string[]>> {
26
+ const groups = await ctx.cache("catalog.groups", async () => {
27
+ const all = await ctx.rpcClient.forPlugin(CatalogApi).getGroups();
28
+ return all.map((g) => ({ id: g.id, systemIds: g.systemIds }));
29
+ });
30
+ return new Map(groups.map((g) => [g.id, g.systemIds] as const));
31
+ }
32
+
33
+ /**
34
+ * The catalog system ids visible under the page's published-environment scope,
35
+ * or null when the page publishes all environments (no filter). Resolved once
36
+ * per page resolve (cache-keyed by the env set) from the catalog's own
37
+ * env->systems mapping, so a system in NONE of the selected environments is
38
+ * omitted from what the widget shows, offers for subscription, and emails about.
39
+ */
40
+ async function envVisibleSystems(
41
+ ctx: WidgetResolveContext,
42
+ ): Promise<Set<string> | null> {
43
+ const envIds = ctx.publishedEnvironmentIds;
44
+ if (!envIds || envIds.length === 0) return null;
45
+ const key = `catalog.systemsInEnv:${[...envIds].toSorted().join(",")}`;
46
+ const ids = await ctx.cache(key, async () => {
47
+ const envs = await ctx.rpcClient
48
+ .forPlugin(CatalogApi)
49
+ .resolveEnvironments({ environmentIds: envIds });
50
+ const set = new Set<string>();
51
+ for (const env of envs) for (const s of env.systemIds) set.add(s);
52
+ return [...set];
53
+ });
54
+ return new Set(ids);
55
+ }
56
+
57
+ /**
58
+ * The CURRENT effective set of catalog system ids this incidents config
59
+ * surfaces: `(systemIds ∪ members(groupIds)) − excludedSystemIds`, expanded from
60
+ * the SAME live catalog source (`groupMembers` via `getGroups`) the DTO resolve
61
+ * uses, then INTERSECTED with the page's published-environment scope. Shared by
62
+ * `resolvePublic` (what the widget shows) and `resolveScopedSystems` (what the
63
+ * subscriber fan-out is allowed to email about) so the two can NEVER diverge.
64
+ * Empty when nothing is bound (fail closed).
65
+ */
66
+ async function effectiveScope(
67
+ config: unknown,
68
+ ctx: WidgetResolveContext,
69
+ ): Promise<Set<string>> {
70
+ const c = IncidentsConfigSchema.parse(config);
71
+ if (c.systemIds.length === 0 && c.groupIds.length === 0) return new Set();
72
+ const scope = resolveEventFeedScope({
73
+ systemIds: c.systemIds,
74
+ groupIds: c.groupIds,
75
+ excludedSystemIds: c.excludedSystemIds,
76
+ groupMembers: c.groupIds.length > 0 ? await groupMembers(ctx) : new Map(),
77
+ });
78
+ const visible = await envVisibleSystems(ctx);
79
+ if (!visible) return scope;
80
+ return new Set([...scope].filter((id) => visible.has(id)));
81
+ }
19
82
 
20
83
  function iso(value: string | Date): string {
21
84
  return value instanceof Date ? value.toISOString() : String(value);
@@ -24,6 +87,10 @@ function iso(value: string | Date): string {
24
87
  /** Newest `max` updates, most-recent first (the current progress at the top). */
25
88
  function latestUpdates(updates: InternalUpdate[], max: number): PublicUpdate[] {
26
89
  return updates
90
+ // The public status page is anonymous: only `public`-visibility updates may
91
+ // appear. `logged_in` / `internal` updates are filtered out here so they
92
+ // never reach the unauthenticated projection (Item 3/5).
93
+ .filter((u) => u.visibility === "public")
27
94
  .toSorted(
28
95
  (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
29
96
  )
@@ -56,23 +123,47 @@ const incidents: WidgetTypeDefinition = {
56
123
  binding: "systems",
57
124
  configSchema: IncidentsConfigSchema,
58
125
  dtoSchema: IncidentsDtoSchema,
59
- boundResources: (config) =>
60
- IncidentsConfigSchema.parse(config).systemIds.map((id) => ({
61
- resourceType: SYSTEM_TYPE,
62
- resourceId: id,
63
- })),
126
+ boundResources: (config) => {
127
+ const c = IncidentsConfigSchema.parse(config);
128
+ return [
129
+ ...c.systemIds.map((id) => ({ resourceType: SYSTEM_TYPE, resourceId: id })),
130
+ ...c.groupIds.map((id) => ({ resourceType: GROUP_TYPE, resourceId: id })),
131
+ ];
132
+ },
64
133
  assertBindingsReadable: async ({ userClient, config }) => {
134
+ const c = IncidentsConfigSchema.parse(config);
65
135
  await assertCatalogResourcesReadable({
66
136
  client: userClient.forPlugin(CatalogApi),
67
- systemIds: IncidentsConfigSchema.parse(config).systemIds,
137
+ systemIds: c.systemIds,
138
+ groupIds: c.groupIds,
68
139
  });
69
140
  },
141
+ // This widget surfaces the INCIDENT category: a page emails incident
142
+ // subscribers about a system only when this widget shows it.
143
+ subscriptionCategory: "incident",
144
+ // Send-time scoping for the subscriber fan-out uses the SAME expansion as the
145
+ // DTO resolve, so a page never emails about a system its widget does not show.
146
+ resolveScopedSystems: ({ config, ctx }) => effectiveScope(config, ctx),
147
+ // Same effective scope WITH public display names, so the subscribe form can
148
+ // offer a per-system scope. Applies the same public label override the widget
149
+ // renders with, so a name here never differs from what the page shows.
150
+ async resolveScopedSystemsDetailed({ config, ctx }) {
151
+ const c = IncidentsConfigSchema.parse(config);
152
+ const bound = await effectiveScope(c, ctx);
153
+ if (bound.size === 0) return [];
154
+ const names = await labelsFor(ctx, [...bound]);
155
+ return [...bound].map((id) => ({
156
+ id,
157
+ name: c.systemLabels[id] ?? names.get(id) ?? id,
158
+ }));
159
+ },
70
160
  async resolvePublic({ config, ctx }) {
71
161
  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).
162
+ // FAIL CLOSED with NO read when nothing is bound: never fall back to "all
163
+ // incidents" (that would be a trusted-service read of every incident). The
164
+ // effective scope is resolved at read time (shared with resolveScopedSystems)
165
+ // so group members added later are included.
166
+ const bound = await effectiveScope(c, ctx);
76
167
  if (bound.size === 0) return IncidentsDtoSchema.parse({ incidents: [] });
77
168
  const inc = ctx.rpcClient.forPlugin(IncidentApi);
78
169
  const { incidents: all } = await inc.listIncidents({
@@ -90,35 +181,43 @@ const incidents: WidgetTypeDefinition = {
90
181
  now: Date.now(),
91
182
  });
92
183
  // Only label BOUND systems; an unbound co-affected system must not leak.
184
+ // A per-system PUBLIC label override wins over the raw catalog name (same
185
+ // override path as the system-health widget), so the public detail page never
186
+ // leaks an internal name inconsistently with the rest of the page.
93
187
  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),
188
+ const labelOf = (id: string): string | undefined =>
189
+ bound.has(id) ? (c.systemLabels[id] ?? names.get(id) ?? id) : undefined;
190
+ const selected = [...active, ...past];
191
+ // ONE bulk fetch of every selected incident's update timeline, instead of
192
+ // an N+1 fan-out of `getIncident` per row. showUpdates=false still skips
193
+ // the fetch entirely (perf). Each incident's updates are keyed by its id.
194
+ const bulkUpdates = c.showUpdates
195
+ ? await inc.getBulkIncidentUpdates({
196
+ incidentIds: selected.map((i) => i.id),
197
+ })
198
+ : undefined;
199
+ const updatesByIncident: Record<string, InternalUpdate[]> =
200
+ bulkUpdates?.updates ?? {};
201
+ const items = selected.map((i) => {
202
+ const updates = latestUpdates(
203
+ updatesByIncident[i.id] ?? [],
204
+ c.maxUpdates,
205
+ );
206
+ const resolved = i.status === "resolved";
207
+ return {
208
+ id: i.id,
209
+ title: i.title,
210
+ status: i.status,
211
+ severity: i.severity,
212
+ systems: i.systemIds
213
+ .map((id) => labelOf(id))
214
+ .filter((l): l is string => l !== undefined),
215
+ startedAt: iso(i.createdAt),
216
+ ...(resolved ? { resolvedAt: iso(i.updatedAt) } : {}),
217
+ updates,
218
+ };
121
219
  });
220
+ return IncidentsDtoSchema.parse({ incidents: items });
122
221
  },
123
222
  };
124
223