@geekmidas/envkit 0.0.8 → 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.
Files changed (57) hide show
  1. package/README.md +228 -174
  2. package/dist/EnvironmentBuilder-DHfDXJUm.d.mts +131 -0
  3. package/dist/EnvironmentBuilder-DfmYRBm-.mjs +83 -0
  4. package/dist/EnvironmentBuilder-DfmYRBm-.mjs.map +1 -0
  5. package/dist/EnvironmentBuilder-W2wku49g.cjs +95 -0
  6. package/dist/EnvironmentBuilder-W2wku49g.cjs.map +1 -0
  7. package/dist/EnvironmentBuilder-Xuf2Dd9u.d.cts +131 -0
  8. package/dist/EnvironmentBuilder.cjs +4 -0
  9. package/dist/EnvironmentBuilder.d.cts +2 -0
  10. package/dist/EnvironmentBuilder.d.mts +2 -0
  11. package/dist/EnvironmentBuilder.mjs +3 -0
  12. package/dist/{EnvironmentParser-cnxuy7lw.cjs → EnvironmentParser-Bt246UeP.cjs} +1 -1
  13. package/dist/{EnvironmentParser-cnxuy7lw.cjs.map → EnvironmentParser-Bt246UeP.cjs.map} +1 -1
  14. package/dist/{EnvironmentParser-B8--woiB.d.cts → EnvironmentParser-CVWU1ooT.d.mts} +1 -1
  15. package/dist/{EnvironmentParser-STvN_RCc.mjs → EnvironmentParser-c06agx31.mjs} +1 -1
  16. package/dist/{EnvironmentParser-STvN_RCc.mjs.map → EnvironmentParser-c06agx31.mjs.map} +1 -1
  17. package/dist/{EnvironmentParser-C_9v2BDw.d.mts → EnvironmentParser-tV-JjCg7.d.cts} +1 -1
  18. package/dist/EnvironmentParser.cjs +1 -1
  19. package/dist/EnvironmentParser.d.cts +1 -1
  20. package/dist/EnvironmentParser.d.mts +1 -1
  21. package/dist/EnvironmentParser.mjs +1 -1
  22. package/dist/SnifferEnvironmentParser.cjs +1 -1
  23. package/dist/SnifferEnvironmentParser.cjs.map +1 -1
  24. package/dist/SnifferEnvironmentParser.d.cts +1 -1
  25. package/dist/SnifferEnvironmentParser.d.mts +1 -1
  26. package/dist/SnifferEnvironmentParser.mjs +1 -1
  27. package/dist/SnifferEnvironmentParser.mjs.map +1 -1
  28. package/dist/SstEnvironmentBuilder-BuFw1hCe.cjs +125 -0
  29. package/dist/SstEnvironmentBuilder-BuFw1hCe.cjs.map +1 -0
  30. package/dist/SstEnvironmentBuilder-CjURMGjW.d.mts +177 -0
  31. package/dist/SstEnvironmentBuilder-D4oSo_KX.d.cts +177 -0
  32. package/dist/SstEnvironmentBuilder-DEa3lTUB.mjs +108 -0
  33. package/dist/SstEnvironmentBuilder-DEa3lTUB.mjs.map +1 -0
  34. package/dist/SstEnvironmentBuilder.cjs +7 -0
  35. package/dist/SstEnvironmentBuilder.d.cts +3 -0
  36. package/dist/SstEnvironmentBuilder.d.mts +3 -0
  37. package/dist/SstEnvironmentBuilder.mjs +4 -0
  38. package/dist/index.cjs +5 -2
  39. package/dist/index.d.cts +3 -2
  40. package/dist/index.d.mts +3 -2
  41. package/dist/index.mjs +3 -2
  42. package/dist/sst.cjs +13 -114
  43. package/dist/sst.cjs.map +1 -1
  44. package/dist/sst.d.cts +14 -93
  45. package/dist/sst.d.mts +14 -93
  46. package/dist/sst.mjs +10 -112
  47. package/dist/sst.mjs.map +1 -1
  48. package/docs/async-secrets-design.md +355 -0
  49. package/package.json +6 -2
  50. package/src/EnvironmentBuilder.ts +196 -0
  51. package/src/SnifferEnvironmentParser.ts +7 -5
  52. package/src/SstEnvironmentBuilder.ts +298 -0
  53. package/src/__tests__/EnvironmentBuilder.spec.ts +274 -0
  54. package/src/__tests__/SstEnvironmentBuilder.spec.ts +373 -0
  55. package/src/__tests__/sst.spec.ts +1 -1
  56. package/src/index.ts +12 -0
  57. package/src/sst.ts +45 -207
