@checkstack/gitops-backend 0.3.6 → 0.4.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,88 @@
1
1
  # @checkstack/gitops-backend
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b995afb: Surface per-variant config documentation for the `Automation` GitOps kind.
8
+
9
+ The GitOps editor and Kind Registry Browser now show the right config schema
10
+ for each automation trigger and provider action when authoring an
11
+ `Automation` YAML, mirroring how the `Healthcheck` kind documents its
12
+ strategy/collector configs:
13
+
14
+ - `triggers[].config` — one entry per registered trigger that declares a
15
+ `configSchema`, conditioned on the chosen `triggers[].event`.
16
+ - `actions[].config` — one entry per registered provider action,
17
+ conditioned on the chosen `actions[].action`.
18
+
19
+ New plugin-author contract on the entity kind registry:
20
+
21
+ - `@checkstack/gitops-common` / `@checkstack/gitops-backend`: add
22
+ `EntityKindRegistry.registerSpecSchemaDocumentationProvider(provider)`. The
23
+ provider is a thunk invoked on every `describeKinds()` (i.e. each time the
24
+ kind-browser RPC is queried), so the docs it returns reflect the current
25
+ state of whatever it reads — order-independent.
26
+
27
+ Why a lazy provider (and not the existing eager
28
+ `registerSpecSchemaDocumentation`): unlike Healthcheck, whose
29
+ strategy/collector registries are core services fully populated before any
30
+ plugin's `afterPluginsReady`, the automation trigger/action registries are
31
+ filled by other plugins across their `init` / `afterPluginsReady` phases with
32
+ no guaranteed ordering. Several plugins (catalog/maintenance/notification)
33
+ register their provider actions in their own `afterPluginsReady`, so the
34
+ previous one-shot eager registration snapshotted a half-populated (often
35
+ empty) registry and the Automation kind's "Additional Schemas" came up empty.
36
+ automation-backend now registers a provider instead, so trigger/action config
37
+ docs always reflect the fully-populated registries.
38
+
39
+ Documentation-only surface; no runtime reconcile behaviour changes.
40
+
41
+ - 270ef29: Add the Secrets platform (Phase 1): a central, plugin-agnostic secret manager with a pluggable backend extension point, a cross-plugin resolver service, and a universal Jenkins-style masking layer.
42
+
43
+ - New packages: `secrets-common` (schemas, contract, `secrets.read`/`secrets.manage`, masking utils), `secrets-backend` (`SecretBackend` extension point, `secretResolverRef`/`secretAdminRef` services, run-scoped masking context, RPC router), `secrets-backend-local` (default AES-256-GCM backend, owns the `secrets` table promoted from gitops), `secrets-frontend` (admin Settings page).
44
+ - Resolution machinery (`resolveSecretsBySchema`, `SecretStore`, `${{ secrets.NAME }}` / `x-secret`) is promoted out of `gitops-backend` into `secrets-backend`. GitOps now resolves and manages secrets through the platform's service refs (single source of truth); its secret table is migrated without loss.
45
+ - Universal masking seam wired at the central script-output boundaries: automation `run_script` / `run_shell` artifacts and the in-UI test panel redact run-scoped secret values from `result`/`stdout`/`stderr`/`error` before persist/return. Phase 1 resolves no run-scoped secrets yet, so masking is a no-op until Phase 2; the seam guarantees the boundary exists.
46
+ - No endpoint returns a secret value to a browser: DTOs expose only name/metadata/`hasValue`.
47
+
48
+ BREAKING CHANGES: `gitops-backend` now depends on `secrets-backend` and resolves/manages secrets through it. The `secrets` table is owned by `secrets-backend-local`; the gitops `secrets` table is retained as a migration source but is no longer the source of truth.
49
+
50
+ ### Patch Changes
51
+
52
+ - Updated dependencies [270ef29]
53
+ - Updated dependencies [b995afb]
54
+ - Updated dependencies [270ef29]
55
+ - Updated dependencies [270ef29]
56
+ - Updated dependencies [b995afb]
57
+ - Updated dependencies [270ef29]
58
+ - Updated dependencies [270ef29]
59
+ - Updated dependencies [270ef29]
60
+ - Updated dependencies [270ef29]
61
+ - Updated dependencies [270ef29]
62
+ - Updated dependencies [270ef29]
63
+ - Updated dependencies [270ef29]
64
+ - Updated dependencies [270ef29]
65
+ - Updated dependencies [270ef29]
66
+ - Updated dependencies [270ef29]
67
+ - Updated dependencies [b995afb]
68
+ - @checkstack/backend-api@0.19.0
69
+ - @checkstack/gitops-common@0.5.0
70
+ - @checkstack/secrets-backend@0.1.0
71
+ - @checkstack/command-backend@0.1.32
72
+ - @checkstack/queue-api@0.3.7
73
+
74
+ ## 0.3.7
75
+
76
+ ### Patch Changes
77
+
78
+ - Updated dependencies [6d52276]
79
+ - Updated dependencies [35bc682]
80
+ - @checkstack/common@0.12.0
81
+ - @checkstack/backend-api@0.18.0
82
+ - @checkstack/command-backend@0.1.31
83
+ - @checkstack/gitops-common@0.4.2
84
+ - @checkstack/queue-api@0.3.6
85
+
3
86
  ## 0.3.6
4
87
 
