@checkstack/incident-backend 1.1.4 → 1.1.5

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,98 @@
1
1
  # @checkstack/incident-backend
2
2
 
3
+ ## 1.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - f23f3c9: Phase 12 of the v1 polishing plan: three coordinated cleanup items that
8
+ close out half-finished features ahead of v1.0.
9
+
10
+ `@checkstack/incident-backend` adds focused unit-test coverage for
11
+ `IncidentService.hasActiveIncidentWithSuppression` in
12
+ `core/incident-backend/src/service.test.ts`. The new tests exercise the
13
+ real query-builder logic against a programmable mock data source and
14
+ pin down the active-only silencing contract: returns `true` only when
15
+ an unresolved incident with `suppressNotifications=true` is associated
16
+ with the queried `systemId`; returns `false` for resolved incidents,
17
+ incidents with `suppressNotifications=false`, systems with no incident
18
+ associations, and other systems' silenced incidents. No runtime
19
+ changes; the service code was already correct end-to-end (write path
20
+ through `IncidentEditor`, read path through the healthcheck queue
21
+ executor and dependency notifications). A companion docs page,
22
+ `docs/src/content/docs/architecture/alert-silencing.md`, documents the
23
+ contract, the two read sites, and the dispatch paths silencing does
24
+ NOT cover so users aren't surprised when an unaware channel keeps
25
+ firing.
26
+
27
+ `@checkstack/auth-frontend` surfaces inline role assignment inside the
28
+ user-creation dialog so admins can pick role(s) atomically with the
29
+ create call. `CreateUserDialog` now renders a checkbox list of
30
+ assignable roles (those with `isAssignable !== false`); on submit,
31
+ `UsersTab` awaits `createCredentialUser`, then immediately calls
32
+ `updateUserRoles` with the selected role IDs. On partial failure
33
+ (user created, role assignment failed) the UI surfaces a warning toast
34
+ naming the recovery path rather than silently misreporting success. No
35
+ new endpoints — reuses the existing `createCredentialUser` +
36
+ `updateUserRoles` contract pair. A companion docs page,
37
+ `docs/src/content/docs/architecture/users-and-teams.md`, documents the
38
+ identity / role / team model, the two S2S endpoints
39
+ (`checkResourceTeamAccess`, `getAccessibleResourceIds`) other plugins
40
+ should call to honour team grants, and explicitly defers audit
41
+ logging, CSV export, team-scoped resource-management UI, and deletion
42
+ side-effect handling to v1.1.
43
+
44
+ The third item — deleting the empty `core/status-frontend/` and
45
+ `core/status-page-backend/` shells — is tooling-only and intentionally
46
+ ships without a changeset; neither shell had a `package.json`, source
47
+ file, or downstream importer.
48
+
49
+ - f23f3c9: Add `correlationMiddleware` to `@checkstack/backend-api` and apply it
50
+ to every plugin/core router so each request carries a stable
51
+ `x-correlation-id` (read from the inbound header, or freshly minted
52
+ via `crypto.randomUUID()` when absent) and an auto-injected child
53
+ logger bound with `{ correlationId, pluginId, userId? }`. The ID is
54
+ echoed back on the response header so the caller can correlate their
55
+ client-side trace to the server logs.
56
+
57
+ The `Logger` interface in `@checkstack/backend-api` now formally
58
+ documents the structured-metadata convention (`logger.info("msg",
59
+ { ...meta })`) alongside the long-standing varargs shape. Winston's
60
+ splat handling already routes both shapes through the same vararg
61
+ slot, so existing call sites are unaffected. A new optional
62
+ `Logger.child(meta)` method captures the metadata-binding contract the
63
+ new middleware relies on; production loggers always implement it,
64
+ minimal test mocks may omit it (the middleware falls back gracefully).
65
+
66
+ `RpcContext` grew two optional `Headers` bags, `requestHeaders` and
67
+ `responseHeaders`, populated by the outer Hono `/api/*` and `/rest/*`
68
+ handlers in `@checkstack/backend`. They are write-through observation
69
+ points for middleware; an `RpcContext` constructed without them (S2S
70
+ clients, tests) keeps working — the echo is a silent no-op and the ID
71
+ is still bound onto the child logger for server-side correlation.
72
+
73
+ The scaffolding template in `@checkstack/scripts` was updated so any
74
+ new plugin generated via `bun run create` wires the middleware in the
75
+ expected `.use(correlationMiddleware).use(autoAuthMiddleware)` order
76
+ out of the box.
77
+
78
+ - Updated dependencies [f23f3c9]
79
+ - Updated dependencies [f23f3c9]
80
+ - Updated dependencies [f23f3c9]
81
+ - Updated dependencies [f23f3c9]
82
+ - @checkstack/common@0.11.0
83
+ - @checkstack/backend-api@0.17.0
84
+ - @checkstack/catalog-backend@1.1.5
85
+ - @checkstack/command-backend@0.1.29
86
+ - @checkstack/integration-backend@0.1.29
87
+ - @checkstack/notification-common@1.2.0
88
+ - @checkstack/integration-common@0.5.0
89
+ - @checkstack/auth-common@0.7.1
90
+ - @checkstack/catalog-common@2.2.2
91
+ - @checkstack/incident-common@1.2.2
92
+ - @checkstack/signal-common@0.2.4
93
+ - @checkstack/cache-api@0.3.4
94
+ - @checkstack/cache-utils@0.2.9
95
+
3
96
  ## 1.1.4