package/dist/sst.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"sst.mjs","names":["name: string","value: Secret","key: string","value: Postgres","value: Bucket","value: any","processors: Record<ResourceType, ResourceProcessor<any>>","record: Record<string, Resource | string>","env: Record<string, string>"],"sources":["../src/sst.ts"],"sourcesContent":["import snakecase from 'lodash.snakecase';\n\n/**\n * Converts a string to environment variable case format (UPPER_SNAKE_CASE).\n * Numbers following underscores are preserved without the underscore.\n *\n * @param name - The string to convert\n * @returns The converted string in environment variable format\n *\n * @example\n * environmentCase('myVariable') // 'MY_VARIABLE'\n * environmentCase('api_v2') // 'APIV2'\n */\nexport function environmentCase(name: string) {\n return snakecase(name)\n .toUpperCase()\n .replace(/_\\d+/g, (r) => {\n return r.replace('_', '');\n });\n}\n\n/**\n * Enumeration of supported SST (Serverless Stack Toolkit) resource types.\n * Used to identify and process different AWS and SST resources.\n */\nexport enum ResourceType {\n ApiGatewayV2 = 'sst.aws.ApiGatewayV2',\n Postgres = 'sst.aws.Postgres',\n Function = 'sst.aws.Function',\n Bucket = 'sst.aws.Bucket',\n Vpc = 'sst.aws.Vpc',\n Secret = 'sst.sst.Secret',\n SSTSecret = 'sst:sst:Secret',\n SSTFunction = 'sst:sst:Function',\n SSTApiGatewayV2 = 'sst:aws:ApiGatewayV2',\n SSTPostgres = 'sst:aws:Postgres',\n SSTBucket = 'sst:aws:Bucket',\n}\n\n/**\n * Processes a Secret resource into environment variables.\n *\n * @param name - The resource name\n * @param value - The Secret resource\n * @returns Object with environment variable mappings\n */\nconst secret = (name: string, value: Secret) => ({\n [environmentCase(name)]: value.value,\n});\n/**\n * Processes a Postgres database resource into environment variables.\n * Creates multiple environment variables for database connection details.\n *\n * @param key - The resource key\n * @param value - The Postgres resource\n * @returns Object with database connection environment variables\n */\nconst postgres = (key: string, value: Postgres) => {\n const prefix = `${environmentCase(key)}`;\n return {\n [`${prefix}_NAME`]: value.database,\n [`${prefix}_HOST`]: value.host,\n [`${prefix}_PASSWORD`]: value.password,\n [`${prefix}_PORT`]: value.port,\n [`${prefix}_USERNAME`]: value.username,\n };\n};\n\n/**\n * Processes a Bucket resource into environment variables.\n *\n * @param name - The resource name\n * @param value - The Bucket resource\n * @returns Object with bucket name environment variable\n */\nconst bucket = (name: string, value: Bucket) => {\n const prefix = `${environmentCase(name)}`;\n return {\n [`${prefix}_NAME`]: value.name,\n };\n};\n\n/**\n * No-operation processor for resources that don't require environment variables.\n *\n * @param name - The resource name (unused)\n * @param value - The resource value (unused)\n * @returns Empty object\n */\nconst noop = (name: string, value: any) => ({});\n\n/**\n * Map of resource types to their corresponding processor functions.\n * Each processor converts resource data into environment variables.\n */\nconst processors: Record<ResourceType, ResourceProcessor<any>> = {\n [ResourceType.ApiGatewayV2]: noop,\n [ResourceType.Function]: noop,\n [ResourceType.Vpc]: noop,\n [ResourceType.Secret]: secret,\n [ResourceType.Postgres]: postgres,\n [ResourceType.Bucket]: bucket,\n\n [ResourceType.SSTSecret]: secret,\n [ResourceType.SSTBucket]: bucket,\n [ResourceType.SSTFunction]: noop,\n [ResourceType.SSTPostgres]: postgres,\n [ResourceType.SSTApiGatewayV2]: noop,\n};\n\n/**\n * Normalizes SST resources and plain strings into environment variables.\n * Processes resources based on their type and converts names to environment case.\n *\n * @param record - Object containing resources and/or string values\n * @returns Normalized environment variables object\n *\n * @example\n * normalizeResourceEnv({\n * apiUrl: 'https://api.example.com',\n * database: { type: ResourceType.Postgres, ... }\n * })\n */\nexport function normalizeResourceEnv(\n record: Record<string, Resource | string>,\n): Record<string, string> {\n const env: Record<string, string> = {};\n for (const [k, value] of Object.entries(record)) {\n if (typeof value === 'string') {\n env[environmentCase(k)] = value;\n continue;\n }\n\n const processor = processors[value.type];\n if (processor) {\n Object.assign(env, processor(k, value));\n } else {\n console.warn(`No processor found for resource type: `, { value });\n }\n }\n\n return env;\n}\n\n/**\n * AWS API Gateway V2 resource type.\n * Represents an HTTP/WebSocket API.\n */\nexport type ApiGatewayV2 = {\n type: ResourceType.ApiGatewayV2;\n url: string;\n};\n\n/**\n * PostgreSQL database resource type.\n * Contains all connection details needed to connect to the database.\n */\nexport type Postgres = {\n database: string;\n host: string;\n password: string;\n port: number;\n type: ResourceType.Postgres;\n username: string;\n};\n\n/**\n * AWS Lambda Function resource type.\n */\nexport type Function = {\n name: string;\n type: ResourceType.Function;\n};\n\n/**\n * AWS S3 Bucket resource type.\n */\nexport type Bucket = {\n name: string;\n type: ResourceType.Bucket;\n};\n\n/**\n * AWS VPC (Virtual Private Cloud) resource type.\n */\nexport type Vpc = {\n bastion: string;\n type: ResourceType.Vpc;\n};\n\n/**\n * Secret resource type for storing sensitive values.\n */\nexport type Secret = {\n type: ResourceType.Secret;\n value: string;\n};\n\n/**\n * Union type of all supported SST resource types.\n */\nexport type Resource =\n | ApiGatewayV2\n | Postgres\n | Function\n | Bucket\n | Vpc\n | Secret;\n\n/**\n * Function type for processing a specific resource type into environment variables.\n *\n * @template K - The specific resource type\n * @param name - The resource name\n * @param value - The resource value\n * @returns Object mapping environment variable names to values\n */\nexport type ResourceProcessor<K extends Resource> = (\n name: string,\n value: K,\n) => Record<string, string | number>;\n"],"mappings":";;;;;;;;;;;;;;AAaA,SAAgB,gBAAgBA,MAAc;AAC5C,QAAO,UAAU,KAAK,CACnB,aAAa,CACb,QAAQ,SAAS,CAAC,MAAM;AACvB,SAAO,EAAE,QAAQ,KAAK,GAAG;CAC1B,EAAC;AACL;;;;;AAMD,IAAY,wDAAL;AACL;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AACD;;;;;;;;AASD,MAAM,SAAS,CAACA,MAAcC,WAAmB,GAC9C,gBAAgB,KAAK,GAAG,MAAM,MAChC;;;;;;;;;AASD,MAAM,WAAW,CAACC,KAAaC,UAAoB;CACjD,MAAM,UAAU,EAAE,gBAAgB,IAAI,CAAC;AACvC,QAAO;IACH,EAAE,OAAO,SAAS,MAAM;IACxB,EAAE,OAAO,SAAS,MAAM;IACxB,EAAE,OAAO,aAAa,MAAM;IAC5B,EAAE,OAAO,SAAS,MAAM;IACxB,EAAE,OAAO,aAAa,MAAM;CAC/B;AACF;;;;;;;;AASD,MAAM,SAAS,CAACH,MAAcI,UAAkB;CAC9C,MAAM,UAAU,EAAE,gBAAgB,KAAK,CAAC;AACxC,QAAO,IACH,EAAE,OAAO,SAAS,MAAM,KAC3B;AACF;;;;;;;;AASD,MAAM,OAAO,CAACJ,MAAcK,WAAgB,CAAE;;;;;AAM9C,MAAMC,aAA2D;EAC9D,aAAa,eAAe;EAC5B,aAAa,WAAW;EACxB,aAAa,MAAM;EACnB,aAAa,SAAS;EACtB,aAAa,WAAW;EACxB,aAAa,SAAS;EAEtB,aAAa,YAAY;EACzB,aAAa,YAAY;EACzB,aAAa,cAAc;EAC3B,aAAa,cAAc;EAC3B,aAAa,kBAAkB;AACjC;;;;;;;;;;;;;;AAeD,SAAgB,qBACdC,QACwB;CACxB,MAAMC,MAA8B,CAAE;AACtC,MAAK,MAAM,CAAC,GAAG,MAAM,IAAI,OAAO,QAAQ,OAAO,EAAE;AAC/C,aAAW,UAAU,UAAU;AAC7B,OAAI,gBAAgB,EAAE,IAAI;AAC1B;EACD;EAED,MAAM,YAAY,WAAW,MAAM;AACnC,MAAI,UACF,QAAO,OAAO,KAAK,UAAU,GAAG,MAAM,CAAC;MAEvC,SAAQ,MAAM,yCAAyC,EAAE,MAAO,EAAC;CAEpE;AAED,QAAO;AACR"}
1
+ {"version":3,"file":"sst.mjs","names":["record: Record<string, SstResource | string>"],"sources":["../src/sst.ts"],"sourcesContent":["// Re-export everything from SstEnvironmentBuilder\nexport {\n SstEnvironmentBuilder,\n sstResolvers,\n ResourceType,\n type ApiGatewayV2,\n type Postgres,\n type Function,\n type Bucket,\n type Vpc,\n type Secret,\n type SnsTopic,\n type SstResource,\n type ResourceProcessor,\n} from './SstEnvironmentBuilder';\n\n// Re-export environmentCase from EnvironmentBuilder\nexport { environmentCase } from './EnvironmentBuilder';\n\n// Re-export types from EnvironmentBuilder\nexport type {\n EnvRecord,\n EnvValue,\n EnvironmentBuilderOptions,\n} from './EnvironmentBuilder';\n\n// Import for deprecated function\nimport {\n SstEnvironmentBuilder,\n type SstResource,\n} from './SstEnvironmentBuilder';\n\n/**\n * @deprecated Use `new SstEnvironmentBuilder(record).build()` instead.\n *\n * Normalizes SST resources and plain strings into environment variables.\n * Processes resources based on their type and converts names to environment case.\n *\n * @param record - Object containing resources and/or string values\n * @returns Normalized environment variables object\n *\n * @example\n * // Old usage (deprecated):\n * normalizeResourceEnv({ database: postgresResource })\n *\n * // New usage:\n * new SstEnvironmentBuilder({ database: postgresResource }).build()\n */\nexport function normalizeResourceEnv(\n record: Record<string, SstResource | string>,\n): Record<string, string | number | boolean | Record<string, unknown>> {\n return new SstEnvironmentBuilder(record).build();\n}\n\n// Keep Resource type as deprecated alias for backwards compatibility\n/**\n * @deprecated Use `SstResource` instead.\n */\nexport type Resource = SstResource;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAgDA,SAAgB,qBACdA,QACqE;AACrE,QAAO,IAAI,sBAAsB,QAAQ,OAAO;AACjD"}
@@ -0,0 +1,355 @@
1
+ # Async Secrets Resolution Design
2
+
3
+ ## Problem Statement
4
+
5
+ Applications often need to fetch sensitive configuration values (secrets) from external providers like HashiCorp Vault, AWS Secrets Manager, or other secret management systems. The current `EnvironmentParser` only supports synchronous parsing of environment variables, which doesn't accommodate async secret fetching.
6
+
7
+ Additionally, environment variables for secrets typically contain **references** to the actual secret (e.g., a Vault path), not the secret value itself:
8
+
9
+ ```bash
10
+ # Environment variables
11
+ DB_HOST=localhost # Actual value
12
+ DB_PASSWORD=/vault/prod/db # Reference to secret, not the actual password
13
+ API_KEY=/vault/prod/api # Reference to secret
14
+ ```
15
+
16
+ ## Proposed Solution
17
+
18
+ Extend `EnvironmentParser` with:
19
+ 1. A separate `getSecret()` getter to distinguish secrets from regular env vars
20
+ 2. A configurable `secretsResolver` that fetches actual values from refs
21
+ 3. A cache to avoid redundant fetches for the same ref
22
+ 4. An `echoSecretsResolver` for testing that returns refs as values
23
+
24
+ ## API Design
25
+
26
+ ### Constructor Options
27
+
28
+ ```typescript
29
+ interface EnvironmentParserOptions {
30
+ /**
31
+ * Function to resolve secret references to actual values.
32
+ * Receives an array of refs (values from env vars marked as secrets).
33
+ * Returns a Map of ref → resolved value.
34
+ */
35
+ secretsResolver?: SecretsResolver;
36
+ }
37
+
38
+ type SecretsResolver = (refs: string[]) => Promise<Map<string, string>>;
39
+ ```
40
+
41
+ ### Getters
42
+
43
+ ```typescript
44
+ parser.create((get, getSecret) => ({
45
+ // Regular env var - resolved synchronously
46
+ host: get('DB_HOST').string(),
47
+ port: get('PORT').string().transform(Number),
48
+
49
+ // Secret env var - resolved asynchronously via secretsResolver
50
+ // The env var value is treated as a ref, not the actual value
51
+ password: getSecret('DB_PASSWORD').string(),
52
+ apiKey: getSecret('API_KEY').string(),
53
+ }));
54
+ ```
55
+
56
+ ### Parsed Config Types
57
+
58
+ ```typescript
59
+ // Regular values are their actual types
60
+ config.host // string
61
+ config.port // number
62
+
63
+ // Secret values are Promises of their types
64
+ config.password // Promise<string>
65
+ config.apiKey // Promise<string>
66
+
67
+ // Usage
68
+ const password = await config.password;
69
+ ```
70
+
71
+ ### Built-in Resolvers
72
+
73
+ ```typescript
74
+ import { echoSecretsResolver } from '@geekmidas/envkit';
75
+
76
+ /**
77
+ * Echo resolver returns the ref as the value.
78
+ * Useful for testing where the "ref" IS the actual test value.
79
+ */
80
+ export const echoSecretsResolver: SecretsResolver = async (refs) =>
81
+ new Map(refs.map((ref) => [ref, ref]));
82
+ ```
83
+
84
+ ## Resolution Flow
85
+
86
+ ```
87
+ ┌─────────────────────────────────────────────────────────────────────┐
88
+ │ parse() called │
89
+ └─────────────────────────────────────────────────────────────────────┘
90
+
91
+
92
+ ┌─────────────────────────────────────────────────────────────────────┐
93
+ │ 1. Regular env vars (get) are parsed synchronously as normal │
94
+ │ config.host = "localhost" │
95
+ │ config.port = 3000 │
96
+ └─────────────────────────────────────────────────────────────────────┘
97
+
98
+
99
+ ┌─────────────────────────────────────────────────────────────────────┐
100
+ │ 2. Secret env vars (getSecret) return Promises │
101
+ │ config.password = Promise<string> │
102
+ │ config.apiKey = Promise<string> │
103
+ └─────────────────────────────────────────────────────────────────────┘
104
+
105
+
106
+ ┌─────────────────────────────────────────────────────────────────────┐
107
+ │ 3. When Promise is awaited: │
108
+ │ a. Read ref from env var (e.g., "/vault/prod/db") │
109
+ │ b. Check cache - if cached, return cached value │
110
+ │ c. If not cached, call secretsResolver([ref]) │
111
+ │ d. Cache the resolved value │
112
+ │ e. Apply Zod validation/transformation │
113
+ │ f. Return validated value │
114
+ └─────────────────────────────────────────────────────────────────────┘
115
+ ```
116
+
117
+ ## Caching Strategy
118
+
119
+ A resolved secrets cache prevents redundant API calls:
120
+
121
+ ```typescript
122
+ // Internal cache (per EnvironmentParser instance)
123
+ private resolvedCache = new Map<string, string>();
124
+
125
+ async resolveSecret(ref: string): Promise<string> {
126
+ // Return cached value if available
127
+ if (this.resolvedCache.has(ref)) {
128
+ return this.resolvedCache.get(ref)!;
129
+ }
130
+
131
+ // Fetch from resolver
132
+ const resolved = await this.secretsResolver([ref]);
133
+ const value = resolved.get(ref);
134
+
135
+ if (value === undefined) {
136
+ throw new Error(`Secret resolver did not return value for ref: ${ref}`);
137
+ }
138
+
139
+ // Cache for future use
140
+ this.resolvedCache.set(ref, value);
141
+ return value;
142
+ }
143
+ ```
144
+
145
+ Benefits:
146
+ - Same ref accessed multiple times → single resolver call
147
+ - Consistent values within a parser instance
148
+ - Reduces load on secret providers
149
+
150
+ ## Usage Examples
151
+
152
+ ### Production with Vault
153
+
154
+ ```typescript
155
+ import { EnvironmentParser } from '@geekmidas/envkit';
156
+
157
+ // Vault resolver implementation
158
+ const vaultResolver: SecretsResolver = async (refs) => {
159
+ const secrets = await vaultClient.batchRead(refs);
160
+ return new Map(refs.map((ref, i) => [ref, secrets[i].value]));
161
+ };
162
+
163
+ const parser = new EnvironmentParser(process.env, {
164
+ secretsResolver: vaultResolver,
165
+ });
166
+
167
+ const config = parser.create((get, getSecret) => ({
168
+ database: {
169
+ host: get('DB_HOST').string(),
170
+ port: get('DB_PORT').coerce.number(),
171
+ password: getSecret('DB_PASSWORD').string(),
172
+ },
173
+ api: {
174
+ baseUrl: get('API_BASE_URL').string().url(),
175
+ key: getSecret('API_KEY').string().min(32),
176
+ },
177
+ })).parse();
178
+
179
+ // Use in service registration
180
+ const databaseService = {
181
+ serviceName: 'database' as const,
182
+ async register(envParser: EnvironmentParser<{}>) {
183
+ const config = envParser.create((get, getSecret) => ({
184
+ host: get('DB_HOST').string(),
185
+ password: getSecret('DB_PASSWORD').string(),
186
+ })).parse();
187
+
188
+ // Await the secret
189
+ const password = await config.password;
190
+
191
+ return new Database({
192
+ host: config.host,
193
+ password,
194
+ });
195
+ },
196
+ };
197
+ ```
198
+
199
+ ### Testing with Echo Resolver
200
+
201
+ ```typescript
202
+ import { EnvironmentParser, echoSecretsResolver } from '@geekmidas/envkit';
203
+
204
+ describe('DatabaseService', () => {
205
+ it('should connect with credentials', async () => {
206
+ // In tests, the "ref" IS the actual test value
207
+ const env = {
208
+ DB_HOST: 'localhost',
209
+ DB_PORT: '5432',
210
+ DB_PASSWORD: 'test-password-123', // This IS the password for tests
211
+ };
212
+
213
+ const parser = new EnvironmentParser(env, {
214
+ secretsResolver: echoSecretsResolver,
215
+ });
216
+
217
+ const service = await databaseService.register(parser);
218
+
219
+ expect(service.isConnected()).toBe(true);
220
+ });
221
+ });
222
+ ```
223
+
224
+ ### AWS Secrets Manager
225
+
226
+ ```typescript
227
+ import { SecretsManagerClient, BatchGetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
228
+
229
+ const awsResolver: SecretsResolver = async (refs) => {
230
+ const client = new SecretsManagerClient({});
231
+ const command = new BatchGetSecretValueCommand({
232
+ SecretIdList: refs,
233
+ });
234
+
235
+ const response = await client.send(command);
236
+ const result = new Map<string, string>();
237
+
238
+ for (const secret of response.SecretValues ?? []) {
239
+ if (secret.ARN && secret.SecretString) {
240
+ result.set(secret.ARN, secret.SecretString);
241
+ }
242
+ }
243
+
244
+ return result;
245
+ };
246
+ ```
247
+
248
+ ## Type Definitions
249
+
250
+ ```typescript
251
+ /**
252
+ * Function type for resolving secret references to actual values.
253
+ */
254
+ export type SecretsResolver = (refs: string[]) => Promise<Map<string, string>>;
255
+
256
+ /**
257
+ * Extended getter that includes secret() method.
258
+ */
259
+ export type SecretEnvFetcher<TPath extends string = string> = (
260
+ name: TPath,
261
+ ) => typeof z;
262
+
263
+ /**
264
+ * Builder function signature with both getters.
265
+ */
266
+ export type EnvironmentBuilderWithSecrets<TResponse extends EmptyObject> = (
267
+ get: EnvFetcher,
268
+ getSecret: SecretEnvFetcher,
269
+ ) => TResponse;
270
+
271
+ /**
272
+ * Infers config type, wrapping secret values in Promise.
273
+ */
274
+ export type InferConfigWithSecrets<T extends EmptyObject> = {
275
+ [K in keyof T]: T[K] extends SecretSchema<infer U>
276
+ ? Promise<U>
277
+ : T[K] extends z.ZodSchema
278
+ ? z.infer<T[K]>
279
+ : T[K] extends Record<string, unknown>
280
+ ? InferConfigWithSecrets<T[K]>
281
+ : T[K];
282
+ };
283
+ ```
284
+
285
+ ## Error Handling
286
+
287
+ ### Missing Resolver
288
+
289
+ ```typescript
290
+ // If getSecret() is used but no resolver provided
291
+ const parser = new EnvironmentParser(env); // no resolver
292
+
293
+ const config = parser.create((get, getSecret) => ({
294
+ password: getSecret('DB_PASSWORD').string(),
295
+ })).parse();
296
+
297
+ await config.password;
298
+ // Error: SecretsResolver is required when using getSecret().
299
+ // Configure it via EnvironmentParser options.
300
+ ```
301
+
302
+ ### Missing Ref in Environment
303
+
304
+ ```typescript
305
+ // If env var doesn't exist
306
+ const env = { DB_HOST: 'localhost' }; // DB_PASSWORD not set
307
+
308
+ const config = parser.create((get, getSecret) => ({
309
+ password: getSecret('DB_PASSWORD').string(),
310
+ })).parse();
311
+
312
+ await config.password;
313
+ // Error: Environment variable "DB_PASSWORD" is not defined.
314
+ // Expected a secret reference.
315
+ ```
316
+
317
+ ### Resolver Doesn't Return Value
318
+
319
+ ```typescript
320
+ // If resolver doesn't return value for a ref
321
+ const brokenResolver: SecretsResolver = async (refs) => new Map();
322
+
323
+ await config.password;
324
+ // Error: Secret resolver did not return value for ref: /vault/prod/db
325
+ ```
326
+
327
+ ## Migration Path
328
+
329
+ Existing code using `EnvironmentParser` continues to work unchanged:
330
+
331
+ ```typescript
332
+ // Before (still works)
333
+ const config = parser.create((get) => ({
334
+ port: get('PORT').string().transform(Number),
335
+ })).parse();
336
+
337
+ // After (opt-in to secrets)
338
+ const config = parser.create((get, getSecret) => ({
339
+ port: get('PORT').string().transform(Number),
340
+ password: getSecret('DB_PASSWORD').string(),
341
+ })).parse();
342
+ ```
343
+
344
+ ## Open Questions
345
+
346
+ 1. **Batch resolution timing**: Should we batch all secret resolutions when `parse()` is called, or resolve lazily when each Promise is awaited?
347
+ - **Lazy (proposed)**: Each secret resolved on first await, cached for subsequent access
348
+ - **Eager**: All secrets resolved upfront in parse(), requires parseAsync()
349
+
350
+ 2. **Cache scope**: Should the cache be per-parser instance or global?
351
+ - **Per-instance (proposed)**: Isolated, predictable behavior
352
+ - **Global**: More efficient for multiple parsers with same refs
353
+
354
+ 3. **Cache invalidation**: Should there be a way to clear the cache?
355
+ - Could add `parser.clearSecretCache()` method if needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/envkit",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,10 @@
20
20
  "require": "./dist/SnifferEnvironmentParser.cjs"
21
21
  }
