@checkstack/healthcheck-backend 1.13.1 → 1.15.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,133 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - faf98f5: Security: config secrets (health-check strategy/collector credentials such as
8
+ SSH passwords, DB credentials, HTTP auth, and integration connection
9
+ credentials) ride ONE shared, domain-agnostic extraction channel instead of
10
+ being stored as plaintext or re-implemented per plugin.
11
+
12
+ New primitive and shared service:
13
+
14
+ - `configSecret({ id })` (in `@checkstack/backend-api`) declares an
15
+ extraction-channel secret keyed by a STABLE `id`, independent of field name or
16
+ position, so renaming or reordering a field never orphans its value. Use it
17
+ (not `configString({ "x-secret": true })`) for any credential whose config is
18
+ relayed to a satellite, projected to AI, or diffed by GitOps. `validateSecretIds`
19
+ rejects, at plugin registration, an `x-secret` field with no `id`, a duplicate
20
+ `id`, or a secret nested in an un-keyable container (array / record / tuple /
21
+ map) - so a mis-keyable schema fails boot rather than at run time.
22
+ - `ConfigSecretChannel` (in `@checkstack/secrets-backend`) is the single
23
+ extract / inflate / collect / redact / merge / delete / prune implementation.
24
+ Health-checks and integration connections both BIND it to their own scope
25
+ (marker prefix + internal-secret key layout); neither re-implements the walk.
26
+
27
+ Lifecycle (both bindings):
28
+
29
+ - **Write**: an inline value is extracted into the encrypted internal secret
30
+ store; the stored config keeps only an opaque marker. `${{ secrets.NAME }}`
31
+ references are stored verbatim and resolve through the active backend (local
32
+ or Vault) at run time.
33
+ - **Read**: configuration and connection reads strip `x-secret` values and
34
+ internal markers while keeping `${{ secrets.NAME }}` references visible; the
35
+ AI `getConfigurations` tool and create/update responses are redacted too. A
36
+ value never reaches a browser or an AI model context.
37
+ - **Run**: the core executor inflates markers/references in memory just before
38
+ the client is built. Satellites receive markers only and fetch values
39
+ just-in-time over the authenticated WS channel, per run, never persisted, then
40
+ fail CLOSED if any marker/reference survives resolution.
41
+ - **No orphan**: clearing a secret, removing a field/collector, swapping an
42
+ inline value for a reference, updating a connection, or deleting a
43
+ configuration/connection deletes the now-unreferenced internal secret. Cleanup
44
+ is schema-free (scans markers by prefix) and best-effort on delete, so it works
45
+ even when the owning plugin is uninstalled and never blocks a delete.
46
+ - **Forged-marker safe**: extract/inflate key each internal secret by the
47
+ SCHEMA leaf's stable `id`, never by an id parsed out of a stored marker string,
48
+ so a crafted marker can never resolve or delete another scope's secret.
49
+
50
+ Health-checks additionally get an idempotent, advisory-locked backfill that
51
+ moves pre-existing plaintext values into the internal store, and per-config-id
52
+ locking so concurrent writers across pods can never leave a dangling marker.
53
+ Integration connection credentials keep their released `__connref__:` marker
54
+ prefix and key layout (id equals the flat field name), so existing stored
55
+ connections are byte-compatible.
56
+
57
+ BREAKING CHANGES:
58
+
59
+ - Configuration and connection reads no longer include `x-secret` field values
60
+ (clients must treat blank-on-save as keep-existing; the bundled editors
61
+ already do).
62
+ - Satellites must be upgraded together with the core: an old satellite cannot
63
+ resolve the markers a new core stores, so its credentialed checks fail until
64
+ upgraded.
65
+
66
+ ### Patch Changes
67
+
68
+ - Updated dependencies [faf98f5]
69
+ - Updated dependencies [faf98f5]
70
+ - @checkstack/ai-backend@0.10.6
71
+ - @checkstack/backend-api@0.29.0
72
+ - @checkstack/secrets-backend@0.3.0
73
+ - @checkstack/secrets-common@0.3.0
74
+ - @checkstack/common@0.20.0
75
+ - @checkstack/healthcheck-common@1.12.0
76
+ - @checkstack/satellite-backend@0.8.0
77
+ - @checkstack/automation-backend@0.10.8
78
+ - @checkstack/catalog-backend@1.6.6
79
+ - @checkstack/incident-backend@1.9.4
80
+ - @checkstack/command-backend@0.2.18
81
+ - @checkstack/gitops-backend@0.5.18
82
+ - @checkstack/script-packages-backend@0.3.22
83
+ - @checkstack/status-page-backend@0.4.5
84
+ - @checkstack/gitops-common@0.7.1
85
+ - @checkstack/sdk@0.122.1
86
+ - @checkstack/ai-common@0.6.4
87
+ - @checkstack/cache-api@0.3.17
88
+ - @checkstack/catalog-common@2.6.1
89
+ - @checkstack/incident-common@1.7.1
90
+ - @checkstack/maintenance-common@1.8.1
91
+ - @checkstack/notification-common@1.5.1
92
+ - @checkstack/queue-api@0.3.17
93
+ - @checkstack/signal-common@0.2.15
94
+ - @checkstack/status-page-common@0.5.1
95
+ - @checkstack/cache-utils@0.2.22
96
+
97
+ ## 1.14.0
98
+
99
+ ### Minor Changes
100
+
101
+ - e819276: Fix JSONPath collector assertions: the executor previously evaluated every
102
+ assertion with a flat field lookup, so a `Body (JSONPath)` assertion compared
103
+ against `undefined` and the configured path was silently ignored (`Exists`
104
+ always failed, `Not Exists` always passed). The executor now parses the source
105
+ field as JSON and extracts the configured path via `jsonpath-plus` (with
106
+ expression evaluation disabled - filter/script expressions are rejected).
107
+ Fail-closed: a non-JSON body, missing expression, or invalid path fails the
108
+ assertion with a diagnostic, never the collection.
109
+
110
+ Also adds `isEmpty` / `isNotEmpty` to the JSONPath operator set (and the
111
+ AssertionBuilder), treating `[]`, `{}`, `""`, and missing values as empty - so
112
+ "no errors reported" is a single `$.errors Is Empty` assertion, and "key exists
113
+ but is empty" is `Exists` + `Is Empty` on the same path.
114
+
115
+ ### Patch Changes
116
+
117
+ - Updated dependencies [e819276]
118
+ - Updated dependencies [e819276]
119
+ - @checkstack/ai-backend@0.10.5
120
+ - @checkstack/backend-api@0.28.0
121
+ - @checkstack/automation-backend@0.10.7
122
+ - @checkstack/catalog-backend@1.6.5
123
+ - @checkstack/incident-backend@1.9.3
124
+ - @checkstack/satellite-backend@0.7.8
125
+ - @checkstack/command-backend@0.2.17
126
+ - @checkstack/gitops-backend@0.5.17
127
+ - @checkstack/script-packages-backend@0.3.21
128
+ - @checkstack/secrets-backend@0.2.17
129
+ - @checkstack/status-page-backend@0.4.4
130
+
3
131
  ## 1.13.1
