@bymax-one/nest-config 1.0.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 +106 -0
- package/LICENSE +21 -0
- package/README.md +572 -0
- package/dist/index.cjs +34 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +1 -0
- package/dist/internal/index.cjs +423 -0
- package/dist/internal/index.d.cts +571 -0
- package/dist/internal/index.d.ts +571 -0
- package/dist/internal/index.mjs +415 -0
- package/dist/testing/index.cjs +171 -0
- package/dist/testing/index.d.cts +88 -0
- package/dist/testing/index.d.ts +88 -0
- package/dist/testing/index.mjs +168 -0
- package/package.json +161 -0
package/README.md
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://img.shields.io/badge/%40bymax--one-nest--config-000000?style=for-the-badge&logo=nestjs&logoColor=E0234E" alt="@bymax-one/nest-config" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">@bymax-one/nest-config</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Typed and validated environment configuration for NestJS</strong><br />
|
|
9
|
+
<sub>Zod v4 · Fail-fast at bootstrap · Value-free errors · Typed dot-path access · Testing helpers · Zero Runtime Dependencies</sub>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://www.npmjs.com/package/@bymax-one/nest-config"><img src="https://img.shields.io/npm/v/@bymax-one/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="npm version" /></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/@bymax-one/nest-config"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
|
|
15
|
+
<a href="https://github.com/bymaxone/nest-config/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-config/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
|
|
16
|
+
<a href="https://github.com/bymaxone/nest-config/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
|
|
17
|
+
<a href="https://github.com/bymaxone/nest-config/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-95.72%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
|
|
18
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-config"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-config/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
|
|
19
|
+
<a href="https://github.com/bymaxone/nest-config/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
|
|
20
|
+
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
|
21
|
+
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-24%2B-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node.js" /></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<a href="https://github.com/bymaxone/nest-config">GitHub</a> ·
|
|
26
|
+
<a href="https://github.com/bymaxone/nest-config/issues">Issues</a> ·
|
|
27
|
+
<a href="#-quick-start">Quick Start</a> ·
|
|
28
|
+
<a href="#-api-reference">API Reference</a> ·
|
|
29
|
+
<a href="https://github.com/bymaxone/nest-config-example">Example App</a>
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## ✨ Overview
|
|
35
|
+
|
|
36
|
+
`@bymax-one/nest-config` gives a NestJS application a single, typed, validated entry point
|
|
37
|
+
for environment configuration. It validates `process.env` exactly once at bootstrap against a
|
|
38
|
+
[Zod](https://zod.dev) v4 schema, fails fast with one aggregated report, and exposes the
|
|
39
|
+
result as a frozen object reached through typed dot paths.
|
|
40
|
+
|
|
41
|
+
The library has **zero direct dependencies** — `zod`, `reflect-metadata` and `@nestjs/*`
|
|
42
|
+
arrive as peer dependencies, so you control exact versions and the supply-chain surface stays
|
|
43
|
+
minimal.
|
|
44
|
+
|
|
45
|
+
### Why nest-config?
|
|
46
|
+
|
|
47
|
+
- **A misconfigured process does not boot.** Validation runs before the application context
|
|
48
|
+
finishes building, so a service that is missing a secret never accepts the first request
|
|
49
|
+
that would need it.
|
|
50
|
+
- **One round trip to fix the environment.** Every violation is collected and reported
|
|
51
|
+
together, not the first one that fails.
|
|
52
|
+
- **Errors are value-free by contract.** Configuration is where the secrets are, and the
|
|
53
|
+
report is the artifact that gets logged and pasted into issues — so messages carry the
|
|
54
|
+
expected constraint and the schema's own enum options, never the received value.
|
|
55
|
+
- **The schema is the type.** `get('database.url')` is `string` and `get('server.port')` is
|
|
56
|
+
`number` because the schema says so; a typo in a path is a build error, not a runtime
|
|
57
|
+
`undefined`.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 🔥 Features
|
|
62
|
+
|
|
63
|
+
### ✅ Validation
|
|
64
|
+
|
|
65
|
+
- ✅ **Validate once, at bootstrap** — parsed and validated a single time, before the
|
|
66
|
+
application serves traffic; there is no lazy path that could discover a problem later
|
|
67
|
+
- ✅ **Fail fast, fail complete** — every violation reported together in one aggregated
|
|
68
|
+
error, so an operator fixes the environment in one round trip
|
|
69
|
+
- ✅ **Never echo values** — messages carry variable names, paths and constraint
|
|
70
|
+
descriptions only, so a secret cannot reach a log or a crash report through this library
|
|
71
|
+
- ✅ **Zod v4 schemas** — the full type language, including coercion, refinements and
|
|
72
|
+
defaults, with `defineEnv` shaping namespaces and leaves
|
|
73
|
+
|
|
74
|
+
### 🔒 The Validated Object
|
|
75
|
+
|
|
76
|
+
- ✅ **Immutable by construction** — deep-frozen before it enters the DI container;
|
|
77
|
+
configuration is a fact about the process, not a mutable bag
|
|
78
|
+
- ✅ **Typed dot-path access** — `get('database.url')` is `string`, `get('server.port')` is
|
|
79
|
+
`number`, with no casts and no `any` in the chain
|
|
80
|
+
- ✅ **Injectable environment** — the raw source defaults to `process.env` but is a plain
|
|
81
|
+
injectable record, so a test validates any input without touching the real environment
|
|
82
|
+
|
|
83
|
+
### 🧩 Developer Experience
|
|
84
|
+
|
|
85
|
+
- ✅ **Zero runtime dependencies** — `zod`, `reflect-metadata` and `@nestjs/*` all arrive as
|
|
86
|
+
peers, so you pin the versions
|
|
87
|
+
- ✅ **Testing entry point** — `./testing` builds the same validated object from an explicit
|
|
88
|
+
record, so a test never passes because a variable happened to be set on the machine
|
|
89
|
+
- ✅ **One class identity** — the module and its testing entry share a single `./internal`
|
|
90
|
+
bundle, so `ConfigService` is the same injection token from either, in ESM and CommonJS
|
|
91
|
+
- ✅ **Dual-format output** — ESM + CJS with declarations for each format, verified against
|
|
92
|
+
the packed tarball on every run
|
|
93
|
+
- ✅ **Typed end to end** — TypeScript `strict` with `exactOptionalPropertyTypes` and
|
|
94
|
+
`noUncheckedIndexedAccess`; zero `any`
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 📦 Subpath Exports
|
|
99
|
+
|
|
100
|
+
| Subpath | Contents |
|
|
101
|
+
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
102
|
+
| `.` | `BymaxConfigModule`, `ConfigService`, `defineEnv`, the DI tokens, `BymaxConfigValidationError` and every option type |
|
|
103
|
+
| `./testing` | `configTestingModule`, `createTestConfig` — a validated config for a test without booting the application's own |
|
|
104
|
+
| `./internal` | The shared runtime both entry points build on. **Not public API**: it is in the `exports` map because it has to resolve at runtime, not because it is meant to be imported |
|
|
105
|
+
|
|
106
|
+
`.` and `./testing` are separate bundles, and a module reached from both by a
|
|
107
|
+
relative path would be _copied_ into each — a copied class is a different injection
|
|
108
|
+
token and a different `instanceof` target. `./internal` is the one bundle they both
|
|
109
|
+
import by package specifier, which is what gives `ConfigService` a single identity in
|
|
110
|
+
ESM and CommonJS alike.
|
|
111
|
+
|
|
112
|
+
### Install
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pnpm add @bymax-one/nest-config @nestjs/common @nestjs/core reflect-metadata zod
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Peer requirements
|
|
119
|
+
|
|
120
|
+
| Package | Version |
|
|
121
|
+
| ------------------ | ---------- |
|
|
122
|
+
| Node.js | `>=24.0.0` |
|
|
123
|
+
| `@nestjs/common` | `^11.0.16` |
|
|
124
|
+
| `@nestjs/core` | `^11.1.18` |
|
|
125
|
+
| `reflect-metadata` | `^0.2.0` |
|
|
126
|
+
| `zod` | `^4.0.0` |
|
|
127
|
+
|
|
128
|
+
## 🚀 Quick Start
|
|
129
|
+
|
|
130
|
+
Define a schema with `defineEnv`. Top-level keys are namespaces, leaves are
|
|
131
|
+
environment-derived values; each leaf reads a `SCREAMING_SNAKE_CASE` variable
|
|
132
|
+
derived from its path (`database.url` reads `DATABASE_URL`).
|
|
133
|
+
|
|
134
|
+
<!-- Source: test/e2e/fixtures/env.schema.ts -->
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { z } from 'zod'
|
|
138
|
+
import { defineEnv } from '@bymax-one/nest-config'
|
|
139
|
+
|
|
140
|
+
export const envSchema = defineEnv({
|
|
141
|
+
server: z.object({
|
|
142
|
+
port: z.coerce.number().int().min(1).max(65535).default(3000),
|
|
143
|
+
env: z.enum(['development', 'test', 'production']).default('development')
|
|
144
|
+
}),
|
|
145
|
+
database: z.object({
|
|
146
|
+
url: z.url()
|
|
147
|
+
}),
|
|
148
|
+
redis: z.object({
|
|
149
|
+
url: z.url()
|
|
150
|
+
}),
|
|
151
|
+
log: z.object({
|
|
152
|
+
level: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
export type AppConfig = typeof envSchema.infer
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Register `BymaxConfigModule` once in the root module (it is global by
|
|
160
|
+
default):
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { Module } from '@nestjs/common'
|
|
164
|
+
import { BymaxConfigModule } from '@bymax-one/nest-config'
|
|
165
|
+
import { envSchema } from './config/env.schema'
|
|
166
|
+
|
|
167
|
+
@Module({
|
|
168
|
+
imports: [BymaxConfigModule.forRoot({ schema: envSchema })]
|
|
169
|
+
})
|
|
170
|
+
export class AppModule {}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Inject the typed `ConfigService` wherever configuration is needed, no casts:
|
|
174
|
+
|
|
175
|
+
<!-- Source: test/e2e/fixtures/feature.provider.ts -->
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import { Inject, Injectable } from '@nestjs/common'
|
|
179
|
+
import { ConfigService } from '@bymax-one/nest-config'
|
|
180
|
+
import type { AppConfig } from './config/env.schema'
|
|
181
|
+
|
|
182
|
+
@Injectable()
|
|
183
|
+
export class FeatureProvider {
|
|
184
|
+
public constructor(@Inject(ConfigService) private readonly config: ConfigService<AppConfig>) {}
|
|
185
|
+
|
|
186
|
+
public describeConnection(): string {
|
|
187
|
+
return `${this.config.get('database.url')}::${this.config.get('server.port')}`
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
A process started with an incomplete environment exits immediately with the
|
|
193
|
+
aggregated report, never one violation at a time:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
BymaxConfigValidationError: environment validation failed (3 issues)
|
|
197
|
+
|
|
198
|
+
DATABASE_URL missing required value (expected: url)
|
|
199
|
+
AUTH_JWT_SECRET too short (expected: string, minimum 32 characters)
|
|
200
|
+
SERVER_PORT invalid value (expected: integer between 1 and 65535)
|
|
201
|
+
|
|
202
|
+
Fix the variables above and restart the process.
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Raw values are never printed, not truncated, not masked, absent. This is a
|
|
206
|
+
hard guarantee of the package, verified by tests.
|
|
207
|
+
|
|
208
|
+
## 📖 API Reference
|
|
209
|
+
|
|
210
|
+
### `defineEnv(shape)`
|
|
211
|
+
|
|
212
|
+
A thin, typed factory over `z.object(...)` that establishes the two-level
|
|
213
|
+
namespace convention: top-level keys are namespaces, leaves are
|
|
214
|
+
environment-derived values. It composes the caller's schemas as-is, never
|
|
215
|
+
rewriting, cloning, or wrapping them, so coercion and defaults stay explicit
|
|
216
|
+
consumer choices. Leaves use `z.coerce.*` because the source arrives as
|
|
217
|
+
strings (the shape of `process.env`).
|
|
218
|
+
|
|
219
|
+
A leaf can override its derived variable name through schema metadata when a
|
|
220
|
+
legacy name must be preserved:
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
database: z.object({
|
|
224
|
+
// Reads DB_CONNECTION_STRING instead of the derived DATABASE_URL.
|
|
225
|
+
url: z.url().meta({ env: 'DB_CONNECTION_STRING' })
|
|
226
|
+
})
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### `BymaxConfigModule`
|
|
230
|
+
|
|
231
|
+
| Method | Signature |
|
|
232
|
+
| ----------------------- | ----------------------------------------------------------------- |
|
|
233
|
+
| `forRoot(options)` | `(options: BymaxConfigModuleOptions) => DynamicModule` |
|
|
234
|
+
| `forRootAsync(options)` | `(options: ConfigurableModuleAsyncOptions<...>) => DynamicModule` |
|
|
235
|
+
|
|
236
|
+
`BymaxConfigModuleOptions`:
|
|
237
|
+
|
|
238
|
+
| Field | Type | Required | Notes |
|
|
239
|
+
| ------------------- | ---------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
240
|
+
| `schema` | `EnvSchema` | yes | Produced by `defineEnv`. |
|
|
241
|
+
| `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env` in the provider factory. Injectable for tests. |
|
|
242
|
+
| `onValidationError` | `(issues: ReadonlyArray<ConfigIssue>) => void` | no | Observability hook invoked before the fail-fast throw. Cannot suppress the failure. |
|
|
243
|
+
| `strict` | `boolean` | no | When true, source variables matching a namespace prefix but no declared leaf raise `BYMAX_CONFIG_UNKNOWN_KEY`. Defaults to `false`. |
|
|
244
|
+
|
|
245
|
+
`forRootAsync` resolves the source through another provider, for example a
|
|
246
|
+
secrets-manager client composed with `process.env`:
|
|
247
|
+
|
|
248
|
+
<!-- Source: test/e2e/fixtures/secrets.provider.ts, secrets.module.ts, config-boot-async.e2e-spec.ts -->
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
BymaxConfigModule.forRootAsync({
|
|
252
|
+
imports: [SecretsModule],
|
|
253
|
+
useFactory: (secrets: SecretsProvider) => ({
|
|
254
|
+
schema: envSchema,
|
|
255
|
+
source: { ...process.env, ...secrets.asEnvRecord() }
|
|
256
|
+
}),
|
|
257
|
+
inject: [SecretsProvider]
|
|
258
|
+
})
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`isGlobal` defaults to `true` and can be set to `false` through the standard
|
|
262
|
+
extras when an application intentionally scopes configuration to a submodule.
|
|
263
|
+
|
|
264
|
+
### `ConfigService<TConfig>`
|
|
265
|
+
|
|
266
|
+
The typed, injectable accessor over the frozen configuration object.
|
|
267
|
+
|
|
268
|
+
| Method | Signature | Notes |
|
|
269
|
+
| ----------- | ------------------------------------------------------------- | ------------------------------------------------ |
|
|
270
|
+
| `get(path)` | `<P extends Path<TConfig>>(path: P) => PathValue<TConfig, P>` | Dot-path access, type inferred from the schema. |
|
|
271
|
+
| `getAll()` | `() => Readonly<TConfig>` | The deep-frozen root object. |
|
|
272
|
+
| `has(path)` | `(path: Path<TConfig>) => boolean` | True when the resolved value is not `undefined`. |
|
|
273
|
+
|
|
274
|
+
Because validation completed at bootstrap, `get` never throws for a declared
|
|
275
|
+
path: every leaf either passed validation, received its default, or the
|
|
276
|
+
process did not start.
|
|
277
|
+
|
|
278
|
+
### `BymaxConfigValidationError` and `ConfigIssue`
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
export class BymaxConfigValidationError extends Error {
|
|
282
|
+
public readonly code: ConfigValidationCode // 'BYMAX_CONFIG_VALIDATION'
|
|
283
|
+
public readonly issues: ReadonlyArray<ConfigIssue>
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface ConfigIssue {
|
|
287
|
+
readonly path: string // e.g. "database.url"
|
|
288
|
+
readonly variable: string // e.g. "DATABASE_URL"
|
|
289
|
+
readonly code: ConfigIssueCode
|
|
290
|
+
readonly message: string // value-free constraint description
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`ConfigErrorCode` is the frozen catalog backing `ConfigValidationCode` (the
|
|
295
|
+
error's own `code`) and `ConfigIssueCode` (each issue's `code`); see the
|
|
296
|
+
error catalog below for every value.
|
|
297
|
+
|
|
298
|
+
### Injection tokens
|
|
299
|
+
|
|
300
|
+
`BYMAX_CONFIG` and `BYMAX_CONFIG_OPTIONS` are module-local `Symbol` tokens,
|
|
301
|
+
exported for advanced factory-style wiring that needs the raw frozen config
|
|
302
|
+
or the resolved options directly instead of going through `ConfigService`:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { Inject, Injectable } from '@nestjs/common'
|
|
306
|
+
import { BYMAX_CONFIG } from '@bymax-one/nest-config'
|
|
307
|
+
import type { AppConfig } from './config/env.schema'
|
|
308
|
+
|
|
309
|
+
@Injectable()
|
|
310
|
+
export class RawConfigConsumer {
|
|
311
|
+
public constructor(@Inject(BYMAX_CONFIG) private readonly config: Readonly<AppConfig>) {}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Type utilities
|
|
316
|
+
|
|
317
|
+
`EnvSchema`, `EnvShape`, `EnvOutput`, `EnvLeaf`, and `EnvNamespace` type the
|
|
318
|
+
two-level schema shape produced by `defineEnv`; `Path<T>` and `PathValue<T, P>`
|
|
319
|
+
are the template-literal utilities behind `ConfigService.get`'s compile-time
|
|
320
|
+
dot-path inference, limited to the two-level namespace convention. All five
|
|
321
|
+
are exported for advanced generic code (custom decorators, typed wrappers)
|
|
322
|
+
that needs to reference the schema shape directly.
|
|
323
|
+
|
|
324
|
+
## 🚨 Error Catalog
|
|
325
|
+
|
|
326
|
+
| Code | Meaning |
|
|
327
|
+
| -------------------------- | ------------------------------------------------------ |
|
|
328
|
+
| `BYMAX_CONFIG_VALIDATION` | One or more schema violations (top-level error code). |
|
|
329
|
+
| `BYMAX_CONFIG_MISSING` | Required variable absent from the source (issue code). |
|
|
330
|
+
| `BYMAX_CONFIG_INVALID` | Present but failed its constraint (issue code). |
|
|
331
|
+
| `BYMAX_CONFIG_UNKNOWN_KEY` | Strict mode: source variable matches no declared leaf. |
|
|
332
|
+
|
|
333
|
+
## 🏗️ Architecture
|
|
334
|
+
|
|
335
|
+
```
|
|
336
|
+
process.env
|
|
337
|
+
(or the record you inject)
|
|
338
|
+
│
|
|
339
|
+
▼
|
|
340
|
+
┌───────────────────────────────────────┐
|
|
341
|
+
│ defineEnv │
|
|
342
|
+
│ namespaces at the top, Zod v4 leaves │
|
|
343
|
+
└───────────────────┬───────────────────┘
|
|
344
|
+
│
|
|
345
|
+
▼
|
|
346
|
+
┌───────────────────────────────────────┐
|
|
347
|
+
│ createValidatedConfig │
|
|
348
|
+
│ runs ONCE, at bootstrap, and │
|
|
349
|
+
│ collects EVERY failure first │
|
|
350
|
+
└─────────┬─────────────────┬───────────┘
|
|
351
|
+
│ │
|
|
352
|
+
all valid any invalid
|
|
353
|
+
│ │
|
|
354
|
+
▼ ▼
|
|
355
|
+
deep-frozen BymaxConfigValidationError
|
|
356
|
+
BYMAX_CONFIG one aggregated, value-free report
|
|
357
|
+
│ │
|
|
358
|
+
▼ ▼
|
|
359
|
+
ConfigService the process does not start
|
|
360
|
+
get('db.host') typed │
|
|
361
|
+
from the schema itself no request is ever served
|
|
362
|
+
│ with a missing secret
|
|
363
|
+
▼
|
|
364
|
+
┌─────────────────────────────┐
|
|
365
|
+
│ ./testing │
|
|
366
|
+
│ same pipeline, explicit │
|
|
367
|
+
│ source — a test cannot │
|
|
368
|
+
│ pass because a variable │
|
|
369
|
+
│ happened to be set │
|
|
370
|
+
└─────────────────────────────┘
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Validation happens at bootstrap and nowhere else. There is no lazy read, no cache to
|
|
374
|
+
invalidate, and no path where a request-time lookup can discover that an environment
|
|
375
|
+
variable was missing — by then the process would already have refused to start.
|
|
376
|
+
|
|
377
|
+
`./testing` builds the same validated object from an explicit record, so a test that
|
|
378
|
+
needs configuration does not need the application's real environment, and cannot
|
|
379
|
+
accidentally pass because a variable happened to be set on the machine.
|
|
380
|
+
|
|
381
|
+
### Design Principles
|
|
382
|
+
|
|
383
|
+
| Principle | Description |
|
|
384
|
+
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
385
|
+
| 💥 **Refuse to start** | A misconfigured process is stopped before it serves a request, rather than discovering the gap at the first call that needs the value |
|
|
386
|
+
| 🤐 **Value-free by contract** | `errors.ts`, `env-validator.ts` and `report-formatter.ts` each state it for themselves: the message carries the expected constraint and the schema's own options, never the received value |
|
|
387
|
+
| 📋 **Report everything at once** | Validation collects every failure before throwing, so fixing an environment is one cycle instead of one per variable |
|
|
388
|
+
| 🧊 **Frozen after validation** | Nothing in the running process rewrites configuration under a component that already read it |
|
|
389
|
+
| 🔤 **The schema is the type** | Dot paths are checked against the schema at compile time, so a typo is a build error rather than a runtime `undefined` |
|
|
390
|
+
| ⚙️ **Configuration over convention** | Everything goes through `forRoot`/`forRootAsync`. No file discovery, no magic paths, no hidden precedence rules |
|
|
391
|
+
| 🧩 **One class identity** | The module and `./testing` import the shared runtime by package specifier, so `ConfigService` is one injection token in ESM and CommonJS alike |
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## 🔐 Security Model
|
|
396
|
+
|
|
397
|
+
Configuration is where the secrets are, and a validation report is the one artifact
|
|
398
|
+
guaranteed to be printed, logged, and pasted into an issue. That shapes the whole contract.
|
|
399
|
+
|
|
400
|
+
### Errors are value-free by contract
|
|
401
|
+
|
|
402
|
+
Every message states the _expected_ constraint — the type, the allowed enum options taken
|
|
403
|
+
from the schema, the bound — and never the received value. `env-validator` and
|
|
404
|
+
`report-formatter` each say so in their own contracts, and property-based tests hold them
|
|
405
|
+
to it.
|
|
406
|
+
|
|
407
|
+
### Failing fast is the security property
|
|
408
|
+
|
|
409
|
+
A service that starts with a missing `JWT_SECRET` and discovers it at the first login is a
|
|
410
|
+
service that has already accepted traffic it cannot authenticate. The whole schema is
|
|
411
|
+
validated before the application context finishes building, so a misconfigured deployment
|
|
412
|
+
does not serve one request.
|
|
413
|
+
|
|
414
|
+
### The validated object is frozen
|
|
415
|
+
|
|
416
|
+
It is deep-frozen before it enters the container, so nothing in the running process
|
|
417
|
+
rewrites configuration under a component that already read it.
|
|
418
|
+
|
|
419
|
+
### Nothing is read from anywhere but the source you name
|
|
420
|
+
|
|
421
|
+
`process.env` by default, or the injectable record you provide. No file, no network, no
|
|
422
|
+
remote provider — there is no path by which configuration arrives from somewhere you did
|
|
423
|
+
not choose.
|
|
424
|
+
|
|
425
|
+
### Your own messages are yours
|
|
426
|
+
|
|
427
|
+
Value-free applies to this library's reports. If a `.refine` or a custom message in your
|
|
428
|
+
schema includes a value, that message is printed as written.
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 🛡️ Security Table
|
|
433
|
+
|
|
434
|
+
| Layer | Implementation |
|
|
435
|
+
| -------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
436
|
+
| Error messages | Expected constraint only — type, schema-declared enum options, bounds; never the received value |
|
|
437
|
+
| Failure mode | Refuse to start; the whole schema is validated before the context finishes building |
|
|
438
|
+
| Aggregation | Every failure reported at once, so a fix cycle is one restart rather than one per variable |
|
|
439
|
+
| Immutability | The validated result is frozen before it is provided |
|
|
440
|
+
| Sources | `process.env` only — no file, no network, no remote provider |
|
|
441
|
+
| Type surface | Dot paths checked against the schema at compile time; a typo is a build error, not a runtime `undefined` |
|
|
442
|
+
| Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
|
|
443
|
+
|
|
444
|
+
> [!IMPORTANT]
|
|
445
|
+
> **Value-free applies to this library's own reports.** If your schema's `.refine`
|
|
446
|
+
> or a custom message includes a value, that message is printed as written.
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## 🧱 Tech Stack
|
|
451
|
+
|
|
452
|
+
- **Runtime:** Node.js 24+
|
|
453
|
+
- **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `@Global()`, `Symbol()` tokens)
|
|
454
|
+
- **Validation:** Zod `^4` (peer) — the schema language, the coercion and the error source
|
|
455
|
+
- **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations,
|
|
456
|
+
and one shared `./internal` bundle so the module and its testing entry point hold
|
|
457
|
+
a single class identity
|
|
458
|
+
- **Tests:** Jest, including property-based tests over the report formatter + Stryker (mutation)
|
|
459
|
+
- **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## 🧪 Testing & Quality
|
|
464
|
+
|
|
465
|
+
Configuration decides whether a process may start at all, so the suite is held to a bar
|
|
466
|
+
beyond "the tests pass".
|
|
467
|
+
|
|
468
|
+
- ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
|
|
469
|
+
- ✅ **95.72% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
|
|
470
|
+
`break: 95`; the eleven survivors are provable equivalents, documented in the
|
|
471
|
+
[report](./docs/mutation_testing_results.md)
|
|
472
|
+
- ✅ **The value-free guarantee is tested, not assumed** — a sentinel secret is placed in a
|
|
473
|
+
source and the assertion is that it appears nowhere in the rendered report or in the thrown
|
|
474
|
+
error, because a guarantee about what is _absent_ is the one thing an ordinary assertion
|
|
475
|
+
about output does not cover
|
|
476
|
+
- ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
|
|
477
|
+
system does, `check:runtime` loads every subpath from the packed tarball in ESM and
|
|
478
|
+
CommonJS, and `check:published` compiles this README's snippets against `dist/`
|
|
479
|
+
- ✅ **Zero suppressions** — no coverage or mutation directives in the production source
|
|
480
|
+
|
|
481
|
+
```bash
|
|
482
|
+
pnpm test # unit suite
|
|
483
|
+
pnpm test:cov # unit suite with the 100% coverage gate
|
|
484
|
+
pnpm mutation # Stryker mutation testing (break: 95)
|
|
485
|
+
pnpm typecheck # tsc strict check
|
|
486
|
+
pnpm lint # ESLint
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### Testing helpers
|
|
490
|
+
|
|
491
|
+
`@bymax-one/nest-config/testing` removes every excuse for touching
|
|
492
|
+
`process.env` in tests. `createTestConfig` synthesizes a complete valid
|
|
493
|
+
source from the schema (defaults where declared, deterministic placeholder
|
|
494
|
+
values elsewhere), applies selective overrides, then runs the exact
|
|
495
|
+
production pipeline (validate, freeze), so a test exercises the same code
|
|
496
|
+
path a running application does.
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
import { createTestConfig } from '@bymax-one/nest-config/testing'
|
|
500
|
+
import { envSchema } from './config/env.schema'
|
|
501
|
+
|
|
502
|
+
const config = createTestConfig(envSchema, {
|
|
503
|
+
database: { url: 'postgres://localhost:5432/test' }
|
|
504
|
+
})
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
`configTestingModule` registers the production `BymaxConfigModule` with a
|
|
508
|
+
synthesized source, ready to drop into a Nest `TestingModule` graph:
|
|
509
|
+
|
|
510
|
+
```typescript
|
|
511
|
+
import { Test } from '@nestjs/testing'
|
|
512
|
+
import { configTestingModule } from '@bymax-one/nest-config/testing'
|
|
513
|
+
import { envSchema } from './config/env.schema'
|
|
514
|
+
import { FeatureProvider } from './feature.provider'
|
|
515
|
+
|
|
516
|
+
const moduleRef = await Test.createTestingModule({
|
|
517
|
+
imports: [configTestingModule(envSchema, { server: { port: 0 } })],
|
|
518
|
+
providers: [FeatureProvider]
|
|
519
|
+
}).compile()
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
Placeholder synthesis never fabricates values that could accidentally pass a
|
|
523
|
+
secret-strength constraint; length and format constraints are honored. The
|
|
524
|
+
subpath has no Jest dependency and works with any runner, though the family
|
|
525
|
+
standard is Jest.
|
|
526
|
+
|
|
527
|
+
## 🚫 Known Limitations
|
|
528
|
+
|
|
529
|
+
1. **Two-level namespace convention.** The path-inference utilities
|
|
530
|
+
(`Path<T>`, `PathValue<T, P>`) target `namespace.leaf` schemas. Deeper
|
|
531
|
+
nesting validates correctly but is not covered by `get()` path inference;
|
|
532
|
+
`getAll()` remains fully typed for arbitrary depth.
|
|
533
|
+
2. **String-shaped sources only.** The source record is
|
|
534
|
+
`Record<string, string | undefined>` by design, the shape of a process
|
|
535
|
+
environment. Structured sources must be serialized into that shape before
|
|
536
|
+
validation.
|
|
537
|
+
3. **No `.env` file loading.** Process environment population is the
|
|
538
|
+
platform's job: Node's native `--env-file`, container orchestrators, or CI
|
|
539
|
+
secrets. Bundling a loader would add a dependency and a second
|
|
540
|
+
source-of-truth.
|
|
541
|
+
4. **No multi-source precedence.** One source record per module
|
|
542
|
+
registration. Precedence between layers (defaults, env, secrets) is the
|
|
543
|
+
caller's composition (`{ ...a, ...b }`), kept explicit on purpose.
|
|
544
|
+
|
|
545
|
+
## 🤝 Contributing
|
|
546
|
+
|
|
547
|
+
Pull requests are welcome. Please open an issue first for significant changes.
|
|
548
|
+
|
|
549
|
+
- Read [`docs/technical_specification.md`](./docs/technical_specification.md) for architecture decisions.
|
|
550
|
+
- Run the full gate listed in [`CONTRIBUTING.md`](./CONTRIBUTING.md) before opening a PR.
|
|
551
|
+
- Conventional Commits are enforced by `commitlint.config.cjs`.
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
## 🔒 Security Policy
|
|
556
|
+
|
|
557
|
+
If you discover a security vulnerability, please **do not** open a public
|
|
558
|
+
issue. Instead, email us at **support@bymax.one** with `[security]
|
|
559
|
+
@bymax-one/nest-config` in the subject. We take security seriously and will
|
|
560
|
+
respond promptly. See [`SECURITY.md`](./SECURITY.md) for the full policy.
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## 📄 License
|
|
565
|
+
|
|
566
|
+
[MIT](./LICENSE) © [Bymax One](https://github.com/bymaxone)
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
<p align="center">
|
|
571
|
+
<sub>Built with ❤️ by <a href="https://github.com/bymaxone">Bymax One</a></sub>
|
|
572
|
+
</p>
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var internal = require('@bymax-one/nest-config/internal');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Object.defineProperty(exports, "BYMAX_CONFIG", {
|
|
8
|
+
enumerable: true,
|
|
9
|
+
get: function () { return internal.BYMAX_CONFIG; }
|
|
10
|
+
});
|
|
11
|
+
Object.defineProperty(exports, "BYMAX_CONFIG_OPTIONS", {
|
|
12
|
+
enumerable: true,
|
|
13
|
+
get: function () { return internal.BYMAX_CONFIG_OPTIONS; }
|
|
14
|
+
});
|
|
15
|
+
Object.defineProperty(exports, "BymaxConfigModule", {
|
|
16
|
+
enumerable: true,
|
|
17
|
+
get: function () { return internal.BymaxConfigModule; }
|
|
18
|
+
});
|
|
19
|
+
Object.defineProperty(exports, "BymaxConfigValidationError", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
get: function () { return internal.BymaxConfigValidationError; }
|
|
22
|
+
});
|
|
23
|
+
Object.defineProperty(exports, "ConfigErrorCode", {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
get: function () { return internal.ConfigErrorCode; }
|
|
26
|
+
});
|
|
27
|
+
Object.defineProperty(exports, "ConfigService", {
|
|
28
|
+
enumerable: true,
|
|
29
|
+
get: function () { return internal.ConfigService; }
|
|
30
|
+
});
|
|
31
|
+
Object.defineProperty(exports, "defineEnv", {
|
|
32
|
+
enumerable: true,
|
|
33
|
+
get: function () { return internal.defineEnv; }
|
|
34
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, ConfigIssue, ConfigIssueCode, ConfigService, ConfigValidationCode, EnvLeaf, EnvNamespace, EnvOutput, EnvSchema, EnvShape, Path, PathValue, defineEnv } from '@bymax-one/nest-config/internal';
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, ConfigIssue, ConfigIssueCode, ConfigService, ConfigValidationCode, EnvLeaf, EnvNamespace, EnvOutput, EnvSchema, EnvShape, Path, PathValue, defineEnv } from '@bymax-one/nest-config/internal';
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, defineEnv } from '@bymax-one/nest-config/internal';
|