5
88
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/gitops-backend",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,11 +14,12 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.17.0",
18
- "@checkstack/gitops-common": "0.4.1",
19
- "@checkstack/common": "0.11.0",
20
- "@checkstack/command-backend": "0.1.29",
21
- "@checkstack/queue-api": "0.3.4",
17
+ "@checkstack/backend-api": "0.18.0",
18
+ "@checkstack/gitops-common": "0.4.2",
19
+ "@checkstack/common": "0.12.0",
20
+ "@checkstack/command-backend": "0.1.31",
21
+ "@checkstack/secrets-backend": "0.0.1",
22
+ "@checkstack/queue-api": "0.3.6",
22
23
  "@orpc/server": "^1.13.2",
23
24
  "drizzle-orm": "^0.45.0",
24
25
  "minimatch": "^10.0.0",
@@ -28,7 +29,7 @@
28
29
  },
29
30
  "devDependencies": {
30
31
  "@checkstack/drizzle-helper": "0.0.5",
31
- "@checkstack/scripts": "0.3.3",
32
+ "@checkstack/scripts": "0.3.4",
32
33
  "@checkstack/tsconfig": "0.0.7",
33
34
  "@types/bun": "^1.3.5",
34
35
  "@types/node": "^20.0.0",
package/src/index.ts CHANGED
@@ -17,9 +17,8 @@ import type {
17
17
  import { createEntityKindRegistry } from "./kind-registry";
18
18
  import { createGitOpsRouter } from "./router";
19
19
  import { setupSyncWorker } from "./sync/sync-worker";
20
- import { decrypt } from "@checkstack/backend-api";
20
+ import { secretResolverRef, secretAdminRef } from "@checkstack/secrets-backend";
21
21
  import * as schema from "./schema";
22
- import { eq } from "drizzle-orm";
23
22
 
24
23
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25
24
  // Extension Points
@@ -75,6 +74,9 @@ export default createBackendPlugin({
75
74
  registerSpecSchemaDocumentation(params) {
76
75
  kindRegistry.registerSpecSchemaDocumentation(params);
77
76
  },
77
+ registerSpecSchemaDocumentationProvider(provider) {
78
+ kindRegistry.registerSpecSchemaDocumentationProvider(provider);
79
+ },
78
80
  });
79
81
 
80
82
  env.registerInit({
@@ -83,18 +85,38 @@ export default createBackendPlugin({
83
85
  logger: coreServices.logger,
84
86
  rpc: coreServices.rpc,
85
87
  queueManager: coreServices.queueManager,
88
+ secretResolver: secretResolverRef,
89
+ secretAdmin: secretAdminRef,
86
90
  },
87
- init: async ({ logger, database, rpc, queueManager }) => {
91
+ init: async ({
92
+ logger,
93
+ database,
94
+ rpc,
95
+ queueManager,
96
+ secretResolver,
97
+ secretAdmin,
98
+ }) => {
88
99
  logger.debug("🔄 Initializing GitOps Backend...");
89
100
 
90
101
  const db = database as SafeDatabase<typeof schema>;
91
102
 
92
- const router = createGitOpsRouter({ database: db, queueManager, kindRegistry });
103
+ const router = createGitOpsRouter({
104
+ database: db,
105
+ queueManager,
106
+ kindRegistry,
107
+ secretAdmin,
108
+ secretResolver,
109
+ });
93
110
  rpc.registerRouter(router, gitopsContract);
94
111
 
95
112
  logger.debug("✅ GitOps Backend initialized.");
96
113
  },
97
- afterPluginsReady: async ({ logger, database, queueManager }) => {
114
+ afterPluginsReady: async ({
115
+ logger,
116
+ database,
117
+ queueManager,
118
+ secretResolver,
119
+ }) => {
98
120
  const registeredKinds = kindRegistry.getKinds();
99
121
  logger.debug(
100
122
  `🔄 GitOps: ${registeredKinds.length} entity kinds registered: ${registeredKinds.map((k) => k.kind).join(", ")}`,
@@ -102,19 +124,12 @@ export default createBackendPlugin({
102
124
 
103
125
  const db = database as SafeDatabase<typeof schema>;
104
126
 
105
- // Create a SecretStore backed by the secrets table
127
+ // Resolve ${{ secrets.NAME }} via the central Secrets platform
128
+ // (secretResolverRef) instead of reading the gitops secrets table
129
+ // directly. The Secrets platform owns the table now.
106
130
  const secretStore = {
107
- resolve: async (name: string): Promise<string> => {
108
- const rows = await db
109
- .select()
110
- .from(schema.secrets)
111
- .where(eq(schema.secrets.name, name));
112
- const secret = rows[0];
113
- if (!secret) {
114
- throw new Error(`Secret not found: ${name}`);
115
- }
116
- return decrypt(secret.encryptedValue);
117
- },
131
+ resolve: (name: string): Promise<string> =>
132
+ secretResolver.resolveSecret({ name }),
118
133
  };
119
134
 
120
135
  // Bootstrap sync worker
@@ -355,4 +355,98 @@ describe("EntityKindRegistry", () => {
355
355
  expect(described[0].specSchemaDocumentation).toHaveLength(1);
356
356
  });
357
357
  });
358
+
359
+ describe("registerSpecSchemaDocumentationProvider", () => {
360
+ it("invokes providers on every describe, reflecting current state", () => {
361
+ const registry = createEntityKindRegistry();
362
+
363
+ registry.registerKind({
364
+ apiVersion: CHECKSTACK_API_VERSION,
365
+ kind: "Automation",
366
+ specSchema: z.object({ actions: z.array(z.unknown()) }),
367
+ reconcile: async () => ({ entityId: "test-id" }),
368
+ });
369
+
370
+ // A mutable source the provider reads — modelling a registry that is
371
+ // populated AFTER the provider is registered.
372
+ const source: Array<{ id: string }> = [];
373
+ registry.registerSpecSchemaDocumentationProvider(() =>
374
+ source.map((s) => ({
375
+ apiVersion: CHECKSTACK_API_VERSION,
376
+ kind: "Automation",
377
+ fieldPath: "actions[].config",
378
+ variantId: s.id,
379
+ label: s.id,
380
+ schema: z.object({}),
381
+ })),
382
+ );
383
+
384
+ // Empty at first describe.
385
+ expect(
386
+ registry.describeKinds()[0].specSchemaDocumentation,
387
+ ).toHaveLength(0);
388
+
389
+ // Source populated later (e.g. another plugin's afterPluginsReady).
390
+ source.push({ id: "jira" }, { id: "teams" });
391
+
392
+ const docs = registry.describeKinds()[0].specSchemaDocumentation;
393
+ expect(docs).toHaveLength(2);
394
+ expect(docs.map((d) => d.variantId).sort()).toEqual(["jira", "teams"]);
395
+ expect(docs[0].fieldPath).toBe("actions[].config");
396
+ });
397
+
398
+ it("merges provider docs with eagerly registered docs for the same kind", () => {
399
+ const registry = createEntityKindRegistry();
400
+
401
+ registry.registerKind({
402
+ apiVersion: CHECKSTACK_API_VERSION,
403
+ kind: "Automation",
404
+ specSchema: z.object({
405
+ triggers: z.array(z.unknown()),
406
+ actions: z.array(z.unknown()),
407
+ }),
408
+ reconcile: async () => ({ entityId: "test-id" }),
409
+ });
410
+
411
+ registry.registerSpecSchemaDocumentation({
412
+ apiVersion: CHECKSTACK_API_VERSION,
413
+ kind: "Automation",
414
+ fieldPath: "triggers[].config",
415
+ variantId: "eager",
416
+ label: "Eager",
417
+ schema: z.object({}),
418
+ });
419
+
420
+ registry.registerSpecSchemaDocumentationProvider(() => [
421
+ {
422
+ apiVersion: CHECKSTACK_API_VERSION,
423
+ kind: "Automation",
424
+ fieldPath: "actions[].config",
425
+ variantId: "lazy",
426
+ label: "Lazy",
427
+ schema: z.object({}),
428
+ },
429
+ ]);
430
+
431
+ const docs = registry.describeKinds()[0].specSchemaDocumentation;
432
+ expect(docs.map((d) => d.variantId).sort()).toEqual(["eager", "lazy"]);
433
+ });
434
+
435
+ it("ignores provider docs whose kind has no base definition", () => {
436
+ const registry = createEntityKindRegistry();
437
+
438
+ registry.registerSpecSchemaDocumentationProvider(() => [
439
+ {
440
+ apiVersion: CHECKSTACK_API_VERSION,
441
+ kind: "Unregistered",
442
+ fieldPath: "config",
443
+ label: "X",
444
+ schema: z.object({}),
445
+ },
446
+ ]);
447
+
448
+ // No base definition for "Unregistered" → not described.
449
+ expect(registry.describeKinds()).toHaveLength(0);
450
+ });
451
+ });
358
452
  });
@@ -5,6 +5,7 @@ import type {
5
5
  EntityKindExtensionDefinition,
6
6
  EntityKindRegistry,
7
7
  SpecSchemaDocumentation,
8
+ SpecSchemaDocumentationProvider,
8
9
  } from "@checkstack/gitops-common";
9
10
  import { entityMetadataSchema } from "@checkstack/gitops-common";
10
11
 
@@ -26,6 +27,13 @@ function kindKey(params: { apiVersion: string; kind: string }): string {
26
27
  */
27
28
  export function createEntityKindRegistry() {
28
29
  const kinds = new Map<string, RegisteredKind>();
30
+ /**
31
+ * Lazy documentation providers, invoked on every `describeKinds()` so the
32
+ * docs they return reflect the current state of whatever they read (e.g.
33
+ * the automation trigger/action registries, populated across plugins with
34
+ * no guaranteed ordering).
35
+ */
36
+ const docProviders: SpecSchemaDocumentationProvider[] = [];
29
37
 
30
38
  const registry: EntityKindRegistry & {
31
39
  /** Get a registered kind definition. */
@@ -148,6 +156,10 @@ export function createEntityKindRegistry() {
148
156
  });
149
157
  },
150
158
 
159
+ registerSpecSchemaDocumentationProvider(provider) {
160
+ docProviders.push(provider);
161
+ },
162
+
151
163
  getKind(params) {
152
164
  return kinds.get(kindKey(params))?.definition;
153
165
  },
@@ -209,7 +221,20 @@ export function createEntityKindRegistry() {
209
221
  }>;
210
222
  }> = [];
211
223
 
212
- for (const registered of kinds.values()) {
224
+ // Collect lazy-provider docs once per describe, grouped by kind key.
225
+ // Each provider re-reads its source, so this reflects the current
226
+ // registry state (order-independent).
227
+ const providedDocsByKey = new Map<string, SpecSchemaDocumentation[]>();
228
+ for (const provider of docProviders) {
229
+ for (const entry of provider()) {
230
+ const key = kindKey(entry);
231
+ const list = providedDocsByKey.get(key) ?? [];
232
+ list.push(entry);
233
+ providedDocsByKey.set(key, list);
234
+ }
235
+ }
236
+
237
+ for (const [key, registered] of kinds.entries()) {
213
238
  if (!registered.definition) continue;
214
239
 
215
240
  const def = registered.definition;
@@ -222,16 +247,18 @@ export function createEntityKindRegistry() {
222
247
  }),
223
248
  );
224
249
 
225
- const specSchemaDocumentation = registered.specSchemaDocumentation.map(
226
- (doc) => ({
227
- fieldPath: doc.fieldPath,
228
- variantId: doc.variantId,
229
- label: doc.label,
230
- description: doc.description,
231
- specSchema: toJsonSchema(doc.schema),
232
- conditions: doc.conditions,
233
- }),
234
- );
250
+ const allDocs = [
251
+ ...registered.specSchemaDocumentation,
252
+ ...(providedDocsByKey.get(key) ?? []),
253
+ ];
254
+ const specSchemaDocumentation = allDocs.map((doc) => ({
255
+ fieldPath: doc.fieldPath,
256
+ variantId: doc.variantId,
257
+ label: doc.label,
258
+ description: doc.description,
259
+ specSchema: toJsonSchema(doc.schema),
260
+ conditions: doc.conditions,
261
+ }));
235
262
 
236
263
  result.push({
237
264
  apiVersion: def.apiVersion,
package/src/router.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { implement, ORPCError } from "@orpc/server";
2
2
  import { z } from "zod";
3
- import { autoAuthMiddleware, correlationMiddleware, type RpcContext } from "@checkstack/backend-api";
4
- import { encrypt, decrypt } from "@checkstack/backend-api";
3
+ import { autoAuthMiddleware, correlationMiddleware, encrypt, type RpcContext } from "@checkstack/backend-api";
5
4
  import { gitopsContract, deriveSourceUrl } from "@checkstack/gitops-common";
6
5
  import type { SafeDatabase } from "@checkstack/backend-api";
7
6
  import type { QueueManager } from "@checkstack/queue-api";
7
+ import type {
8
+ SecretAdminService,
9
+ SecretResolverService,
10
+ } from "@checkstack/secrets-backend";
8
11
  import type { InternalEntityKindRegistry } from "./kind-registry";
9
12
  import { triggerSyncForProvider, scheduleSyncForProvider, cancelSyncForProvider } from "./sync/sync-worker";
10
13
  import * as schema from "./schema";
@@ -26,12 +29,18 @@ export interface GitOpsRouterDeps {
26
29
  database: SafeDatabase<typeof schema>;
27
30
  queueManager: QueueManager;
28
31
  kindRegistry: InternalEntityKindRegistry;
32
+ /** Central Secrets platform admin service (single source of truth). */
33
+ secretAdmin: SecretAdminService;
34
+ /** Central Secrets platform resolver (service-only). */
35
+ secretResolver: SecretResolverService;
29
36
  }
30
37
 
31
38
  export const createGitOpsRouter = ({
32
39
  database: db,
33
40
  queueManager,
34
41
  kindRegistry,
42
+ secretAdmin,
43
+ secretResolver,
35
44
  }: GitOpsRouterDeps) => {
36
45
  // ─── Provenance ──────────────────────────────────────────────────────
37
46
 
@@ -312,92 +321,78 @@ export const createGitOpsRouter = ({
312
321
 
313
322
  // ─── Secret Management ───────────────────────────────────────────────
314
323
 
324
+ // Secret management is delegated to the central Secrets platform via
325
+ // secretAdminRef so there is a single source of truth. The gitops table
326
+ // is no longer the home of secrets (rows were migrated to the platform's
327
+ // local backend); these handlers proxy to keep the existing gitops UI
328
+ // working until it is retired.
315
329
  const listSecrets = os.listSecrets.handler(async () => {
316
- const rows = await db.select().from(schema.secrets);
317
- return rows.map((r) => ({
318
- id: r.id,
319
- name: r.name,
320
- description: r.description,
321
- createdAt: r.createdAt,
322
- updatedAt: r.updatedAt,
330
+ const metadata = await secretAdmin.list();
331
+ return metadata.map((m) => ({
332
+ id: m.id,
333
+ name: m.name,
334
+ description: m.description,
335
+ createdAt: m.createdAt,
336
+ updatedAt: m.updatedAt,
323
337
  }));
324
338
  });
325
339
 
326
340
  const createSecret = os.createSecret.handler(async ({ input }) => {
327
- // Check for duplicate name
328
- const existing = await db
329
- .select()
330
- .from(schema.secrets)
331
- .where(eq(schema.secrets.name, input.name));
332
-
333
- if (existing[0]) {
341
+ const existing = await secretAdmin.list();
342
+ if (existing.some((m) => m.name === input.name)) {
334
343
  throw new ORPCError("CONFLICT", {
335
344
  message: `Secret with name "${input.name}" already exists`,
336
345
  });
337
346
  }
338
-
339
- const id = uuidv4();
340
- const encryptedValue = encrypt(input.value);
341
- await db.insert(schema.secrets).values({
342
- id,
347
+ await secretAdmin.setSecret({
343
348
  name: input.name,
344
- encryptedValue,
349
+ value: input.value,
345
350
  description: input.description,
346
351
  });
347
- return { id, name: input.name };
352
+ const after = await secretAdmin.list();
353
+ const meta = after.find((m) => m.name === input.name);
354
+ return { id: meta?.id ?? input.name, name: input.name };
348
355
  });
349
356
 
350
357
  const rotateSecret = os.rotateSecret.handler(async ({ input }) => {
351
- const existing = await db
352
- .select()
353
- .from(schema.secrets)
354
- .where(eq(schema.secrets.id, input.id));
355
-
356
- if (!existing[0]) {
358
+ const existing = await secretAdmin.list();
359
+ const meta = existing.find((m) => m.id === input.id);
360
+ if (!meta) {
357
361
  throw new ORPCError("NOT_FOUND", {
358
362
  message: `Secret not found: ${input.id}`,
359
363
  });
360
364
  }
361
365
 
362
- const encryptedValue = encrypt(input.value);
363
- await db
364
- .update(schema.secrets)
365
- .set({ encryptedValue, updatedAt: new Date() })
366
- .where(eq(schema.secrets.id, input.id));
366
+ await secretAdmin.setSecret({ name: meta.name, value: input.value });
367
367
 
368
368
  // Invalidate provenance for all entities referencing this secret
369
- // so the next sync cycle re-reconciles them with the updated value
370
- const secretName = existing[0].name;
369
+ // so the next sync cycle re-reconciles them with the updated value.
371
370
  await db
372
371
  .update(schema.provenance)
373
372
  .set({ lastSyncHash: "" })
374
- .where(
375
- sql`${secretName} = ANY(${schema.provenance.secretRefs})`,
376
- );
373
+ .where(sql`${meta.name} = ANY(${schema.provenance.secretRefs})`);
377
374
 
378
375
  return { success: true };
379
376
  });
380
377
 
381
378
  const deleteSecret = os.deleteSecret.handler(async ({ input }) => {
382
- await db.delete(schema.secrets).where(eq(schema.secrets.id, input.id));
383
-
379
+ const existing = await secretAdmin.list();
380
+ const meta = existing.find((m) => m.id === input.id);
381
+ if (meta) {
382
+ await secretAdmin.deleteSecret({ name: meta.name });
383
+ }
384
384
  return { success: true };
385
385
  });
386
386
 
387
387
  const resolveSecret = os.resolveSecret.handler(async ({ input }) => {
388
- const rows = await db
389
- .select()
390
- .from(schema.secrets)
391
- .where(eq(schema.secrets.name, input.name));
392
-
393
- const secret = rows[0];
394
- if (!secret) {
388
+ try {
389
+ const value = await secretResolver.resolveSecret({ name: input.name });
390
+ return { value };
391
+ } catch {
395
392
  throw new ORPCError("NOT_FOUND", {
396
393
  message: `Secret not found: ${input.name}`,
397
394
  });
398
395
  }
399
-
400
- return { value: decrypt(secret.encryptedValue) };
401
396
  });
402
397
 
403
398
  const getSecretUsage = os.getSecretUsage.handler(async ({ input }) => {
@@ -1,221 +1,11 @@
1
- import { z } from "zod";
2
- import { SECRET_TEMPLATE_REGEX } from "@checkstack/gitops-common";
3
- import { isSecretSchema } from "@checkstack/backend-api";
4
-
5
1
  /**
6
- * Interface for resolving secret names to their decrypted values.
2
+ * The schema-driven secret resolver was promoted to the central Secrets
3
+ * platform (`@checkstack/secrets-backend`). This module re-exports it so
4
+ * gitops's sync worker / reconciler keep their existing import paths while
5
+ * the canonical implementation + tests live in secrets-backend.
7
6
  */
8
- export interface SecretStore {
9
- resolve: (name: string) => Promise<string>;
10
- }
11
-
12
- /**
13
- * Result of schema-driven secret resolution.
14
- */
15
- export interface SecretResolutionResult<T> {
16
- /** The value with x-secret fields resolved. */
17
- resolved: T;
18
- /**
19
- * Warnings for `${{ secrets.NAME }}` templates found in non-secret fields.
20
- * These templates will NOT be resolved — the field must be annotated with
21
- * `configString({ "x-secret": true })` for resolution to occur.
22
- */
23
- warnings: string[];
24
- }
25
-
26
- /**
27
- * Schema-driven secret resolver.
28
- *
29
- * Walks a Zod schema to find fields annotated with `configString({ "x-secret": true })`,
30
- * then resolves `${{ secrets.NAME }}` templates **only** in those fields.
31
- * Non-secret fields are returned as-is, preventing accidental leaks into display fields.
32
- *
33
- * Templates found in non-secret fields are reported as warnings — the value
34
- * is still returned unmodified, but the caller can surface these to the user.
35
- *
36
- * Supports both full-value templates and inline interpolation:
37
- * - `"${{ secrets.DB_PASS }}"` → `"actual-password"`
38
- * - `"postgres://user:${{ secrets.PASS }}@host/db"` → `"postgres://user:actual-password@host/db"`
39
- */
40
- export async function resolveSecretsBySchema(params: {
41
- value: unknown;
42
- schema: z.ZodTypeAny;
43
- secretStore: SecretStore;
44
- }): Promise<SecretResolutionResult<unknown>> {
45
- const { value, schema, secretStore } = params;
46
- const warnings: string[] = [];
47
- const resolved = await walkAndResolve({ value, schema, secretStore, warnings, path: "" });
48
- return { resolved, warnings };
49
- }
50
-
51
- // ─── Recursive Walker ──────────────────────────────────────────────────────
52
-
53
- async function walkAndResolve(params: {
54
- value: unknown;
55
- schema: z.ZodTypeAny;
56
- secretStore: SecretStore;
57
- warnings: string[];
58
- path: string;
59
- }): Promise<unknown> {
60
- const { value, secretStore, warnings, path } = params;
61
- const schema = unwrapZod(params.schema);
62
-
63
- if (value === null || value === undefined) {
64
- return value;
65
- }
66
-
67
- // Leaf node: if this schema is marked x-secret, resolve templates in the string
68
- if (isSecretSchema(schema)) {
69
- if (typeof value === "string") {
70
- return resolveTemplateString({ value, secretStore });
71
- }
72
- return value;
73
- }
74
-
75
- // Non-secret string leaf: check for unresolved templates and warn
76
- if (typeof value === "string" && containsSecretTemplate(value)) {
77
- const fieldPath = path || "(root)";
78
- warnings.push(
79
- `Field "${fieldPath}" contains a secret template but is not marked as a secret field. ` +
80
- `Add configString({ "x-secret": true }) to the schema for this field to enable resolution.`,
81
- );
82
- return value;
83
- }
84
-
85
- // Object: recurse into each property using the schema's shape
86
- if (schema instanceof z.ZodObject && typeof value === "object" && !Array.isArray(value)) {
87
- const shape = schema.shape as Record<string, z.ZodTypeAny>;
88
- const result: Record<string, unknown> = { ...(value as Record<string, unknown>) };
89
-
90
- for (const [key, fieldSchema] of Object.entries(shape)) {
91
- if (key in result) {
92
- result[key] = await walkAndResolve({
93
- value: result[key],
94
- schema: fieldSchema,
95
- secretStore,
96
- warnings,
97
- path: path ? `${path}.${key}` : key,
98
- });
99
- }
100
- }
101
-
102
- return result;
103
- }
104
-
105
- // Array: recurse into each element using the element schema
106
- if (schema instanceof z.ZodArray && Array.isArray(value)) {
107
- const elementSchema = schema.element as z.ZodTypeAny;
108
- return Promise.all(
109
- value.map((item, index) =>
110
- walkAndResolve({
111
- value: item,
112
- schema: elementSchema,
113
- secretStore,
114
- warnings,
115
- path: `${path}[${index}]`,
116
- }),
117
- ),
118
- );
119
- }
120
-
121
- // Discriminated union: find the matching variant and recurse
122
- if (schema instanceof z.ZodDiscriminatedUnion && typeof value === "object" && value !== null) {
123
- const discriminator = (schema.def as { discriminator: string }).discriminator;
124
- const discriminatorValue = (value as Record<string, unknown>)[discriminator];
125
- const options = schema.options as z.ZodObject<z.ZodRawShape>[];
126
- const matchedVariant = options.find((option) => {
127
- const discField = option.shape[discriminator];
128
- if (discField instanceof z.ZodLiteral) {
129
- return discField.value === discriminatorValue;
130
- }
131
- return false;
132
- });
133
-
134
- if (matchedVariant) {
135
- return walkAndResolve({ value, schema: matchedVariant, secretStore, warnings, path });
136
- }
137
- }
138
-
139
- // Union (oneOf/anyOf without discriminator): try each variant
140
- if (schema instanceof z.ZodUnion && typeof value === "object" && value !== null) {
141
- const options = schema.options as z.ZodTypeAny[];
142
- for (const option of options) {
143
- const parsed = option.safeParse(value);
144
- if (parsed.success) {
145
- return walkAndResolve({ value, schema: option, secretStore, warnings, path });
146
- }
147
- }
148
- }
149
-
150
- // No schema match — return value unchanged
151
- return value;
152
- }
153
-
154
- /**
155
- * Quick check whether a string contains any `${{ secrets.NAME }}` pattern.
156
- */
157
- function containsSecretTemplate(value: string): boolean {
158
- SECRET_TEMPLATE_REGEX.lastIndex = 0;
159
- return SECRET_TEMPLATE_REGEX.test(value);
160
- }
161
-
162
- // ─── Zod Unwrapping ────────────────────────────────────────────────────────
163
-
164
- /**
165
- * Unwraps Optional, Default, and Nullable wrappers to get the inner schema.
166
- */
167
- function unwrapZod(schema: z.ZodTypeAny): z.ZodTypeAny {
168
- let unwrapped = schema;
169
-
170
- if (unwrapped instanceof z.ZodOptional) {
171
- unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
172
- }
173
-
174
- if (unwrapped instanceof z.ZodDefault) {
175
- unwrapped = unwrapped.def.innerType as z.ZodTypeAny;
176
- }
177
-
178
- if (unwrapped instanceof z.ZodNullable) {
179
- unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
180
- }
181
-
182
- return unwrapped;
183
- }
184
-
185
- // ─── Template Resolution ───────────────────────────────────────────────────
186
-
187
- /**
188
- * Resolves all `${{ secrets.NAME }}` patterns in a string.
189
- * Each unique secret name is resolved once (cached per call) to avoid duplicate lookups.
190
- */
191
- async function resolveTemplateString(params: {
192
- value: string;
193
- secretStore: SecretStore;
194
- }): Promise<string> {
195
- const { value, secretStore } = params;
196
-
197
- // Collect all secret names first
198
- const secretNames = new Set<string>();
199
- SECRET_TEMPLATE_REGEX.lastIndex = 0;
200
- let match: RegExpExecArray | null;
201
- while ((match = SECRET_TEMPLATE_REGEX.exec(value)) !== null) {
202
- secretNames.add(match[1]);
203
- }
204
-
205
- // No templates found — return as-is
206
- if (secretNames.size === 0) {
207
- return value;
208
- }
209
-
210
- // Resolve all unique secret names
211
- const resolvedValues = new Map<string, string>();
212
- for (const name of secretNames) {
213
- resolvedValues.set(name, await secretStore.resolve(name));
214
- }
215
-
216
- // Replace all template expressions with resolved values
217
- SECRET_TEMPLATE_REGEX.lastIndex = 0;
218
- return value.replaceAll(SECRET_TEMPLATE_REGEX, (_fullMatch, secretName: string) => {
219
- return resolvedValues.get(secretName)!;
220
- });
221
- }
7
+ export {
8
+ resolveSecretsBySchema,
9
+ type SecretStore,
10
+ type SecretResolutionResult,
11
+ } from "@checkstack/secrets-backend";
package/tsconfig.json CHANGED
@@ -21,6 +21,9 @@
21
21
  },
22
22
  {
23
23
  "path": "../queue-api"
24
+ },
25
+ {
26
+ "path": "../secrets-backend"
24
27
  }
25
28
  ]
26
29
  }
@@ -1,325 +0,0 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { z } from "zod";
3
- import { configString } from "@checkstack/backend-api";
4
- import { resolveSecretsBySchema } from "./secret-resolver";
5
-
6
- const mockSecretStore = {
7
- resolve: async (name: string): Promise<string> => {
8
- const secrets: Record<string, string> = {
9
- DB_PASS: "s3cret!",
10
- API_KEY: "key-12345",
11
- DB_USER: "admin",
12
- DB_HOST: "db.production.internal",
13
- };
14
- const value = secrets[name];
15
- if (!value) throw new Error(`Secret not found: ${name}`);
16
- return value;
17
- },
18
- };
19
-
20
- describe("resolveSecretsBySchema", () => {
21
- it("resolves a field marked with x-secret", async () => {
22
- const schema = z.object({
23
- host: z.string(),
24
- password: configString({ "x-secret": true }),
25
- });
26
-
27
- const { resolved, warnings } = await resolveSecretsBySchema({
28
- value: { host: "localhost", password: "${{ secrets.DB_PASS }}" },
29
- schema,
30
- secretStore: mockSecretStore,
31
- });
32
-
33
- expect(resolved).toEqual({ host: "localhost", password: "s3cret!" });
34
- expect(warnings).toEqual([]);
35
- });
36
-
37
- it("leaves non-secret fields untouched and emits warnings", async () => {
38
- const schema = z.object({
39
- description: z.string(),
40
- password: configString({ "x-secret": true }),
41
- });
42
-
43
- const { resolved, warnings } = await resolveSecretsBySchema({
44
- value: {
45
- description: "Contains ${{ secrets.DB_PASS }} but should NOT be resolved",
46
- password: "${{ secrets.DB_PASS }}",
47
- },
48
- schema,
49
- secretStore: mockSecretStore,
50
- });
51
-
52
- expect(resolved).toEqual({
53
- description: "Contains ${{ secrets.DB_PASS }} but should NOT be resolved",
54
- password: "s3cret!",
55
- });
56
- expect(warnings).toHaveLength(1);
57
- expect(warnings[0]).toContain("description");
58
- expect(warnings[0]).toContain("not marked as a secret field");
59
- });
60
-
61
- it("resolves inline interpolation in secret fields", async () => {
62
- const schema = z.object({
63
- connectionString: configString({ "x-secret": true }),
64
- });
65
-
66
- const { resolved } = await resolveSecretsBySchema({
67
- value: {
68
- connectionString:
69
- "postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@${{ secrets.DB_HOST }}/mydb",
70
- },
71
- schema,
72
- secretStore: mockSecretStore,
73
- });
74
-
75
- expect(resolved).toEqual({
76
- connectionString:
77
- "postgres://admin:s3cret!@db.production.internal/mydb",
78
- });
79
- });
80
-
81
- it("resolves secrets in nested objects", async () => {
82
- const schema = z.object({
83
- connection: z.object({
84
- host: z.string(),
85
- password: configString({ "x-secret": true }),
86
- }),
87
- });
88
-
89
- const { resolved, warnings } = await resolveSecretsBySchema({
90
- value: {
91
- connection: { host: "db.internal", password: "${{ secrets.DB_PASS }}" },
92
- },
93
- schema,
94
- secretStore: mockSecretStore,
95
- });
96
-
97
- expect(resolved).toEqual({
98
- connection: { host: "db.internal", password: "s3cret!" },
99
- });
100
- expect(warnings).toEqual([]);
101
- });
102
-
103
- it("resolves secrets in arrays of objects", async () => {
104
- const schema = z.object({
105
- credentials: z.array(
106
- z.object({
107
- name: z.string(),
108
- secret: configString({ "x-secret": true }),
109
- }),
110
- ),
111
- });
112
-
113
- const { resolved } = await resolveSecretsBySchema({
114
- value: {
115
- credentials: [
116
- { name: "db", secret: "${{ secrets.DB_PASS }}" },
117
- { name: "api", secret: "${{ secrets.API_KEY }}" },
118
- ],
119
- },
120
- schema,
121
- secretStore: mockSecretStore,
122
- });
123
-
124
- expect(resolved).toEqual({
125
- credentials: [
126
- { name: "db", secret: "s3cret!" },
127
- { name: "api", secret: "key-12345" },
128
- ],
129
- });
130
- });
131
-
132
- it("handles optional secret fields", async () => {
133
- const schema = z.object({
134
- password: configString({ "x-secret": true }).optional(),
135
- });
136
-
137
- const { resolved } = await resolveSecretsBySchema({
138
- value: { password: "${{ secrets.DB_PASS }}" },
139
- schema,
140
- secretStore: mockSecretStore,
141
- });
142
-
143
- expect(resolved).toEqual({ password: "s3cret!" });
144
- });
145
-
146
- it("handles default-wrapped secret fields", async () => {
147
- const schema = z.object({
148
- password: configString({ "x-secret": true }).default("fallback"),
149
- });
150
-
151
- const { resolved } = await resolveSecretsBySchema({
152
- value: { password: "${{ secrets.DB_PASS }}" },
153
- schema,
154
- secretStore: mockSecretStore,
155
- });
156
-
157
- expect(resolved).toEqual({ password: "s3cret!" });
158
- });
159
-
160
- it("returns non-secret objects unchanged", async () => {
161
- const schema = z.object({
162
- host: z.string(),
163
- port: z.number(),
164
- });
165
-
166
- const { resolved, warnings } = await resolveSecretsBySchema({
167
- value: { host: "localhost", port: 5432 },
168
- schema,
169
- secretStore: mockSecretStore,
170
- });
171
-
172
- expect(resolved).toEqual({ host: "localhost", port: 5432 });
173
- expect(warnings).toEqual([]);
174
- });
175
-
176
- it("handles null and undefined values gracefully", async () => {
177
- const schema = z.object({
178
- password: configString({ "x-secret": true }).optional(),
179
- });
180
-
181
- const { resolved } = await resolveSecretsBySchema({
182
- value: { password: undefined },
183
- schema,
184
- secretStore: mockSecretStore,
185
- });
186
-
187
- expect(resolved).toEqual({ password: undefined });
188
- });
189
-
190
- it("throws when a referenced secret is not found", async () => {
191
- const schema = z.object({
192
- password: configString({ "x-secret": true }),
193
- });
194
-
195
- await expect(
196
- resolveSecretsBySchema({
197
- value: { password: "${{ secrets.NONEXISTENT }}" },
198
- schema,
199
- secretStore: mockSecretStore,
200
- }),
201
- ).rejects.toThrow("Secret not found: NONEXISTENT");
202
- });
203
-
204
- it("resolves templates with extra whitespace", async () => {
205
- const schema = z.object({
206
- password: configString({ "x-secret": true }),
207
- });
208
-
209
- const { resolved } = await resolveSecretsBySchema({
210
- value: { password: "${{ secrets.DB_PASS }}" },
211
- schema,
212
- secretStore: mockSecretStore,
213
- });
214
-
215
- expect(resolved).toEqual({ password: "s3cret!" });
216
- });
217
-
218
- it("preserves fields not in the schema (extra keys in value)", async () => {
219
- const schema = z.object({
220
- password: configString({ "x-secret": true }),
221
- });
222
-
223
- const { resolved } = await resolveSecretsBySchema({
224
- value: {
225
- password: "${{ secrets.DB_PASS }}",
226
- extraField: "should remain",
227
- },
228
- schema,
229
- secretStore: mockSecretStore,
230
- });
231
-
232
- expect(resolved).toEqual({
233
- password: "s3cret!",
234
- extraField: "should remain",
235
- });
236
- });
237
-
238
- it("does not resolve secret templates in secret fields when no template is present", async () => {
239
- const schema = z.object({
240
- password: configString({ "x-secret": true }),
241
- });
242
-
243
- const { resolved } = await resolveSecretsBySchema({
244
- value: { password: "plain-password" },
245
- schema,
246
- secretStore: mockSecretStore,
247
- });
248
-
249
- expect(resolved).toEqual({ password: "plain-password" });
250
- });
251
-
252
- // ─── Warning tests ──────────────────────────────────────────────────────
253
-
254
- it("warns for template in nested non-secret field with correct path", async () => {
255
- const schema = z.object({
256
- connection: z.object({
257
- host: z.string(),
258
- password: configString({ "x-secret": true }),
259
- }),
260
- });
261
-
262
- const { resolved, warnings } = await resolveSecretsBySchema({
263
- value: {
264
- connection: {
265
- host: "${{ secrets.DB_HOST }}",
266
- password: "${{ secrets.DB_PASS }}",
267
- },
268
- },
269
- schema,
270
- secretStore: mockSecretStore,
271
- });
272
-
273
- // Password resolved, host left as-is
274
- expect(resolved).toEqual({
275
- connection: {
276
- host: "${{ secrets.DB_HOST }}",
277
- password: "s3cret!",
278
- },
279
- });
280
- expect(warnings).toHaveLength(1);
281
- expect(warnings[0]).toContain("connection.host");
282
- });
283
-
284
- it("warns for template in array element non-secret field with index", async () => {
285
- const schema = z.object({
286
- items: z.array(
287
- z.object({
288
- label: z.string(),
289
- secret: configString({ "x-secret": true }),
290
- }),
291
- ),
292
- });
293
-
294
- const { warnings } = await resolveSecretsBySchema({
295
- value: {
296
- items: [
297
- { label: "${{ secrets.DB_PASS }}", secret: "${{ secrets.DB_PASS }}" },
298
- ],
299
- },
300
- schema,
301
- secretStore: mockSecretStore,
302
- });
303
-
304
- expect(warnings).toHaveLength(1);
305
- expect(warnings[0]).toContain("items[0].label");
306
- });
307
-
308
- it("emits no warnings when all templates are in secret fields", async () => {
309
- const schema = z.object({
310
- password: configString({ "x-secret": true }),
311
- apiKey: configString({ "x-secret": true }),
312
- });
313
-
314
- const { warnings } = await resolveSecretsBySchema({
315
- value: {
316
- password: "${{ secrets.DB_PASS }}",
317
- apiKey: "${{ secrets.API_KEY }}",
318
- },
319
- schema,
320
- secretStore: mockSecretStore,
321
- });
322
-
323
- expect(warnings).toEqual([]);
324
- });
325
- });