Skip to content

feat(core): schema DTOs survive class-validator whitelist pipes (#83) - #89

Open
tnramalho wants to merge 32 commits into
mainfrom
feat/nestjs-zod-pipe-83
Open

feat(core): schema DTOs survive class-validator whitelist pipes (#83)#89
tnramalho wants to merge 32 commits into
mainfrom
feat/nestjs-zod-pipe-83

Conversation

@tnramalho

Copy link
Copy Markdown
Collaborator

What

Issue #83 from a production field report: hand-written controllers using schema DTOs (createZodDto) under a global ValidationPipe({ whitelist: true }) receive {} — validated by the schema, then emptied by the whitelist, silently, with a success status.

Closes #83

Three pieces:

  1. RecognitionStandardSchemaDtoValidationPipe now recognises any class carrying a Standard Schema as its static schema (bare nestjs-zod DTOs included), via one predicate (getCarriedStandardSchema) defined once and imported everywhere; the tolerant same-named alias is deleted.
  2. The stampallowStandardSchemaKeys(dto) writes @Allow() per declared key so a schema DTO survives anyone's whitelist pipe; compileDtoClass output ships stamped. Open (catchall/passthrough) schemas are refused — stamping them would strip keys the schema itself accepts.
  3. The aware pipeStandardSchemaAwareValidationPipe VALIDATES schema carriers with their own schema; register exactly one schema validator per route. Rejects the ambiguous both-schema-and-constraints shape loudly.

What two review rounds changed

The seal BLOCKED the first version and reshaped the design — the notable catches, each now pinned by a test proven to fail when its mechanism is reverted:

  • The aware pipe originally skipped carriers on the unchecked assumption that an upstream pipe validated them — standalone use disabled all validation ({name:'', evil:'x'} → 201). It validates now.
  • The stamp on an open schema would reintroduce nestjs-zod DTOs are stripped by ValidationPipe whitelist in custom controllers #83's silent loss through the fix — refused, with compileDtoClass's deliberate bypass named in code and docs.
  • Two vacuous tests exposed and rewritten: a route that never saw its DTO (InstanceType<typeof X> emits Object into reflected param types), and an "idempotence" pin using a schema that could not fail — replaced by a transforming-schema test showing why pairing pipes is wrong.
  • The ambiguity detector compared against the wrong class-validator type string and would have rejected every DTO Rockets stamps — probed and pinned.

Escapes documented, limits stated

README names all three escapes (including the @Body({ schema }) dto: TypeAlias idiom sample-server already uses), what each protects against, and what none of them do: the stamp is survival, not validation. The original trap stays pinned by an e2e so the hazard description cannot go stale.

@kauandotnet — two of these pieces sit on your #75 design: recognition widened from brand-gated to carrier (was brand-only deliberate?), and the aware pipe's contract (validate-in-place). Both flagged for your ruling.

Gates

Build, api:report:check-built, typecheck:spec, 838 unit, 309 package e2e, lint:all. 12 e2e + 5 unit new.

Stacked on feat/operation-resource-43 (#49); retargets on its merge.

🤖 Generated with Claude Code

tnramalho and others added 30 commits August 14, 2026 09:07
Introduce query/command builders with Zod→DTO OpenAPI, fail-closed
validation, transport-agnostic OperationContext, and a sample-server
ops demo so consumers can copy the pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the hand-written PetTransferController with operationResource
over the existing CQRS handler, and drop the toy /ops ping/shout sample.

Co-authored-by: Cursor <cursoragent@cursor.com>
CI failed YN0028 — package.json listed @types/express but yarn.lock
did not. Express types are no longer imported after OperationRequest
façade; remove the unused dep instead of churning the lockfile.

Co-authored-by: Cursor <cursoragent@cursor.com>
* main:
  Harden repository and public API quality gates (#48)
CI api:report:check-built fails after #48 gate — record ResourceKind.Operation,
defineOperationResource, and the operationResource zod surface as reviewed
additive public declarations.

Co-authored-by: Cursor <cursoragent@cursor.com>
Lock the zod authoring API before it ships so the wire contract is
readable from the source and the compiler enforces intent instead of
comments.

- Builders are method-constrained: op.read (GET), op.write
  (POST/PUT/PATCH), op.delete (DELETE), replacing query/command.
- operations is callback-only, so base-path :params can type ctx.params.
- Operation path defaults to its key verbatim; path: '' mounts at root.
- Drop the dead kind field: HTTP method already decides input sourcing.
- Reject operation keys that would shadow Object.prototype members or the
  generated controller's ModuleRef field.

Co-authored-by: Cursor <cursoragent@cursor.com>
Catch response leakage, incoherent 204+body, bad path params, and
cross-resource route collisions when the app is planned — not after a
wrong route ships to production. Preserve Nest path params that sit
outside the resource-level params schema so op-path segments are not
stripped by whitelist validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Document callback op.read/write/delete, required output, params validation,
and path-as-key defaults across READMEs, CONFIGURATION, CHANGELOGs, and AGENTS.

Co-authored-by: Cursor <cursoragent@cursor.com>
Address PR review feedback around optional Zod peers and handler resolution.

Add structured route collision checks and post-boot registered-route validation.

Refs #49

Refs #50
Use the minimal adapter inspection contract so route-validator tests typecheck in CI.

Refs #49
Honor module/DynamicModule exports when collecting operation handlers so an
explicit import provider is not shadowed by a trailing class registration.

Co-authored-by: Cursor <cursoragent@cursor.com>
## `{ useClass }` was rejected before it could be interpreted

`assertOperationHandler` in the zod path required `typeof handler ===
'function'`, so the tagged form documented in CONFIGURATION.md and the
core README compiled and then threw during `operationResource()`
construction. It exists precisely for the shape no runtime check can
identify — a class whose `handle` is an instance field — so rejecting it
made the escape hatch unusable. Now accepted, with the malformed case
still failing fast.

## Module re-exports were not followed

`collectImportedExportTokens` read only an imported module's direct
`exports`. Nest re-exports transitively: `exports: [InnerModule]`
publishes everything Inner exports. A handler reachable through
`Outer → Inner` therefore looked unsupplied, was auto-registered in the
generated module, and shadowed the imported provider — and when its
dependencies were private to `InnerModule`, the app failed to boot.

Export walking is now transitive across module classes and
`DynamicModule` re-exports, with a `visited` guard for the
mutually-re-exporting case Nest tolerates.

That exposed the second half: not registering the handler locally meant
`moduleRef.resolve(..., { strict: true })` could no longer find it. The
strict resolve is what stops two operation resources sharing a handler
CLASS from resolving each other's instance, so it stays — for handlers
this module actually registers. A handler supplied by an import has no
local provider to be strict about and resolves through the module's
injector, where exactly one provider for that token is reachable.
`defineOperationResource` now passes the locally-registered token set to
the controller builder, which picks the mode per handler.

Covered by a bootstrap e2e whose handler depends on a value private to
the inner module — the case that used to fail at boot, asserted by
serving a request rather than by inspecting providers.

## Operation IDs and DTO names were not unique

`controllerName + method + key` collides for configurations the planner
accepts: two bundles on one base path can each declare a `GET` keyed
`action` with different explicit paths. Both routes are legal and
distinct; both received one operation ID and one DTO name, so Swagger
pointed them at a single component and the second schema overwrote the
first. The operation's own path is now part of the discriminator —
appended only when it differs from the key, so the common case keeps its
short name. Shared with the zod DTO namer so IDs and components stay in
step.

## Route dimensions were discarded for operation resources

`collectOperationRoutes` hardcoded `host`/`version` to `undefined`, so a
v1 CRUD `GET /widgets` beside an operation `GET /widgets` carrying
`Version('2')` was rejected as a duplicate although Nest routes them
apart. Both are now read off the GENERATED controller — method-level
version wins over the controller's, matching Nest.

(Nest's `Version` is a method decorator: it always writes to
`descriptor.value`. The regression test uses per-operation `decorators`
accordingly.)

## Wildcard overlap (P2)

`segmentListsMayOverlap` returned `true` at the first wildcard and
dropped every remaining segment, so `a/*rest/x` was reported as
overlapping `a/y/z` — a false positive that rejects a valid
configuration. Wildcards now match against zero-or-more segments with the
suffix still required to align. A match that can ONLY be reached by a
wildcard absorbing zero segments depends on router semantics that vary by
version, so it returns `'unknown'`, which the collision validator already
reads as "not proven" and accepts.
A third review pass looked only at whether the previous round's fixes
created new problems. Four did. All verified against the installed Nest
dist, not against docs.

## Handler resolution escaped its module (HIGH)

The previous fix resolved an imported handler with `strict: false`. That
is not "the module that owns it", as the docstring claimed:
`instance-links-host.js` returns `links[links.length - 1]` when given no
module id — the last module scanned app-wide, unrelated to the import
graph. One handler class exported by two modules would silently give both
operation resources the same instance, with per-boot arbitrary ordering.
It traded a loud `UnknownElementException` for a quiet wrong answer.

Resolution is strict again, always. For a handler this module does not
register, `defineOperationResource` now adds a local
`{ provide: Symbol(...), useExisting: HandlerClass }`: `useExisting`
resolves through normal DI so imports are honoured, while the token the
route asks for is local, so the strict lookup is unambiguous by
construction.

## Transitive export walking missed the dynamic-module host (HIGH)

`isModuleClass` tested for `@Module()` metadata keys. `@Module({})`
writes NONE — the decorator only defines keys present in the object it
receives — and that is exactly the canonical
`@Module({}) class X { static forRoot(): DynamicModule }` shape of every
`@concepta/nestjs-*` module. So `exports: [BillingModule]` re-exporting a
dynamic module was invisible, the handler looked unsupplied, and with a
dependency private to that module the app failed to boot with the very
error the previous fix was meant to remove.

A metadata-less exported class is now matched against the host module's
own `imports` for a `DynamicModule` whose `.module` is that class, and
its exports are walked. Covered by a bootstrap e2e that reproduces the
original failure message.

## Operation ids were still collidable (MEDIUM)

`operationDiscriminator` slugifies non-alphanumerics to `_`, which is not
injective: `{ key: 'run', path: 'a' }` and `{ key: 'run_a' }` both yield
`run_a`, as do paths `a/b` and `a-b`. The routes differ, so the path
check passes, and the second OpenAPI component silently overwrites the
first.

Rather than chase an injective slug, the planner now asserts operation-id
uniqueness directly and names both sources. The discriminator goes back
to being a readability optimisation instead of something correctness
rests on.

## Route dimensions produced false negatives (MEDIUM)

`dimensionsMayOverlap` compared `String(value)`. `VERSION_NEUTRAL` is a
Symbol that matches every version in Nest; stringified it compares
unequal to `'1'`, so a version-neutral route sharing a path with a v1
route was reported as disjoint and accepted — then last-wins at runtime.
RegExp and pattern hosts had the same problem.

The dimension half now mirrors the discipline the route-pattern half
already had: only a literal-value inequality may prove routes disjoint.
`VERSION_NEUTRAL` and undecidable hosts count as overlapping, because a
missed collision is silent while an over-reported one is a boot error the
author can resolve.

Refs #43, #50
Issue #50 freezes the `operationResource` authoring surface before 1.0,
so this is the last moment these two cost nothing.

## `outputDto?` + `outputDisabled?` → `output: Type<object> | false`

One decision was modelled as two optional fields, which made two
meaningless states expressible — both set, and neither set — and
`defineOperationResource` had to reject each at runtime. Required and
closed, they cannot be written, so there is nothing to reject: two of the
three validations are deleted rather than kept. Only the genuine rule
survives (`status: 204` with an output).

The authoring layer already modelled it correctly — `op.read({ output })`
has always taken `schema | false`. The compiled form was the half that
disagreed; now both say the same thing.

The spec that asserted the runtime rejection is replaced by a
`@ts-expect-error` pin: the guarantee moved from a throw to the type, and
the pin fails if the field ever becomes optional again.

## `PendingBuilder` unexported

`'read' | 'write' | 'delete'` was a named export. Publishing the builder
taxonomy means adding `op.stream` or `op.upload` later is a breaking
change to a union nobody constructs — consumers write `op.read(...)` and
never name the type. It is now referenced-only (via `PendingOperation`
and `OperationRecord`, which stay public because #54's typed-client work
consumes `authored`), so it can widen without breaking a contract.

BREAKING CHANGE: `CompiledOperationDescriptor.outputDto` /
`outputDisabled` are replaced by a required `output: Type<object> | false`.
Only hand-built descriptors are affected — `operationResource()` and
`defineOperationResource()` callers are not. `PendingBuilder` is no
longer exported from `@concepta/rockets-core/zod`.

Refs #50
`da24cff` moved `AccessControlQuery` from the controller class to each
route, but the `acl` doc comment — the public interface documentation
for the option — still described the class-level behaviour it replaced.

That inverts the meaning of the feature. Class-level stamping is
precisely what made a per-operation `query` unable to override
anything: upstream merges with `getAllAndMerge([getClass(),
getHandler()])` and breaks on the first service returning `true`,
class-first, so the permissive entry wins. Anyone reading the old text
would conclude the opposite of what the code now guarantees.

Adds the invariant next to the description so the reason survives the
next edit.
Four findings, re-verified against b838177 and still reproducing behind
green CI. Two were the incomplete half of the round-2 fixes: the case in
the reproduction was closed, the space the public types admit was not.

## One namer for operation ids and component names

Operation ids keyed off an underscore slug while DTO names pascal-cased,
so `foo-bar` and `fooBar` received DISTINCT ids and ONE component name.
The path check and the id check both passed, and the second schema
silently replaced the first in the generated document.

Both now derive from `operationDtoBaseName`, and the planner asserts the
result rather than trusting the transform — `pascal` stays lossy on
purpose, because making it injective would be a guess about which
characters carry meaning where asserting is total. Same reasoning as the
neighbouring id assertion.

The assertion compares class IDENTITY, not the name plus where it was
declared. One compiled DTO reused as the output of several operations is
one class and one component; an earlier revision of this commit compared
names, rejected that configuration, and simultaneously swallowed a real
collision between two bundles sharing a base path because their source
labels matched. `paramsDto` is deliberately not claimed: path params are
emitted as inline `ApiParam` entries and never occupy a component.

`nameGeneratedDto` is now the single place a generated name is minted.
The `z.array` branch of the DTO compiler and `namedZodDto` both bypassed
the brand, and `z.array` is the documented shape for a list endpoint, so
the most common output was invisible to the very check meant to protect
it.

## Module exports are a union, on every path

Nest unions a module's static `@Module` exports with the dynamic ones its
factory returned, and unions imports the same way
(`@nestjs/core/scanner.js:55-57` and `:151-155`). Three shapes each lost
one half:

- `Host.forRoot()` imported directly, exports declared statically
- a host populating BOTH halves, reached through a re-export
- a wrapper returning `{ imports: [Inner.forRoot()], exports: [Inner] }`

In each case the handler was considered unsupplied, re-registered
locally, and failed to resolve a module-private dependency — or, worse,
resolved and ran as a silent duplicate instance. All three now have an
e2e that fails without the fix.

## A non-object payload is rejected, not narrowed

`POST []` against `z.object({ note: z.string().optional() })` returned
200 with an empty input: the body was narrowed to `{}` before validation,
and the same narrowing bypassed any all-optional class-validator DTO.
Substituting a valid value for an invalid one is not something a
validation boundary should do quietly.

The predicate checks the prototype rather than `typeof value === 'object'`,
so a `Buffer` from a raw parser is rejected too. A MISSING body still
becomes `{}` — a `POST` with no payload against an all-optional input
stays legal, and a required field still fails with its own field-level
message. Express's query object is null-prototype, which the check
accounts for.

## Documentation that claimed a guard it does not have

`ResourceOperationConfig.acl` said combining `acl` with a manual
`AccessControl*` decorator is "rejected at definition time". There is no
such check. Worse, the old text called upstream's behaviour a merge: the
grant is a plain `SetMetadata` write read with `reflector.get`, the route
applies `decorators` first and the `acl` grant last, so `acl`
deterministically OVERWRITES a hand-written grant — a manual grant
deliberately tighter than the inferred action is discarded in silence.

The doc now says that, points at CONFIGURATION.md §5a rather than §7, and
records that detection is decidable at controller-build time and simply
not implemented. §5a is aligned, and its stale "v1 does not wire ACL
grants" line is gone.
Adversarial review of the just-merged route policy audit (#76) returned
BLOCKED: the load-bearing judgement — "is this app guarded?" — was wrong
in both directions, and the composition the README advertises could not
use the feature at all.

## Counting guards is not authentication

The audit counted APP_GUARD provider wrappers. Upstream access-control
registers one unconditionally and resolves it to `null` under
`appGuard: false`, so an app with ZERO authentication reported every
route as guarded and `requireAuth` booted green — the permissive lie
this feature exists to remove, told by the feature itself. In the other
direction, request-scoped guards live in Nest's `injectables`, invisible
to `DiscoveryService.getProviders()`, so a correctly guarded app
hard-failed.

Both defects trace to one false comment: "`ApplicationConfig` is not in
`@nestjs/core`'s export map." It is — via `export * from
'./application-config.js'`, which a grep for the symbol name cannot see.
A bad grep became a load-bearing design constraint. The service now
reads `getGlobalGuards()` (resolved instances, nulls filtered) and
`getGlobalRequestGuards()`, and classifies: a route is `guarded` only
when a guard RECOGNISED as authentication is present — `AuthServerGuard`
or classes in `routePolicy.authGuards`, subclasses included on both the
instance and metatype branches. A throttler or an ACL guard is a global
guard that authenticates nothing.

## The flagship composition could not declare a policy

`defineRocketsAuth()` swaps the global guard for upstream `JwtGuard`
(`providesAppGuard`), so every route in a built-in-auth app read
`unguarded-app` and any declared rule aborted the boot — on exactly the
routes the README promises to cover. The contributions seam declared
THAT an integration owns the guard but not WHICH class. It now carries
`authGuards`; `defineRocketsAuth` contributes `[JwtGuard]` (or a custom
`appGuard` instance's constructor), and the SERVER composition merges
them into the forwarded policy — the merge cannot live in core, which
rejects contribution-carrying bootstraps outright. An earlier revision
of this commit put it there anyway; it was unreachable code and is gone.

## Smaller truths

- `acl-metadata-keys` reads `AccessControlGrant().KEY` — the key
  `SetMetadata` attaches to the decorator it returns — instead of a
  75-line throwaway-class probe. Resolved lazily, throws loudly: a
  missing key must never degrade into every-route-ungranted silence.
- `allowControllers` matches by class identity; a decoy class sharing
  the name no longer exempts anything (e2e proves the decoy fails).
- `staleAllow` fires only while at least one rule is declared: a
  recognition-only policy polices nothing, and aborting a boot over
  list hygiene when nothing is enforced would make the audit the
  incident.
- Path arrays (`@Controller(['a','b'])`) report one row per
  combination.
- Docs de-overclaimed twice: the README's staleness sentence and the
  same sentence surviving unqualified in the public JSDoc.

Every mechanism here is pinned by a test that fails when the mechanism
is reverted — verified for each by reverting it: wrapper-counting,
name-based exemption, the missing merge, the staleAllow gate, first-path
collection, and the dropped contribution all turn tests red.
…ce-43

# Conflicts:
#	CHANGELOG.md
#	packages/rockets-core/CHANGELOG.md
#	packages/rockets-core/README.md
#	packages/rockets-core/package.json
A production field report (issue #83): hand-written controllers using
schema-carrying DTOs (`createZodDto`) under a global
`ValidationPipe({ whitelist: true })` receive `{}` — the whitelist
strips every property without class-validator metadata AFTER the schema
already validated the body. Silent, with a success status, which is
what makes a consumer delete their manual safety net.

## Three pieces, each with the failure mode it removes

**One predicate.** `getCarriedStandardSchema` in the standard-schema
subpath is the single definition of "this class carries a schema";
every internal call site imports it, and the tolerant same-named alias
in common/utils is gone — a fix written against the wrong of two
opposite-contract predicates imports fine and misbehaves at runtime.
The DTO pipe now recognises carriers, not just Rockets-branded classes:
a bare nestjs-zod DTO was silently skipped by the brand check.

**The stamp.** `allowStandardSchemaKeys` writes class-validator
`@Allow()` per declared key, so a schema DTO survives ANYONE's
whitelist pipe — including pipes Rockets will never own. Generated
`compileDtoClass` output ships stamped. The stamp is SURVIVAL, not
validation, and the docs say so. It refuses OPEN schemas
(catchall/passthrough): stamping declared keys there would let the
whitelist strip keys the schema itself accepts — issue #83's silent
loss reintroduced through the fix. `compileDtoClass` bypasses that
refusal deliberately (an open generated-only resource must not become
a boot failure), with the cost named in the README.

**The aware pipe.** `StandardSchemaAwareValidationPipe` VALIDATES
schema carriers with their own schema. The first draft SKIPPED them,
trusting that "something upstream validated" — an assumption it never
checked, which meant the pipe used standalone (exactly how the README
presented it) disabled all validation: `{ name: '', evil: 'x' }`
reached the handler with a 201. Adversarial review blocked it; the
shipped pipe has no such trust. Register exactly one schema validator
per route — pairing double-parses, and a transforming schema is not
idempotent (pinned with `.transform()`, after the first "idempotence"
pin was shown to use a schema that could not fail). `transform` and
`errorHttpStatusCode` are forwarded so both DTO kinds fail alike; a
DTO carrying BOTH a schema and class-validator constraints is rejected
loudly as ambiguous, with `@Allow()` metadata never counting as a
constraint (probed: it registers as 'whitelistValidation' — the first
draft compared against 'whitelist' and would have rejected every DTO
Rockets stamps).

The trap itself stays pinned: unstamped DTO + plain whitelist pipe
still yields `{}` in an e2e, so the hazard the docs describe cannot
silently go stale. Two review rounds (one BLOCKED, one REVISE) shaped
this commit; every mechanism fails a test when reverted, including two
tests the review exposed as vacuous (a metatype the pipe never saw via
`InstanceType<typeof X>` emitting `Object`, and the false idempotence
pin) that were rewritten to bite.
Two reviews ran over this branch on the owner's order — a clean-room
seal with no prior context, and a separate automated review pass — and
their cross-checked findings surfaced eleven defects that three rounds
of external review had not reached. Every fix below is pinned by a test
proven to fail with the mechanism reverted.

## The serialization leak (both reviews, independently)

Restoring free-form JSON columns had dropped `strategy: 'excludeAll'`
from the outbound options. Without it, an `@Expose()`d relation with no
`@Type()` — the common hand-written class-DTO shape — emitted the FULL
child row where the projection yields `{}`: `owner.passwordHash` on the
wire, proven by probe. Reachable wherever rows are plain objects
(Firestore-style adapters, JSON columns, handler-returned data);
TypeORM-hydrated instances emptied either way. The strategy is
restored, `@FreeFormJson` is required on response DTOs as well as
inputs, and the leak is pinned in unit and e2e so it cannot ship a
second time. The one test asserting the old behaviour was asserting the
leak mechanism as a feature; it now asserts the safe contract.

## ACL truth and loudness

- `acl` + a hand-written `AccessControl*` on one operation now fails at
  definition time — grant metadata is last-write-wins, and the
  combination silently REPLACED a possibly tighter manual rule. The
  read-back covers BOTH keys: the first cut checked only the grant, and
  a manual `AccessControlQuery` (a deliberately tighter row filter) was
  still being clobbered — the sibling slot of the defect just fixed.
  `acl: false` plus manual decorators stays legal (single writer), and
  CRUD keeps its documented plan-time limitation; both pinned.
- The route audit reads `AccessControlQuery` from handler THEN class,
  mirroring upstream's `getAllAndMerge([class, handler])` — auditing
  the handler alone aborted the boot of correctly-enforced apps.

## Validation and error-path resilience

- CRUD-vs-CRUD route collisions are checked in operation-free apps: an
  early return had silently gated the entire check on "any operation
  bundle exists".
- Route matching is compared case-insensitively, as Express routes.
- Nested class-validator failures name the failing field
  (`child.street: ...`) instead of answering `message: []`.
- A throwing custom error serializer falls back to the default envelope
  instead of replacing every error response with the adapter's bare 500.
- Hook-hidden columns no longer reappear on create responses.

## Dependency-wiring traps

- Handlers with request-scoped dependencies resolve `REQUEST`:
  the generated route now registers the request under the minted
  context id before resolving.
- A `forwardRef` whose factory throws (the TDZ circular-import case) is
  a sentinel, not a swallowed error — auto-registering a handler next
  to one refuses loudly and asks for explicit `providers`, in the
  top-level imports AND one frame down inside a module's re-exports,
  where the first cut left the silent-duplicate defect alive.
- `path-to-regexp` stays a range matching @nestjs/core's own, with the
  reasoning recorded where the next editor will look: an exact pin
  guarantees a second copy the day Nest bumps.

Deliberately NOT here, flagged for the owner as API decisions: a typed
transaction context on `OperationContext`, and an allow-style opt-out
for the planner collision check.
Four P1s, revalidated by the reviewer against f1f3ef3 and confirmed
current. Each fix is pinned by a test proven to fail with the mechanism
reverted — including two whose mutations reproduce the reviewer's exact
error messages.

## The module walk descends through ONE function, everywhere

Three prior fixes each taught one call site to carry a dynamic module's
imports; the re-export resolution path still walked exports without
them, so a three-level chain (Platform → Billing → Payments, each
re-exporting the bare host class) failed at exactly the depth that site
reached: Payments never matched, its handler was re-registered locally,
and its module-private symbol failed the boot. Every descent now goes
through `addDynamicModuleExports`, which carries BOTH import halves
(dynamic and static — Nest unions them, scanner.js:55-57) and the host
class's static exports. The fourth variant of a bug is the point where
per-site patches stop being fixes.

## Route ids carry the routing dimensions they ignored

A public v1 and a guarded v2 of one METHOD+path are different wire
routes; one unqualified id collapsed them, and a single `allow` entry
exempted BOTH — the silently-widening exemption the design document
forbids. Ids gain deterministic qualifiers when the dimension is
declared (`GET /widgets [v1]`, `[host:...]`), an unqualified entry that
matches nothing fails as stale, and an entry that still matches MORE
than one row fails closed as ambiguous.

## Promised injection is real injection

`RouteAuditService` was registered but never exported: `app.get()`
passed through the container-wide lookup while a consumer module's
`inject: [RouteAuditService]` failed DI — the docs promise the latter.
Exported under the same `routePolicy` condition; the pin is a real
consumer-module factory, and its mutation reproduces the reviewer's
exact resolution error.

## The documented import exists

`FreeFormJson` was reachable only by deep source import; the README's
root-entry example was `undefined` at runtime. Exported from the root
barrel (with the serialization option constants), and the e2e now
imports it THROUGH the root barrel so the documented path cannot drift
from the shipped surface again.
Base automatically changed from feat/operation-resource-43 to main August 21, 2026 19:10
# Conflicts:
#	CHANGELOG.md
#	packages/rockets-core/src/infrastructure/resource/operation-resource/build-operation-controller.ts
#	packages/rockets-core/src/zod/zod-dto.ts
typeof def === 'object' && def !== null
? Reflect.get(def, 'catchall')
: undefined;
if (catchall !== undefined && catchall !== null) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowStandardSchemaKeys() rejects closed Zod .strict() object schemas as “OPEN”. In Zod 4, .strict() is represented as def.catchall = ZodNever, so this condition treats a closed schema as catchall/passthrough. That breaks the documented “closed object schemas can be stamped” path for strict DTOs. Fix by treating ZodNever / def.type === "never" as closed, and add a test for z.object({ a: z.string() }).strict().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants