@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.
- package/CHANGELOG.md +388 -0
- package/drizzle/0005_real_leper_queen.sql +4 -0
- package/drizzle/0006_last_princess_powerful.sql +1 -0
- package/drizzle/0007_colorful_true_believers.sql +2 -0
- package/drizzle/meta/0005_snapshot.json +346 -0
- package/drizzle/meta/0006_snapshot.json +353 -0
- package/drizzle/meta/0007_snapshot.json +391 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +15 -15
- package/src/ai/incident-add-link.test.ts +1 -0
- package/src/ai/incident-add-update.test.ts +6 -1
- package/src/ai/incident-delete-update.test.ts +59 -0
- package/src/ai/incident-delete-update.ts +73 -0
- package/src/ai/register-ai-tools.ts +2 -0
- package/src/automations.test.ts +70 -0
- package/src/automations.ts +77 -4
- package/src/hooks.ts +54 -4
- package/src/index.ts +4 -0
- package/src/notifications.test.ts +181 -0
- package/src/notifications.ts +12 -1
- package/src/read-visibility.test.ts +158 -0
- package/src/read-visibility.ts +99 -0
- package/src/router.test.ts +69 -1
- package/src/router.ts +195 -15
- package/src/schema.ts +55 -10
- package/src/service-reads.it.test.ts +353 -0
- package/src/service-updates.it.test.ts +340 -0
- package/src/service.it.test.ts +57 -0
- package/src/service.test.ts +235 -1
- package/src/service.ts +489 -177
- package/src/status-page-widget.test.ts +149 -0
- package/src/status-page-widget.ts +136 -37
|
@@ -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)
|
|
61
|
-
|
|
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:
|
|
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
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
|
95
|
-
[
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|