4
132
 
5
133
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.13.1",
3
+ "version": "1.15.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,45 +14,46 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.27.1",
18
- "@checkstack/ai-backend": "0.10.4",
19
- "@checkstack/ai-common": "0.6.3",
20
- "@checkstack/script-packages-backend": "0.3.20",
21
- "@checkstack/cache-api": "0.3.16",
22
- "@checkstack/cache-utils": "0.2.21",
23
- "@checkstack/catalog-backend": "1.6.4",
24
- "@checkstack/catalog-common": "2.6.0",
25
- "@checkstack/command-backend": "0.2.16",
26
- "@checkstack/common": "0.19.0",
27
- "@checkstack/gitops-backend": "0.5.16",
28
- "@checkstack/gitops-common": "0.7.0",
29
- "@checkstack/healthcheck-common": "1.11.0",
30
- "@checkstack/secrets-common": "0.2.8",
31
- "@checkstack/secrets-backend": "0.2.16",
32
- "@checkstack/incident-backend": "1.9.2",
33
- "@checkstack/incident-common": "1.7.0",
34
- "@checkstack/automation-backend": "0.10.6",
35
- "@checkstack/maintenance-common": "1.8.0",
36
- "@checkstack/notification-common": "1.5.0",
37
- "@checkstack/queue-api": "0.3.16",
38
- "@checkstack/satellite-backend": "0.7.7",
39
- "@checkstack/sdk": "0.120.0",
40
- "@checkstack/signal-common": "0.2.14",
41
- "@checkstack/status-page-backend": "0.4.3",
42
- "@checkstack/status-page-common": "0.5.0",
17
+ "@checkstack/ai-backend": "0.10.6",
18
+ "@checkstack/ai-common": "0.6.4",
19
+ "@checkstack/automation-backend": "0.10.8",
20
+ "@checkstack/backend-api": "0.29.0",
21
+ "@checkstack/cache-api": "0.3.17",
22
+ "@checkstack/cache-utils": "0.2.22",
23
+ "@checkstack/catalog-backend": "1.6.6",
24
+ "@checkstack/catalog-common": "2.6.1",
25
+ "@checkstack/command-backend": "0.2.18",
26
+ "@checkstack/common": "0.20.0",
27
+ "@checkstack/gitops-backend": "0.5.18",
28
+ "@checkstack/gitops-common": "0.7.1",
29
+ "@checkstack/healthcheck-common": "1.12.0",
30
+ "@checkstack/incident-backend": "1.9.4",
31
+ "@checkstack/incident-common": "1.7.1",
32
+ "@checkstack/maintenance-common": "1.8.1",
33
+ "@checkstack/notification-common": "1.5.1",
34
+ "@checkstack/queue-api": "0.3.17",
35
+ "@checkstack/satellite-backend": "0.8.0",
36
+ "@checkstack/script-packages-backend": "0.3.22",
37
+ "@checkstack/sdk": "0.122.1",
38
+ "@checkstack/secrets-backend": "0.3.0",
39
+ "@checkstack/secrets-common": "0.3.0",
40
+ "@checkstack/signal-common": "0.2.15",
41
+ "@checkstack/status-page-backend": "0.4.5",
42
+ "@checkstack/status-page-common": "0.5.1",
43
43
  "@hono/zod-validator": "^0.7.6",
