@checkstack/secrets-backend 0.1.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,112 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { z } from "zod";
3
+ import { configString } from "@checkstack/backend-api";
4
+ import { resolveSecretsBySchema, type SecretStore } from "./secret-resolver";
5
+
6
+ const mockSecretStore: SecretStore = {
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 (promoted from gitops)", () => {
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
+ const { resolved, warnings } = await resolveSecretsBySchema({
27
+ value: { host: "localhost", password: "${{ secrets.DB_PASS }}" },
28
+ schema,
29
+ secretStore: mockSecretStore,
30
+ });
31
+ expect(resolved).toEqual({ host: "localhost", password: "s3cret!" });
32
+ expect(warnings).toEqual([]);
33
+ });
34
+
35
+ it("leaves non-secret fields untouched and emits warnings", async () => {
36
+ const schema = z.object({
37
+ description: z.string(),
38
+ password: configString({ "x-secret": true }),
39
+ });
40
+ const { resolved, warnings } = await resolveSecretsBySchema({
41
+ value: {
42
+ description: "Contains ${{ secrets.DB_PASS }} but should NOT resolve",
43
+ password: "${{ secrets.DB_PASS }}",
44
+ },
45
+ schema,
46
+ secretStore: mockSecretStore,
47
+ });
48
+ expect(resolved).toEqual({
49
+ description: "Contains ${{ secrets.DB_PASS }} but should NOT resolve",
50
+ password: "s3cret!",
51
+ });
52
+ expect(warnings).toHaveLength(1);
53
+ expect(warnings[0]).toContain("description");
54
+ });
55
+
56
+ it("resolves inline interpolation in secret fields", async () => {
57
+ const schema = z.object({
58
+ connectionString: configString({ "x-secret": true }),
59
+ });
60
+ const { resolved } = await resolveSecretsBySchema({
61
+ value: {
62
+ connectionString:
63
+ "postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@${{ secrets.DB_HOST }}/mydb",
64
+ },
65
+ schema,
66
+ secretStore: mockSecretStore,
67
+ });
68
+ expect(resolved).toEqual({
69
+ connectionString: "postgres://admin:s3cret!@db.production.internal/mydb",
70
+ });
71
+ });
72
+
73
+ it("resolves secrets in nested objects and arrays", async () => {
74
+ const schema = z.object({
75
+ credentials: z.array(
76
+ z.object({
77
+ name: z.string(),
78
+ secret: configString({ "x-secret": true }),
79
+ }),
80
+ ),
81
+ });
82
+ const { resolved } = await resolveSecretsBySchema({
83
+ value: {
84
+ credentials: [
85
+ { name: "db", secret: "${{ secrets.DB_PASS }}" },
86
+ { name: "api", secret: "${{ secrets.API_KEY }}" },
87
+ ],
88
+ },
89
+ schema,
90
+ secretStore: mockSecretStore,
91
+ });
92
+ expect(resolved).toEqual({
93
+ credentials: [
94
+ { name: "db", secret: "s3cret!" },
95
+ { name: "api", secret: "key-12345" },
96
+ ],
97
+ });
98
+ });
99
+
100
+ it("throws when a referenced secret is not found", async () => {
101
+ const schema = z.object({
102
+ password: configString({ "x-secret": true }),
103
+ });
104
+ await expect(
105
+ resolveSecretsBySchema({
106
+ value: { password: "${{ secrets.NONEXISTENT }}" },
107
+ schema,
108
+ secretStore: mockSecretStore,
109
+ }),
110
+ ).rejects.toThrow("Secret not found: NONEXISTENT");
111
+ });
112
+ });
@@ -0,0 +1,250 @@
1
+ import { z } from "zod";
2
+ import { SECRET_TEMPLATE_REGEX } from "@checkstack/secrets-common";
3
+ import { isSecretSchema } from "@checkstack/backend-api";
4
+
5
+ /**
6
+ * Interface for resolving secret names to their decrypted values.
7
+ * Promoted out of `@checkstack/gitops-backend`.
8
+ */
9
+ export interface SecretStore {
10
+ resolve: (name: string) => Promise<string>;
11
+ }
12
+
13
+ /**
14
+ * Result of schema-driven secret resolution.
15
+ */
16
+ export interface SecretResolutionResult<T> {
17
+ /** The value with x-secret fields resolved. */
18
+ resolved: T;
19
+ /**
20
+ * Warnings for `${{ secrets.NAME }}` templates found in non-secret
21
+ * fields. These templates are NOT resolved — the field must be
22
+ * annotated with `configString({ "x-secret": true })` for resolution.
23
+ */
24
+ warnings: string[];
25
+ }
26
+
27
+ /**
28
+ * Schema-driven secret resolver.
29
+ *
30
+ * Walks a Zod schema to find fields annotated with
31
+ * `configString({ "x-secret": true })`, then resolves
32
+ * `${{ secrets.NAME }}` templates **only** in those fields. Non-secret
33
+ * fields are returned as-is, preventing accidental leaks into display
34
+ * fields. Templates in non-secret fields are reported as warnings.
35
+ *
36
+ * Supports full-value templates and inline interpolation:
37
+ * - `"${{ secrets.DB_PASS }}"` → `"actual-password"`
38
+ * - `"postgres://u:${{ secrets.PASS }}@host/db"` → interpolated
39
+ */
40
+ export async function resolveSecretsBySchema<T = unknown>(params: {
41
+ value: T;
42
+ schema: z.ZodTypeAny;
43
+ secretStore: SecretStore;
44
+ }): Promise<SecretResolutionResult<T>> {
45
+ const { value, schema, secretStore } = params;
46
+ const warnings: string[] = [];
47
+ const resolved = await walkAndResolve({
48
+ value,
49
+ schema,
50
+ secretStore,
51
+ warnings,
52
+ path: "",
53
+ });
54
+ return { resolved: resolved as T, warnings };
55
+ }
56
+
57
+ // ─── Recursive Walker ──────────────────────────────────────────────────────
58
+
59
+ async function walkAndResolve(params: {
60
+ value: unknown;
61
+ schema: z.ZodTypeAny;
62
+ secretStore: SecretStore;
63
+ warnings: string[];
64
+ path: string;
65
+ }): Promise<unknown> {
66
+ const { value, secretStore, warnings, path } = params;
67
+ const schema = unwrapZod(params.schema);
68
+
69
+ if (value === null || value === undefined) {
70
+ return value;
71
+ }
72
+
73
+ // Leaf node: if this schema is marked x-secret, resolve templates.
74
+ if (isSecretSchema(schema)) {
75
+ if (typeof value === "string") {
76
+ return resolveTemplateString({ value, secretStore });
77
+ }
78
+ return value;
79
+ }
80
+
81
+ // Non-secret string leaf: check for unresolved templates and warn.
82
+ if (typeof value === "string" && containsSecretTemplate(value)) {
83
+ const fieldPath = path || "(root)";
84
+ warnings.push(
85
+ `Field "${fieldPath}" contains a secret template but is not marked as a secret field. ` +
86
+ `Add configString({ "x-secret": true }) to the schema for this field to enable resolution.`,
87
+ );
88
+ return value;
89
+ }
90
+
91
+ // Object: recurse into each property using the schema's shape.
92
+ if (
93
+ schema instanceof z.ZodObject &&
94
+ typeof value === "object" &&
95
+ !Array.isArray(value)
96
+ ) {
97
+ const shape = schema.shape as Record<string, z.ZodTypeAny>;
98
+ const result: Record<string, unknown> = {
99
+ ...(value as Record<string, unknown>),
100
+ };
101
+
102
+ for (const [key, fieldSchema] of Object.entries(shape)) {
103
+ if (key in result) {
104
+ result[key] = await walkAndResolve({
105
+ value: result[key],
106
+ schema: fieldSchema,
107
+ secretStore,
108
+ warnings,
109
+ path: path ? `${path}.${key}` : key,
110
+ });
111
+ }
112
+ }
113
+
114
+ return result;
115
+ }
116
+
117
+ // Array: recurse into each element using the element schema.
118
+ if (schema instanceof z.ZodArray && Array.isArray(value)) {
119
+ const elementSchema = schema.element as z.ZodTypeAny;
120
+ return Promise.all(
121
+ value.map((item, index) =>
122
+ walkAndResolve({
123
+ value: item,
124
+ schema: elementSchema,
125
+ secretStore,
126
+ warnings,
127
+ path: `${path}[${index}]`,
128
+ }),
129
+ ),
130
+ );
131
+ }
132
+
133
+ // Discriminated union: find the matching variant and recurse.
134
+ if (
135
+ schema instanceof z.ZodDiscriminatedUnion &&
136
+ typeof value === "object" &&
137
+ value !== null
138
+ ) {
139
+ const discriminator = (schema.def as { discriminator: string })
140
+ .discriminator;
141
+ const discriminatorValue = (value as Record<string, unknown>)[
142
+ discriminator
143
+ ];
144
+ const options = schema.options as z.ZodObject<z.ZodRawShape>[];
145
+ const matchedVariant = options.find((option) => {
146
+ const discField = option.shape[discriminator];
147
+ if (discField instanceof z.ZodLiteral) {
148
+ return discField.value === discriminatorValue;
149
+ }
150
+ return false;
151
+ });
152
+
153
+ if (matchedVariant) {
154
+ return walkAndResolve({
155
+ value,
156
+ schema: matchedVariant,
157
+ secretStore,
158
+ warnings,
159
+ path,
160
+ });
161
+ }
162
+ }
163
+
164
+ // Union (oneOf/anyOf without discriminator): try each variant.
165
+ if (
166
+ schema instanceof z.ZodUnion &&
167
+ typeof value === "object" &&
168
+ value !== null
169
+ ) {
170
+ const options = schema.options as z.ZodTypeAny[];
171
+ for (const option of options) {
172
+ const parsed = option.safeParse(value);
173
+ if (parsed.success) {
174
+ return walkAndResolve({
175
+ value,
176
+ schema: option,
177
+ secretStore,
178
+ warnings,
179
+ path,
180
+ });
181
+ }
182
+ }
183
+ }
184
+
185
+ // No schema match — return value unchanged.
186
+ return value;
187
+ }
188
+
189
+ /** Quick check whether a string contains any `${{ secrets.NAME }}` pattern. */
190
+ function containsSecretTemplate(value: string): boolean {
191
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
192
+ return SECRET_TEMPLATE_REGEX.test(value);
193
+ }
194
+
195
+ // ─── Zod Unwrapping ────────────────────────────────────────────────────────
196
+
197
+ /** Unwraps Optional, Default, and Nullable wrappers to get the inner schema. */
198
+ function unwrapZod(schema: z.ZodTypeAny): z.ZodTypeAny {
199
+ let unwrapped = schema;
200
+
201
+ if (unwrapped instanceof z.ZodOptional) {
202
+ unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
203
+ }
204
+
205
+ if (unwrapped instanceof z.ZodDefault) {
206
+ unwrapped = unwrapped.def.innerType as z.ZodTypeAny;
207
+ }
208
+
209
+ if (unwrapped instanceof z.ZodNullable) {
210
+ unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
211
+ }
212
+
213
+ return unwrapped;
214
+ }
215
+
216
+ // ─── Template Resolution ───────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Resolves all `${{ secrets.NAME }}` patterns in a string. Each unique
220
+ * secret name is resolved once (cached per call) to avoid duplicate
221
+ * lookups.
222
+ */
223
+ async function resolveTemplateString(params: {
224
+ value: string;
225
+ secretStore: SecretStore;
226
+ }): Promise<string> {
227
+ const { value, secretStore } = params;
228
+
229
+ const secretNames = new Set<string>();
230
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
231
+ let match: RegExpExecArray | null;
232
+ while ((match = SECRET_TEMPLATE_REGEX.exec(value)) !== null) {
233
+ secretNames.add(match[1]);
234
+ }
235
+
236
+ if (secretNames.size === 0) {
237
+ return value;
238
+ }
239
+
240
+ const resolvedValues = new Map<string, string>();
241
+ for (const name of secretNames) {
242
+ resolvedValues.set(name, await secretStore.resolve(name));
243
+ }
244
+
245
+ SECRET_TEMPLATE_REGEX.lastIndex = 0;
246
+ return value.replaceAll(
247
+ SECRET_TEMPLATE_REGEX,
248
+ (_fullMatch, secretName: string) => resolvedValues.get(secretName)!,
249
+ );
250
+ }
@@ -0,0 +1,140 @@
1
+ import { z } from "zod";
2
+ import { isSecretSchema } from "@checkstack/backend-api";
3
+
4
+ /**
5
+ * Generic schema-driven walk over `x-secret`-annotated string fields.
6
+ *
7
+ * This is the shared machinery behind both the runtime secret resolution
8
+ * (`resolveSecretsBySchema`) and the connection-credential consolidation
9
+ * (extract inline values to internal secrets / inflate references). It
10
+ * walks a value against its Zod schema exactly like the resolver does
11
+ * (objects, arrays, optional/default/nullable, discriminated + plain
12
+ * unions) and invokes `visit` for every `x-secret` STRING leaf, replacing
13
+ * that leaf with whatever `visit` returns.
14
+ *
15
+ * `visit` is async and receives the field path (for diagnostics) and the
16
+ * current string value. Non-secret fields are returned unchanged — so this
17
+ * never touches non-credential config, which is the whole point of acting
18
+ * only on `x-secret` fields.
19
+ */
20
+ export async function walkSecretFields(params: {
21
+ value: unknown;
22
+ schema: z.ZodTypeAny;
23
+ visit: (input: { path: string; value: string }) => Promise<string>;
24
+ }): Promise<unknown> {
25
+ return walk({
26
+ value: params.value,
27
+ schema: params.schema,
28
+ visit: params.visit,
29
+ path: "",
30
+ });
31
+ }
32
+
33
+ async function walk(params: {
34
+ value: unknown;
35
+ schema: z.ZodTypeAny;
36
+ visit: (input: { path: string; value: string }) => Promise<string>;
37
+ path: string;
38
+ }): Promise<unknown> {
39
+ const { value, visit, path } = params;
40
+ const schema = unwrapZod(params.schema);
41
+
42
+ if (value === null || value === undefined) {
43
+ return value;
44
+ }
45
+
46
+ // x-secret string leaf: hand it to the visitor.
47
+ if (isSecretSchema(schema)) {
48
+ if (typeof value === "string") {
49
+ return visit({ path: path || "(root)", value });
50
+ }
51
+ return value;
52
+ }
53
+
54
+ if (
55
+ schema instanceof z.ZodObject &&
56
+ typeof value === "object" &&
57
+ !Array.isArray(value)
58
+ ) {
59
+ const shape = schema.shape as Record<string, z.ZodTypeAny>;
60
+ const result: Record<string, unknown> = {
61
+ ...(value as Record<string, unknown>),
62
+ };
63
+ for (const [key, fieldSchema] of Object.entries(shape)) {
64
+ if (key in result) {
65
+ result[key] = await walk({
66
+ value: result[key],
67
+ schema: fieldSchema,
68
+ visit,
69
+ path: path ? `${path}.${key}` : key,
70
+ });
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+
76
+ if (schema instanceof z.ZodArray && Array.isArray(value)) {
77
+ const elementSchema = schema.element as z.ZodTypeAny;
78
+ return Promise.all(
79
+ value.map((item, index) =>
80
+ walk({
81
+ value: item,
82
+ schema: elementSchema,
83
+ visit,
84
+ path: `${path}[${index}]`,
85
+ }),
86
+ ),
87
+ );
88
+ }
89
+
90
+ if (
91
+ schema instanceof z.ZodDiscriminatedUnion &&
92
+ typeof value === "object" &&
93
+ value !== null
94
+ ) {
95
+ const discriminator = (schema.def as { discriminator: string })
96
+ .discriminator;
97
+ const discriminatorValue = (value as Record<string, unknown>)[
98
+ discriminator
99
+ ];
100
+ const options = schema.options as z.ZodObject<z.ZodRawShape>[];
101
+ const matched = options.find((option) => {
102
+ const discField = option.shape[discriminator];
103
+ return discField instanceof z.ZodLiteral
104
+ ? discField.value === discriminatorValue
105
+ : false;
106
+ });
107
+ if (matched) {
108
+ return walk({ value, schema: matched, visit, path });
109
+ }
110
+ }
111
+
112
+ if (
113
+ schema instanceof z.ZodUnion &&
114
+ typeof value === "object" &&
115
+ value !== null
116
+ ) {
117
+ const options = schema.options as z.ZodTypeAny[];
118
+ for (const option of options) {
119
+ if (option.safeParse(value).success) {
120
+ return walk({ value, schema: option, visit, path });
121
+ }
122
+ }
123
+ }
124
+
125
+ return value;
126
+ }
127
+
128
+ function unwrapZod(schema: z.ZodTypeAny): z.ZodTypeAny {
129
+ let unwrapped = schema;
130
+ if (unwrapped instanceof z.ZodOptional) {
131
+ unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
132
+ }
133
+ if (unwrapped instanceof z.ZodDefault) {
134
+ unwrapped = unwrapped.def.innerType as z.ZodTypeAny;
135
+ }
136
+ if (unwrapped instanceof z.ZodNullable) {
137
+ unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
138
+ }
139
+ return unwrapped;
140
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../backend"
9
+ },
10
+ {
11
+ "path": "../backend-api"
12
+ },
13
+ {
14
+ "path": "../common"
15
+ },
16
+ {
17
+ "path": "../dev-server"
18
+ },
19
+ {
20
+ "path": "../drizzle-helper"
21
+ },
22
+ {
23
+ "path": "../secrets-common"
24
+ },
25
+ {
26
+ "path": "../test-utils-backend"
27
+ }
28
+ ]
29
+ }