Skip to content

feat(core/zod): opt-in strictInput — reject unknown top-level body keys (#79) - #85

Open
tnramalho wants to merge 32 commits into
mainfrom
feat/strict-zod-create-79
Open

feat(core/zod): opt-in strictInput — reject unknown top-level body keys (#79)#85
tnramalho wants to merge 32 commits into
mainfrom
feat/strict-zod-create-79

Conversation

@tnramalho

Copy link
Copy Markdown
Collaborator

What

Opt-in strictInput: true on zodResource body operations (create / update / replace): unknown top-level JSON keys return 400 naming the offending keys, instead of being silently stripped. Applies to whichever input schema is in effect — the derived projection or an input override. Default unchanged.

Closes #79

The contract, stated honestly

Adversarial review caught three overclaims in the first draft; each is now a documented limit pinned by a test rather than a marketing line:

  • Top-level only. zod's .strict() does not recurse; nested objects still strip. Pinned by e2e.
  • Server-owned fields are rejected, not ignored. id, timestamps, version, owner columns are outside the input projection, so echoing a fetched row into a strict replace is 400 — proven over HTTP. Upside: an owner-column spoof gets named instead of silently overwritten.
  • additionalProperties: false appears only once the document passes nestjs-zod's cleanupOpenApiDoc — a step no doc in this repo had ever mentioned. The e2e asserts both halves (strict → false, non-strict → absent).

strictInput: boolean over unknownKeys: 'strip' | 'reject' was an explicit owner decision — no demand and no channel for a third mode today.

Coverage

9 new e2e cases: repro 400 naming the key; clean 201; override+strict; derived-update+strict; strict replace echo-back; non-strict op on the same resource keeps stripping; nested-key top-level pin; bodyless misuse throws at definition time; OpenAPI keyword through cleanup. Mechanism verified load-bearing by reverting .strict() (derived and override cases both fail).

Gates: build, api:report:check-built, typecheck:spec, 833 unit, 306 package e2e, lint:all.

Notes

🤖 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 zodResource create today accepts `{ "modelId": "x", "unexpected": 1 }`
with 201 and silently drops the unknown key: a plain `z.object` strips
during parse, so a client believes a strict API validated a body it in
fact discarded. Products needing strict creates were attaching a
hand-written guard per route, duplicating the schema the projection had
already derived (issue #79, from a production field report).

`strictInput: true` on a body operation compiles the EFFECTIVE input
schema — the derived projection, or an `input` override, whichever is
in use — with `.strict()`, so unknown keys become zod issues and the
existing body-validation interceptor answers 400 naming them. One
helper replaces the three inline DTO compilations precisely so the flag
cannot apply to one schema source and miss the other. Declaring it on a
bodyless operation fails at definition time. The default is unchanged:
stripping is the long-standing generated-CRUD contract, and flipping it
under existing consumers would turn tolerated clients into broken ones.

A boolean rather than an `unknownKeys: 'strip' | 'reject'` mode was an
explicit owner decision: the third mode ("warn") has no demand and no
channel today, and the house rule is not to widen a published surface
for a hypothesis.

Adversarial review caught three overclaims in the first version of this
change, now corrected and pinned by tests rather than asserted:

- `.strict()` is TOP-LEVEL only — an unknown key inside a nested object
  is still stripped. Documented, and pinned by an e2e so a future
  deep-strict zod flips a test rather than silently changing contract.
- Fields the projection excludes (`id`, timestamps, `version`, owner
  columns) are REJECTED under strict, so echoing a fetched row back
  into a strict `replace` is 400 — proven over HTTP, sold in the README
  with its upside (an owner-column spoof is named, not overwritten) and
  its cost (read-modify-write clients strip server-owned keys first).
- `additionalProperties: false` appears in the OpenAPI document only
  after `nestjs-zod`'s `cleanupOpenApiDoc` — which no doc in this repo
  had ever mentioned. The e2e asserts both halves: strict DTO gains the
  keyword through cleanup, non-strict DTO does not.

Every mechanism is pinned by a test that fails with the mechanism
reverted — verified by reverting: dropping `.strict()` fails the
derived AND override cases.
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
…e-79

# Conflicts:
#	CHANGELOG.md
#	api/public-api-reports.json
#	packages/rockets-core/README.md
#	packages/rockets-core/src/__e2e__/rockets-core-zod-operation-io.e2e-spec.ts
#	packages/rockets-core/src/zod/compile-zod-resource-core.ts
#	packages/rockets-core/src/zod/zod-operations.ts
@tnramalho
tnramalho requested a review from leoafarias August 21, 2026 19:20
…gaps

Clean-room review findings on #85:

- The echoed-back-row test passed without `.strict()`: the 400 came from
  `meta: null` failing `f.string().optional()`, and `toMatch(/id/)`
  matched "Invalid". The fixture now uses `f.string().nullish()` so the
  echoed row round-trips the input schema, and the assertion names the
  rejected key. Mutation-proved: stubbing `.strict()` out turns this
  test (and the new ones below) red.
- `compileZodCore` has two callers but only `zodResource` was covered:
  added a `zodSubResource` strict create case.
- The OpenAPI cleanup test now also asserts `additionalProperties:
  false` on an `input`-override strict DTO, not just the derived one.
- `strictInput: false` (e.g. a computed flag) on a non-body operation no
  longer throws "no request body to be strict about" — only `true` is a
  config mistake there.
- Inlined the single-valued `kind` parameter left over in `overrideDto`.
- README: state explicitly that core's `SwaggerUiModule` does not run
  `cleanupOpenApiDoc`, so the app bootstrap must, or generated clients
  learn the strict contract only via 400.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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