@bymax-one/nest-core 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,48 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.5.2] - 2026-08-15
15
+
16
+ The production guard read `NODE_ENV` and nothing else, and treated an unset
17
+ variable as production. An application that validates its own `APP_ENV` and
18
+ never sets `NODE_ENV` was therefore classified as production on evidence it
19
+ never gave — the OpenAPI document was refused in a development deployment, with
20
+ no way to answer back. Two independent consumers reported the same split.
21
+
22
+ **Apply to a derived backend:** nothing to change. A deployment that sets
23
+ `NODE_ENV` behaves exactly as before. If yours validates its own variable
24
+ instead, pass it as `environment` and the document is served where that variable
25
+ says `development` or `test`.
26
+
27
+ ### Added
28
+
29
+ - **`environment`, for applications that validate their own environment
30
+ variable.** The production guard read `NODE_ENV` and nothing else, and treated
31
+ an unset variable as production. An application that parses an `APP_ENV`
32
+ through its config schema and never sets `NODE_ENV` was therefore classified
33
+ as production on evidence it never gave — the OpenAPI document was refused in
34
+ a development deployment, with no way to answer back. Two independent
35
+ consumers reported the same split between the library's view of the
36
+ environment and their own validated one.
37
+
38
+ A top-level `environment` option is now consulted **where the process declares
39
+ nothing**: `NODE_ENV` unset, or set to whitespace. `NODE_ENV` wins whenever it
40
+ says anything at all, so no configured value can make a runtime that named
41
+ itself production serve the document — asserted in both guards rather than in
42
+ one. The declaration enters the same fail-closed classification, so an
43
+ unrecognized name is production like any other: this is a second source for
44
+ the value, never a second set of rules.
45
+
46
+ The narrowing is stated rather than buried. Both guards previously classified
47
+ from the process alone; now, in the single case where the process says
48
+ nothing, the snapshot a consumer bound decides the answer, because there is
49
+ nothing else to decide it with. Replacing a guess with a declaration is not an
50
+ override, but it is a real change to what the second guard depends on.
51
+
52
+ **Apply to a derived backend:** nothing to change. The option is optional and
53
+ every existing classification is unchanged — a deployment that sets `NODE_ENV`
54
+ behaves exactly as before.
55
+
14
56
  ## [1.5.1] - 2026-08-15
15
57
 
16
58
  Documentation only; no source change. The 1.5.0 warning's known-limit note told
@@ -736,4 +778,5 @@ have regressed from. They are kept because the reasoning is worth having.
736
778
  [1.4.0]: https://github.com/bymaxone/nest-core/compare/v1.3.2...v1.4.0
737
779
  [1.5.0]: https://github.com/bymaxone/nest-core/compare/v1.4.0...v1.5.0
738
780
  [1.5.1]: https://github.com/bymaxone/nest-core/compare/v1.5.0...v1.5.1
