@checkstack/gitops-backend 0.1.1 → 0.2.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.
@@ -1,11 +1,15 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { resolveSecrets } from "./secret-resolver";
2
+ import { z } from "zod";
3
+ import { configString } from "@checkstack/backend-api";
4
+ import { resolveSecretsBySchema } from "./secret-resolver";
3
5
 
4
6
  const mockSecretStore = {
5
7
  resolve: async (name: string): Promise<string> => {
6
8
  const secrets: Record<string, string> = {
7
- "prod-db-password": "s3cret!",
8
- "api-key": "key-12345",
9
+ DB_PASS: "s3cret!",
10
+ API_KEY: "key-12345",
11
+ DB_USER: "admin",
12
+ DB_HOST: "db.production.internal",
9
13
  };
10
14
  const value = secrets[name];
11
15
  if (!value) throw new Error(`Secret not found: ${name}`);
@@ -13,52 +17,110 @@ const mockSecretStore = {
13
17
  },
14
18
  };
15
19
 
16
- describe("resolveSecrets", () => {
17
- it("resolves a top-level secretRef", async () => {
18
- const spec = { password: { secretRef: "prod-db-password" } };
19
- const resolved = await resolveSecrets({
20
- spec,
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,
21
30
  secretStore: mockSecretStore,
22
31
  });
23
- expect(resolved).toEqual({ password: "s3cret!" });
32
+
33
+ expect(resolved).toEqual({ host: "localhost", password: "s3cret!" });
34
+ expect(warnings).toEqual([]);
24
35
  });
25
36
 
26
- it("leaves plain strings untouched", async () => {
27
- const spec = { host: "localhost", port: 5432 };
28
- const resolved = await resolveSecrets({
29
- spec,
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,
30
49
  secretStore: mockSecretStore,
31
50
  });
32
- expect(resolved).toEqual({ host: "localhost", port: 5432 });
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");
33
59
  });
34
60
 
35
- it("resolves nested secretRefs", async () => {
36
- const spec = {
37
- connection: {
38
- host: "db.internal",
39
- password: { secretRef: "prod-db-password" },
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",
40
70
  },
41
- };
42
- const resolved = await resolveSecrets({
43
- spec,
71
+ schema,
44
72
  secretStore: mockSecretStore,
45
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
+
46
97
  expect(resolved).toEqual({
47
98
  connection: { host: "db.internal", password: "s3cret!" },
48
99
  });
100
+ expect(warnings).toEqual([]);
49
101
  });
50
102
 
51
- it("resolves secretRefs in arrays", async () => {
52
- const spec = {
53
- credentials: [
54
- { name: "db", secret: { secretRef: "prod-db-password" } },
55
- { name: "api", secret: { secretRef: "api-key" } },
56
- ],
57
- };
58
- const resolved = await resolveSecrets({
59
- spec,
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,
60
121
  secretStore: mockSecretStore,
61
122
  });
123
+
62
124
  expect(resolved).toEqual({
63
125
  credentials: [
64
126
  { name: "db", secret: "s3cret!" },
@@ -67,20 +129,197 @@ describe("resolveSecrets", () => {
67
129
  });
68
130
  });
69
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
+
70
190
  it("throws when a referenced secret is not found", async () => {
71
- const spec = { password: { secretRef: "nonexistent" } };
191
+ const schema = z.object({
192
+ password: configString({ "x-secret": true }),
193
+ });
194
+
72
195
  await expect(
73
- resolveSecrets({ spec, secretStore: mockSecretStore }),
74
- ).rejects.toThrow("Secret not found: nonexistent");
196
+ resolveSecretsBySchema({
197
+ value: { password: "${{ secrets.NONEXISTENT }}" },
198
+ schema,
199
+ secretStore: mockSecretStore,
200
+ }),
201
+ ).rejects.toThrow("Secret not found: NONEXISTENT");
75
202
  });
76
203
 
77
- it("handles null and undefined values gracefully", async () => {
78
- const spec = { a: null, b: undefined, c: "value" };
79
- const resolved = await resolveSecrets({
80
- spec,
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,
81
229
  secretStore: mockSecretStore,
82
230
  });
83
- expect(resolved.a).toBeNull();
84
- expect(resolved.c).toBe("value");
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([]);
85
324
  });
86
325
  });
@@ -1,4 +1,6 @@
1
- import { isSecretRef } from "@checkstack/gitops-common";
1
+ import { z } from "zod";
2
+ import { SECRET_TEMPLATE_REGEX } from "@checkstack/gitops-common";
3
+ import { isSecretSchema } from "@checkstack/backend-api";
2
4
 
3
5
  /**
4
6
  * Interface for resolving secret names to their decrypted values.
@@ -8,47 +10,212 @@ export interface SecretStore {
8
10
  }
9
11
 
10
12
  /**
11
- * Recursively walks a parsed spec object and resolves any `{ secretRef: "name" }`
12
- * values by looking them up in the secret store.
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.
13
28
  *
14
- * After resolution, all secretRef objects are replaced with plain strings.
15
- * This function is called by the reconciliation engine before invoking plugin reconcilers.
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"`
16
39
  */
17
- export async function resolveSecrets(params: {
18
- spec: Record<string, unknown>;
40
+ export async function resolveSecretsBySchema(params: {
41
+ value: unknown;
42
+ schema: z.ZodTypeAny;
19
43
  secretStore: SecretStore;
20
- }): Promise<Record<string, unknown>> {
21
- const { spec, secretStore } = params;
22
- return resolveValue(spec, secretStore) as Promise<Record<string, unknown>>;
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 };
23
49
  }
24
50
 
25
- async function resolveValue(
26
- value: unknown,
27
- secretStore: SecretStore,
28
- ): Promise<unknown> {
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
+
29
63
  if (value === null || value === undefined) {
30
64
  return value;
31
65
  }
32
66
 
33
- // Check if this value is a secretRef object
34
- if (isSecretRef(value)) {
35
- return secretStore.resolve(value.secretRef);
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;
36
73
  }
37
74
 
38
- // Recurse into arrays
39
- if (Array.isArray(value)) {
40
- return Promise.all(value.map((item) => resolveValue(item, secretStore)));
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;
41
83
  }
42
84
 
43
- // Recurse into plain objects
44
- if (typeof value === "object") {
45
- const resolved: Record<string, unknown> = {};
46
- for (const [key, val] of Object.entries(value)) {
47
- resolved[key] = await resolveValue(val, secretStore);
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
+ }
48
100
  }
49
- return resolved;
101
+
102
+ return result;
50
103
  }
51
104
 
52
- // Primitives pass through
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
53
151
  return value;
54
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
+ }
@@ -42,6 +42,8 @@ describe("Kind delete reconciler wiring", () => {
42
42
  error: () => {},
43
43
  },
44
44
  resolveEntityRef: async () => undefined,
45
+ resolveSecretsBySchema: async <T>(params: { value: T }): Promise<{ resolved: T; warnings: string[] }> =>
46
+ ({ resolved: params.value, warnings: [] }),
45
47
  },
46
48
  });
47
49
 
@@ -105,6 +107,8 @@ describe("Kind delete reconciler wiring", () => {
105
107
  error: () => {},
106
108
  },
107
109
  resolveEntityRef: async () => undefined,
110
+ resolveSecretsBySchema: async <T>(params: { value: T }): Promise<{ resolved: T; warnings: string[] }> =>
111
+ ({ resolved: params.value, warnings: [] }),
108
112
  },
109
113
  }),
110
114
  ).rejects.toThrow("DB connection failed");