@checkstack/healthcheck-backend 0.14.3 → 0.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,47 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8ef367a: Added `registerSpecSchemaDocumentation` to EntityKindRegistry to allow plugins to provide detailed JSON Schemas for specific configurations. The frontend now displays these registered schemas as dropdown alternatives, improving the developer experience when authoring GitOps configurations.
8
+
9
+ ### Patch Changes
10
+
11
+ - cb65e9d: ### Schema-driven secret resolution, rotation invalidation, and security hardening
12
+
13
+ **Breaking**: Replaced `{ secretRef: "..." }` object syntax with `${{ secrets.NAME }}` template interpolation. The `secretField()`, `secretRefSchema`, `isSecretRef`, `SecretRef`, and `ResolvedSecretField` exports have been removed from `@checkstack/gitops-common`.
14
+
15
+ **Breaking**: `ReconcileContext.resolveSecretsBySchema()` now returns `{ resolved: T; warnings: string[] }` instead of `T` directly. Plugins must destructure the result. Warnings contain messages for `${{ secrets.NAME }}` templates found in non-secret fields (fields without `x-secret` annotation).
16
+
17
+ **New features**:
18
+
19
+ - Secrets can be referenced in **any string field** using `${{ secrets.NAME }}` syntax
20
+ - Inline interpolation is supported: `"postgres://user:${{ secrets.DB_PASS }}@host/db"`
21
+ - Resolution is **schema-driven** — reuses the existing `configString({ "x-secret": true })` pattern from DynamicForm
22
+ - Secret rotation now automatically invalidates affected entities, triggering re-reconciliation on the next sync cycle
23
+ - New `getSecretUsage` RPC endpoint to look up which entities reference a given secret
24
+ - Secrets UI now shows an expandable usage panel per secret showing referencing entities
25
+ - Reconciliation warnings: templates in non-secret fields are detected and surfaced in the provenance UI
26
+ - New `secretNameSchema` and `SECRET_NAME_REGEX` exports for validating secret names
27
+
28
+ **Security**:
29
+
30
+ - Secret names are validated at creation: must start with a letter, contain only `[a-zA-Z0-9_-]`, max 63 chars
31
+ - Secrets are validated to exist at sync time but **not pre-resolved** into the spec
32
+ - Templates in `metadata` fields are **rejected** to prevent secret leaks via display fields
33
+ - Only fields with `x-secret` schema annotations get resolved — no escape hatch
34
+ - Templates in non-secret fields emit warnings (stored in provenance, visible in UI) instead of silently passing
35
+
36
+ **Migration**: Update YAML descriptors to use `${{ secrets.NAME }}` instead of `secretRef: name`. Remove `secretField()` imports from plugin schemas — use `configString({ "x-secret": true })` to annotate secret fields. Destructure `const { resolved } = await context.resolveSecretsBySchema({ value, schema })` (return type changed from `T` to `{ resolved: T; warnings: string[] }`).
37
+
38
+ - Updated dependencies [8ef367a]
39
+ - Updated dependencies [cb65e9d]
40
+ - @checkstack/gitops-common@0.2.0
41
+ - @checkstack/gitops-backend@0.2.0
42
+ - @checkstack/catalog-backend@0.4.3
43
+ - @checkstack/satellite-backend@0.2.6
44
+
3
45
  ## 0.14.3
4
46
 
5
47
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,18 +14,18 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@checkstack/backend-api": "0.12.0",
17
- "@checkstack/catalog-backend": "0.4.0",
17
+ "@checkstack/catalog-backend": "0.4.2",
18
18
  "@checkstack/catalog-common": "1.3.1",
19
19
  "@checkstack/command-backend": "0.1.19",
20
20
  "@checkstack/common": "0.6.5",
21
- "@checkstack/gitops-backend": "0.1.0",
22
- "@checkstack/gitops-common": "0.1.0",
21
+ "@checkstack/gitops-backend": "0.1.2",
22
+ "@checkstack/gitops-common": "0.1.1",
23
23
  "@checkstack/healthcheck-common": "0.11.0",
24
24
  "@checkstack/incident-common": "0.4.7",
25
25
  "@checkstack/integration-backend": "0.1.19",
26
26
  "@checkstack/maintenance-common": "0.4.9",
27
27
  "@checkstack/queue-api": "0.2.13",
28
- "@checkstack/satellite-backend": "0.2.3",
28
+ "@checkstack/satellite-backend": "0.2.5",
29
29
  "@checkstack/signal-common": "0.1.9",
30
30
  "@hono/zod-validator": "^0.7.6",
31
31
  "drizzle-orm": "^0.45.0",
@@ -191,6 +191,8 @@ const mockContext: ReconcileContext = {
191
191
  error: () => {},
192
192
  },
193
193
  resolveEntityRef: async () => undefined,
194
+ resolveSecretsBySchema: async <T>(params: { value: T }): Promise<{ resolved: T; warnings: string[] }> =>
195
+ ({ resolved: params.value, warnings: [] }),
194
196
  };
195
197
 
196
198
  // ─── Tests: kind: Healthcheck ──────────────────────────────────────────────
@@ -594,3 +596,111 @@ describe("Healthcheck GitOps Kind: System Extension", () => {
594
596
  expect(mockService.associateSystem).not.toHaveBeenCalled();
595
597
  });
596
598
  });
599
+
600
+ // ─── Tests: Secret template resolution in healthcheck configs ──────────────
601
+
602
+ describe("Healthcheck GitOps: Secret template resolution", () => {
603
+ it("collectSecretNames extracts secrets from healthcheck config", async () => {
604
+ const { collectSecretNames } = await import("@checkstack/gitops-common");
605
+
606
+ const spec = {
607
+ strategy: "postgres",
608
+ intervalSeconds: 30,
609
+ config: {
610
+ host: "db.internal",
611
+ port: 5432,
612
+ database: "payments",
613
+ user: "monitor",
614
+ password: "${{ secrets.DB_PASS }}",
615
+ },
616
+ };
617
+
618
+ const names = collectSecretNames({ value: spec });
619
+ expect(names).toEqual(["DB_PASS"]);
620
+ });
621
+
622
+ it("collectSecretNames extracts multiple secrets from config", async () => {
623
+ const { collectSecretNames } = await import("@checkstack/gitops-common");
624
+
625
+ const spec = {
626
+ strategy: "postgres",
627
+ intervalSeconds: 30,
628
+ config: {
629
+ host: "db.internal",
630
+ user: "${{ secrets.DB_USER }}",
631
+ password: "${{ secrets.DB_PASS }}",
632
+ },
633
+ };
634
+
635
+ const names = collectSecretNames({ value: spec });
636
+ expect(names).toContain("DB_USER");
637
+ expect(names).toContain("DB_PASS");
638
+ expect(names).toHaveLength(2);
639
+ });
640
+
641
+ it("collectSecretNames handles inline interpolation in config values", async () => {
642
+ const { collectSecretNames } = await import("@checkstack/gitops-common");
643
+
644
+ const spec = {
645
+ strategy: "postgres",
646
+ intervalSeconds: 30,
647
+ config: {
648
+ connectionString:
649
+ "postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@host/db",
650
+ },
651
+ };
652
+
653
+ const names = collectSecretNames({ value: spec });
654
+ expect(names).toContain("DB_USER");
655
+ expect(names).toContain("DB_PASS");
656
+ });
657
+
658
+ it("SECRET_TEMPLATE_REGEX correctly identifies templates in healthcheck config values", async () => {
659
+ const { SECRET_TEMPLATE_REGEX } = await import("@checkstack/gitops-common");
660
+
661
+ const configValues = [
662
+ { input: "${{ secrets.DB_PASS }}", expectedSecrets: ["DB_PASS"] },
663
+ { input: "db-${{ secrets.ENV }}.internal", expectedSecrets: ["ENV"] },
664
+ {
665
+ input:
666
+ "postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@host/db",
667
+ expectedSecrets: ["DB_USER", "DB_PASS"],
668
+ },
669
+ { input: "plain-value", expectedSecrets: [] },
670
+ ];
671
+
672
+ for (const { input, expectedSecrets } of configValues) {
673
+ const found: string[] = [];
674
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
675
+ let match: RegExpExecArray | null;
676
+ while ((match = SECRET_TEMPLATE_REGEX.exec(input)) !== null) {
677
+ found.push(match[1]);
678
+ }
679
+ expect(found).toEqual(expectedSecrets);
680
+ }
681
+ });
682
+
683
+ it("template replacement produces expected resolved config", () => {
684
+ // Simulates what the reconciler does: replace ${{ secrets.X }} with values
685
+ const secrets: Record<string, string> = {
686
+ DB_PASS: "production-password-123",
687
+ ENV: "production",
688
+ };
689
+
690
+ const rawConfig: Record<string, string> = {
691
+ host: "db-${{ secrets.ENV }}.internal",
692
+ password: "${{ secrets.DB_PASS }}",
693
+ };
694
+
695
+ const resolvedConfig: Record<string, string> = {};
696
+ for (const [key, value] of Object.entries(rawConfig)) {
697
+ resolvedConfig[key] = value.replaceAll(
698
+ /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g,
699
+ (_match, secretName: string) => secrets[secretName] ?? _match,
700
+ );
701
+ }
702
+
703
+ expect(resolvedConfig.host).toBe("db-production.internal");
704
+ expect(resolvedConfig.password).toBe("production-password-123");
705
+ });
706
+ });
@@ -5,13 +5,23 @@ import type {
5
5
  EntityKindRegistry,
6
6
  ReconcileContext,
7
7
  } from "@checkstack/gitops-common";
8
- import { CHECKSTACK_API_VERSION, secretField, entityRefSchema } from "@checkstack/gitops-common";
8
+ import {
9
+ CHECKSTACK_API_VERSION,
10
+ entityRefSchema,
11
+ } from "@checkstack/gitops-common";
9
12
  import type {
10
13
  HealthCheckRegistry,
11
14
  CollectorRegistry,
12
15
  } from "@checkstack/backend-api";
13
16
  import { HealthCheckService } from "./service";
14
-
17
+ import {
18
+ DynamicOperators,
19
+ numericField,
20
+ stringField,
21
+ booleanField,
22
+ arrayField,
23
+ enumField,
24
+ } from "@checkstack/backend-api";
15
25
 
16
26
  /**
17
27
  * Lazy accessor functions — populated during init(), consumed during reconcile.
@@ -29,13 +39,13 @@ interface HealthcheckGitOpsKindsDeps {
29
39
  /**
30
40
  * GitOps spec schema for kind: Healthcheck.
31
41
  *
32
- * Strategy `config` fields use `secretField()` for sensitive values
33
- * (passwords, tokens) that are resolved by the reconciliation engine.
42
+ * Strategy `config` values may contain `${{ secrets.NAME }}` templates
43
+ * which are automatically resolved by the reconciliation engine.
34
44
  */
35
45
  const healthcheckSpecSchema = z.object({
36
46
  strategy: z.string().min(1),
37
47
  intervalSeconds: z.number().int().min(1),
38
- config: z.record(z.string(), z.union([z.unknown(), secretField()])),
48
+ config: z.record(z.string(), z.unknown()),
39
49
  collectors: z
40
50
  .array(
41
51
  z.object({
@@ -97,53 +107,83 @@ export function buildHealthcheckKind(
97
107
  existingEntityId,
98
108
  context,
99
109
  }: {
100
- entity: { metadata: { name: string; title?: string; description?: string }; spec: HealthcheckSpec };
110
+ entity: {
111
+ metadata: { name: string; title?: string; description?: string };
112
+ spec: HealthcheckSpec;
113
+ };
101
114
  existingEntityId?: string;
102
115
  context: ReconcileContext;
103
116
  }) => {
104
117
  const service = deps.createService();
105
118
  const spec = entity.spec;
106
119
 
107
- // Validate strategy exists in registry
120
+ // Look up strategy first we need its typed schema for secret resolution
108
121
  const healthCheckRegistry = deps.getHealthCheckRegistry();
109
122
  const strategy = healthCheckRegistry.getStrategy(spec.strategy);
110
123
  if (!strategy) {
111
124
  throw new Error(
112
125
  `Unknown health check strategy "${spec.strategy}". ` +
113
- `Available: ${healthCheckRegistry.getStrategies().map((s) => s.id).join(", ")}`,
126
+ `Available: ${healthCheckRegistry
127
+ .getStrategies()
128
+ .map((s) => s.id)
129
+ .join(", ")}`,
114
130
  );
115
131
  }
116
132
 
117
- // Validate config against strategy's Zod schema
118
- const configValidation = strategy.config.schema.safeParse(spec.config);
133
+ // Resolve secrets using the strategy's typed schema.
134
+ // Only fields marked with configString({ "x-secret": true }) get resolved.
135
+ const { resolved: resolvedConfig } = await context.resolveSecretsBySchema(
136
+ {
137
+ value: spec.config,
138
+ schema: strategy.config.schema,
139
+ },
140
+ );
141
+
142
+ // Validate resolved config against strategy's Zod schema
143
+ const configValidation = strategy.config.schema.safeParse(resolvedConfig);
119
144
  if (!configValidation.success) {
120
145
  throw new Error(
121
146
  `Strategy "${spec.strategy}" config validation failed: ${configValidation.error.message}`,
122
147
  );
123
148
  }
124
149
 
125
- // Validate collector configs against their registry schemas
126
- if (spec.collectors) {
127
- for (const collector of spec.collectors) {
128
- const collectorReg = deps.getCollectorRegistry();
129
- const registered = collectorReg.getCollector(
130
- collector.collectorId,
131
- );
132
- if (!registered) {
133
- throw new Error(
134
- `Unknown collector "${collector.collectorId}". ` +
135
- `Available: ${collectorReg.getCollectors().map((c) => c.qualifiedId).join(", ")}`,
136
- );
137
- }
138
- const collectorConfigValidation =
139
- registered.collector.config.schema.safeParse(collector.config);
140
- if (!collectorConfigValidation.success) {
141
- throw new Error(
142
- `Collector "${collector.collectorId}" config validation failed: ${collectorConfigValidation.error.message}`,
143
- );
144
- }
145
- }
146
- }
150
+ // Resolve and validate collector configs using their registry schemas
151
+ const resolvedCollectors = spec.collectors
152
+ ? await Promise.all(
153
+ spec.collectors.map(async (c) => {
154
+ const collectorReg = deps.getCollectorRegistry();
155
+ const registered = collectorReg.getCollector(c.collectorId);
156
+ if (!registered) {
157
+ throw new Error(
158
+ `Unknown collector "${c.collectorId}". ` +
159
+ `Available: ${collectorReg
160
+ .getCollectors()
161
+ .map((col) => col.qualifiedId)
162
+ .join(", ")}`,
163
+ );
164
+ }
165
+
166
+ // Resolve secrets using the collector's typed schema
167
+ const { resolved: resolvedCollectorConfig } =
168
+ await context.resolveSecretsBySchema({
169
+ value: c.config,
170
+ schema: registered.collector.config.schema,
171
+ });
172
+
173
+ const collectorConfigValidation =
174
+ registered.collector.config.schema.safeParse(
175
+ resolvedCollectorConfig,
176
+ );
177
+ if (!collectorConfigValidation.success) {
178
+ throw new Error(
179
+ `Collector "${c.collectorId}" config validation failed: ${collectorConfigValidation.error.message}`,
180
+ );
181
+ }
182
+
183
+ return { ...c, config: resolvedCollectorConfig };
184
+ }),
185
+ )
186
+ : undefined;
147
187
 
148
188
  // Create or update configuration
149
189
  const displayName = entity.metadata.title ?? entity.metadata.name;
@@ -152,9 +192,9 @@ export function buildHealthcheckKind(
152
192
  await service.updateConfiguration(existingEntityId, {
153
193
  name: displayName,
154
194
  strategyId: spec.strategy,
155
- config: spec.config,
195
+ config: resolvedConfig,
156
196
  intervalSeconds: spec.intervalSeconds,
157
- collectors: spec.collectors?.map((c) => ({
197
+ collectors: resolvedCollectors?.map((c) => ({
158
198
  id: c.collectorId,
159
199
  collectorId: c.collectorId,
160
200
  config: c.config,
@@ -170,9 +210,9 @@ export function buildHealthcheckKind(
170
210
  const config = await service.createConfiguration({
171
211
  name: displayName,
172
212
  strategyId: spec.strategy,
173
- config: spec.config,
213
+ config: resolvedConfig,
174
214
  intervalSeconds: spec.intervalSeconds,
175
- collectors: spec.collectors?.map((c) => ({
215
+ collectors: resolvedCollectors?.map((c) => ({
176
216
  id: c.collectorId,
177
217
  collectorId: c.collectorId,
178
218
  config: c.config,
@@ -254,7 +294,7 @@ export function buildSystemHealthcheckExtension(
254
294
  // Build state thresholds from the shorthand
255
295
  const stateThresholds =
256
296
  entry.degradedThreshold || entry.unhealthyThreshold
257
- ? ({
297
+ ? {
258
298
  mode: "consecutive" as const,
259
299
  healthy: { minSuccessCount: 1 },
260
300
  degraded: {
@@ -263,7 +303,7 @@ export function buildSystemHealthcheckExtension(
263
303
  unhealthy: {
264
304
  minFailureCount: entry.unhealthyThreshold ?? 5,
265
305
  },
266
- })
306
+ }
267
307
  : undefined;
268
308
 
269
309
  await service.associateSystem({
@@ -310,3 +350,138 @@ export function registerHealthcheckGitOpsKinds({
310
350
  kindRegistry.registerKind(buildHealthcheckKind(deps));
311
351
  kindRegistry.registerKindExtension(buildSystemHealthcheckExtension(deps));
312
352
  }
353
+
354
+ /**
355
+ * Register spec schema documentation for the Healthcheck kind.
356
+ * Called from healthcheck-backend's `afterPluginsReady` phase once registries are populated.
357
+ */
358
+ export function registerHealthcheckGitOpsDocumentation({
359
+ kindRegistry,
360
+ healthCheckRegistry,
361
+ collectorRegistry,
362
+ }: {
363
+ kindRegistry: EntityKindRegistry;
364
+ healthCheckRegistry: HealthCheckRegistry;
365
+ collectorRegistry: CollectorRegistry;
366
+ }): void {
367
+ // 1. Register documentation for strategy configs (fieldPath: "config")
368
+ for (const registered of healthCheckRegistry.getStrategiesWithMeta()) {
369
+ kindRegistry.registerSpecSchemaDocumentation({
370
+ apiVersion: CHECKSTACK_API_VERSION,
371
+ kind: "Healthcheck",
372
+ fieldPath: "config",
373
+ variantId: registered.ownerPluginId,
374
+ label: registered.strategy.displayName,
375
+ description: `ID: ${registered.ownerPluginId}\n\n${registered.strategy.description}`,
376
+ schema: registered.strategy.config.schema,
377
+ });
378
+ }
379
+
380
+ // 2. Register documentation for collector configs (fieldPath: "collectors[].config")
381
+ for (const registered of collectorRegistry.getCollectors()) {
382
+ kindRegistry.registerSpecSchemaDocumentation({
383
+ apiVersion: CHECKSTACK_API_VERSION,
384
+ kind: "Healthcheck",
385
+ fieldPath: "collectors[].config",
386
+ variantId: registered.qualifiedId,
387
+ label: registered.collector.displayName,
388
+ description: `ID: ${registered.qualifiedId}\n\n${registered.collector.description}`,
389
+ schema: registered.collector.config.schema,
390
+ conditions: [
391
+ {
392
+ fieldPath: "config",
393
+ variantIds: registered.collector.supportedPlugins.map(
394
+ (p) => p.pluginId,
395
+ ),
396
+ },
397
+ ],
398
+ });
399
+
400
+ // 3. Register documentation for collector assertions (fieldPath: "collectors[].assertions")
401
+ const unwrapped = unwrapZodType(registered.collector.result.schema);
402
+
403
+ if (unwrapped instanceof z.ZodObject) {
404
+ const shape = unwrapped.shape;
405
+
406
+ for (const [key, prop] of Object.entries(shape)) {
407
+ const propUnwrapped = unwrapZodType(prop as z.ZodTypeAny);
408
+
409
+ let fieldSchema: z.ZodTypeAny;
410
+
411
+ if (propUnwrapped instanceof z.ZodNumber) {
412
+ fieldSchema = numericField(key);
413
+ } else if (propUnwrapped instanceof z.ZodString) {
414
+ fieldSchema = stringField(key);
415
+ } else if (propUnwrapped instanceof z.ZodBoolean) {
416
+ fieldSchema = booleanField(key);
417
+ } else if (propUnwrapped instanceof z.ZodArray) {
418
+ fieldSchema = arrayField(key);
419
+ } else if (propUnwrapped instanceof z.ZodEnum) {
420
+ const enumValues = propUnwrapped.options;
421
+ const stringValues = enumValues.filter((v: unknown) => typeof v === "string") as string[];
422
+ fieldSchema = stringValues.length > 0 ? enumField(key, stringValues) : stringField(key);
423
+ } else {
424
+ fieldSchema = stringField(key);
425
+ }
426
+
427
+ kindRegistry.registerSpecSchemaDocumentation({
428
+ apiVersion: CHECKSTACK_API_VERSION,
429
+ kind: "Healthcheck",
430
+ fieldPath: "collectors[].assertions",
431
+ variantId: `${registered.qualifiedId}.assert.${key}`,
432
+ label: `Assert: ${key}`,
433
+ description: `Assertion documentation for the '${key}' field of ${registered.collector.displayName}.`,
434
+ schema: z.array(fieldSchema).describe(`Assertions array`),
435
+ conditions: [
436
+ {
437
+ fieldPath: "collectors[].config",
438
+ variantIds: [registered.qualifiedId],
439
+ },
440
+ ],
441
+ });
442
+ }
443
+ } else {
444
+ // Fallback if result schema is not an object
445
+ kindRegistry.registerSpecSchemaDocumentation({
446
+ apiVersion: CHECKSTACK_API_VERSION,
447
+ kind: "Healthcheck",
448
+ fieldPath: "collectors[].assertions",
449
+ variantId: `${registered.qualifiedId}.assert.generic`,
450
+ label: `${registered.collector.displayName} Assertions`,
451
+ description: `Define assertions against the result of this collector.`,
452
+ schema: z.array(
453
+ z.object({
454
+ field: z.string(),
455
+ operator: DynamicOperators,
456
+ value: z.unknown().optional(),
457
+ })
458
+ ).describe(`Assertions array`),
459
+ conditions: [
460
+ {
461
+ fieldPath: "collectors[].config",
462
+ variantIds: [registered.qualifiedId],
463
+ },
464
+ ],
465
+ });
466
+ }
467
+ }
468
+ }
469
+
470
+ function unwrapZodType(type: z.ZodTypeAny): z.ZodTypeAny {
471
+ let current = type;
472
+ while (current) {
473
+ if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
474
+ current = current.unwrap() as z.ZodTypeAny;
475
+ } else if (current instanceof z.ZodDefault) {
476
+ current = current._def.innerType as z.ZodTypeAny;
477
+ } else if ("innerType" in current && typeof current.innerType === "function") {
478
+ // Handles ZodEffects/ZodBranded generically without relying on removed types
479
+ current = current.innerType() as z.ZodTypeAny;
480
+ } else {
481
+ break;
482
+ }
483
+ }
484
+ return current;
485
+ }
486
+
487
+
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
24
24
  import { z } from "zod";
25
25
  import { createHealthCheckRouter } from "./router";
26
26
  import { HealthCheckService } from "./service";
27
- import { registerHealthcheckGitOpsKinds } from "./healthcheck-gitops-kinds";
27
+ import { registerHealthcheckGitOpsKinds, registerHealthcheckGitOpsDocumentation } from "./healthcheck-gitops-kinds";
28
28
  import { catalogHooks } from "@checkstack/catalog-backend";
29
29
  import { satelliteHooks } from "@checkstack/satellite-backend";
30
30
  import { CatalogApi } from "@checkstack/catalog-common";
@@ -239,6 +239,13 @@ export default createBackendPlugin({
239
239
  logger,
240
240
  });
241
241
 
242
+ // Register GitOps documentation now that registries are populated
243
+ registerHealthcheckGitOpsDocumentation({
244
+ kindRegistry,
245
+ healthCheckRegistry,
246
+ collectorRegistry,
247
+ });
248
+
242
249
  // Subscribe to catalog system deletion to clean up associations
243
250
  const service = new HealthCheckService(
244
251
  database,