22
22
  },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/geekmidas/toolbox"
26
+ },
23
27
  "publishConfig": {
24
28
  "registry": "https://registry.npmjs.org/",
25
29
  "access": "public"
@@ -30,7 +34,7 @@
30
34
  "lodash.snakecase": "~4.1.1"
31
35
  },
32
36
  "peerDependencies": {
33
- "zod": "~3.25.67"
37
+ "zod": "~4.1.13"
34
38
  },
35
39
  "devDependencies": {
36
40
  "@types/lodash.set": "~4.3.9",
@@ -0,0 +1,196 @@
1
+ import snakecase from 'lodash.snakecase';
2
+
3
+ /**
4
+ * Converts a string to environment variable case format (UPPER_SNAKE_CASE).
5
+ * Numbers following underscores are preserved without the underscore.
6
+ *
7
+ * @param name - The string to convert
8
+ * @returns The converted string in environment variable format
9
+ *
10
+ * @example
11
+ * environmentCase('myVariable') // 'MY_VARIABLE'
12
+ * environmentCase('apiV2') // 'APIV2'
13
+ */
14
+ export function environmentCase(name: string): string {
15
+ return snakecase(name)
16
+ .toUpperCase()
17
+ .replace(/_\d+/g, (r) => {
18
+ return r.replace('_', '');
19
+ });
20
+ }
21
+
22
+ /**
23
+ * A record of environment variable names to their values.
24
+ * Values can be primitives or nested records.
25
+ */
26
+ export interface EnvRecord {
27
+ [key: string]: EnvValue;
28
+ }
29
+
30
+ /**
31
+ * Represents a value that can be stored in an environment record.
32
+ * Can be a primitive value or a nested record of environment values.
33
+ */
34
+ export type EnvValue = string | number | boolean | EnvRecord;
35
+
36
+ /**
37
+ * A resolver function that converts a typed value into environment variables.
38
+ *
39
+ * @template T - The type of value this resolver handles (without the `type` key)
40
+ * @param key - The key name from the input record
41
+ * @param value - The value to resolve (without the `type` key)
42
+ * @returns A record of environment variable names to their values
43
+ */
44
+ export type EnvironmentResolver<T = any> = (key: string, value: T) => EnvRecord;
45
+
46
+ /**
47
+ * A map of type discriminator strings to their resolver functions.
48
+ */
49
+ export type Resolvers = Record<string, EnvironmentResolver<any>>;
50
+
51
+ /**
52
+ * Options for configuring the EnvironmentBuilder.
53
+ */
54
+ export interface EnvironmentBuilderOptions {
55
+ /**
56
+ * Handler called when a value's type doesn't match any registered resolver.
57
+ * Defaults to console.warn.
58
+ */
59
+ onUnmatchedValue?: (key: string, value: unknown) => void;
60
+ }
61
+
62
+ /**
63
+ * Input value type - either a string or an object with a `type` discriminator.
64
+ */
65
+ export type InputValue = string | { type: string; [key: string]: unknown };
66
+
67
+ /**
68
+ * Base type for typed input values with a specific type discriminator.
69
+ */
70
+ export type TypedInputValue<TType extends string = string> = {
71
+ type: TType;
72
+ [key: string]: unknown;
73
+ };
74
+
75
+ /**
76
+ * Extracts the `type` string value from an input value.
77
+ */
78
+ type ExtractType<T> = T extends { type: infer U extends string } ? U : never;
79
+
80
+ /**
81
+ * Removes the `type` key from an object type.
82
+ */
83
+ type OmitType<T> = T extends { type: string } ? Omit<T, 'type'> : never;
84
+
85
+ /**
86
+ * Extracts all unique `type` values from a record (excluding plain strings).
87
+ */
88
+ type AllTypeValues<TRecord extends Record<string, InputValue>> = {
89
+ [K in keyof TRecord]: ExtractType<TRecord[K]>;
90
+ }[keyof TRecord];
91
+
92
+ /**
93
+ * For a given type value, finds the corresponding value type (without `type` key).
94
+ */
95
+ type ValueForType<
96
+ TRecord extends Record<string, InputValue>,
97
+ TType extends string,
98
+ > = {
99
+ [K in keyof TRecord]: TRecord[K] extends { type: TType }
100
+ ? OmitType<TRecord[K]>
101
+ : never;
102
+ }[keyof TRecord];
103
+
104
+ /**
105
+ * Generates typed resolvers based on the input record.
106
+ * Keys are the `type` values, values are resolver functions receiving the value without `type`.
107
+ */
108
+ export type TypedResolvers<TRecord extends Record<string, InputValue>> = {
109
+ [TType in AllTypeValues<TRecord>]: EnvironmentResolver<
110
+ ValueForType<TRecord, TType>
111
+ >;
112
+ };
113
+
114
+ /**
115
+ * A generic, extensible class for building environment variables from
116
+ * objects with type-discriminated values.
117
+ *
118
+ * @template TRecord - The input record type for type inference
119
+ * @template TResolvers - The resolvers type (defaults to TypedResolvers<TRecord>)
120
+ *
121
+ * @example
122
+ * ```typescript
123
+ * const env = new EnvironmentBuilder(
124
+ * {
125
+ * apiKey: { type: 'secret', value: 'xyz' },
126
+ * appName: 'my-app'
127
+ * },
128
+ * {
129
+ * // `value` is typed as { value: string } (without `type`)
130
+ * secret: (key, value) => ({ [key]: value.value }),
131
+ * }
132
+ * ).build();
133
+ * // { API_KEY: 'xyz', APP_NAME: 'my-app' }
134
+ * ```
135
+ */
136
+ export class EnvironmentBuilder<
137
+ TRecord extends Record<string, InputValue> = Record<string, InputValue>,
138
+ TResolvers extends Resolvers = TypedResolvers<TRecord>,
139
+ > {
140
+ private readonly record: TRecord;
141
+ private readonly resolvers: TResolvers;
142
+ private readonly options: Required<EnvironmentBuilderOptions>;
143
+
144
+ constructor(
145
+ record: TRecord,
146
+ resolvers: TResolvers,
147
+ options: EnvironmentBuilderOptions = {},
148
+ ) {
149
+ this.record = record;
150
+ this.resolvers = resolvers;
151
+ this.options = {
152
+ onUnmatchedValue:
153
+ options.onUnmatchedValue ??
154
+ ((key, value) => {
155
+ console.warn(`No resolver found for key "${key}":`, { value });
156
+ }),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Build environment variables from the input record.
162
+ *
163
+ * - Plain string values are passed through with key transformation
164
+ * - Object values with a `type` property are matched against resolvers
165
+ * - Resolvers receive values without the `type` key
166
+ * - Only root-level keys are transformed to UPPER_SNAKE_CASE
167
+ *
168
+ * @returns A record of environment variables
169
+ */
170
+ build(): EnvRecord {
171
+ const env: EnvRecord = {};
172
+
173
+ for (const [key, value] of Object.entries(this.record)) {
174
+ // Handle plain string values
175
+ if (typeof value === 'string') {
176
+ env[environmentCase(key)] = value;
177
+ continue;
178
+ }
179
+
180
+ // Handle objects with type discriminator
181
+ const { type, ...rest } = value;
182
+ const resolver = this.resolvers[type];
183
+ if (resolver) {
184
+ const resolved = resolver(key, rest);
185
+ // Transform only root-level keys from resolver output
186
+ for (const [resolvedKey, resolvedValue] of Object.entries(resolved)) {
187
+ env[environmentCase(resolvedKey)] = resolvedValue;
188
+ }
189
+ } else {
190
+ this.options.onUnmatchedValue(key, value);
191
+ }
192
+ }
193
+
194
+ return env;
195
+ }
196
+ }
@@ -24,9 +24,7 @@ import {
24
24
  * const envVars = sniffer.getEnvironmentVariables(); // ['DATABASE_URL', 'API_KEY']
25
25
  * ```
26
26
  */
27
- export class SnifferEnvironmentParser<
28
- T extends EmptyObject = EmptyObject,
29
- > {
27
+ export class SnifferEnvironmentParser<T extends EmptyObject = EmptyObject> {
30
28
  private readonly accessedVars: Set<string> = new Set();
31
29
 
32
30
  /**
@@ -152,7 +150,9 @@ export class SnifferEnvironmentParser<
152
150
  /**
153
151
  * A ConfigParser that always succeeds with mock values.
154
152
  */
155
- class SnifferConfigParser<TResponse extends EmptyObject> extends ConfigParser<TResponse> {
153
+ class SnifferConfigParser<
154
+ TResponse extends EmptyObject,
155
+ > extends ConfigParser<TResponse> {
156
156
  parse(): any {
157
157
  return this.parseWithMocks(this.getConfig());
158
158
  }
@@ -175,7 +175,9 @@ class SnifferConfigParser<TResponse extends EmptyObject> extends ConfigParser<TR
175
175
  if (schema instanceof z.ZodType) {
176
176
  // Use safeParse which will return mock values from our wrapped schema
177
177
  const parsed = schema.safeParse(undefined);
178
- result[key] = parsed.success ? parsed.data : this.getDefaultForSchema(schema);
178
+ result[key] = parsed.success
179
+ ? parsed.data
180
+ : this.getDefaultForSchema(schema);
179
181
  } else if (schema && typeof schema === 'object') {
180
182
  result[key] = this.parseWithMocks(schema as EmptyObject);
181
183
  }