44
+ "@orpc/contract": "^1.14.4",
45
+ "@orpc/server": "^1.14.4",
44
46
  "drizzle-orm": "^0.45.0",
45
47
  "hono": "^4.12.25",
48
+ "jsonpath-plus": "^10.4.0",
46
49
  "tdigest": "^0.1.2",
47
50
  "yaml": "^2.6.1",
48
- "zod": "^4.2.1",
49
- "@orpc/contract": "^1.14.4",
50
- "@orpc/server": "^1.14.4"
51
+ "zod": "^4.2.1"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@checkstack/drizzle-helper": "0.0.6",
54
- "@checkstack/scripts": "0.7.0",
55
- "@checkstack/test-utils-backend": "0.1.50",
55
+ "@checkstack/scripts": "0.7.1",
56
+ "@checkstack/test-utils-backend": "0.1.52",
56
57
  "@checkstack/tsconfig": "0.0.7",
57
58
  "@types/bun": "^1.0.0",
58
59
  "@types/tdigest": "^0.1.5",
@@ -103,9 +103,17 @@ const UNASSIGNED_GUIDANCE =
103
103
  export async function validateHealthcheckDraft({
104
104
  input,
105
105
  rpcClient,
106
+ existingConfigurationId,
106
107
  }: {
107
108
  input: HealthcheckProposeInput;
108
109
  rpcClient: RpcClient;
110
+ /**
111
+ * When validating an UPDATE, the id of the config being edited. The server
112
+ * restores that config's stored secrets before validating, so a kept secret
113
+ * (redacted out of the read) does not fail a required-secret check. Omit for
114
+ * a create draft, where blank secrets stay required.
115
+ */
116
+ existingConfigurationId?: string;
109
117
  }): Promise<{ strategy: HealthCheckStrategyDto; collectors: CollectorDto[] }> {
110
118
  const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
111
119
 
@@ -116,6 +124,7 @@ export async function validateHealthcheckDraft({
116
124
  config: input.config,
117
125
  intervalSeconds: input.intervalSeconds,
118
126
  collectors: input.collectors,
127
+ ...(existingConfigurationId ? { existingConfigurationId } : {}),
119
128
  });
120
129
  if (!validation.valid) {
121
130
  throw new HealthcheckProposeValidationError(
@@ -82,10 +82,14 @@ export function createHealthcheckUpdateTool(): RegisteredAiTool<
82
82
  }
83
83
  const merged = mergeConfig({ existing, body: input.body });
84
84
  // Deep-validate the merged result (throws with structured detail if invalid,
85
- // including bad assertion fields/operators).
85
+ // including bad assertion fields/operators). Pass the id so the server
86
+ // restores stored secrets before validating - `existing.config` is
87
+ // redacted, so a kept `x-secret` field arrives blank and would otherwise
88
+ // fail a required-secret check even for a name-only edit.
86
89
  const { strategy } = await validateHealthcheckDraft({
87
90
  input: merged,
88
91
  rpcClient,
92
+ existingConfigurationId: input.id,
89
93
  });
90
94
  // before -> after diff over the updatable fields only (the existing config
91
95
  // also carries id/timestamps/paused that are not part of an update).
@@ -0,0 +1,276 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { CollectorAssertion } from "@checkstack/healthcheck-common";
3
+ import { evaluateCollectorAssertions } from "./collector-assertions";
4
+
5
+ /** The HTTP Request collector's result shape, as the executor sees it. */
6
+ const httpResult = (body: string): Record<string, unknown> => ({
7
+ statusCode: 200,
8
+ statusText: "OK",
9
+ responseTimeMs: 12,
10
+ body,
11
+ bodyLength: body.length,
12
+ success: true,
13
+ });
14
+
15
+ const jsonPathAssertion = (
16
+ jsonPath: string,
17
+ operator: string,
18
+ value?: unknown,
19
+ ): CollectorAssertion => ({
20
+ field: "body.$",
21
+ jsonPath,
22
+ operator,
23
+ ...(value === undefined ? {} : { value }),
24
+ });
25
+
26
+ describe("evaluateCollectorAssertions", () => {
27
+ describe("plain field assertions (regression)", () => {
28
+ it("passes and fails on a direct result field", () => {
29
+ const result = httpResult("{}");
30
+
31
+ expect(
32
+ evaluateCollectorAssertions({
33
+ assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
34
+ result,
35
+ }),
36
+ ).toBeUndefined();
37
+
38
+ expect(
39
+ evaluateCollectorAssertions({
40
+ assertions: [{ field: "statusCode", operator: "equals", value: 404 }],
41
+ result,
42
+ }),
43
+ ).toBe("statusCode equals 404");
44
+ });
45
+
46
+ it("returns undefined for no assertions", () => {
47
+ expect(
48
+ evaluateCollectorAssertions({
49
+ assertions: undefined,
50
+ result: httpResult(""),
51
+ }),
52
+ ).toBeUndefined();
53
+ expect(
54
+ evaluateCollectorAssertions({ assertions: [], result: httpResult("") }),
55
+ ).toBeUndefined();
56
+ });
57
+
58
+ it("returns the FIRST failing assertion in configured order", () => {
59
+ const failure = evaluateCollectorAssertions({
60
+ assertions: [
61
+ { field: "success", operator: "isFalse" },
62
+ { field: "statusCode", operator: "equals", value: 404 },
63
+ ],
64
+ result: httpResult("{}"),
65
+ });
66
+ expect(failure).toBe("success isFalse");
67
+ });
68
+ });
69
+
70
+ describe("JSONPath assertions: extraction actually happens", () => {
71
+ it("exists fails when the key is missing and passes when present", () => {
72
+ expect(
73
+ evaluateCollectorAssertions({
74
+ assertions: [jsonPathAssertion("$.error", "exists")],
75
+ result: httpResult(`{"status":"ok"}`),
76
+ }),
77
+ ).toBe("body.$ $.error exists");
78
+
79
+ expect(
80
+ evaluateCollectorAssertions({
81
+ assertions: [jsonPathAssertion("$.error", "exists")],
82
+ result: httpResult(`{"error":""}`),
83
+ }),
84
+ ).toBeUndefined();
85
+ });
86
+
87
+ it("notExists passes for a missing key and fails for a present one", () => {
88
+ expect(
89
+ evaluateCollectorAssertions({
90
+ assertions: [jsonPathAssertion("$.error", "notExists")],
91
+ result: httpResult(`{"status":"ok"}`),
92
+ }),
93
+ ).toBeUndefined();
94
+
95
+ expect(
96
+ evaluateCollectorAssertions({
97
+ assertions: [jsonPathAssertion("$.error", "notExists")],
98
+ result: httpResult(`{"error":"boom"}`),
99
+ }),
100
+ ).toBe("body.$ $.error notExists");
101
+ });
102
+
103
+ it("equals compares the EXTRACTED value, not the whole body", () => {
104
+ expect(
105
+ evaluateCollectorAssertions({
106
+ assertions: [jsonPathAssertion("$.data.id", "equals", "42")],
107
+ result: httpResult(`{"data":{"id":42},"noise":"x"}`),
108
+ }),
109
+ ).toBeUndefined();
110
+
111
+ expect(
112
+ evaluateCollectorAssertions({
113
+ assertions: [jsonPathAssertion("$.data.id", "equals", "43")],
114
+ result: httpResult(`{"data":{"id":42}}`),
115
+ }),
116
+ ).toBe("body.$ $.data.id equals 43");
117
+ });
118
+
119
+ it('asserts "error exists but is empty" via exists + isEmpty', () => {
120
+ const assertions = [
121
+ jsonPathAssertion("$.error", "exists"),
122
+ jsonPathAssertion("$.error", "isEmpty"),
123
+ ];
124
+
125
+ // Present and empty string: both pass.
126
+ expect(
127
+ evaluateCollectorAssertions({
128
+ assertions,
129
+ result: httpResult(`{"error":""}`),
130
+ }),
131
+ ).toBeUndefined();
132
+
133
+ // Missing entirely: exists fails (isEmpty alone would have passed).
134
+ expect(
135
+ evaluateCollectorAssertions({
136
+ assertions,
137
+ result: httpResult(`{"status":"ok"}`),
138
+ }),
139
+ ).toBe("body.$ $.error exists");
140
+
141
+ // Present and non-empty: isEmpty fails.
142
+ expect(
143
+ evaluateCollectorAssertions({
144
+ assertions,
145
+ result: httpResult(`{"error":"boom"}`),
146
+ }),
147
+ ).toBe("body.$ $.error isEmpty");
148
+ });
149
+ });
150
+
151
+ describe("empty arrays in JSON responses", () => {
152
+ it("isEmpty passes for [] and fails for a populated array", () => {
153
+ expect(
154
+ evaluateCollectorAssertions({
155
+ assertions: [jsonPathAssertion("$.errors", "isEmpty")],
156
+ result: httpResult(`{"errors":[]}`),
157
+ }),
158
+ ).toBeUndefined();
159
+
160
+ expect(
161
+ evaluateCollectorAssertions({
162
+ assertions: [jsonPathAssertion("$.errors", "isEmpty")],
163
+ result: httpResult(`{"errors":["boom"]}`),
164
+ }),
165
+ ).toBe("body.$ $.errors isEmpty");
166
+ });
167
+
168
+ it("isNotEmpty passes for a populated array and fails for []", () => {
169
+ expect(
170
+ evaluateCollectorAssertions({
171
+ assertions: [jsonPathAssertion("$.items", "isNotEmpty")],
172
+ result: httpResult(`{"items":[1]}`),
173
+ }),
174
+ ).toBeUndefined();
175
+
176
+ expect(
177
+ evaluateCollectorAssertions({
178
+ assertions: [jsonPathAssertion("$.items", "isNotEmpty")],
179
+ result: httpResult(`{"items":[]}`),
180
+ }),
181
+ ).toBe("body.$ $.items isNotEmpty");
182
+ });
183
+
184
+ it("isEmpty handles empty objects and nested paths", () => {
185
+ expect(
186
+ evaluateCollectorAssertions({
187
+ assertions: [jsonPathAssertion("$.meta.warnings", "isEmpty")],
188
+ result: httpResult(`{"meta":{"warnings":{}}}`),
189
+ }),
190
+ ).toBeUndefined();
191
+
192
+ expect(
193
+ evaluateCollectorAssertions({
194
+ assertions: [jsonPathAssertion("$.meta.warnings", "isEmpty")],
195
+ result: httpResult(`{"meta":{"warnings":{"w1":"x"}}}`),
196
+ }),
197
+ ).toBe("body.$ $.meta.warnings isEmpty");
198
+ });
199
+
200
+ it("array length is assertable via the .length property", () => {
201
+ expect(
202
+ evaluateCollectorAssertions({
203
+ assertions: [
204
+ jsonPathAssertion("$.items.length", "greaterThan", "2"),
205
+ ],
206
+ result: httpResult(`{"items":[1,2,3]}`),
207
+ }),
208
+ ).toBeUndefined();
209
+
210
+ expect(
211
+ evaluateCollectorAssertions({
212
+ assertions: [
213
+ jsonPathAssertion("$.items.length", "greaterThan", "5"),
214
+ ],
215
+ result: httpResult(`{"items":[1,2,3]}`),
216
+ }),
217
+ ).toBe("body.$ $.items.length greaterThan 5");
218
+ });
219
+ });
220
+
221
+ describe("fail-closed diagnostics", () => {
222
+ it("fails with a diagnostic when the body is not valid JSON", () => {
223
+ expect(
224
+ evaluateCollectorAssertions({
225
+ assertions: [jsonPathAssertion("$.error", "notExists")],
226
+ result: httpResult("<html>not json</html>"),
227
+ }),
228
+ ).toBe('body.$ $.error notExists (field "body" is not valid JSON)');
229
+ });
230
+
231
+ it("fails with a diagnostic when the source field is absent", () => {
232
+ expect(
233
+ evaluateCollectorAssertions({
234
+ assertions: [jsonPathAssertion("$.error", "exists")],
235
+ result: { statusCode: 200 },
236
+ }),
237
+ ).toBe('body.$ $.error exists (field "body" has no value)');
238
+ });
239
+
240
+ it("fails with a diagnostic when the JSONPath expression is missing", () => {
241
+ expect(
242
+ evaluateCollectorAssertions({
243
+ assertions: [{ field: "body.$", jsonPath: " ", operator: "exists" }],
244
+ result: httpResult("{}"),
245
+ }),
246
+ ).toBe("body.$ exists (missing JSONPath expression)");
247
+ });
248
+
249
+ it("rejects filter/script expressions instead of evaluating them", () => {
250
+ const failure = evaluateCollectorAssertions({
251
+ assertions: [
252
+ jsonPathAssertion(`$.items[?(@.x==1)]`, "exists"),
253
+ ],
254
+ result: httpResult(`{"items":[{"x":1}]}`),
255
+ });
256
+ expect(failure).toContain("invalid JSONPath");
257
+ });
258
+ });
259
+
260
+ describe("structured (non-string) source fields", () => {
261
+ it("evaluates JSONPath against an already-parsed object field", () => {
262
+ expect(
263
+ evaluateCollectorAssertions({
264
+ assertions: [
265
+ {
266
+ field: "details.$",
267
+ jsonPath: "$.nodes",
268
+ operator: "isEmpty",
269
+ },
270
+ ],
271
+ result: { details: { nodes: [] } },
272
+ }),
273
+ ).toBeUndefined();
274
+ });
275
+ });
276
+ });