739
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.5.1...HEAD
781
+ [1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
782
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.5.2...HEAD
package/README.md CHANGED
@@ -202,6 +202,20 @@ BymaxCoreModule.forRoot({ isGlobal: false })
202
202
  Every block is optional; an omitted block, or an omitted field within it,
203
203
  falls back to the documented default. Pass only what you want to change.
204
204
 
205
+ ### `environment`
206
+
207
+ The one top-level option rather than a block, because it describes the
208
+ deployment rather than a feature.
209
+
210
+ | Option | Type | Default | Description |
211
+ | ------------- | -------- | ------- | ------------------------------------------------------------------------------------- |
212
+ | `environment` | `string` | unset | The environment this deployment runs in, read only where `NODE_ENV` declares nothing. |
213
+
214
+ Set it when your application validates its own environment variable and does not
215
+ also set `NODE_ENV`. `NODE_ENV` wins whenever it says anything, so this can never
216
+ serve the OpenAPI document in a runtime that named itself production. Full rules
217
+ and the classification table: [Production is a closed door](#production-is-a-closed-door).
218
+
205
219
  ### `envelope`
206
220
 
207
221
  | Option | Type | Default | Description |
@@ -959,16 +973,61 @@ can emit it once and never branch:
959
973
  ### Production is a closed door
960
974
 
961
975
  `NODE_ENV` decides, and the decision is fail-closed: only `development` and
962
- `test` are non-production. Any other value including an unset variable
963
- is production, and in production the document is never built and never mounted,
964
- whatever the configuration says. The guard runs twice, independently: the option
965
- resolver forces the feature off, and the bootstrap helper refuses again without
966
- trusting that resolution. There is no override.
976
+ `test` are non-production. Any other value is production, and in production the
977
+ document is never built and never mounted, whatever the configuration says. The
978
+ guard runs twice, independently: the option resolver forces the feature off, and
979
+ the bootstrap helper classifies the runtime again without trusting that
980
+ resolution.
981
+
982
+ **`NODE_ENV` cannot be overridden.** With it set to anything, no option serves
983
+ the document in a runtime it named production.
967
984
 
968
985
  Enabling it in production is not an error, it is a no-op with a warning naming
969
986
  the option that was ignored, so a single configuration can be shared across
970
987
  environments.
971
988
 
989
+ #### When your application validates its own environment variable
990
+
991
+ Plenty of applications parse an `APP_ENV` through a config schema and never set
992
+ `NODE_ENV` at all. Those deployments used to be classified as production —
993
+ absence was the only evidence available — so the document was refused in an
994
+ environment that never asked for the refusal, with no way to answer back.
995
+
996
+ Declare the environment and it is used **where the process declares nothing**:
997
+
998
+ ```typescript
999
+ BymaxCoreModule.forRootAsync({
1000
+ inject: [ConfigService],
1001
+ useFactory: (config: ConfigService) => ({
1002
+ // Your validated value, not a second copy of NODE_ENV.
1003
+ environment: config.get('APP_ENV'),
1004
+ openapi: { enabled: true }
1005
+ })
1006
+ })
1007
+ ```
1008
+
1009
+ | `NODE_ENV` | `environment` | Classified as |
1010
+ | --------------- | ------------- | -------------- |
1011
+ | `production` | `development` | **production** |
1012
+ | `development` | (anything) | development |
1013
+ | unset, or blank | `development` | development |
1014
+ | unset, or blank | `staging` | **production** |
1015
+ | unset, or blank | unset | **production** |
1016
+
1017
+ Two properties are worth reading off that table. A declaration never overrules a
1018
+ process that named its own environment — the first row is the one that matters,
1019
+ and it is asserted in both guards rather than in one. And the declaration enters
1020
+ the same fail-closed classification, so an unrecognized name is production like
1021
+ any other; this is a second **source** for the value, never a second set of
1022
+ rules.
1023
+
1024
+ The narrowing is deliberate and worth naming rather than burying: in the one
1025
+ case where the process declares nothing, the configuration a consumer bound does
1026
+ decide the answer, because there is nothing else to decide it with. Replacing a
1027
+ guess with a declaration is not the same as allowing an override — but it is a
1028
+ real change to what the second guard depends on, and you should know it before
1029
+ relying on either.
1030
+
972
1031
  ### Testing the enabled path under Jest
973
1032
 
974
1033
  `applyBymaxOpenApi` loads `@nestjs/swagger` through a dynamic `import()` — that
@@ -1359,9 +1418,11 @@ guard you would apply to any internal endpoint, or keep it off the public listen
1359
1418
  A published document is a map of every route, parameter and error shape an application has —
1360
1419
  useful to a developer, and just as useful to anyone probing the service. So unlike the metrics
1361
1420
  endpoint, it is not left to a guard: it is refused outright whenever the runtime is not
1362
- positively `development` or `test`, in two independent layers, with no option to override.
1363
- An unset `NODE_ENV` counts as production, because the deployment nobody configured is the one
1364
- most likely to be exposed.
1421
+ positively `development` or `test`, in two independent layers. **`NODE_ENV` cannot be
1422
+ overridden** with it set to anything, no option serves the document in a runtime it named
1423
+ production. A runtime that declares nothing is classified from the application's own
1424
+ [`environment`](#environment) when it supplied one, and counts as production otherwise, because
1425
+ the deployment nobody configured is the one most likely to be exposed.
1365
1426
 
1366
1427
  ---
1367
1428
 
package/dist/index.cjs CHANGED
@@ -30,6 +30,13 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
30
30
  }
31
31
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
32
32
  }
33
+ function runtimeEnvironmentName(declared) {
34
+ const fromProcess = process.env["NODE_ENV"];
35
+ if (fromProcess !== void 0 && fromProcess.trim() !== "") {
36
+ return fromProcess;
37
+ }
38
+ return declared;
39
+ }
33
40
 
34
41
  // src/core.options.ts
35
42
  var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
@@ -92,9 +99,9 @@ function cloneServers(raw) {
92
99
  (server) => server.description === void 0 ? { url: server.url } : { url: server.url, description: server.description }
93
100
  );
94
101
  }
95
- function resolveOpenApi(raw) {
102
+ function resolveOpenApi(raw, declaredEnvironment) {
96
103
  const requested = raw?.enabled ?? false;
97
- const production = isProductionRuntime();
104
+ const production = isProductionRuntime(runtimeEnvironmentName(declaredEnvironment));
98
105
  return {
99
106
  enabled: requested && !production,
100
107
  suppressedInProduction: requested && production,
@@ -131,8 +138,12 @@ function normalizeCoreOptions(raw) {
131
138
  timing: resolveTiming(raw?.timing),
132
139
  health: resolveHealth(raw?.health),
133
140
  metrics: resolveMetrics(raw?.metrics),
134
- openapi: resolveOpenApi(raw?.openapi),
135
- telemetry: resolveTelemetry(raw?.telemetry)
141
+ openapi: resolveOpenApi(raw?.openapi, raw?.environment),
142
+ telemetry: resolveTelemetry(raw?.telemetry),
143
+ // Spread rather than assigned so an application that declared nothing has
144
+ // no `environment` member at all, matching every other optional member on
145
+ // this snapshot under `exactOptionalPropertyTypes`.
146
+ ...raw?.environment === void 0 ? {} : { environment: raw.environment }
136
147
  });
137
148
  }
138
149
  normalizeCoreOptions();
package/dist/index.d.cts CHANGED
@@ -273,6 +273,28 @@ interface BymaxCoreModuleOptions {
273
273
  openapi?: OpenApiOptions;
274
274
  /** Trace correlation. Default: disabled. */
275
275
  telemetry?: TelemetryOptions;
276
+ /**
277
+ * The environment this deployment is running in, for the features that must
278
+ * never exist outside development — today, the OpenAPI document and its UI.
279
+ *
280
+ * **`NODE_ENV` always wins.** This is consulted only when the process
281
+ * declares nothing: `NODE_ENV` unset, or set to whitespace. It cannot make a
282
+ * runtime that identified itself as production serve the document, and no
283
+ * value here overrides one there.
284
+ *
285
+ * Set it when your application validates its own environment variable — an
286
+ * `APP_ENV` your config schema parses — and does not also set `NODE_ENV`.
287
+ * Without it, that deployment is classified as production because absence was
288
+ * the only evidence available, and the document is refused in an environment
289
+ * that never asked for the refusal.
290
+ *
291
+ * Recognized non-production values are `development` and `test`, compared
292
+ * case-insensitively and ignoring surrounding whitespace. Anything else,
293
+ * including an unrecognized name, is production.
294
+ *
295
+ * @example 'development'
296
+ */
297
+ environment?: string;
276
298
  }
277
299
  /** Fully-resolved envelope options. */
278
300
  interface ResolvedEnvelopeOptions {
@@ -334,9 +356,9 @@ interface ResolvedOpenApiOptions {
334
356
  }
335
357
  /**
336
358
  * The effective, defaults-applied configuration exposed under
337
- * `BYMAX_CORE_OPTIONS`. Fields with a documented default are always present;
338
- * the only optional field is `timing.slowRequestThresholdMs`, which has no
339
- * default and is absent unless the consumer sets it.
359
+ * `BYMAX_CORE_OPTIONS`. Fields with a documented default are always present.
360
+ * Two fields have no default and are absent unless the consumer supplies them:
361
+ * `timing.slowRequestThresholdMs`, and `environment`.
340
362
  */
341
363
  interface ResolvedCoreOptions {
342
364
  envelope: ResolvedEnvelopeOptions;
@@ -345,6 +367,12 @@ interface ResolvedCoreOptions {
345
367
  metrics: ResolvedMetricsOptions;
346
368
  openapi: ResolvedOpenApiOptions;
347
369
  telemetry: ResolvedTelemetryOptions;
370
+ /**
371
+ * The environment the application declared, carried through so the bootstrap
372
+ * helper classifies the runtime from the same two inputs the resolver did.
373
+ * Absent when the application declared none.
374
+ */
375
+ environment?: string;
348
376
  }
349
377
 
350
378
  /** Non-option extras accepted by `forRoot` / `forRootAsync`. */
package/dist/index.d.ts CHANGED
@@ -273,6 +273,28 @@ interface BymaxCoreModuleOptions {
273
273
  openapi?: OpenApiOptions;
274
274
  /** Trace correlation. Default: disabled. */
275
275
  telemetry?: TelemetryOptions;
276
+ /**
277
+ * The environment this deployment is running in, for the features that must
278
+ * never exist outside development — today, the OpenAPI document and its UI.
279
+ *
280
+ * **`NODE_ENV` always wins.** This is consulted only when the process
281
+ * declares nothing: `NODE_ENV` unset, or set to whitespace. It cannot make a
282
+ * runtime that identified itself as production serve the document, and no
283
+ * value here overrides one there.
284
+ *
285
+ * Set it when your application validates its own environment variable — an
286
+ * `APP_ENV` your config schema parses — and does not also set `NODE_ENV`.
287
+ * Without it, that deployment is classified as production because absence was
288
+ * the only evidence available, and the document is refused in an environment
289
+ * that never asked for the refusal.
290
+ *
291
+ * Recognized non-production values are `development` and `test`, compared
292
+ * case-insensitively and ignoring surrounding whitespace. Anything else,
293
+ * including an unrecognized name, is production.
294
+ *
295
+ * @example 'development'
296
+ */
297
+ environment?: string;
276
298
  }
277
299
  /** Fully-resolved envelope options. */
278
300
  interface ResolvedEnvelopeOptions {
@@ -334,9 +356,9 @@ interface ResolvedOpenApiOptions {
334
356
  }
335
357
  /**
336
358
  * The effective, defaults-applied configuration exposed under
337
- * `BYMAX_CORE_OPTIONS`. Fields with a documented default are always present;
338
- * the only optional field is `timing.slowRequestThresholdMs`, which has no
339
- * default and is absent unless the consumer sets it.
359
+ * `BYMAX_CORE_OPTIONS`. Fields with a documented default are always present.
360
+ * Two fields have no default and are absent unless the consumer supplies them:
361
+ * `timing.slowRequestThresholdMs`, and `environment`.
340
362
  */
341
363
  interface ResolvedCoreOptions {
342
364
  envelope: ResolvedEnvelopeOptions;
@@ -345,6 +367,12 @@ interface ResolvedCoreOptions {
345
367
  metrics: ResolvedMetricsOptions;
346
368
  openapi: ResolvedOpenApiOptions;
347
369
  telemetry: ResolvedTelemetryOptions;
370
+ /**
371
+ * The environment the application declared, carried through so the bootstrap
372
+ * helper classifies the runtime from the same two inputs the resolver did.
373
+ * Absent when the application declared none.
374
+ */
375
+ environment?: string;
348
376
  }
349
377
 
350
378
  /** Non-option extras accepted by `forRoot` / `forRootAsync`. */
package/dist/index.mjs CHANGED
@@ -28,6 +28,13 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
28
28
  }
29
29
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
30
30
  }
31
+ function runtimeEnvironmentName(declared) {
32
+ const fromProcess = process.env["NODE_ENV"];
33
+ if (fromProcess !== void 0 && fromProcess.trim() !== "") {
34
+ return fromProcess;
35
+ }
36
+ return declared;
37
+ }
31
38
 
32
39
  // src/core.options.ts
33
40
  var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
@@ -90,9 +97,9 @@ function cloneServers(raw) {
90
97
  (server) => server.description === void 0 ? { url: server.url } : { url: server.url, description: server.description }
91
98
  );
92
99
  }
93
- function resolveOpenApi(raw) {
100
+ function resolveOpenApi(raw, declaredEnvironment) {
94
101
  const requested = raw?.enabled ?? false;
95
- const production = isProductionRuntime();
102
+ const production = isProductionRuntime(runtimeEnvironmentName(declaredEnvironment));
96
103
  return {
97
104
  enabled: requested && !production,
98
105
  suppressedInProduction: requested && production,
@@ -129,8 +136,12 @@ function normalizeCoreOptions(raw) {
129
136
  timing: resolveTiming(raw?.timing),
130
137
  health: resolveHealth(raw?.health),
131
138
  metrics: resolveMetrics(raw?.metrics),
132
- openapi: resolveOpenApi(raw?.openapi),
133
- telemetry: resolveTelemetry(raw?.telemetry)
139
+ openapi: resolveOpenApi(raw?.openapi, raw?.environment),
140
+ telemetry: resolveTelemetry(raw?.telemetry),
141
+ // Spread rather than assigned so an application that declared nothing has
142
+ // no `environment` member at all, matching every other optional member on
143
+ // this snapshot under `exactOptionalPropertyTypes`.
144
+ ...raw?.environment === void 0 ? {} : { environment: raw.environment }
134
145
  });
135
146
  }
136
147
  normalizeCoreOptions();
@@ -16,6 +16,13 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
16
16
  }
17
17
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
18
18
  }
19
+ function runtimeEnvironmentName(declared) {
20
+ const fromProcess = process.env["NODE_ENV"];
21
+ if (fromProcess !== void 0 && fromProcess.trim() !== "") {
22
+ return fromProcess;
23
+ }
24
+ return declared;
25
+ }
19
26
 
20
27
  // src/discovery.ts
21
28
  function labelFor(className, token) {
@@ -653,7 +660,7 @@ async function applyBymaxOpenApi(app) {
653
660
  const logger = new common.Logger("BymaxCoreModule");
654
661
  const resolved = resolveCoreOptions(app);
655
662
  const options = resolved.openapi;
656
- if (isProductionRuntime()) {
663
+ if (isProductionRuntime(runtimeEnvironmentName(resolved.environment))) {
657
664
  if (options.suppressedInProduction || options.enabled) {
658
665
  logger.warn(
659
666
  'openapi.enabled was requested but the OpenAPI document is never served in production. Set NODE_ENV to "development" or "test" to serve it.'
@@ -14,6 +14,13 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
14
14
  }
15
15
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
16
16
  }
17
+ function runtimeEnvironmentName(declared) {
18
+ const fromProcess = process.env["NODE_ENV"];
19
+ if (fromProcess !== void 0 && fromProcess.trim() !== "") {
20
+ return fromProcess;
21
+ }
22
+ return declared;
23
+ }
17
24
 
18
25
  // src/discovery.ts
19
26
  function labelFor(className, token) {
@@ -651,7 +658,7 @@ async function applyBymaxOpenApi(app) {
651
658
  const logger = new Logger("BymaxCoreModule");
652
659
  const resolved = resolveCoreOptions(app);
653
660
  const options = resolved.openapi;
654
- if (isProductionRuntime()) {
661
+ if (isProductionRuntime(runtimeEnvironmentName(resolved.environment))) {
655
662
  if (options.suppressedInProduction || options.enabled) {
656
663
  logger.warn(
657
664
  'openapi.enabled was requested but the OpenAPI document is never served in production. Set NODE_ENV to "development" or "test" to serve it.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing interceptor, pagination helpers, health endpoints with indicator discovery, an optional Prometheus metrics endpoint with a contribution contract, OpenAPI documents in development, and OpenTelemetry trace correlation.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",