@bymax-one/nest-core 1.4.0 → 1.5.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 CHANGED
@@ -11,6 +11,46 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.5.0] - 2026-08-15
15
+
16
+ An OpenAPI document could stop requiring credentials without anything saying
17
+ so. Deleting a document-level `security` default — typically alongside the
18
+ per-operation entries a library has taken over describing — leaves every route
19
+ the backend itself owns with no requirement from any source, and the document
20
+ stays valid, no requirement dangles, and the runtime still answers `401`. The
21
+ only observable change is that a client generated from the document stops
22
+ sending credentials.
23
+
24
+ **Apply to a derived backend:** bump the dependency. No code change is needed.
25
+ If the boot log now names operations, they are the ones a generated client will
26
+ call without credentials — set `openapi.security`, or mark each public with an
27
+ explicit `[]` in `openapi.operationSecurity`.
28
+
29
+ ### Added
30
+
31
+ - **The document build warns when an operation ends up requiring no credential
32
+ at all.** Deleting a document-level `security` default — typically alongside
33
+ the per-operation entries a library has taken over describing — leaves every
34
+ route the backend itself owns with no requirement from any source. Nothing
35
+ catches it today: the document is valid, no requirement dangles so
36
+ `assertSchemesDeclared` is satisfied, the runtime still answers `401` so a
37
+ status-code probe finds nothing, and a consumer's document test stays green if
38
+ it asserts only the operations it enumerated. The only observable change is
39
+ that a client generated from the document sends no credentials.
40
+ `applyBymaxOpenApi` now emits one warning per build naming the affected
41
+ operations, capped at ten with a count of the rest.
42
+
43
+ It warns and never throws — an API that is public on purpose is legitimate —
44
+ and the trigger is narrow so the line stays worth reading: only when the
45
+ document declares no top-level `security`, **and** at least one other
46
+ operation does state a requirement, **and** the operation is not one of the
47
+ three this package registers. An explicit `[]` — from `operationSecurity`, a
48
+ decorator, or a library's fragment — states the intent and stops the report.
49
+ The known limit is documented rather than closed: a document with nothing
50
+ explicit anywhere is indistinguishable from an API that is public on purpose,
51
+ so removing _every_ requirement at once is not warned. Render the document
52
+ with and without your libraries and diff the operations you mount.
53
+
14
54
  ## [1.4.0] - 2026-08-13
15
55
 
16
56
  HTTP metrics were blind to every request that did not reach a handler. Nest runs
@@ -649,4 +689,5 @@ have regressed from. They are kept because the reasoning is worth having.
649
689
  [1.2.1]: https://github.com/bymaxone/nest-core/compare/v1.2.0...v1.2.1
650
690
  [1.2.0]: https://github.com/bymaxone/nest-core/compare/v1.1.1...v1.2.0
651
691
  [1.4.0]: https://github.com/bymaxone/nest-core/compare/v1.3.2...v1.4.0
652
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.4.0...HEAD
692
+ [1.5.0]: https://github.com/bymaxone/nest-core/compare/v1.4.0...v1.5.0
693
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.5.0...HEAD
package/README.md CHANGED
@@ -379,6 +379,56 @@ configuration went wrong. If you gate the document behind an environment flag,
379
379
  make sure at least one environment that runs your tests has it on, or these
380
380
  checks never fire.
381
381
 