4
97
 
5
98
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,17 +14,17 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.15.3",
18
- "@checkstack/cache-api": "0.3.2",
19
- "@checkstack/cache-utils": "0.2.7",
20
- "@checkstack/incident-common": "1.2.0",
21
- "@checkstack/catalog-common": "2.2.0",
22
- "@checkstack/catalog-backend": "1.1.3",
23
- "@checkstack/notification-common": "1.1.0",
17
+ "@checkstack/backend-api": "0.16.0",
18
+ "@checkstack/cache-api": "0.3.3",
19
+ "@checkstack/cache-utils": "0.2.8",
20
+ "@checkstack/incident-common": "1.2.1",
21
+ "@checkstack/catalog-common": "2.2.1",
22
+ "@checkstack/catalog-backend": "1.1.4",
23
+ "@checkstack/notification-common": "1.1.1",
24
24
  "@checkstack/auth-common": "0.7.0",
25
- "@checkstack/command-backend": "0.1.27",
25
+ "@checkstack/command-backend": "0.1.28",
26
26
  "@checkstack/signal-common": "0.2.3",
27
- "@checkstack/integration-backend": "0.1.27",
27
+ "@checkstack/integration-backend": "0.1.28",
28
28
  "@checkstack/integration-common": "0.4.0",
29
29
  "@checkstack/common": "0.10.0",
30
30
  "drizzle-orm": "^0.45.0",
@@ -34,7 +34,7 @@
34
34
  "devDependencies": {
35
35
  "@checkstack/drizzle-helper": "0.0.5",
36
36
  "@checkstack/scripts": "0.3.2",
37
- "@checkstack/test-utils-backend": "0.1.27",
37
+ "@checkstack/test-utils-backend": "0.1.28",
38
38
  "@checkstack/tsconfig": "0.0.7",
39
39
  "@types/bun": "^1.0.0",
40
40
  "drizzle-kit": "^0.31.10",
package/src/router.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  } from "@checkstack/incident-common";
6
6
  import {
7
7
  autoAuthMiddleware,
8
+ correlationMiddleware,
8
9
  Logger,
9
10
  type RpcContext,
10
11
  } from "@checkstack/backend-api";
