@bymax-one/nest-core 1.5.1 → 1.5.3
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 +140 -1
- package/README.md +194 -10
- package/dist/index.cjs +21 -7
- package/dist/index.d.cts +37 -6
- package/dist/index.d.ts +37 -6
- package/dist/index.mjs +21 -7
- package/dist/openapi/index.cjs +49 -18
- package/dist/openapi/index.mjs +49 -18
- package/dist/pagination/index.cjs +11 -1
- package/dist/pagination/index.d.cts +32 -3
- package/dist/pagination/index.d.ts +32 -3
- package/dist/pagination/index.mjs +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,143 @@ heading here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.5.3] - 2026-08-18
|
|
15
|
+
|
|
16
|
+
Three findings from a functional and security audit of a derived backend
|
|
17
|
+
running against real Postgres, Redis and MinIO, plus the corrections that
|
|
18
|
+
review found inside those fixes.
|
|
19
|
+
|
|
20
|
+
The one that reaches a running deployment: an unprotected `/metrics` was
|
|
21
|
+
documented as **requiring a credential**. The endpoint answers anyone when no
|
|
22
|
+
`metrics.authToken` is set — the documented "protected at the edge"
|
|
23
|
+
arrangement — and it inherited the document-level default instead of declaring
|
|
24
|
+
itself public. That is the opposite of what 1.5.0 fixed and the more dangerous
|
|
25
|
+
direction: documenting a guarded route as open fails loudly at the first
|
|
26
|
+
generated client that omits the credential, while documenting an open route as
|
|
27
|
+
guarded fails nowhere and hands the wrong answer to whoever opened the document
|
|
28
|
+
to ask what is exposed.
|
|
29
|
+
|
|
30
|
+
**Apply to a derived backend:** bump the dependency. Nothing to change in code.
|
|
31
|
+
If you serve a document and leave the scrape endpoint unprotected, re-render it
|
|
32
|
+
and confirm `/metrics` now carries `security: []`. If your page indexes reach
|
|
33
|
+
SQL, `maxOffset` is now available and is opt-in.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **An unprotected `/metrics` was documented as requiring a credential.** This
|
|
38
|
+
package writes an explicit `security: []` on its health probes so they do not
|
|
39
|
+
inherit a document-level default, and did not do the same for the scrape
|
|
40
|
+
endpoint. With `metrics.authToken` unset — the documented "protected at the
|
|
41
|
+
edge" arrangement, where the endpoint answers anyone — `GET /metrics` fell
|
|
42
|
+
through and inherited the default, so a document served by any backend with a
|
|
43
|
+
default claimed a credential was required for an endpoint serving process
|
|
44
|
+
metrics to whoever asked.
|
|
45
|
+
|
|
46
|
+
Measured on a running derived backend, not reasoned about: no credential →
|
|
47
|
+
`200` with the full Prometheus body, while the served document said
|
|
48
|
+
`security: [{ bymaxAuthAccessCookie: [] }]`.
|
|
49
|
+
|
|
50
|
+
**One half of the fix is covered by unit tests only, and that is worth saying
|
|
51
|
+
rather than leaving it to look field-verified.** The reported symptom reaches
|
|
52
|
+
a deployment through `openapi.security`, and that path was measured. Review
|
|
53
|
+
then found the same hole on the other path — a document that arrives carrying
|
|
54
|
+
its own default, whose `openapi.security` is therefore empty — and it is fixed
|
|
55
|
+
by reading the effective default from the document that will be served. No
|
|
56
|
+
consumer known to this project reaches that state today, so the only coverage
|
|
57
|
+
that can be pointed at is this repository's tests — which is a statement about
|
|
58
|
+
what is known, not a guarantee that nothing else exercises it. The health probes carried the same defect on
|
|
59
|
+
that path and are fixed by the same change.
|
|
60
|
+
|
|
61
|
+
This is the more dangerous of the two ways to describe a route wrongly, and
|
|
62
|
+
the opposite of what 1.5.0 fixed. Documenting a **guarded** route as open
|
|
63
|
+
fails loudly — a generated client omits the credential and gets a `401`.
|
|
64
|
+
Documenting an **open** route as guarded fails nowhere, and hands the wrong
|
|
65
|
+
answer to whoever opened the document to ask what is exposed.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **`maxOffset`, a bound on how far into a dataset a request may start.**
|
|
70
|
+
`normalizePageQuery` capped the page size through `maxLimit` and bounded the
|
|
71
|
+
page index only for arithmetic safety, so `?page=1000000000&limit=20` resolved
|
|
72
|
+
to `OFFSET 19999999980`. Harmless against an in-memory repository and paid in
|
|
73
|
+
full by an offset-paginated database: twenty bytes of query for a table scan.
|
|
74
|
+
|
|
75
|
+
It is **absent by default and deliberately so** — legitimate deep paging
|
|
76
|
+
exists, and a silent ceiling would change the rows a working query returns.
|
|
77
|
+
Set it wherever the page index reaches SQL. `0` is a valid bound meaning "the
|
|
78
|
+
first page only"; any value that is not a non-negative safe integer reads as
|
|
79
|
+
absent rather than as an invented cap. Clamping matches how `maxLimit` already
|
|
80
|
+
behaves, and the resolved values come back in `meta`.
|
|
81
|
+
|
|
82
|
+
### Documentation
|
|
83
|
+
|
|
84
|
+
- **What the error filter classifies from, and what it cannot.** An error raised
|
|
85
|
+
before any handler ran becomes a clean `4xx` because the filter recognizes it
|
|
86
|
+
by **shape** — `expose: true` with a `4xx` status, the convention Node's body
|
|
87
|
+
pipeline follows — not by class. An error carrying no such marking is a `500`
|
|
88
|
+
even when a client caused it: a few kilobytes nested thousands of levels deep
|
|
89
|
+
overflows the stack during validation and surfaces as `RangeError`, well under
|
|
90
|
+
any size limit.
|
|
91
|
+
|
|
92
|
+
That is deliberate. Mapping `RangeError` to a `4xx` would make the filter
|
|
93
|
+
infer causation from an error class and would be wrong where it matters most —
|
|
94
|
+
a genuine stack overflow in application code is a `500` that should page
|
|
95
|
+
someone. Body-shape limits are the application's floor, applied in the one
|
|
96
|
+
window where the body exists and nothing has walked it yet.
|
|
97
|
+
|
|
98
|
+
**Apply to a derived backend:** cap nesting depth **after the body parser and
|
|
99
|
+
before validation**, so a hostile body is rejected as the `400` it is instead
|
|
100
|
+
of becoming a `5xx` that pollutes your error rate and writes a stack per
|
|
101
|
+
request. On Express that means module middleware, not `app.use()` during
|
|
102
|
+
bootstrap — measured, a middleware registered there runs ahead of Nest's own
|
|
103
|
+
parser and sees `req.body` as `undefined`, so the guard inspects nothing and
|
|
104
|
+
protects nothing while reading as present. Walk the parsed body iteratively; a
|
|
105
|
+
recursive depth check on a hostile payload overflows the stack it exists to
|
|
106
|
+
protect. The README carries the per-adapter table and a test that proves the
|
|
107
|
+
floor by behaviour rather than by where it is registered.
|
|
108
|
+
|
|
109
|
+
## [1.5.2] - 2026-08-15
|
|
110
|
+
|
|
111
|
+
The production guard read `NODE_ENV` and nothing else, and treated an unset
|
|
112
|
+
variable as production. An application that validates its own `APP_ENV` and
|
|
113
|
+
never sets `NODE_ENV` was therefore classified as production on evidence it
|
|
114
|
+
never gave — the OpenAPI document was refused in a development deployment, with
|
|
115
|
+
no way to answer back. Two independent consumers reported the same split.
|
|
116
|
+
|
|
117
|
+
**Apply to a derived backend:** nothing to change. A deployment that sets
|
|
118
|
+
`NODE_ENV` behaves exactly as before. If yours validates its own variable
|
|
119
|
+
instead, pass it as `environment` and the document is served where that variable
|
|
120
|
+
says `development` or `test`.
|
|
121
|
+
|
|
122
|
+
### Added
|
|
123
|
+
|
|
124
|
+
- **`environment`, for applications that validate their own environment
|
|
125
|
+
variable.** The production guard read `NODE_ENV` and nothing else, and treated
|
|
126
|
+
an unset variable as production. An application that parses an `APP_ENV`
|
|
127
|
+
through its config schema and never sets `NODE_ENV` was therefore classified
|
|
128
|
+
as production on evidence it never gave — the OpenAPI document was refused in
|
|
129
|
+
a development deployment, with no way to answer back. Two independent
|
|
130
|
+
consumers reported the same split between the library's view of the
|
|
131
|
+
environment and their own validated one.
|
|
132
|
+
|
|
133
|
+
A top-level `environment` option is now consulted **where the process declares
|
|
134
|
+
nothing**: `NODE_ENV` unset, or set to whitespace. `NODE_ENV` wins whenever it
|
|
135
|
+
says anything at all, so no configured value can make a runtime that named
|
|
136
|
+
itself production serve the document — asserted in both guards rather than in
|
|
137
|
+
one. The declaration enters the same fail-closed classification, so an
|
|
138
|
+
unrecognized name is production like any other: this is a second source for
|
|
139
|
+
the value, never a second set of rules.
|
|
140
|
+
|
|
141
|
+
The narrowing is stated rather than buried. Both guards previously classified
|
|
142
|
+
from the process alone; now, in the single case where the process says
|
|
143
|
+
nothing, the snapshot a consumer bound decides the answer, because there is
|
|
144
|
+
nothing else to decide it with. Replacing a guess with a declaration is not an
|
|
145
|
+
override, but it is a real change to what the second guard depends on.
|
|
146
|
+
|
|
147
|
+
**Apply to a derived backend:** nothing to change. The option is optional and
|
|
148
|
+
every existing classification is unchanged — a deployment that sets `NODE_ENV`
|
|
149
|
+
behaves exactly as before.
|
|
150
|
+
|
|
14
151
|
## [1.5.1] - 2026-08-15
|
|
15
152
|
|
|
16
153
|
Documentation only; no source change. The 1.5.0 warning's known-limit note told
|
|
@@ -736,4 +873,6 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
736
873
|
[1.4.0]: https://github.com/bymaxone/nest-core/compare/v1.3.2...v1.4.0
|
|
737
874
|
[1.5.0]: https://github.com/bymaxone/nest-core/compare/v1.4.0...v1.5.0
|
|
738
875
|
[1.5.1]: https://github.com/bymaxone/nest-core/compare/v1.5.0...v1.5.1
|
|
739
|
-
[
|
|
876
|
+
[1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
|
|
877
|
+
[1.5.3]: https://github.com/bymaxone/nest-core/compare/v1.5.2...v1.5.3
|
|
878
|
+
[Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.5.3...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 |
|
|
@@ -562,6 +576,96 @@ import { BadRequestException } from '@nestjs/common'
|
|
|
562
576
|
throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is overdue' })
|
|
563
577
|
```
|
|
564
578
|
|
|
579
|
+
### What the filter classifies from, and what it cannot
|
|
580
|
+
|
|
581
|
+
An error raised before any handler ran — a malformed JSON body, a payload over
|
|
582
|
+
the size limit — still becomes a clean `4xx` envelope rather than a 500. The
|
|
583
|
+
filter recognizes those by **shape**, not by class: it honours an error that
|
|
584
|
+
marks itself `expose: true` with a `4xx` status, which is the `http-errors`
|
|
585
|
+
convention Node's body pipeline follows. Nothing in the application failed, so
|
|
586
|
+
it is not routed through the unexpected-error seam either.
|
|
587
|
+
|
|
588
|
+
**An error that carries no such marking is a 500, including when a client
|
|
589
|
+
caused it.** The clearest case is depth: a body of a few kilobytes nested
|
|
590
|
+
thousands of levels deep overflows the stack during validation and surfaces as
|
|
591
|
+
`RangeError: Maximum call stack size exceeded` — well under any size limit, and
|
|
592
|
+
answered `500 BYMAX_INTERNAL_ERROR`.
|
|
593
|
+
|
|
594
|
+
That is deliberate, and the alternative is worse. Mapping `RangeError` to a
|
|
595
|
+
`4xx` would make the filter infer causation from an error class, and it would be
|
|
596
|
+
wrong exactly where it matters: a genuine stack overflow in your own code is a
|
|
597
|
+
`500` that should page someone, and relabelling it as a client error would hide
|
|
598
|
+
the failure the `500` exists to surface. By the time the filter sees the error,
|
|
599
|
+
the body that caused it is gone.
|
|
600
|
+
|
|
601
|
+
**So body-shape limits are the application's floor, not the filter's.** Cap
|
|
602
|
+
nesting depth and the request is rejected as the `400` it is, instead of
|
|
603
|
+
becoming a `5xx` that pollutes your error rate and writes a stack per request.
|
|
604
|
+
|
|
605
|
+
**Position it after the body parser and before validation** — that window is the
|
|
606
|
+
only place the body exists in a form you can measure and nothing has walked it
|
|
607
|
+
yet. Earlier there is nothing to inspect; later the overflow has already
|
|
608
|
+
happened, which is the failure you are trying to prevent.
|
|
609
|
+
|
|
610
|
+
**Where that window is depends on the adapter, and the obvious answer is wrong
|
|
611
|
+
on Express.** Measured against a real Nest application:
|
|
612
|
+
|
|
613
|
+
| Registration point | `req.body` when it runs |
|
|
614
|
+
| ---------------------------------------- | ----------------------- |
|
|
615
|
+
| `app.use(...)` in `bootstrap.ts` | **`undefined`** |
|
|
616
|
+
| Module middleware, `configure(consumer)` | the parsed body |
|
|
617
|
+
|
|
618
|
+
Nest registers its own parser during `app.init()`, so a middleware added with
|
|
619
|
+
`app.use()` before that is mounted _ahead_ of it — a depth guard there inspects
|
|
620
|
+
nothing and silently protects nothing. Register it as module middleware
|
|
621
|
+
instead. On Fastify the ordering differs again, since Nest middleware runs
|
|
622
|
+
through `@fastify/middie` ahead of body parsing; a `preValidation` hook is the
|
|
623
|
+
place to look, and it is worth measuring rather than assuming.
|
|
624
|
+
|
|
625
|
+
**Registering it in the right place is not enough — the route pattern silently
|
|
626
|
+
skips paths too.** This package hit the same trap with its own timing
|
|
627
|
+
middleware, and the measured behaviour is in `core.module.ts`:
|
|
628
|
+
|
|
629
|
+
| `forRoutes(...)` | Express | Fastify |
|
|
630
|
+
| ---------------- | -------------- | ---------------- |
|
|
631
|
+
| `'*splat'` | skips the root | — |
|
|
632
|
+
| `'{*splat}'` | skips `/api` | every path |
|
|
633
|
+
| `'/'` | every path | matches `/` only |
|
|
634
|
+
|
|
635
|
+
So the named-wildcard form every migration guide reaches for leaves `POST /`
|
|
636
|
+
unguarded on Express. A consumer measured exactly that: with `'*path'`, a
|
|
637
|
+
2000-level body to the root returned `404` because the middleware never ran;
|
|
638
|
+
with `'{*path}'`, `400`. If your application mounts nothing at the root, both
|
|
639
|
+
forms answer `4xx` and the status alone cannot tell you which one you have.
|
|
640
|
+
|
|
641
|
+
**Verify by behaviour, not by wiring — this is the part worth insisting on.**
|
|
642
|
+
Checking where the middleware is registered is what the consumer above did; it
|
|
643
|
+
looked correct, they confirmed it to us, and the guard was inert. Send a body
|
|
644
|
+
nested past your ceiling and require your own rejection:
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
it('refuses a body nested past the ceiling', async () => {
|
|
648
|
+
const deep = JSON.parse(`${'['.repeat(2000)}${']'.repeat(2000)}`)
|
|
649
|
+
|
|
650
|
+
const res = await request(app.getHttpServer()).post('/anything').send({ name: deep })
|
|
651
|
+
|
|
652
|
+
// Match your guard's own message, not the status: a validation pipe rejects
|
|
653
|
+
// this shape with a 400 as well, so a status assertion passes with the floor
|
|
654
|
+
// removed and proves nothing.
|
|
655
|
+
expect(res.body.message).toBe('Request body is nested too deeply.')
|
|
656
|
+
})
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
Use a depth that actually overflows, and assert the guard's own message. A test
|
|
660
|
+
at a depth your DTO validation already rejects passes identically with the guard
|
|
661
|
+
deleted — which is a check that cannot produce a negative result, and the reason
|
|
662
|
+
this defect survived a green suite.
|
|
663
|
+
|
|
664
|
+
A depth ceiling well above anything a legitimate payload nests and well below
|
|
665
|
+
what exhausts the stack leaves a wide margin: one consumer runs `32`, against
|
|
666
|
+
the ~2000 levels that overflow. Walk the parsed body iteratively — a recursive
|
|
667
|
+
depth check on a hostile payload overflows the stack it was written to protect.
|
|
668
|
+
|
|
565
669
|
## ⏱️ Request Timing
|
|
566
670
|
|
|
567
671
|
One `RequestTimingSample` is delivered to whatever implements `ITimingSink` for
|
|
@@ -675,13 +779,36 @@ export class InvoiceController {
|
|
|
675
779
|
|
|
676
780
|
@Get()
|
|
677
781
|
async list(@Query() raw: Record<string, unknown>): Promise<PageResult<Invoice>> {
|
|
678
|
-
const query = normalizePageQuery(raw, { maxLimit: 50 })
|
|
782
|
+
const query = normalizePageQuery(raw, { maxLimit: 50, maxOffset: 100_000 })
|
|
679
783
|
const { rows, total } = await this.invoices.findPage(query)
|
|
680
784
|
return buildPageResult(rows, total, query)
|
|
681
785
|
}
|
|
682
786
|
}
|
|
683
787
|
```
|
|
684
788
|
|
|
789
|
+
#### Bound the offset, not just the page size
|
|
790
|
+
|
|
791
|
+
`maxLimit` caps how many rows a request reads. `maxOffset` caps how far in it
|
|
792
|
+
starts — and on an offset-paginated database that is the half that costs:
|
|
793
|
+
|
|
794
|
+
```
|
|
795
|
+
GET /invoices?page=1000000000&limit=20 → OFFSET 19999999980
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
Twenty bytes of query, and Postgres walks the table to reach a page that does
|
|
799
|
+
not exist. The page index has a floor of `1` and an arithmetic guard that keeps
|
|
800
|
+
`(page - 1) * limit` an exact integer, but nothing bounds the product itself
|
|
801
|
+
unless you say so.
|
|
802
|
+
|
|
803
|
+
`maxOffset` is **absent by default and deliberately so**: legitimate deep paging
|
|
804
|
+
exists, and a silent ceiling would change the rows a working query returns. Set
|
|
805
|
+
it wherever the page index reaches SQL and your dataset has a knowable ceiling.
|
|
806
|
+
`0` is a valid bound and means "the first page only".
|
|
807
|
+
|
|
808
|
+
Clamping matches how `maxLimit` already behaves — the resolved values come back
|
|
809
|
+
in `meta`, so a caller that cares can compare what it asked for against what it
|
|
810
|
+
got.
|
|
811
|
+
|
|
685
812
|
### Cursor pagination
|
|
686
813
|
|
|
687
814
|
```typescript
|
|
@@ -959,16 +1086,61 @@ can emit it once and never branch:
|
|
|
959
1086
|
### Production is a closed door
|
|
960
1087
|
|
|
961
1088
|
`NODE_ENV` decides, and the decision is fail-closed: only `development` and
|
|
962
|
-
`test` are non-production. Any other value
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1089
|
+
`test` are non-production. Any other value is production, and in production the
|
|
1090
|
+
document is never built and never mounted, whatever the configuration says. The
|
|
1091
|
+
guard runs twice, independently: the option resolver forces the feature off, and
|
|
1092
|
+
the bootstrap helper classifies the runtime again without trusting that
|
|
1093
|
+
resolution.
|
|
1094
|
+
|
|
1095
|
+
**`NODE_ENV` cannot be overridden.** With it set to anything, no option serves
|
|
1096
|
+
the document in a runtime it named production.
|
|
967
1097
|
|
|
968
1098
|
Enabling it in production is not an error, it is a no-op with a warning naming
|
|
969
1099
|
the option that was ignored, so a single configuration can be shared across
|
|
970
1100
|
environments.
|
|
971
1101
|
|
|
1102
|
+
#### When your application validates its own environment variable
|
|
1103
|
+
|
|
1104
|
+
Plenty of applications parse an `APP_ENV` through a config schema and never set
|
|
1105
|
+
`NODE_ENV` at all. Those deployments used to be classified as production —
|
|
1106
|
+
absence was the only evidence available — so the document was refused in an
|
|
1107
|
+
environment that never asked for the refusal, with no way to answer back.
|
|
1108
|
+
|
|
1109
|
+
Declare the environment and it is used **where the process declares nothing**:
|
|
1110
|
+
|
|
1111
|
+
```typescript
|
|
1112
|
+
BymaxCoreModule.forRootAsync({
|
|
1113
|
+
inject: [ConfigService],
|
|
1114
|
+
useFactory: (config: ConfigService) => ({
|
|
1115
|
+
// Your validated value, not a second copy of NODE_ENV.
|
|
1116
|
+
environment: config.get('APP_ENV'),
|
|
1117
|
+
openapi: { enabled: true }
|
|
1118
|
+
})
|
|
1119
|
+
})
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
| `NODE_ENV` | `environment` | Classified as |
|
|
1123
|
+
| --------------- | ------------- | -------------- |
|
|
1124
|
+
| `production` | `development` | **production** |
|
|
1125
|
+
| `development` | (anything) | development |
|
|
1126
|
+
| unset, or blank | `development` | development |
|
|
1127
|
+
| unset, or blank | `staging` | **production** |
|
|
1128
|
+
| unset, or blank | unset | **production** |
|
|
1129
|
+
|
|
1130
|
+
Two properties are worth reading off that table. A declaration never overrules a
|
|
1131
|
+
process that named its own environment — the first row is the one that matters,
|
|
1132
|
+
and it is asserted in both guards rather than in one. And the declaration enters
|
|
1133
|
+
the same fail-closed classification, so an unrecognized name is production like
|
|
1134
|
+
any other; this is a second **source** for the value, never a second set of
|
|
1135
|
+
rules.
|
|
1136
|
+
|
|
1137
|
+
The narrowing is deliberate and worth naming rather than burying: in the one
|
|
1138
|
+
case where the process declares nothing, the configuration a consumer bound does
|
|
1139
|
+
decide the answer, because there is nothing else to decide it with. Replacing a
|
|
1140
|
+
guess with a declaration is not the same as allowing an override — but it is a
|
|
1141
|
+
real change to what the second guard depends on, and you should know it before
|
|
1142
|
+
relying on either.
|
|
1143
|
+
|
|
972
1144
|
### Testing the enabled path under Jest
|
|
973
1145
|
|
|
974
1146
|
`applyBymaxOpenApi` loads `@nestjs/swagger` through a dynamic `import()` — that
|
|
@@ -1153,13 +1325,23 @@ being asked:
|
|
|
1153
1325
|
| Route | Documented as |
|
|
1154
1326
|
| ----------------------------------- | ------------------------------------------------------- |
|
|
1155
1327
|
| `GET /health/live`, `/health/ready` | Public (`security: []`), when a document default exists |
|
|
1156
|
-
| `GET /metrics
|
|
1328
|
+
| `GET /metrics`, token set | Bearer-protected |
|
|
1329
|
+
| `GET /metrics`, no token | Public (`security: []`), when a document default exists |
|
|
1157
1330
|
|
|
1158
1331
|
The probes are polled by an orchestrator holding no credential, and the scrape
|
|
1159
1332
|
endpoint is protected exactly when you configured a token — this package owns
|
|
1160
1333
|
both the route and the option, so you should not have to restate either. Your
|
|
1161
1334
|
own `operationSecurity` entry still wins.
|
|
1162
1335
|
|
|
1336
|
+
The last row matters more than it looks. Without a token the scrape endpoint
|
|
1337
|
+
answers anyone, which is the deliberate "protected at the edge" arrangement — and
|
|
1338
|
+
an open route must **say** it is open rather than inherit your document default.
|
|
1339
|
+
Of the two ways to describe a route wrongly, this is the direction that hides:
|
|
1340
|
+
documenting a guarded route as open fails loudly at the first generated client
|
|
1341
|
+
that omits the credential and gets a `401`, while documenting an open route as
|
|
1342
|
+
guarded fails nowhere at all, and hands the wrong answer to whoever opened the
|
|
1343
|
+
document to ask what is exposed.
|
|
1344
|
+
|
|
1163
1345
|
## 🧵 Trace correlation
|
|
1164
1346
|
|
|
1165
1347
|
Off by default. Enabled, it reads the span your instrumentation already opened
|
|
@@ -1359,9 +1541,11 @@ guard you would apply to any internal endpoint, or keep it off the public listen
|
|
|
1359
1541
|
A published document is a map of every route, parameter and error shape an application has —
|
|
1360
1542
|
useful to a developer, and just as useful to anyone probing the service. So unlike the metrics
|
|
1361
1543
|
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
|
|
1363
|
-
|
|
1364
|
-
|
|
1544
|
+
positively `development` or `test`, in two independent layers. **`NODE_ENV` cannot be
|
|
1545
|
+
overridden** — with it set to anything, no option serves the document in a runtime it named
|
|
1546
|
+
production. A runtime that declares nothing is classified from the application's own
|
|
1547
|
+
[`environment`](#environment) when it supplied one, and counts as production otherwise, because
|
|
1548
|
+
the deployment nobody configured is the one most likely to be exposed.
|
|
1365
1549
|
|
|
1366
1550
|
---
|
|
1367
1551
|
|
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();
|
|
@@ -1386,9 +1397,12 @@ exports.BymaxCoreModule = class BymaxCoreModule extends BymaxCoreModuleBase {
|
|
|
1386
1397
|
* it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
|
|
1387
1398
|
* prescribes for "all routes" — stops matching the prefixed root once an
|
|
1388
1399
|
* application calls `setGlobalPrefix`. That was reported as nest#14520 and
|
|
1389
|
-
* fixed by nest#14522, whose regression test covers Fastify; on
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1400
|
+
* fixed by nest#14522, whose regression test covers Fastify; on the Express
|
|
1401
|
+
* adapter the prefixed root still reaches no middleware while resolving to
|
|
1402
|
+
* `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
|
|
1403
|
+
* 11.2.1 — a minor release is exactly where this would plausibly have been
|
|
1404
|
+
* fixed, so the version this was last confirmed against is part of the
|
|
1405
|
+
* claim rather than a footnote to it.
|
|
1392
1406
|
*
|
|
1393
1407
|
* On Fastify the same `'/'` is an exact match rather than a mount — one of
|
|
1394
1408
|
* three requests reached the middleware — so the wildcard is the only form
|
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
|
-
*
|
|
339
|
-
*
|
|
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`. */
|
|
@@ -402,9 +430,12 @@ declare class BymaxCoreModule extends BymaxCoreModuleBase implements NestModule
|
|
|
402
430
|
* it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
|
|
403
431
|
* prescribes for "all routes" — stops matching the prefixed root once an
|
|
404
432
|
* application calls `setGlobalPrefix`. That was reported as nest#14520 and
|
|
405
|
-
* fixed by nest#14522, whose regression test covers Fastify; on
|
|
406
|
-
*
|
|
407
|
-
*
|
|
433
|
+
* fixed by nest#14522, whose regression test covers Fastify; on the Express
|
|
434
|
+
* adapter the prefixed root still reaches no middleware while resolving to
|
|
435
|
+
* `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
|
|
436
|
+
* 11.2.1 — a minor release is exactly where this would plausibly have been
|
|
437
|
+
* fixed, so the version this was last confirmed against is part of the
|
|
438
|
+
* claim rather than a footnote to it.
|
|
408
439
|
*
|
|
409
440
|
* On Fastify the same `'/'` is an exact match rather than a mount — one of
|
|
410
441
|
* three requests reached the middleware — so the wildcard is the only form
|
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
|
-
*
|
|
339
|
-
*
|
|
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`. */
|
|
@@ -402,9 +430,12 @@ declare class BymaxCoreModule extends BymaxCoreModuleBase implements NestModule
|
|
|
402
430
|
* it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
|
|
403
431
|
* prescribes for "all routes" — stops matching the prefixed root once an
|
|
404
432
|
* application calls `setGlobalPrefix`. That was reported as nest#14520 and
|
|
405
|
-
* fixed by nest#14522, whose regression test covers Fastify; on
|
|
406
|
-
*
|
|
407
|
-
*
|
|
433
|
+
* fixed by nest#14522, whose regression test covers Fastify; on the Express
|
|
434
|
+
* adapter the prefixed root still reaches no middleware while resolving to
|
|
435
|
+
* `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
|
|
436
|
+
* 11.2.1 — a minor release is exactly where this would plausibly have been
|
|
437
|
+
* fixed, so the version this was last confirmed against is part of the
|
|
438
|
+
* claim rather than a footnote to it.
|
|
408
439
|
*
|
|
409
440
|
* On Fastify the same `'/'` is an exact match rather than a mount — one of
|
|
410
441
|
* three requests reached the middleware — so the wildcard is the only form
|
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();
|
|
@@ -1384,9 +1395,12 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
|
|
|
1384
1395
|
* it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
|
|
1385
1396
|
* prescribes for "all routes" — stops matching the prefixed root once an
|
|
1386
1397
|
* application calls `setGlobalPrefix`. That was reported as nest#14520 and
|
|
1387
|
-
* fixed by nest#14522, whose regression test covers Fastify; on
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1398
|
+
* fixed by nest#14522, whose regression test covers Fastify; on the Express
|
|
1399
|
+
* adapter the prefixed root still reaches no middleware while resolving to
|
|
1400
|
+
* `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
|
|
1401
|
+
* 11.2.1 — a minor release is exactly where this would plausibly have been
|
|
1402
|
+
* fixed, so the version this was last confirmed against is part of the
|
|
1403
|
+
* claim rather than a footnote to it.
|
|
1390
1404
|
*
|
|
1391
1405
|
* On Fastify the same `'/'` is an exact match rather than a mount — one of
|
|
1392
1406
|
* three requests reached the middleware — so the wildcard is the only form
|
package/dist/openapi/index.cjs
CHANGED
|
@@ -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) {
|
|
@@ -319,7 +326,7 @@ var CORE_PARAMETERS = {
|
|
|
319
326
|
}
|
|
320
327
|
};
|
|
321
328
|
|
|
322
|
-
// src/openapi/openapi.
|
|
329
|
+
// src/openapi/openapi.shape.ts
|
|
323
330
|
var OPERATION_METHODS = [
|
|
324
331
|
"get",
|
|
325
332
|
"post",
|
|
@@ -330,21 +337,26 @@ var OPERATION_METHODS = [
|
|
|
330
337
|
"options",
|
|
331
338
|
"trace"
|
|
332
339
|
];
|
|
333
|
-
var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
|
|
334
|
-
var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
|
|
335
|
-
var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
|
|
336
340
|
function asRecord(value) {
|
|
337
341
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
338
342
|
return {};
|
|
339
343
|
}
|
|
340
344
|
return value;
|
|
341
345
|
}
|
|
342
|
-
function mergeAbsent(existing, additions) {
|
|
343
|
-
return { ...additions, ...existing };
|
|
344
|
-
}
|
|
345
346
|
function operationsOf(item) {
|
|
346
347
|
return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
|
|
347
348
|
}
|
|
349
|
+
function operationKey(method, path) {
|
|
350
|
+
return `${method.toUpperCase()} ${path}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/openapi/openapi.document.ts
|
|
354
|
+
var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
|
|
355
|
+
var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
|
|
356
|
+
var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
|
|
357
|
+
function mergeAbsent(existing, additions) {
|
|
358
|
+
return { ...additions, ...existing };
|
|
359
|
+
}
|
|
348
360
|
function mergeResponses(existing, additions) {
|
|
349
361
|
const merged = new Map(Object.entries(existing));
|
|
350
362
|
for (const [status, value] of Object.entries(additions)) {
|
|
@@ -370,21 +382,24 @@ function withoutDisabledRoutes(paths, options, routes) {
|
|
|
370
382
|
});
|
|
371
383
|
return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
|
|
372
384
|
}
|
|
373
|
-
function
|
|
385
|
+
function inheritsRequirement(document, openapi) {
|
|
386
|
+
if (document.security === void 0) {
|
|
387
|
+
return openapi.security.length > 0;
|
|
388
|
+
}
|
|
389
|
+
return Array.isArray(document.security) && document.security.length > 0;
|
|
390
|
+
}
|
|
391
|
+
function ownRouteSecurity(path, method, options, routes, inherits) {
|
|
374
392
|
if (method !== "get") {
|
|
375
393
|
return void 0;
|
|
376
394
|
}
|
|
377
395
|
if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
|
|
378
396
|
return [{ [METRICS_SCHEME_NAME]: [] }];
|
|
379
397
|
}
|
|
380
|
-
if (
|
|
398
|
+
if (inherits && (routes.isHealth(path) || routes.isMetrics(path))) {
|
|
381
399
|
return [];
|
|
382
400
|
}
|
|
383
401
|
return void 0;
|
|
384
402
|
}
|
|
385
|
-
function operationKey(method, path) {
|
|
386
|
-
return `${method.toUpperCase()} ${path}`;
|
|
387
|
-
}
|
|
388
403
|
function coreResponses(path, options, routes) {
|
|
389
404
|
const responses = {};
|
|
390
405
|
if (options.envelope.enabled) {
|
|
@@ -418,7 +433,7 @@ function mergeFragment(operation, fragment) {
|
|
|
418
433
|
}
|
|
419
434
|
return merged;
|
|
420
435
|
}
|
|
421
|
-
function augmentOperation(operation, path, method, options, routes, contributions) {
|
|
436
|
+
function augmentOperation(operation, path, method, options, routes, contributions, inherits) {
|
|
422
437
|
const declaredByDocument = operation["security"] !== void 0;
|
|
423
438
|
let result = { ...operation };
|
|
424
439
|
for (const fragment of fragmentsFor(result["operationId"], contributions)) {
|
|
@@ -427,7 +442,7 @@ function augmentOperation(operation, path, method, options, routes, contribution
|
|
|
427
442
|
if (!declaredByDocument) {
|
|
428
443
|
const override = options.openapi.operationSecurity[operationKey(method, path)];
|
|
429
444
|
const describedByLibrary = result["security"] !== void 0;
|
|
430
|
-
const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes));
|
|
445
|
+
const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes, inherits));
|
|
431
446
|
if (security !== void 0) {
|
|
432
447
|
result["security"] = security;
|
|
433
448
|
}
|
|
@@ -502,12 +517,20 @@ function assertOverridesMatch(paths, openapi) {
|
|
|
502
517
|
`[BymaxCoreModule] openapi.operationSecurity addresses ${unmatched.length} operation(s) that the document does not contain: ${unmatched.join(", ")}. Keys are "<METHOD> <path>" with the path exactly as documented, including any global prefix. The document contains: ${documented.length === 0 ? "(none)" : documented.join(", ")}.`
|
|
503
518
|
);
|
|
504
519
|
}
|
|
505
|
-
function augmentPaths(paths, options, routes, contributions) {
|
|
520
|
+
function augmentPaths(paths, options, routes, contributions, inherits) {
|
|
506
521
|
return Object.fromEntries(
|
|
507
522
|
Object.entries(paths).map(([path, item]) => {
|
|
508
523
|
const augmented = operationsOf(item).map(([method, operation]) => [
|
|
509
524
|
method,
|
|
510
|
-
augmentOperation(
|
|
525
|
+
augmentOperation(
|
|
526
|
+
asRecord(operation),
|
|
527
|
+
path,
|
|
528
|
+
method,
|
|
529
|
+
options,
|
|
530
|
+
routes,
|
|
531
|
+
contributions,
|
|
532
|
+
inherits
|
|
533
|
+
)
|
|
511
534
|
]);
|
|
512
535
|
return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
|
|
513
536
|
})
|
|
@@ -545,7 +568,15 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
|
|
|
545
568
|
const routes = indexOwnRoutes(options, pathPrefixes);
|
|
546
569
|
const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
|
|
547
570
|
assertOverridesMatch(served, openapi);
|
|
548
|
-
const paths = document.paths === void 0 ? {} : {
|
|
571
|
+
const paths = document.paths === void 0 ? {} : {
|
|
572
|
+
paths: augmentPaths(
|
|
573
|
+
served,
|
|
574
|
+
options,
|
|
575
|
+
routes,
|
|
576
|
+
contributions,
|
|
577
|
+
inheritsRequirement(document, openapi)
|
|
578
|
+
)
|
|
579
|
+
};
|
|
549
580
|
const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
|
|
550
581
|
return { ...document, components: Object.fromEntries(merged), ...paths, ...security };
|
|
551
582
|
}
|
|
@@ -653,7 +684,7 @@ async function applyBymaxOpenApi(app) {
|
|
|
653
684
|
const logger = new common.Logger("BymaxCoreModule");
|
|
654
685
|
const resolved = resolveCoreOptions(app);
|
|
655
686
|
const options = resolved.openapi;
|
|
656
|
-
if (isProductionRuntime()) {
|
|
687
|
+
if (isProductionRuntime(runtimeEnvironmentName(resolved.environment))) {
|
|
657
688
|
if (options.suppressedInProduction || options.enabled) {
|
|
658
689
|
logger.warn(
|
|
659
690
|
'openapi.enabled was requested but the OpenAPI document is never served in production. Set NODE_ENV to "development" or "test" to serve it.'
|
package/dist/openapi/index.mjs
CHANGED
|
@@ -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) {
|
|
@@ -317,7 +324,7 @@ var CORE_PARAMETERS = {
|
|
|
317
324
|
}
|
|
318
325
|
};
|
|
319
326
|
|
|
320
|
-
// src/openapi/openapi.
|
|
327
|
+
// src/openapi/openapi.shape.ts
|
|
321
328
|
var OPERATION_METHODS = [
|
|
322
329
|
"get",
|
|
323
330
|
"post",
|
|
@@ -328,21 +335,26 @@ var OPERATION_METHODS = [
|
|
|
328
335
|
"options",
|
|
329
336
|
"trace"
|
|
330
337
|
];
|
|
331
|
-
var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
|
|
332
|
-
var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
|
|
333
|
-
var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
|
|
334
338
|
function asRecord(value) {
|
|
335
339
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
336
340
|
return {};
|
|
337
341
|
}
|
|
338
342
|
return value;
|
|
339
343
|
}
|
|
340
|
-
function mergeAbsent(existing, additions) {
|
|
341
|
-
return { ...additions, ...existing };
|
|
342
|
-
}
|
|
343
344
|
function operationsOf(item) {
|
|
344
345
|
return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
|
|
345
346
|
}
|
|
347
|
+
function operationKey(method, path) {
|
|
348
|
+
return `${method.toUpperCase()} ${path}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/openapi/openapi.document.ts
|
|
352
|
+
var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
|
|
353
|
+
var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
|
|
354
|
+
var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
|
|
355
|
+
function mergeAbsent(existing, additions) {
|
|
356
|
+
return { ...additions, ...existing };
|
|
357
|
+
}
|
|
346
358
|
function mergeResponses(existing, additions) {
|
|
347
359
|
const merged = new Map(Object.entries(existing));
|
|
348
360
|
for (const [status, value] of Object.entries(additions)) {
|
|
@@ -368,21 +380,24 @@ function withoutDisabledRoutes(paths, options, routes) {
|
|
|
368
380
|
});
|
|
369
381
|
return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
|
|
370
382
|
}
|
|
371
|
-
function
|
|
383
|
+
function inheritsRequirement(document, openapi) {
|
|
384
|
+
if (document.security === void 0) {
|
|
385
|
+
return openapi.security.length > 0;
|
|
386
|
+
}
|
|
387
|
+
return Array.isArray(document.security) && document.security.length > 0;
|
|
388
|
+
}
|
|
389
|
+
function ownRouteSecurity(path, method, options, routes, inherits) {
|
|
372
390
|
if (method !== "get") {
|
|
373
391
|
return void 0;
|
|
374
392
|
}
|
|
375
393
|
if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
|
|
376
394
|
return [{ [METRICS_SCHEME_NAME]: [] }];
|
|
377
395
|
}
|
|
378
|
-
if (
|
|
396
|
+
if (inherits && (routes.isHealth(path) || routes.isMetrics(path))) {
|
|
379
397
|
return [];
|
|
380
398
|
}
|
|
381
399
|
return void 0;
|
|
382
400
|
}
|
|
383
|
-
function operationKey(method, path) {
|
|
384
|
-
return `${method.toUpperCase()} ${path}`;
|
|
385
|
-
}
|
|
386
401
|
function coreResponses(path, options, routes) {
|
|
387
402
|
const responses = {};
|
|
388
403
|
if (options.envelope.enabled) {
|
|
@@ -416,7 +431,7 @@ function mergeFragment(operation, fragment) {
|
|
|
416
431
|
}
|
|
417
432
|
return merged;
|
|
418
433
|
}
|
|
419
|
-
function augmentOperation(operation, path, method, options, routes, contributions) {
|
|
434
|
+
function augmentOperation(operation, path, method, options, routes, contributions, inherits) {
|
|
420
435
|
const declaredByDocument = operation["security"] !== void 0;
|
|
421
436
|
let result = { ...operation };
|
|
422
437
|
for (const fragment of fragmentsFor(result["operationId"], contributions)) {
|
|
@@ -425,7 +440,7 @@ function augmentOperation(operation, path, method, options, routes, contribution
|
|
|
425
440
|
if (!declaredByDocument) {
|
|
426
441
|
const override = options.openapi.operationSecurity[operationKey(method, path)];
|
|
427
442
|
const describedByLibrary = result["security"] !== void 0;
|
|
428
|
-
const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes));
|
|
443
|
+
const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes, inherits));
|
|
429
444
|
if (security !== void 0) {
|
|
430
445
|
result["security"] = security;
|
|
431
446
|
}
|
|
@@ -500,12 +515,20 @@ function assertOverridesMatch(paths, openapi) {
|
|
|
500
515
|
`[BymaxCoreModule] openapi.operationSecurity addresses ${unmatched.length} operation(s) that the document does not contain: ${unmatched.join(", ")}. Keys are "<METHOD> <path>" with the path exactly as documented, including any global prefix. The document contains: ${documented.length === 0 ? "(none)" : documented.join(", ")}.`
|
|
501
516
|
);
|
|
502
517
|
}
|
|
503
|
-
function augmentPaths(paths, options, routes, contributions) {
|
|
518
|
+
function augmentPaths(paths, options, routes, contributions, inherits) {
|
|
504
519
|
return Object.fromEntries(
|
|
505
520
|
Object.entries(paths).map(([path, item]) => {
|
|
506
521
|
const augmented = operationsOf(item).map(([method, operation]) => [
|
|
507
522
|
method,
|
|
508
|
-
augmentOperation(
|
|
523
|
+
augmentOperation(
|
|
524
|
+
asRecord(operation),
|
|
525
|
+
path,
|
|
526
|
+
method,
|
|
527
|
+
options,
|
|
528
|
+
routes,
|
|
529
|
+
contributions,
|
|
530
|
+
inherits
|
|
531
|
+
)
|
|
509
532
|
]);
|
|
510
533
|
return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
|
|
511
534
|
})
|
|
@@ -543,7 +566,15 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
|
|
|
543
566
|
const routes = indexOwnRoutes(options, pathPrefixes);
|
|
544
567
|
const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
|
|
545
568
|
assertOverridesMatch(served, openapi);
|
|
546
|
-
const paths = document.paths === void 0 ? {} : {
|
|
569
|
+
const paths = document.paths === void 0 ? {} : {
|
|
570
|
+
paths: augmentPaths(
|
|
571
|
+
served,
|
|
572
|
+
options,
|
|
573
|
+
routes,
|
|
574
|
+
contributions,
|
|
575
|
+
inheritsRequirement(document, openapi)
|
|
576
|
+
)
|
|
577
|
+
};
|
|
547
578
|
const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
|
|
548
579
|
return { ...document, components: Object.fromEntries(merged), ...paths, ...security };
|
|
549
580
|
}
|
|
@@ -651,7 +682,7 @@ async function applyBymaxOpenApi(app) {
|
|
|
651
682
|
const logger = new Logger("BymaxCoreModule");
|
|
652
683
|
const resolved = resolveCoreOptions(app);
|
|
653
684
|
const options = resolved.openapi;
|
|
654
|
-
if (isProductionRuntime()) {
|
|
685
|
+
if (isProductionRuntime(runtimeEnvironmentName(resolved.environment))) {
|
|
655
686
|
if (options.suppressedInProduction || options.enabled) {
|
|
656
687
|
logger.warn(
|
|
657
688
|
'openapi.enabled was requested but the OpenAPI document is never served in production. Set NODE_ENV to "development" or "test" to serve it.'
|
|
@@ -19,6 +19,12 @@ function coercePositiveInt(value, fallback) {
|
|
|
19
19
|
function clampPageToLimit(page, limit) {
|
|
20
20
|
return Math.min(page, Math.floor(Number.MAX_SAFE_INTEGER / limit) + 1);
|
|
21
21
|
}
|
|
22
|
+
function clampPageToOffset(page, limit, maxOffset) {
|
|
23
|
+
if (maxOffset === void 0 || !Number.isSafeInteger(maxOffset) || maxOffset < 0) {
|
|
24
|
+
return page;
|
|
25
|
+
}
|
|
26
|
+
return Math.min(page, Math.floor(maxOffset / limit) + 1);
|
|
27
|
+
}
|
|
22
28
|
function clampLimit(rawLimit, options) {
|
|
23
29
|
const defaultLimit = coercePositiveInt(options?.defaultLimit, DEFAULT_LIMIT);
|
|
24
30
|
const maxLimit = coercePositiveInt(options?.maxLimit, DEFAULT_MAX_LIMIT);
|
|
@@ -29,7 +35,11 @@ function clampLimit(rawLimit, options) {
|
|
|
29
35
|
function normalizePageQuery(raw, options) {
|
|
30
36
|
const limit = clampLimit(raw.limit, options);
|
|
31
37
|
return {
|
|
32
|
-
page:
|
|
38
|
+
page: clampPageToOffset(
|
|
39
|
+
clampPageToLimit(coercePositiveInt(raw.page, MINIMUM), limit),
|
|
40
|
+
limit,
|
|
41
|
+
options?.maxOffset
|
|
42
|
+
),
|
|
33
43
|
limit
|
|
34
44
|
};
|
|
35
45
|
}
|
|
@@ -51,14 +51,43 @@ interface PageResult<T> {
|
|
|
51
51
|
* Options are per-call and never retained between calls.
|
|
52
52
|
*
|
|
53
53
|
* @param raw - The untrusted page and limit values from the request.
|
|
54
|
-
* @param options - Per-call `defaultLimit` (default `20`)
|
|
55
|
-
*
|
|
54
|
+
* @param options - Per-call `defaultLimit` (default `20`), `maxLimit` (default
|
|
55
|
+
* `100`) and `maxOffset` (absent by default) overrides. `maxLimit` bounds how
|
|
56
|
+
* many rows a request reads; `maxOffset` bounds how far in it starts, which is
|
|
57
|
+
* the half an offset-paginated database pays for.
|
|
56
58
|
* @returns A clamped, safe query ready to hand to a repository.
|
|
57
59
|
*/
|
|
60
|
+
/**
|
|
61
|
+
* Options for {@link normalizePageQuery}: the shared limit bounds, plus the one
|
|
62
|
+
* that only means anything to offset pagination.
|
|
63
|
+
*
|
|
64
|
+
* Declared here rather than beside the shared bounds so it cannot reach the
|
|
65
|
+
* cursor normalizer, which takes {@link PaginationLimitOptions} and has no
|
|
66
|
+
* offset to bound. An option that type-checks on a function that ignores it is
|
|
67
|
+
* worse than a missing one — it reads as configured and does nothing.
|
|
68
|
+
*/
|
|
69
|
+
interface PageQueryOptions extends PaginationLimitOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Hard cap applied to the repository offset the query drives,
|
|
72
|
+
* `(page - 1) * limit`. Absent by default, which bounds nothing beyond
|
|
73
|
+
* arithmetic safety.
|
|
74
|
+
*
|
|
75
|
+
* `maxLimit` bounds how many rows a request reads; this bounds how far in it
|
|
76
|
+
* starts, which is the half that costs on an offset-paginated database — a
|
|
77
|
+
* `SELECT … OFFSET 20000000000` is a twenty-byte request that scans a table.
|
|
78
|
+
* Set it wherever the page index reaches SQL and the dataset has a knowable
|
|
79
|
+
* ceiling. There is deliberately no default: legitimate deep paging exists,
|
|
80
|
+
* and a silent cap would change the rows a working query returns.
|
|
81
|
+
*
|
|
82
|
+
* `0` is meaningful and means "the first page only". Any other value that is
|
|
83
|
+
* not a non-negative safe integer is read as absent.
|
|
84
|
+
*/
|
|
85
|
+
maxOffset?: number;
|
|
86
|
+
}
|
|
58
87
|
declare function normalizePageQuery(raw: {
|
|
59
88
|
page?: unknown;
|
|
60
89
|
limit?: unknown;
|
|
61
|
-
}, options?:
|
|
90
|
+
}, options?: PageQueryOptions): PageQuery;
|
|
62
91
|
/**
|
|
63
92
|
* Assemble a {@link PageResult} from a page of items and the total count.
|
|
64
93
|
*
|
|
@@ -51,14 +51,43 @@ interface PageResult<T> {
|
|
|
51
51
|
* Options are per-call and never retained between calls.
|
|
52
52
|
*
|
|
53
53
|
* @param raw - The untrusted page and limit values from the request.
|
|
54
|
-
* @param options - Per-call `defaultLimit` (default `20`)
|
|
55
|
-
*
|
|
54
|
+
* @param options - Per-call `defaultLimit` (default `20`), `maxLimit` (default
|
|
55
|
+
* `100`) and `maxOffset` (absent by default) overrides. `maxLimit` bounds how
|
|
56
|
+
* many rows a request reads; `maxOffset` bounds how far in it starts, which is
|
|
57
|
+
* the half an offset-paginated database pays for.
|
|
56
58
|
* @returns A clamped, safe query ready to hand to a repository.
|
|
57
59
|
*/
|
|
60
|
+
/**
|
|
61
|
+
* Options for {@link normalizePageQuery}: the shared limit bounds, plus the one
|
|
62
|
+
* that only means anything to offset pagination.
|
|
63
|
+
*
|
|
64
|
+
* Declared here rather than beside the shared bounds so it cannot reach the
|
|
65
|
+
* cursor normalizer, which takes {@link PaginationLimitOptions} and has no
|
|
66
|
+
* offset to bound. An option that type-checks on a function that ignores it is
|
|
67
|
+
* worse than a missing one — it reads as configured and does nothing.
|
|
68
|
+
*/
|
|
69
|
+
interface PageQueryOptions extends PaginationLimitOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Hard cap applied to the repository offset the query drives,
|
|
72
|
+
* `(page - 1) * limit`. Absent by default, which bounds nothing beyond
|
|
73
|
+
* arithmetic safety.
|
|
74
|
+
*
|
|
75
|
+
* `maxLimit` bounds how many rows a request reads; this bounds how far in it
|
|
76
|
+
* starts, which is the half that costs on an offset-paginated database — a
|
|
77
|
+
* `SELECT … OFFSET 20000000000` is a twenty-byte request that scans a table.
|
|
78
|
+
* Set it wherever the page index reaches SQL and the dataset has a knowable
|
|
79
|
+
* ceiling. There is deliberately no default: legitimate deep paging exists,
|
|
80
|
+
* and a silent cap would change the rows a working query returns.
|
|
81
|
+
*
|
|
82
|
+
* `0` is meaningful and means "the first page only". Any other value that is
|
|
83
|
+
* not a non-negative safe integer is read as absent.
|
|
84
|
+
*/
|
|
85
|
+
maxOffset?: number;
|
|
86
|
+
}
|
|
58
87
|
declare function normalizePageQuery(raw: {
|
|
59
88
|
page?: unknown;
|
|
60
89
|
limit?: unknown;
|
|
61
|
-
}, options?:
|
|
90
|
+
}, options?: PageQueryOptions): PageQuery;
|
|
62
91
|
/**
|
|
63
92
|
* Assemble a {@link PageResult} from a page of items and the total count.
|
|
64
93
|
*
|
|
@@ -17,6 +17,12 @@ function coercePositiveInt(value, fallback) {
|
|
|
17
17
|
function clampPageToLimit(page, limit) {
|
|
18
18
|
return Math.min(page, Math.floor(Number.MAX_SAFE_INTEGER / limit) + 1);
|
|
19
19
|
}
|
|
20
|
+
function clampPageToOffset(page, limit, maxOffset) {
|
|
21
|
+
if (maxOffset === void 0 || !Number.isSafeInteger(maxOffset) || maxOffset < 0) {
|
|
22
|
+
return page;
|
|
23
|
+
}
|
|
24
|
+
return Math.min(page, Math.floor(maxOffset / limit) + 1);
|
|
25
|
+
}
|
|
20
26
|
function clampLimit(rawLimit, options) {
|
|
21
27
|
const defaultLimit = coercePositiveInt(options?.defaultLimit, DEFAULT_LIMIT);
|
|
22
28
|
const maxLimit = coercePositiveInt(options?.maxLimit, DEFAULT_MAX_LIMIT);
|
|
@@ -27,7 +33,11 @@ function clampLimit(rawLimit, options) {
|
|
|
27
33
|
function normalizePageQuery(raw, options) {
|
|
28
34
|
const limit = clampLimit(raw.limit, options);
|
|
29
35
|
return {
|
|
30
|
-
page:
|
|
36
|
+
page: clampPageToOffset(
|
|
37
|
+
clampPageToLimit(coercePositiveInt(raw.page, MINIMUM), limit),
|
|
38
|
+
limit,
|
|
39
|
+
options?.maxOffset
|
|
40
|
+
),
|
|
31
41
|
limit
|
|
32
42
|
};
|
|
33
43
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bymax-one/nest-core",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.3",
|
|
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",
|