382
+ #### When an operation ends up requiring nothing
383
+
384
+ The checks above catch a requirement that points at nothing. The opposite
385
+ mistake — an operation that ends up pointing at _no requirement at all_ — breaks
386
+ nothing, and that is what makes it dangerous. It is a valid document. No name
387
+ dangles. The runtime still answers `401`. A document test that asserts only the
388
+ operations you enumerated stays green. The only thing that changed is that a
389
+ client generated from the document now sends no credentials.
390
+
391
+ The edit that produces it is ordinary: a library starts describing its own
392
+ routes, its adoption note tells you to delete the entries you had written for
393
+ them, and the document-level `security` default is sitting in the same options
394
+ block and goes with them. Every route _you_ own then reads as public.
395
+
396
+ So the document build says so, once, naming the operations:
397
+
398
+ ```
399
+ [BymaxCoreModule] a client generated from the OpenAPI document will send no
400
+ credentials to 2 operation(s): GET /examples, POST /orders. They state no
401
+ security requirement, the document declares no default, and other operations in
402
+ it do state one — so this is more often a missing openapi.security default than
403
+ a public API. Set openapi.security, or state the intent per operation with an
404
+ explicit [] in openapi.operationSecurity.
405
+ ```
406
+
407
+ It is a **warning, not a failure**: an API that is public on purpose is a
408
+ legitimate configuration, and failing a boot over one would be worse than the
409
+ silence it replaces. The trigger is deliberately narrow, so the line stays worth
410
+ reading:
411
+
412
+ | Condition | Why |
413
+ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
414
+ | The document declares no top-level `security` | With a default present, a bare operation inherits it. An explicit `security: []` counts as an answer, not an omission. |
415
+ | At least one other operation _does_ state a requirement | Somebody described a posture on purpose, so the bare ones beside it are an omission rather than a public API. |
416
+ | The operation is not one of this package's own | A health probe carrying nothing is the correct description of a route an orchestrator polls without a credential. |
417
+
418
+ The escape hatch is to say what you mean, in the document's own vocabulary
419
+ rather than by silencing output: `operationSecurity: { 'GET /examples': [] }`
420
+ marks the operation public, and it stops being reported. A library can do the
421
+ same for its own routes by contributing `security: []` in its fragment.
422
+
423
+ **And there is one shape nothing can warn you about, so the diff is the only
424
+ check that covers it.** If you remove _every_ requirement at once — no library
425
+ describing anything, no decorator, no override, no document default — what
426
+ remains is indistinguishable from the document of an API that is public on
427
+ purpose. Both are a set of operations that ask for nothing. No tool can separate
428
+ the two without also shouting at every genuinely public API, which is how a
429
+ warning earns the right to be ignored. Render the document twice and compare; it
430
+ is the one step that does not depend on somebody having anticipated your case.
431
+
382
432
  ## 🔑 DI Tokens
383
433
 
384
434
  Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
@@ -131,6 +131,34 @@ function collectContributions(discovery, reflector, handlers) {
131
131
  var DEFAULT_HEALTH_PATH = "health";
132
132
  var DEFAULT_METRICS_PATH = "metrics";
133
133
 
134
+ // src/openapi/openapi.routes.ts
135
+ function trimSlashes(segment) {
136
+ return segment.split("/").filter((part) => part !== "").join("/");
137
+ }
138
+ function routePath(prefix, suffix) {
139
+ return prefix === "" ? `/${suffix}` : `/${prefix}/${suffix}`;
140
+ }
141
+ function healthRoutes(options) {
142
+ const bases = /* @__PURE__ */ new Set([trimSlashes(options.health.path), DEFAULT_HEALTH_PATH]);
143
+ return [...bases].flatMap((base) => [`${base}/live`, `${base}/ready`]);
144
+ }
145
+ function metricsRoutes(options) {
146
+ return [.../* @__PURE__ */ new Set([trimSlashes(options.metrics.path), DEFAULT_METRICS_PATH])];
147
+ }
148
+ function indexOwnRoutes(options, prefixes) {
149
+ const normalized = prefixes.map(trimSlashes);
150
+ const expand = (suffixes) => normalized.flatMap((prefix) => suffixes.map((suffix) => routePath(prefix, suffix)));
151
+ const health = expand(healthRoutes(options));
152
+ const metrics = expand(metricsRoutes(options));
153
+ return {
154
+ isHealth: (path) => health.includes(path),
155
+ isMetrics: (path) => metrics.includes(path)
156
+ };
157
+ }
158
+ function isOwnRoute(path, method, routes) {
159
+ return method === "get" && (routes.isHealth(path) || routes.isMetrics(path));
160
+ }
161
+
134
162
  // src/envelope/error-codes.ts
135
163
  var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
136
164
  var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
@@ -329,29 +357,6 @@ function mergeResponses(existing, additions) {
329
357
  }
330
358
  return Object.fromEntries(merged);
331
359
  }
