@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.
@@ -0,0 +1,361 @@
1
+ import { z } from "zod";
2
+ import { type AdvisoryLockService } from "@checkstack/backend-api";
3
+ import {
4
+ type InternalSecretsService,
5
+ type SecretResolverService,
6
+ type ConfigSecretChannel,
7
+ extractScopeSecrets,
8
+ inflateScopeSecrets,
9
+ collectScopeSecretValues,
10
+ deleteScopeSecrets,
11
+ pruneScopeSecrets,
12
+ mergeSecretFields,
13
+ } from "@checkstack/secrets-backend";
14
+ import {
15
+ HEALTHCHECK_SECRET_MARKER_PREFIX,
16
+ type CollectorConfigEntry,
17
+ } from "@checkstack/healthcheck-common";
18
+
19
+ // Re-export the marker vocabulary (defined in healthcheck-common so the
20
+ // satellite runtime shares it) and the schema-only channel helpers (defined in
21
+ // secrets-backend, the ONE extraction channel) for this package + its tests.
22
+ export {
23
+ healthcheckSecretMarker,
24
+ isHealthcheckSecretMarker,
25
+ } from "@checkstack/healthcheck-common";
26
+ // `mergeSecretFields` is also imported above for local use in this module;
27
+ // this re-export surfaces the channel helpers for the package + its tests.
28
+ export {
29
+ redactSecretFields,
30
+ mergeSecretFields,
31
+ listPopulatedSecretKeys,
32
+ } from "@checkstack/secrets-backend";
33
+
34
+ /**
35
+ * Health-check config credentials ride the platform's ONE config-secret
36
+ * extraction channel ({@link ConfigSecretChannel} in `@checkstack/secrets-backend`,
37
+ * shared with integration connections). This module only binds that channel to a
38
+ * health-check scope (strategy config, or one collector entry) and orchestrates
39
+ * the strategy + collectors of a whole configuration. Fields are declared with
40
+ * `configSecret({ id })`; the internal secret is keyed by the stable id.
41
+ */
42
+
43
+ /**
44
+ * Scope of a secret within a configuration: the strategy config itself, or one
45
+ * collector entry's config (keyed by the entry's stable UUID, which survives
46
+ * collector re-ordering).
47
+ */
48
+ export type SecretScope =
49
+ | { kind: "strategy" }
50
+ | { kind: "collector"; entryId: string };
51
+
52
+ /** Internal-secret name parts for a health-check config credential field. */
53
+ export function healthcheckSecretParts({
54
+ configurationId,
55
+ scope,
56
+ secretId,
57
+ }: {
58
+ configurationId: string;
59
+ scope: SecretScope;
60
+ secretId: string;
61
+ }): string[] {
62
+ return scope.kind === "strategy"
63
+ ? ["healthcheck", configurationId, "strategy", secretId]
64
+ : ["healthcheck", configurationId, "collector", scope.entryId, secretId];
65
+ }
66
+
67
+ /** The extraction channel bound to one health-check scope. */
68
+ function scopeChannel(
69
+ configurationId: string,
70
+ scope: SecretScope,
71
+ ): ConfigSecretChannel {
72
+ return {
73
+ markerPrefix: HEALTHCHECK_SECRET_MARKER_PREFIX,
74
+ keyParts: (secretId) =>
75
+ healthcheckSecretParts({ configurationId, scope, secretId }),
76
+ };
77
+ }
78
+
79
+ export interface HealthCheckSecretsDeps {
80
+ internalSecrets: InternalSecretsService;
81
+ /** Only the per-run resolution surface is needed (narrows test fakes). */
82
+ secretResolver: Pick<SecretResolverService, "resolveForRun">;
83
+ /**
84
+ * Optional cluster-wide mutex. When present, `updateConfiguration` and
85
+ * `deleteConfiguration` serialize their read-modify-write-prune for a given
86
+ * config id under a per-id advisory lock, so a concurrent writer to the SAME
87
+ * id cannot have its just-written secret deleted by another writer's stale
88
+ * orphan-prune (which would leave a dangling marker).
89
+ */
90
+ advisoryLock?: AdvisoryLockService;
91
+ }
92
+
93
+ /** Advisory-lock key serializing writes to ONE health-check configuration. */
94
+ export function healthcheckConfigLockKey(configurationId: string): string {
95
+ return `healthcheck:config:${configurationId}`;
96
+ }
97
+
98
+ /** Resolve a collector entry's config schema, or undefined when unknown. */
99
+ export type GetCollectorSchema = (
100
+ collectorId: string,
101
+ ) => z.ZodTypeAny | undefined;
102
+
103
+ // ============================================================================
104
+ // EXTRACT (write path)
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Extract inline secrets from a configuration's strategy config AND every
109
+ * collector entry's config into internal secrets, replacing each with a marker.
110
+ * An UNREGISTERED strategy has no schema, so its config passes through unchanged
111
+ * while REGISTERED collectors still extract their own secrets.
112
+ */
113
+ export async function extractConfigurationSecrets({
114
+ configurationId,
115
+ strategySchema,
116
+ config,
117
+ collectors,
118
+ getCollectorSchema,
119
+ internalSecrets,
120
+ }: {
121
+ strategySchema: z.ZodTypeAny | undefined;
122
+ configurationId: string;
123
+ config: Record<string, unknown>;
124
+ collectors: CollectorConfigEntry[] | undefined;
125
+ getCollectorSchema: GetCollectorSchema;
126
+ internalSecrets: InternalSecretsService;
127
+ }): Promise<{
128
+ config: Record<string, unknown>;
129
+ collectors: CollectorConfigEntry[] | undefined;
130
+ extracted: number;
131
+ }> {
132
+ let extracted = 0;
133
+
134
+ let strategyConfig = config;
135
+ if (strategySchema) {
136
+ const result = await extractScopeSecrets({
137
+ channel: scopeChannel(configurationId, { kind: "strategy" }),
138
+ schema: strategySchema,
139
+ config,
140
+ internalSecrets,
141
+ });
142
+ strategyConfig = result.config;
143
+ extracted += result.extracted;
144
+ }
145
+
146
+ let rewrittenCollectors = collectors;
147
+ if (collectors?.length) {
148
+ rewrittenCollectors = await Promise.all(
149
+ collectors.map(async (entry) => {
150
+ const schema = getCollectorSchema(entry.collectorId);
151
+ if (!schema) return entry;
152
+ const result = await extractScopeSecrets({
153
+ channel: scopeChannel(configurationId, {
154
+ kind: "collector",
155
+ entryId: entry.id,
156
+ }),
157
+ schema,
158
+ config: entry.config,
159
+ internalSecrets,
160
+ });
161
+ extracted += result.extracted;
162
+ return { ...entry, config: result.config };
163
+ }),
164
+ );
165
+ }
166
+
167
+ return { config: strategyConfig, collectors: rewrittenCollectors, extracted };
168
+ }
169
+
170
+ // ============================================================================
171
+ // INFLATE / COLLECT (run path)
172
+ // ============================================================================
173
+
174
+ /** Inflate ONE stored config's secret fields to real values (in memory). */
175
+ export async function inflateConfigSecrets({
176
+ configurationId,
177
+ scope,
178
+ schema,
179
+ config,
180
+ deps,
181
+ }: {
182
+ configurationId: string;
183
+ scope: SecretScope;
184
+ schema: z.ZodTypeAny;
185
+ config: Record<string, unknown>;
186
+ deps: HealthCheckSecretsDeps;
187
+ }): Promise<{ config: Record<string, unknown>; values: string[] }> {
188
+ return inflateScopeSecrets({
189
+ channel: scopeChannel(configurationId, scope),
190
+ schema,
191
+ config,
192
+ internalSecrets: deps.internalSecrets,
193
+ secretResolver: deps.secretResolver,
194
+ });
195
+ }
196
+
197
+ /** Resolve a config's secret fields to a `path -> value` map (satellite JIT). */
198
+ export async function collectConfigSecretValues({
199
+ configurationId,
200
+ scope,
201
+ schema,
202
+ config,
203
+ deps,
204
+ }: {
205
+ configurationId: string;
206
+ scope: SecretScope;
207
+ schema: z.ZodTypeAny;
208
+ config: Record<string, unknown>;
209
+ deps: HealthCheckSecretsDeps;
210
+ }): Promise<Record<string, string>> {
211
+ return collectScopeSecretValues({
212
+ channel: scopeChannel(configurationId, scope),
213
+ schema,
214
+ config,
215
+ internalSecrets: deps.internalSecrets,
216
+ secretResolver: deps.secretResolver,
217
+ });
218
+ }
219
+
220
+ // ============================================================================
221
+ // MERGE (update path)
222
+ // ============================================================================
223
+
224
+ /**
225
+ * Merge stored secrets into an incoming configuration update: the strategy
226
+ * config plus each collector entry (paired with the stored entry of the SAME
227
+ * id - a brand-new entry has nothing to restore). An unregistered strategy has
228
+ * no schema, so its config passes through unchanged.
229
+ */
230
+ export function mergeConfigurationSecrets({
231
+ strategySchema,
232
+ incomingConfig,
233
+ storedConfig,
234
+ incomingCollectors,
235
+ storedCollectors,
236
+ getCollectorSchema,
237
+ }: {
238
+ strategySchema: z.ZodTypeAny | undefined;
239
+ incomingConfig: Record<string, unknown>;
240
+ storedConfig: Record<string, unknown> | undefined;
241
+ incomingCollectors: CollectorConfigEntry[] | undefined;
242
+ storedCollectors: CollectorConfigEntry[] | undefined;
243
+ getCollectorSchema: GetCollectorSchema;
244
+ }): {
245
+ config: Record<string, unknown>;
246
+ collectors: CollectorConfigEntry[] | undefined;
247
+ } {
248
+ // Imported lazily-free: mergeSecretFields is re-exported above.
249
+ const config = strategySchema
250
+ ? mergeSecretFields({
251
+ schema: strategySchema,
252
+ incoming: incomingConfig,
253
+ stored: storedConfig,
254
+ })
255
+ : incomingConfig;
256
+
257
+ const storedById = new Map(
258
+ (storedCollectors ?? []).map((entry) => [entry.id, entry]),
259
+ );
260
+ const collectors = incomingCollectors?.map((entry) => {
261
+ const schema = getCollectorSchema(entry.collectorId);
262
+ const stored = storedById.get(entry.id);
263
+ if (!schema) return entry;
264
+ return {
265
+ ...entry,
266
+ config: mergeSecretFields({
267
+ schema,
268
+ incoming: entry.config,
269
+ stored: stored?.config,
270
+ }),
271
+ };
272
+ });
273
+
274
+ return { config, collectors };
275
+ }
276
+
277
+ // ============================================================================
278
+ // CLEANUP (schema-free delete + orphan prune)
279
+ // ============================================================================
280
+
281
+ /** Collector scopes present across an old + new collector list, deduped. */
282
+ function collectorScopes(
283
+ ...lists: (CollectorConfigEntry[] | undefined)[]
284
+ ): { kind: "collector"; entryId: string }[] {
285
+ const ids = new Set<string>();
286
+ for (const list of lists) for (const entry of list ?? []) ids.add(entry.id);
287
+ return [...ids].map((entryId) => ({ kind: "collector", entryId }));
288
+ }
289
+
290
+ /**
291
+ * Delete every internal secret a stored configuration's markers point at (the
292
+ * strategy config plus every collector entry). Schema-free, so it cleans up even
293
+ * when the strategy/collector plugin is uninstalled - a deleted check never
294
+ * orphans its secrets.
295
+ */
296
+ export async function deleteConfigurationSecrets({
297
+ configurationId,
298
+ config,
299
+ collectors,
300
+ internalSecrets,
301
+ }: {
302
+ configurationId: string;
303
+ config: Record<string, unknown>;
304
+ collectors: CollectorConfigEntry[] | undefined;
305
+ internalSecrets: InternalSecretsService;
306
+ }): Promise<void> {
307
+ await deleteScopeSecrets({
308
+ channel: scopeChannel(configurationId, { kind: "strategy" }),
309
+ config,
310
+ internalSecrets,
311
+ });
312
+ const byId = new Map((collectors ?? []).map((e) => [e.id, e]));
313
+ for (const scope of collectorScopes(collectors)) {
314
+ await deleteScopeSecrets({
315
+ channel: scopeChannel(configurationId, scope),
316
+ config: byId.get(scope.entryId)?.config ?? {},
317
+ internalSecrets,
318
+ });
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Delete internal secrets ORPHANED by an update: a cleared/removed field, an
324
+ * inline secret swapped for a reference, or a removed collector. Prunes the
325
+ * strategy scope and every collector scope (old ∪ new), comparing markers by
326
+ * exact internal-secret coordinates. Returns the number deleted.
327
+ */
328
+ export async function pruneOrphanedConfigurationSecrets({
329
+ configurationId,
330
+ oldConfig,
331
+ newConfig,
332
+ oldCollectors,
333
+ newCollectors,
334
+ internalSecrets,
335
+ }: {
336
+ configurationId: string;
337
+ oldConfig: Record<string, unknown>;
338
+ newConfig: Record<string, unknown>;
339
+ oldCollectors: CollectorConfigEntry[] | undefined;
340
+ newCollectors: CollectorConfigEntry[] | undefined;
341
+ internalSecrets: InternalSecretsService;
342
+ }): Promise<number> {
343
+ let deleted = await pruneScopeSecrets({
344
+ channel: scopeChannel(configurationId, { kind: "strategy" }),
345
+ oldConfig,
346
+ newConfig: newConfig ?? {},
347
+ internalSecrets,
348
+ });
349
+
350
+ const oldById = new Map((oldCollectors ?? []).map((e) => [e.id, e]));
351
+ const newById = new Map((newCollectors ?? []).map((e) => [e.id, e]));
352
+ for (const scope of collectorScopes(oldCollectors, newCollectors)) {
353
+ deleted += await pruneScopeSecrets({
354
+ channel: scopeChannel(configurationId, scope),
355
+ oldConfig: oldById.get(scope.entryId)?.config ?? {},
356
+ newConfig: newById.get(scope.entryId)?.config ?? {},
357
+ internalSecrets,
358
+ });
359
+ }
360
+ return deleted;
361
+ }
@@ -146,14 +146,16 @@ export function buildHealthcheckKind(
146
146
 
147
147
  const strategy = matchStrategy.strategy;
148
148
 
149
- // Resolve secrets using the strategy's typed schema.
150
- // Only fields marked with configString({ "x-secret": true }) get resolved.
151
- const { resolved: resolvedConfig } = await context.resolveSecretsBySchema(
152
- {
153
- value: spec.config,
154
- schema: strategy.config.schema,
155
- },
156
- );
149
+ // Resolvability check ONLY: resolving `${{ secrets.* }}` references in
150
+ // `x-secret` fields surfaces a missing/undeclared secret as a clear
151
+ // apply-time error. The RESOLVED VALUES ARE DISCARDED - the ORIGINAL
152
+ // spec (references intact) is what gets validated and persisted, so a
153
+ // resolved secret never lands in the stored row. The executor resolves
154
+ // references just-in-time at run.
155
+ await context.resolveSecretsBySchema({
156
+ value: spec.config,
157
+ schema: strategy.config.schema,
158
+ });
157
159
 
158
160
  // Migrate-then-validate-strict: authored gitops YAML may be in an OLD
159
161
  // config shape, so run the migration chain (assume-v1-on-read) before
@@ -162,10 +164,11 @@ export function buildHealthcheckKind(
162
164
  // exact strict-validate path the `validateConfiguration` RPC uses, so the
163
165
  // two agree on what counts as valid. A strategy config is always a plain
164
166
  // object validated by the strategy's own schema, so narrowing the
165
- // `unknown` result to the stored `Record` shape is safe.
167
+ // `unknown` result to the stored `Record` shape is safe. Secret fields
168
+ // hold reference strings here, which validate as ordinary strings.
166
169
  const strategyResult = await validateVersionedConfigStrict({
167
170
  config: strategy.config,
168
- value: resolvedConfig,
171
+ value: spec.config,
169
172
  basePath: ["config"],
170
173
  });
171
174
  if (!strategyResult.ok) {
@@ -176,7 +179,7 @@ export function buildHealthcheckKind(
176
179
  const migratedConfig = strategyResult.value as Record<string, unknown>;
177
180
 
178
181
  // Resolve and validate collector configs using their registry schemas
179
- const resolvedCollectors = spec.collectors
182
+ const validatedCollectors = spec.collectors
180
183
  ? await Promise.all(
181
184
  spec.collectors.map(async (c) => {
182
185
  // Look up collector using strictly the fully qualified ID
@@ -196,12 +199,13 @@ export function buildHealthcheckKind(
196
199
  }
197
200
  const registered = matchCollector;
198
201
 
199
- // Resolve secrets using the collector's typed schema
200
- const { resolved: resolvedCollectorConfig } =
201
- await context.resolveSecretsBySchema({
202
- value: c.config,
203
- schema: registered.collector.config.schema,
204
- });
202
+ // Resolvability check ONLY (see the strategy-config note): the
203
+ // resolved values are discarded and the ORIGINAL config (with
204
+ // references intact) is validated and persisted.
205
+ await context.resolveSecretsBySchema({
206
+ value: c.config,
207
+ schema: registered.collector.config.schema,
208
+ });
205
209
 
206
210
  // Migrate-then-validate-strict: authored gitops YAML may use an
207
211
  // OLD collector config shape. Run the migration chain before
@@ -213,7 +217,7 @@ export function buildHealthcheckKind(
213
217
  // `Record` shape is safe.
214
218
  const collectorResult = await validateVersionedConfigStrict({
215
219
  config: registered.collector.config,
216
- value: resolvedCollectorConfig,
220
+ value: c.config,
217
221
  basePath: ["config"],
218
222
  });
219
223
  if (!collectorResult.ok) {
@@ -235,18 +239,25 @@ export function buildHealthcheckKind(
235
239
  const displayName = entity.metadata.title ?? entity.metadata.name;
236
240
 
237
241
  if (existingEntityId && !existingEntityId.startsWith("pending-")) {
238
- await service.updateConfiguration(existingEntityId, {
239
- name: displayName,
240
- strategyId: spec.strategy,
241
- config: migratedConfig,
242
- intervalSeconds: spec.intervalSeconds,
243
- collectors: resolvedCollectors?.map((c) => ({
244
- id: c.collectorId,
245
- collectorId: c.collectorId,
246
- config: c.config,
247
- assertions: c.assertions,
248
- })),
249
- });
242
+ await service.updateConfiguration(
243
+ existingEntityId,
244
+ {
245
+ name: displayName,
246
+ strategyId: spec.strategy,
247
+ config: migratedConfig,
248
+ intervalSeconds: spec.intervalSeconds,
249
+ collectors: validatedCollectors?.map((c) => ({
250
+ id: c.collectorId,
251
+ collectorId: c.collectorId,
252
+ config: c.config,
253
+ assertions: c.assertions,
254
+ })),
255
+ },
256
+ // GitOps is declarative: the authored YAML is the whole truth, so an
257
+ // omitted secret field means "not set" and must be removed, not
258
+ // silently restored from the stored row (keep-existing is UI-only).
259
+ { mergeSecrets: false },
260
+ );
250
261
  context.logger.info(
251
262
  `GitOps: updated Healthcheck "${displayName}" (id: ${existingEntityId})`,
252
263
  );
@@ -258,7 +269,7 @@ export function buildHealthcheckKind(
258
269
  strategyId: spec.strategy,
259
270
  config: migratedConfig,
260
271
  intervalSeconds: spec.intervalSeconds,
261
- collectors: resolvedCollectors?.map((c) => ({
272
+ collectors: validatedCollectors?.map((c) => ({
262
273
  id: c.collectorId,
263
274
  collectorId: c.collectorId,
264
275
  config: c.config,
package/src/index.ts CHANGED
@@ -56,7 +56,9 @@ import {
56
56
  type HealthEntityState,
57
57
  } from "./health-entity";
58
58
  import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
59
- import { secretResolverRef } from "@checkstack/secrets-backend";
59
+ import { secretResolverRef, internalSecretsRef } from "@checkstack/secrets-backend";
60
+ import type { HealthCheckSecretsDeps } from "./config-secrets";
61
+ import { backfillConfigSecrets } from "./config-secrets-backfill";
60
62
  import { createHealthCheckRouter } from "./router";
61
63
  import { HealthCheckService } from "./service";
62
64
  import {
@@ -171,6 +173,7 @@ export default createBackendPlugin({
171
173
  let gitopsHealthCheckRegistry: HealthCheckRegistry | undefined;
172
174
  let gitopsCollectorRegistry: CollectorRegistry | undefined;
173
175
  let gitopsQueueManager: QueueManager | undefined;
176
+ let gitopsConfigSecrets: HealthCheckSecretsDeps | undefined;
174
177
  let healthCheckCache:
175
178
  | ReturnType<typeof createHealthCheckCache>
176
179
  | undefined;
@@ -188,6 +191,9 @@ export default createBackendPlugin({
188
191
  gitopsDb,
189
192
  gitopsHealthCheckRegistry,
190
193
  gitopsCollectorRegistry,
194
+ undefined,
195
+ undefined,
196
+ gitopsConfigSecrets,
191
197
  );
192
198
  },
193
199
  getHealthCheckRegistry: () => {
@@ -220,6 +226,7 @@ export default createBackendPlugin({
220
226
  cacheManager: coreServices.cacheManager,
221
227
  config: coreServices.config,
222
228
  secretResolver: secretResolverRef,
229
+ internalSecrets: internalSecretsRef,
223
230
  advisoryLock: coreServices.advisoryLock,
224
231
  resourceResolverRegistry: coreServices.resourceResolverRegistry,
225
232
  },
@@ -236,6 +243,7 @@ export default createBackendPlugin({
236
243
  cacheManager,
237
244
  config,
238
245
  secretResolver,
246
+ internalSecrets,
239
247
  advisoryLock,
240
248
  resourceResolverRegistry,
241
249
  }) => {
@@ -243,6 +251,34 @@ export default createBackendPlugin({
243
251
 
244
252
  const typedDb = database as SafeDatabase<typeof schema>;
245
253
 
254
+ // Secrets channel for config credentials: extract-on-write /
255
+ // redact-on-read / inflate-at-run. Shared by the router, the gitops
256
+ // reconcile service, the executor, and the boot backfill.
257
+ const configSecrets: HealthCheckSecretsDeps = {
258
+ internalSecrets,
259
+ secretResolver,
260
+ // Serializes concurrent updateConfiguration writes to the SAME config
261
+ // id so one writer's orphan-prune cannot delete a secret a concurrent
262
+ // writer just set (which would leave a dangling marker).
263
+ advisoryLock,
264
+ };
265
+ gitopsConfigSecrets = configSecrets;
266
+
267
+ // Move any pre-channel inline secrets out of stored rows (idempotent,
268
+ // advisory-locked, fail-open: a backfill failure must not block boot).
269
+ try {
270
+ await backfillConfigSecrets({
271
+ db: typedDb,
272
+ registry: healthCheckRegistry,
273
+ collectorRegistry,
274
+ internalSecrets,
275
+ advisoryLock,
276
+ logger,
277
+ });
278
+ } catch (error) {
279
+ logger.warn("Config-secrets backfill failed; continuing boot", error);
280
+ }
281
+
246
282
  // Resolve/search health-check configurations by name for the Teams admin
247
283
  // UI (team grants are stored as opaque `<type>:<configId>` rows, where
248
284
  // <type> is `healthCheckResourceTypes.configuration` — i.e.
@@ -458,6 +494,7 @@ export default createBackendPlugin({
458
494
  getHealthEntity: () => healthEntity,
459
495
  cache,
460
496
  secretResolver,
497
+ internalSecrets,
461
498
  });
462
499
 
463
500
  // Setup retention job for tiered storage (daily aggregation)
@@ -486,6 +523,7 @@ export default createBackendPlugin({
486
523
  maintenanceClient,
487
524
  logger,
488
525
  signalService,
526
+ configSecrets,
489
527
  recomputeSystemRollupHealth: (systemId) =>
490
528
  recomputeSystemRollupHealth({
491
529
  systemId,
@@ -629,6 +667,14 @@ export default createBackendPlugin({
629
667
  // Re-export hooks for other plugins to use
630
668
  export { healthCheckHooks } from "./hooks";
631
669
 
670
+ // Re-export the config-secrets channel so satellite-backend can resolve a
671
+ // satellite's assignment config secrets just-in-time (the same walk +
672
+ // marker/reference semantics the core executor uses).
673
+ export {
674
+ collectConfigSecretValues,
675
+ type HealthCheckSecretsDeps,
676
+ } from "./config-secrets";
677
+
632
678
  // Re-export the reactive `health` entity surface so cross-plugin consumers
633
679
  // (slo, dependency) can subscribe via onEntityChanged + classify changes
634
680
  // without duplicating the kind id / transition predicate (§10.3).