@@ -89,6 +90,7 @@ export function createRouter(
89
90
 
90
91
  const os = implement(incidentContract)
91
92
  .$context<RpcContext>()
93
+ .use(correlationMiddleware)
92
94
  .use(autoAuthMiddleware);
93
95
 
94
96
  return os.router({
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { IncidentService } from "./service";
3
+
4
+ /**
5
+ * Programmable mock DB that records each `select(...).from(...).where(...)`
6
+ * (and optional `.limit(...)`) chain and returns a configurable row array
7
+ * per invocation. Tests exercise the real query-builder calls inside
8
+ * `IncidentService`, only swapping out the terminal data source.
9
+ */
10
+ function createProgrammableSelectDb(resultsByCall: unknown[][]) {
11
+ let callIndex = 0;
12
+
13
+ const nextResult = (): unknown[] => {
14
+ const result = resultsByCall[callIndex] ?? [];
15
+ callIndex += 1;
16
+ return result;
17
+ };
18
+
19
+ const select = mock((projection?: Record<string, unknown>) => {
20
+ void projection;
21
+ const rows = nextResult();
22
+
23
+ const limit = mock(() => Promise.resolve(rows));
24
+ const whereResult = Object.assign(Promise.resolve(rows), { limit });
25
+ const where = mock(() => whereResult);
26
+ const fromResult = Object.assign(Promise.resolve(rows), { where });
27
+ const from = mock(() => fromResult);
28
+
29
+ return { from };
30
+ });
31
+
32
+ return {
33
+ db: { select } as unknown,
34
+ select,
35
+ getCallCount: () => callIndex,
36
+ };
37
+ }
38
+
39
+ describe("IncidentService.hasActiveIncidentWithSuppression", () => {
40
+ let dbHelper: ReturnType<typeof createProgrammableSelectDb>;
41
+ let service: IncidentService;
42
+
43
+ const setup = (resultsByCall: unknown[][]) => {
44
+ dbHelper = createProgrammableSelectDb(resultsByCall);
45
+ service = new IncidentService(dbHelper.db as never);
46
+ };
47
+
48
+ beforeEach(() => {
49
+ dbHelper = createProgrammableSelectDb([]);
50
+ });
51
+
52
+ it("returns true when an active incident with suppressNotifications=true exists for the system", async () => {
53
+ setup([
54
+ // 1st query: incidentSystems lookup for systemId="sys-1"
55
+ [{ incidentId: "inc-1" }],
56
+ // 2nd query: incidents lookup with .where(active AND suppression).limit(1)
57
+ [{ id: "inc-1" }],
58
+ ]);
59
+
60
+ const result = await service.hasActiveIncidentWithSuppression("sys-1");
61
+
62
+ expect(result).toBe(true);
63
+ expect(dbHelper.getCallCount()).toBe(2);
64
+ });
65
+
66
+ it("returns false when no incidents are associated with the system", async () => {
67
+ setup([
68
+ // 1st query: empty -> short-circuits before the 2nd query
69
+ [],
70
+ ]);
71
+
72
+ const result = await service.hasActiveIncidentWithSuppression("sys-1");
73
+
74
+ expect(result).toBe(false);
75
+ // Only one query should have run; the incidents lookup is skipped.
76
+ expect(dbHelper.getCallCount()).toBe(1);
77
+ });
78
+
79
+ it("returns false when the matching incident is resolved (silencing is scoped to active incidents)", async () => {
80
+ setup([
81
+ // 1st query: the system has an incident association.
82
+ [{ incidentId: "inc-resolved" }],
83
+ // 2nd query: the WHERE clause filters out resolved incidents, so the
84
+ // limit(1) projection finds nothing. The real query builder enforces
85
+ // this via `ne(incidents.status, "resolved")`.
86
+ [],
87
+ ]);
88
+
89
+ const result = await service.hasActiveIncidentWithSuppression("sys-1");
90
+
91
+ expect(result).toBe(false);
92
+ expect(dbHelper.getCallCount()).toBe(2);
93
+ });
94
+
95
+ it("returns false when the matching incident has suppressNotifications=false", async () => {
96
+ setup([
97
+ // 1st query: the system has an incident association.
98
+ [{ incidentId: "inc-no-suppress" }],
99
+ // 2nd query: the WHERE clause filters by suppressNotifications=true,
100
+ // so a row with suppressNotifications=false is excluded — the result
101
+ // set is empty.
102
+ [],
103
+ ]);
104
+
105
+ const result = await service.hasActiveIncidentWithSuppression("sys-1");
106
+
107
+ expect(result).toBe(false);
108
+ expect(dbHelper.getCallCount()).toBe(2);
109
+ });
110
+
111
+ it("filters by systemId — does not return true for another system's silenced incident", async () => {
112
+ // The systemId filter is enforced by the WHERE clause on the
113
+ // incidentSystems lookup. Querying "sys-other" returns an empty
114
+ // association set even though "sys-1" has a silenced incident, so the
115
+ // method short-circuits to false.
116
+ setup([
117
+ // 1st query for systemId="sys-other": no associations.
118
+ [],
119
+ ]);
120
+
121
+ const result = await service.hasActiveIncidentWithSuppression("sys-other");
122
+
123
+ expect(result).toBe(false);
124
+ expect(dbHelper.getCallCount()).toBe(1);
125
+ });
126
+ });