@checkstack/healthcheck-backend 1.14.0 → 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.
@@ -46,7 +46,11 @@ import { NotificationApi } from "@checkstack/notification-common";
46
46
  import { healthcheckSystemSubscription } from "@checkstack/healthcheck-common";
47
47
  import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
48
48
  import { secretEnvMappingSchema } from "@checkstack/secrets-common";
49
- import type { SecretResolverService } from "@checkstack/secrets-backend";
49
+ import type {
50
+ SecretResolverService,
51
+ InternalSecretsService,
52
+ } from "@checkstack/secrets-backend";
53
+ import { inflateConfigSecrets } from "./config-secrets";
50
54
  import { HealthCheckService } from "./service";
51
55
  import { healthCheckHooks } from "./hooks";
52
56
  import { incrementHourlyAggregate } from "./realtime-aggregation";
@@ -538,6 +542,14 @@ async function executeHealthCheckJob(props: {
538
542
  * / test isolation.
539
543
  */
540
544
  secretResolver?: SecretResolverService;
545
+ /**
546
+ * Internal secret store. When set (together with `secretResolver`), stored
547
+ * strategy/collector config `x-secret` fields - internal markers and
548
+ * `${{ secrets.* }}` references - are INFLATED to their real values just
549
+ * before use, in memory only. Optional for version-skew / test isolation;
550
+ * without it, marker-bearing configs fail their runs clearly.
551
+ */
552
+ internalSecrets?: InternalSecretsService;
541
553
  }): Promise<void> {
542
554
  const {
543
555
  payload,
@@ -555,6 +567,7 @@ async function executeHealthCheckJob(props: {
555
567
  getHealthEntity,
556
568
  cache,
557
569
  secretResolver,
570
+ internalSecrets,
558
571
  } = props;
559
572
  const { configId, systemId } = payload;
560
573
 
@@ -656,6 +669,22 @@ async function executeHealthCheckJob(props: {
656
669
  return;
657
670
  }
658
671
 
672
+ // Inflate stored secret markers / `${{ secrets.* }}` references to their
673
+ // real values ONCE, memory-only, BEFORE migrate+validate - so validation
674
+ // sees real values. Old-shape rows (whose current-schema secret keys do
675
+ // not exist yet) and legacy bare literals pass through untouched.
676
+ let rawStrategyConfig = configRow.config;
677
+ if (internalSecrets && secretResolver) {
678
+ const inflated = await inflateConfigSecrets({
679
+ configurationId: configId,
680
+ scope: { kind: "strategy" },
681
+ schema: strategy.config.schema,
682
+ config: configRow.config,
683
+ deps: { internalSecrets, secretResolver },
684
+ });
685
+ rawStrategyConfig = inflated.config;
686
+ }
687
+
659
688
  // Migrate the stored (UNVERSIONED) strategy config ONCE, before the
660
689
  // per-environment render loop, so every env renders from the same
661
690
  // migrated shape. Stored configs predate explicit versioning and may be
@@ -663,7 +692,7 @@ async function executeHealthCheckJob(props: {
663
692
  // -on-read runs the declared migration chain, then validates. The
664
693
  // migrations are idempotent, so an already-current config is a no-op.
665
694
  const strategyConfig: BaseStrategyConfig =
666
- await strategy.config.parseAssumingV1(configRow.config);
695
+ await strategy.config.parseAssumingV1(rawStrategyConfig);
667
696
  const executionTimeout = strategyConfig.timeout ?? 60_000;
668
697
 
669
698
  // ── Per-environment fan-out (§7) ────────────────────────────────────────
@@ -872,9 +901,26 @@ async function executeHealthCheckJob(props: {
872
901
  // reads the raw `secretEnv` mapping (a constant string field
873
902
  // unaffected by the strategy/collector reshapes), keeping the
874
903
  // migrate -> secret resolve -> render -> execute order intact.
904
+ // Inflate this entry's secret markers / references (memory
905
+ // only) before its migrate+validate parse, mirroring the
906
+ // strategy-config inflation above.
907
+ let rawCollectorConfig = collectorEntry.config;
908
+ if (internalSecrets && secretResolver) {
909
+ const inflated = await inflateConfigSecrets({
910
+ configurationId: configId,
911
+ scope: {
912
+ kind: "collector",
913
+ entryId: collectorEntry.id,
914
+ },
915
+ schema: registered.collector.config.schema,
916
+ config: collectorEntry.config,
917
+ deps: { internalSecrets, secretResolver },
918
+ });
919
+ rawCollectorConfig = inflated.config;
920
+ }
875
921
  const migratedCollectorConfig =
876
922
  await registered.collector.config.parseAssumingV1(
877
- collectorEntry.config,
923
+ rawCollectorConfig,
878
924
  );
879
925
 
880
926
  // (2) Environment/templating pass for the collector config -
@@ -1518,6 +1564,7 @@ export async function setupHealthCheckWorker(props: {
1518
1564
  getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
1519
1565
  cache: HealthCheckCache;
1520
1566
  secretResolver?: SecretResolverService;
1567
+ internalSecrets?: InternalSecretsService;
1521
1568
  }): Promise<void> {
1522
1569
  const {
1523
1570
  db,
@@ -1535,6 +1582,7 @@ export async function setupHealthCheckWorker(props: {
1535
1582
  getHealthEntity,
1536
1583
  cache,
1537
1584
  secretResolver,
1585
+ internalSecrets,
1538
1586
  } = props;
1539
1587
 
1540
1588
  const queue =
@@ -1559,6 +1607,7 @@ export async function setupHealthCheckWorker(props: {
1559
1607
  getHealthEntity,
1560
1608
  cache,
1561
1609
  secretResolver,
1610
+ internalSecrets,
1562
1611
  });
1563
1612
  },
1564
1613
  {
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { z } from "zod";
3
+ import { call } from "@orpc/server";
4
+ import {
5
+ createMockRpcContext,
6
+ configSecret,
7
+ Versioned,
8
+ } from "@checkstack/backend-api";
9
+ import { internalSecretName } from "@checkstack/secrets-common";
10
+ import type {
11
+ InternalSecretsService,
12
+ SecretResolverService,
13
+ } from "@checkstack/secrets-backend";
14
+ import { createHealthCheckRouter } from "./router";
15
+ import {
16
+ healthcheckSecretMarker,
17
+ isHealthcheckSecretMarker,
18
+ } from "./config-secrets";
19
+ import type { HealthCheckCache } from "./cache";
20
+
21
+ /**
22
+ * Guards the SEC-1 fix: `createAndAssign` (the first-check wizard / AI propose
23
+ * creation path) MUST extract inline `x-secret` values into the internal store
24
+ * and return a REDACTED config - never persist or echo a plaintext credential.
25
+ */
26
+
27
+ const passthroughCache: HealthCheckCache = {
28
+ wrapSystemHealthStatus: (_systemId, loader) => loader(),
29
+ invalidateSystem: async () => {},
30
+ invalidateAllSystems: async () => 0,
31
+ scope: {} as HealthCheckCache["scope"],
32
+ };
33
+
34
+ const mockUser = {
35
+ type: "user" as const,
36
+ id: "test-user",
37
+ accessRules: ["*"],
38
+ roles: ["admin"],
39
+ };
40
+
41
+ // A strategy config with one plain field and one x-secret field.
42
+ const strategySchema = z.object({
43
+ url: z.string(),
44
+ password: configSecret({ id: "password" }).optional(),
45
+ });
46
+
47
+ function fakeInternalSecrets(): InternalSecretsService & {
48
+ store: Map<string, string>;
49
+ } {
50
+ const store = new Map<string, string>();
51
+ return {
52
+ store,
53
+ async set({ parts, value }) {
54
+ store.set(internalSecretName(...parts), value);
55
+ },
56
+ async get({ parts }) {
57
+ return store.get(internalSecretName(...parts));
58
+ },
59
+ async delete({ parts }) {
60
+ store.delete(internalSecretName(...parts));
61
+ },
62
+ };
63
+ }
64
+
65
+ const fakeResolver = {
66
+ resolveForRun: mock(async () => ({ env: {}, masking: undefined })),
67
+ } as unknown as SecretResolverService;
68
+
69
+ interface CapturedInsert {
70
+ values: Record<string, unknown>;
71
+ }
72
+
73
+ function createCapturingDb(captured: CapturedInsert[]) {
74
+ const insert = () => ({
75
+ values: (values: Record<string, unknown>) => {
76
+ captured.push({ values });
77
+ return Object.assign(Promise.resolve(undefined), {
78
+ returning: () =>
79
+ Promise.resolve([
80
+ {
81
+ paused: false,
82
+ createdAt: new Date(),
83
+ updatedAt: new Date(),
84
+ collectors: null,
85
+ ...values,
86
+ },
87
+ ]),
88
+ });
89
+ },
90
+ });
91
+ const emptyWhere = Object.assign(Promise.resolve([]), {
92
+ where: () => Promise.resolve([]),
93
+ });
94
+ return {
95
+ insert,
96
+ select: () => ({ from: () => emptyWhere }),
97
+ transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
98
+ fn({ insert }),
99
+ };
100
+ }
101
+
102
+ function buildRouter(captured: CapturedInsert[]) {
103
+ const internalSecrets = fakeInternalSecrets();
104
+ const strategy = { config: new Versioned({ version: 1, schema: strategySchema }) };
105
+ const router = createHealthCheckRouter({
106
+ database: createCapturingDb(captured) as never,
107
+ registry: { getStrategy: mock(() => strategy) } as never,
108
+ collectorRegistry: { getCollector: mock(() => undefined) } as never,
109
+ gitOpsClient: { getProvenance: mock(() => Promise.resolve(null)) } as never,
110
+ getEmitHook: () => undefined,
111
+ cache: passthroughCache,
112
+ configService: { get: mock(async () => undefined), set: mock(async () => {}) } as never,
113
+ catalogClient: { getSystem: mock(async () => null) } as never,
114
+ maintenanceClient: {
115
+ hasActiveMaintenance: mock(async () => ({ active: false })),
116
+ } as never,
117
+ logger: {
118
+ debug: mock(() => {}),
119
+ info: mock(() => {}),
120
+ warn: mock(() => {}),
121
+ error: mock(() => {}),
122
+ } as never,
123
+ configSecrets: { internalSecrets, secretResolver: fakeResolver },
124
+ });
125
+ return { router, internalSecrets };
126
+ }
127
+
128
+ describe("createAndAssign secret extraction (SEC-1 regression)", () => {
129
+ it("extracts an inline secret into the internal store and never persists or returns it", async () => {
130
+ const captured: CapturedInsert[] = [];
131
+ const { router, internalSecrets } = buildRouter(captured);
132
+ const context = createMockRpcContext({ user: mockUser });
133
+
134
+ const result = await call(
135
+ router.createAndAssign,
136
+ {
137
+ systemId: "sys-1",
138
+ configuration: {
139
+ name: "DB check",
140
+ strategyId: "healthcheck-http.http",
141
+ config: { url: "https://x", password: "super-secret" },
142
+ intervalSeconds: 60,
143
+ },
144
+ enabled: false,
145
+ includeLocal: true,
146
+ environmentIds: null,
147
+ },
148
+ { context },
149
+ );
150
+
151
+ // The persisted config row holds a MARKER, never the plaintext.
152
+ const configInsert = captured.find((c) => c.values.name === "DB check");
153
+ expect(configInsert).toBeDefined();
154
+ const storedConfig = configInsert?.values.config as Record<string, unknown>;
155
+ expect(isHealthcheckSecretMarker(storedConfig.password as string)).toBe(true);
156
+ expect(JSON.stringify(captured)).not.toContain("super-secret");
157
+
158
+ // The plaintext lives ONLY in the internal (encrypted) store.
159
+ expect([...internalSecrets.store.values()]).toContain("super-secret");
160
+
161
+ // The response is redacted - the secret field is absent.
162
+ expect(result.config.password).toBeUndefined();
163
+ expect(result.config.url).toBe("https://x");
164
+ });
165
+ });
@@ -184,7 +184,11 @@ describe("createAndAssign router handler", () => {
184
184
  expect(assignmentInsert?.values.environmentIds).toBeNull();
185
185
  // Returns the created configuration.
186
186
  expect(result.name).toBe("Payments API root");
187
- expect(result.id).toBe("cfg-new");
187
+ // The id is generated up front (SEC-1: needed to key extracted secrets
188
+ // before insert), persisted on the config row, and used to link the
189
+ // assignment - so all three must agree.
190
+ expect(configInsert?.values.id).toBe(result.id);
191
+ expect(assignmentInsert?.values.configurationId).toBe(result.id);
188
192
  });
189
193
 
190
194
  it("broadcasts healthcheck.config.changed so open clients refresh", async () => {
package/src/router.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  resolveScriptPackagesDir,
21
21
  } from "@checkstack/script-packages-backend";
22
22
  import { HealthCheckService } from "./service";
23
+ import type { HealthCheckSecretsDeps } from "./config-secrets";
23
24
  import {
24
25
  canReadRunScope,
25
26
  hasGlobalHistoryAccess,
@@ -74,6 +75,12 @@ export const createHealthCheckRouter = (opts: {
74
75
  * run / the SLO self-heal converge the rollup lazily.
75
76
  */
76
77
  recomputeSystemRollupHealth?: (systemId: string) => Promise<void>;
78
+ /**
79
+ * Secrets channel for config credentials (extract-on-write, redact-on-read,
80
+ * blank-keeps-existing on update). Optional only for tests; the real
81
+ * router MUST receive it or writes would store inline secrets verbatim.
82
+ */
83
+ configSecrets?: HealthCheckSecretsDeps;
77
84
  }) => {
78
85
  const {
79
86
  database,
@@ -95,6 +102,7 @@ export const createHealthCheckRouter = (opts: {
95
102
  collectorRegistry,
96
103
  configService,
97
104
  catalogClient,
105
+ opts.configSecrets,
98
106
  );
99
107
 
100
108
  // Create contract implementer with context type AND auto auth middleware
@@ -252,12 +260,15 @@ export const createHealthCheckRouter = (opts: {
252
260
  return runCollectorScriptTest({ input, deps: { resolutionRoot } });
253
261
  }),
254
262
 
263
+ // UI/AI reads are ALWAYS redacted: `x-secret` fields (values, references,
264
+ // internal markers alike) are stripped server-side. The editor renders a
265
+ // blank secret input and blank-on-save means "keep existing".
255
266
  getConfigurations: os.getConfigurations.handler(async () => {
256
- return { configurations: await service.getConfigurations() };
267
+ return { configurations: await service.getConfigurationsRedacted() };
257
268
  }),
258
269
 
259
270
  getConfiguration: os.getConfiguration.handler(async ({ input }) => {
260
- return service.getConfiguration(input.id);
271
+ return service.getConfigurationRedacted(input.id);
261
272
  }),
262
273
 
263
274
  createConfiguration: os.createConfiguration.handler(async ({ input }) => {
@@ -271,7 +282,8 @@ export const createHealthCheckRouter = (opts: {
271
282
  action: "created",
272
283
  configurationId: created.id,
273
284
  });
274
- return created;
285
+ // The response goes back to the editor: keep it redacted like reads.
286
+ return service.redactConfiguration(created);
275
287
  }),
276
288
 
277
289
  validateConfiguration: os.validateConfiguration.handler(
@@ -283,8 +295,24 @@ export const createHealthCheckRouter = (opts: {
283
295
  // `z.record(z.unknown())` on the input) is validated against each
284
296
  // registered schema, surfacing wrong types, missing required fields,
285
297
  // and unknown keys - not just missing-field presence.
298
+ //
299
+ // For an UPDATE (existingConfigurationId set), restore the stored
300
+ // config's secrets into the proposed body first: reads are redacted,
301
+ // so a kept secret arrives blank/absent and would otherwise fail a
302
+ // required-secret check even though the apply path would preserve it.
303
+ // The restored values are used ONLY to validate and never returned.
304
+ let toValidate = input;
305
+ if (input.existingConfigurationId) {
306
+ const restored = await service.restoreSecretsForValidation({
307
+ existingConfigurationId: input.existingConfigurationId,
308
+ strategyId: input.strategyId,
309
+ config: input.config,
310
+ collectors: input.collectors,
311
+ });
312
+ toValidate = { ...input, ...restored };
313
+ }
286
314
  const errors = await collectConfigurationIssues({
287
- input,
315
+ input: toValidate,
288
316
  registry: context.healthCheckRegistry,
289
317
  collectorRegistry: context.collectorRegistry,
290
318
  });
@@ -307,7 +335,8 @@ export const createHealthCheckRouter = (opts: {
307
335
  action: "updated",
308
336
  configurationId: config.id,
309
337
  });
310
- return config;
338
+ // The response goes back to the editor: keep it redacted like reads.
339
+ return service.redactConfiguration(config);
311
340
  }),
312
341
 
313
342
  deleteConfiguration: os.deleteConfiguration.handler(async ({ input }) => {
@@ -381,7 +410,8 @@ export const createHealthCheckRouter = (opts: {
381
410
 
382
411
  getSystemConfigurations: os.getSystemConfigurations.handler(
383
412
  async ({ input }) => {
384
- return service.getSystemConfigurations(input.systemId);
413
+ // Redacted like every other UI config read - x-secret fields stripped.
414
+ return service.getSystemConfigurationsRedacted(input.systemId);
385
415
  },
386
416
  ),
387
417
 
@@ -431,7 +461,9 @@ export const createHealthCheckRouter = (opts: {
431
461
  enabled: input.enabled,
432
462
  queueManager: context.queueManager,
433
463
  });
434
- return configuration;
464
+ // The response goes back to the caller (wizard / AI tool): redact it
465
+ // like every other config read/write response.
466
+ return service.redactConfiguration(configuration);
435
467
  }),
436
468
 
437
469
  disassociateSystem: os.disassociateSystem.handler(async ({ input }) => {