332
- function trimSlashes(segment) {
333
- return segment.split("/").filter((part) => part !== "").join("/");
334
- }
335
- function routePath(prefix, suffix) {
336
- return prefix === "" ? `/${suffix}` : `/${prefix}/${suffix}`;
337
- }
338
- function healthRoutes(options) {
339
- const bases = /* @__PURE__ */ new Set([trimSlashes(options.health.path), DEFAULT_HEALTH_PATH]);
340
- return [...bases].flatMap((base) => [`${base}/live`, `${base}/ready`]);
341
- }
342
- function metricsRoutes(options) {
343
- return [.../* @__PURE__ */ new Set([trimSlashes(options.metrics.path), DEFAULT_METRICS_PATH])];
344
- }
345
- function indexOwnRoutes(options, prefixes) {
346
- const normalized = prefixes.map(trimSlashes);
347
- const expand = (suffixes) => normalized.flatMap((prefix) => suffixes.map((suffix) => routePath(prefix, suffix)));
348
- const health = expand(healthRoutes(options));
349
- const metrics = expand(metricsRoutes(options));
350
- return {
351
- isHealth: (path) => health.includes(path),
352
- isMetrics: (path) => metrics.includes(path)
353
- };
354
- }
355
360
  function withoutDisabledRoutes(paths, options, routes) {
356
361
  const disabled = (path) => !options.health.enabled && routes.isHealth(path) || !options.metrics.enabled && routes.isMetrics(path);
357
362
  const kept = Object.entries(paths).map(([path, item]) => {
@@ -435,6 +440,25 @@ function augmentOperation(operation, path, method, options, routes, contribution
435
440
  }
436
441
  return result;
437
442
  }
443
+ function consumerOperations(paths, routes) {
444
+ return Object.entries(paths).flatMap(
445
+ ([path, item]) => operationsOf(item).filter(([method]) => !isOwnRoute(path, method, routes)).map(([method, operation]) => ({
446
+ key: operationKey(method, path),
447
+ operation: asRecord(operation)
448
+ }))
449
+ );
450
+ }
451
+ function unsecuredOperations(document, options, pathPrefixes = [""]) {
452
+ if (document.security !== void 0) {
453
+ return [];
454
+ }
455
+ const routes = indexOwnRoutes(options, pathPrefixes);
456
+ const candidates = consumerOperations(asRecord(document.paths), routes);
457
+ if (!candidates.some(({ operation }) => operation["security"] !== void 0)) {
458
+ return [];
459
+ }
460
+ return candidates.filter(({ operation }) => operation["security"] === void 0).map(({ key }) => key);
461
+ }
438
462
  function assertSchemesDeclared(openapi, schemes) {
439
463
  const required = [...openapi.security, ...Object.values(openapi.operationSecurity).flat()];
440
464
  const named = [...new Set(required.flatMap((requirement) => Object.keys(requirement)))];
@@ -549,6 +573,7 @@ async function loadSwagger() {
549
573
  }
550
574
 
551
575
  // src/openapi/openapi.bootstrap.ts
576
+ var MAX_WARNED_OPERATIONS = 10;
552
577
  var OPTIONS_UNRESOLVED_MESSAGE = "[BymaxCoreModule] applyBymaxOpenApi could not resolve BYMAX_CORE_OPTIONS from the application. Register BymaxCoreModule (forRoot or forRootAsync) before calling it, and keep the module global or import it into the module you bootstrap.";
553
578
  function resolveCoreOptions(app) {
554
579
  try {
@@ -607,6 +632,16 @@ function readContributions(app, handlers) {
607
632
  }
608
633
  return collectContributions(discovery, reflector, handlers);
609
634
  }
635
+ function warnUnsecuredOperations(logger, keys) {
636
+ if (keys.length === 0) {
637
+ return;
638
+ }
639
+ const elided = keys.length - MAX_WARNED_OPERATIONS;
640
+ const listed = keys.slice(0, MAX_WARNED_OPERATIONS).join(", ");
641
+ logger.warn(
642
+ `a client generated from the OpenAPI document will send no credentials to ${keys.length} operation(s): ${listed}${elided > 0 ? `, and ${elided} more` : ""}. They state no security requirement, the document declares no default, and other operations in it do state one \u2014 so this is more often a missing openapi.security default than a public API. Set openapi.security, or state the intent per operation with an explicit [] in openapi.operationSecurity.`
643
+ );
644
+ }
610
645
  function buildConfig(builder, options) {
611
646
  builder.setTitle(options.title).setDescription(options.description).setVersion(options.version);
612
647
  for (const server of options.servers) {
@@ -635,12 +670,9 @@ async function applyBymaxOpenApi(app) {
635
670
  const generated = swagger.SwaggerModule.createDocument(app, config, {
636
671
  operationIdFactory: recordingOperationIdFactory(handlers, options.operationIdFactory)
637
672
  });
638
- const document = augmentDocument(
639
- generated,
640
- resolved,
641
- readPathPrefixes(app),
642
- readContributions(app, handlers)
643
- );
673
+ const prefixes = readPathPrefixes(app);
674
+ const document = augmentDocument(generated, resolved, prefixes, readContributions(app, handlers));
675
+ warnUnsecuredOperations(logger, unsecuredOperations(document, resolved, prefixes));
644
676
  swagger.SwaggerModule.setup(options.path, app, document, {
645
677
  jsonDocumentUrl: options.jsonPath
646
678
  });
@@ -129,6 +129,34 @@ function collectContributions(discovery, reflector, handlers) {
129
129
  var DEFAULT_HEALTH_PATH = "health";
130
130
  var DEFAULT_METRICS_PATH = "metrics";
131
131
 
132
+ // src/openapi/openapi.routes.ts
133
+ function trimSlashes(segment) {
134
+ return segment.split("/").filter((part) => part !== "").join("/");
135
+ }
136
+ function routePath(prefix, suffix) {
137
+ return prefix === "" ? `/${suffix}` : `/${prefix}/${suffix}`;
138
+ }
139
+ function healthRoutes(options) {
140
+ const bases = /* @__PURE__ */ new Set([trimSlashes(options.health.path), DEFAULT_HEALTH_PATH]);
141
+ return [...bases].flatMap((base) => [`${base}/live`, `${base}/ready`]);
142
+ }
143
+ function metricsRoutes(options) {
144
+ return [.../* @__PURE__ */ new Set([trimSlashes(options.metrics.path), DEFAULT_METRICS_PATH])];
145
+ }
146
+ function indexOwnRoutes(options, prefixes) {
147
+ const normalized = prefixes.map(trimSlashes);
148
+ const expand = (suffixes) => normalized.flatMap((prefix) => suffixes.map((suffix) => routePath(prefix, suffix)));
149
+ const health = expand(healthRoutes(options));
150
+ const metrics = expand(metricsRoutes(options));
151
+ return {
152
+ isHealth: (path) => health.includes(path),
153
+ isMetrics: (path) => metrics.includes(path)
154
+ };
155
+ }
156
+ function isOwnRoute(path, method, routes) {
157
+ return method === "get" && (routes.isHealth(path) || routes.isMetrics(path));
158
+ }
159
+
132
160
  // src/envelope/error-codes.ts
133
161
  var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
134
162
  var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
@@ -327,29 +355,6 @@ function mergeResponses(existing, additions) {
327
355
  }
328
356
  return Object.fromEntries(merged);
329
357
  }
330
- function trimSlashes(segment) {
331
- return segment.split("/").filter((part) => part !== "").join("/");
332
- }
333
- function routePath(prefix, suffix) {
334
- return prefix === "" ? `/${suffix}` : `/${prefix}/${suffix}`;
335
- }
336
- function healthRoutes(options) {
337
- const bases = /* @__PURE__ */ new Set([trimSlashes(options.health.path), DEFAULT_HEALTH_PATH]);
338
- return [...bases].flatMap((base) => [`${base}/live`, `${base}/ready`]);
339
- }
340
- function metricsRoutes(options) {
341
- return [.../* @__PURE__ */ new Set([trimSlashes(options.metrics.path), DEFAULT_METRICS_PATH])];
342
- }
343
- function indexOwnRoutes(options, prefixes) {
344
- const normalized = prefixes.map(trimSlashes);
345
- const expand = (suffixes) => normalized.flatMap((prefix) => suffixes.map((suffix) => routePath(prefix, suffix)));
346
- const health = expand(healthRoutes(options));
347
- const metrics = expand(metricsRoutes(options));
348
- return {
349
- isHealth: (path) => health.includes(path),
350
- isMetrics: (path) => metrics.includes(path)
351
- };
352
- }
353
358
  function withoutDisabledRoutes(paths, options, routes) {
354
359
  const disabled = (path) => !options.health.enabled && routes.isHealth(path) || !options.metrics.enabled && routes.isMetrics(path);
355
360
  const kept = Object.entries(paths).map(([path, item]) => {
@@ -433,6 +438,25 @@ function augmentOperation(operation, path, method, options, routes, contribution
433
438
  }
434
439
  return result;
435
440
  }
441
+ function consumerOperations(paths, routes) {
442
+ return Object.entries(paths).flatMap(
443
+ ([path, item]) => operationsOf(item).filter(([method]) => !isOwnRoute(path, method, routes)).map(([method, operation]) => ({
444
+ key: operationKey(method, path),
445
+ operation: asRecord(operation)
446
+ }))
447
+ );
448
+ }
449
+ function unsecuredOperations(document, options, pathPrefixes = [""]) {
450
+ if (document.security !== void 0) {
451
+ return [];
452
+ }
453
+ const routes = indexOwnRoutes(options, pathPrefixes);
454
+ const candidates = consumerOperations(asRecord(document.paths), routes);
455
+ if (!candidates.some(({ operation }) => operation["security"] !== void 0)) {
456
+ return [];
457
+ }
458
+ return candidates.filter(({ operation }) => operation["security"] === void 0).map(({ key }) => key);
459
+ }
436
460
  function assertSchemesDeclared(openapi, schemes) {
437
461
  const required = [...openapi.security, ...Object.values(openapi.operationSecurity).flat()];
438
462
  const named = [...new Set(required.flatMap((requirement) => Object.keys(requirement)))];
@@ -547,6 +571,7 @@ async function loadSwagger() {
547
571
  }
548
572
 
549
573
  // src/openapi/openapi.bootstrap.ts
574
+ var MAX_WARNED_OPERATIONS = 10;
550
575
  var OPTIONS_UNRESOLVED_MESSAGE = "[BymaxCoreModule] applyBymaxOpenApi could not resolve BYMAX_CORE_OPTIONS from the application. Register BymaxCoreModule (forRoot or forRootAsync) before calling it, and keep the module global or import it into the module you bootstrap.";
551
576
  function resolveCoreOptions(app) {
552
577
  try {
@@ -605,6 +630,16 @@ function readContributions(app, handlers) {
605
630
  }
606
631
  return collectContributions(discovery, reflector, handlers);
607
632
  }
633
+ function warnUnsecuredOperations(logger, keys) {
634
+ if (keys.length === 0) {
635
+ return;
636
+ }
637
+ const elided = keys.length - MAX_WARNED_OPERATIONS;
638
+ const listed = keys.slice(0, MAX_WARNED_OPERATIONS).join(", ");
639
+ logger.warn(
640
+ `a client generated from the OpenAPI document will send no credentials to ${keys.length} operation(s): ${listed}${elided > 0 ? `, and ${elided} more` : ""}. They state no security requirement, the document declares no default, and other operations in it do state one \u2014 so this is more often a missing openapi.security default than a public API. Set openapi.security, or state the intent per operation with an explicit [] in openapi.operationSecurity.`
641
+ );
642
+ }
608
643
  function buildConfig(builder, options) {
609
644
  builder.setTitle(options.title).setDescription(options.description).setVersion(options.version);
610
645
  for (const server of options.servers) {
@@ -633,12 +668,9 @@ async function applyBymaxOpenApi(app) {
633
668
  const generated = swagger.SwaggerModule.createDocument(app, config, {
634
669
  operationIdFactory: recordingOperationIdFactory(handlers, options.operationIdFactory)
635
670
  });
636
- const document = augmentDocument(
637
- generated,
638
- resolved,
639
- readPathPrefixes(app),
640
- readContributions(app, handlers)
641
- );
671
+ const prefixes = readPathPrefixes(app);
672
+ const document = augmentDocument(generated, resolved, prefixes, readContributions(app, handlers));
673
+ warnUnsecuredOperations(logger, unsecuredOperations(document, resolved, prefixes));
642
674
  swagger.SwaggerModule.setup(options.path, app, document, {
643
675
  jsonDocumentUrl: options.jsonPath
644
676
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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",