Skip to content

#104: one schema engine (upstream alpha.9, zod everywhere) - #105

Open
tnramalho wants to merge 43 commits into
mainfrom
feat/schema-engine-104
Open

#104: one schema engine (upstream alpha.9, zod everywhere)#105
tnramalho wants to merge 43 commits into
mainfrom
feat/schema-engine-104

Conversation

@tnramalho

@tnramalho tnramalho commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Implements RFC #104: one schema engine for the whole stack.

What changes, in plain terms

  • Every request and response in Rockets is now described by one zod schema — one schema contract, not one execution path. The same schema validates the request, shapes the response and generates the API docs. Three executors read it, each where it fits: Nest's per-route Standard Schema pipe for every request body/query/param; upstream's CRUD serializer for generated responses; and operationResource keeps its own inline output validation on purpose (Nest's serializer lets null/undefined through and maps arrays per item, which contradicts the pinned 500-on-mismatch policy). There are no more DTO classes, no class-validator, no class-transformer, no nestjs-zod.
  • Upstream @concepta/nestjs-* moves to 8.0.0-alpha.10 and Nest to stable 12.0.1 (the upstream CRUD engine already works this way; this PR adopts it).
  • @nestjs/config is no longer needed by Rockets.
  • Generated CRUD, operationResource, /me, the built-in auth routes and all three examples run on the same engine.

What you get

  • Responses can no longer leak columns a schema does not declare.
  • Every validation error carries structured details (which field, which message).
  • The OpenAPI document references named components (TagResponseDto, TagResponseDtoPaginatedDto, …) instead of inlining shapes.
  • Real defects found on the way are fixed with tests: hook errors (409/403/400) were being served as 500, generated operation routes were not documenting their parameters, and defineSubResource({ scope: false }) skipped the ancestor-chain check (see the alpha.10 pass below).

How to read it

One commit per stage (1–4, then 5+6 together); the root CHANGELOG.md has the migration notes per stage. The public API report (api/public-api-reports.json) lists every removed and added symbol.

Review pass (commit 4419137)

An adversarial review of the branch plus the first run of the only external consumer (realtystack) surfaced these; each one landed with a test that failed first:

  • A create whose body is legitimately empty (everything stamped by hooks — path parent, owner, ids) is a 201 now, not an unexplained 400. Generated resources run on RocketsCrudAdapter.
  • The "no open objects in a response" check reaches every wrapper (intersections, tuples, records, .readonly(), .catch(), …) and the paginated envelope; hidden columns stay hidden at every depth of a computed field.
  • One OpenAPI component describes one side: a schema reused as both request and response is a document-build error, instead of silently documenting one side with the other's shape.
  • f.date() rejects null and booleans (they used to become 1970).
  • Invitation acceptance validates userMetadata with the same default as signup and admin when the app configures none.
  • A route that declares a schema on a body/query/param but has no validation pipe fails the boot (requireSchemaPipe). The route audit is always on; the route policy stays opt-in.
  • The built-in auth request bodies keep their component names (LocalLoginDto, RefreshDto, Recovery*Dto).
  • Docs and agent instructions that still described the class-DTO era are rewritten.

Checked against realtystack (re-run on this branch's head): typecheck clean, 264/264, plus a real boot via tsx with a single @nestjs/core@12.0.1 resolved, auth answering 401 and /api/docs-json serving 44 paths. Its only change beyond version pins was routing document generation through SwaggerUiService.createDocument — hand-rolling it had silently dropped the schema converter. None of this branch's public API breaks touch anything it uses. Earlier run notes: typecheck clean, 249/249 tests green. Two real gaps came back from that run (the empty create body, and three hand-written routes that documented a schema but validated by hand — the new boot check caught them).

Second review pass (commit eca78ea)

A second adversarial review after the first fixes, plus the realtystack rerun:

  • Generated CRUD request bodies are named components again (PetCreateDto, TagUpdateDto, …) — upstream stamps them inline (nestjs-modules#467); Rockets drops that stamp where the route's own schema is named, so bodies and responses go through the same path. Both example contracts regenerated; nothing else in them moved.
  • The "one component, one side" rule also covers named schemas nested inside a request and a response.
  • A hand-written route's @SerializeOptions({ schema }) gets the same "no open objects" check at boot as generated resources; the schema-pipe check has its own exemption list instead of riding on allow.
  • rockets-auth: the admin PATCH /admin/users/:id and /admin/roles/:id bodies were documented but not validated (schema declared at controller level; upstream only wires the pipe from the operation) — fixed, and the user update no longer answers 500 (it ran a query on the finished transaction upstream leaves behind, nestjs-modules#468). The metadata repository pins userId from the caller on updates.
  • Migration note for the renamed/removed OpenAPI components (AuthenticationResponse, RocketsAuthUserDto, RocketsAuthRoleDto, per-resource PaginatedDto, CrudInvalidResponseDto gone).

realtystack on this commit: typecheck clean, 264/264.

Third review pass (commits bad0dfb, f3098e5)

  • The /me metadata handlers now forward the request context to every repository call (hooks run, the write joins the request transaction) and pin userId from the caller on updates — the same fix rockets-auth got, applied where it was still missing. UpsertUserMetadataCommand(ctx, userId, data) / GetUserMetadataQuery(ctx, userId) take the context first: breaking for apps that override or dispatch them directly (migration note in the changelogs).
  • The defect class behind the admin update bodies now has a structural guard: a generated CRUD body with no schema fails the boot. Two e2e fixtures that relied on unvalidated bodies now declare theirs.
  • The schema-pipe exemption list gets the same "more than one match" guard as allow; the audit report lists responses documented with standardSchema but not serialized (reported, not enforced).
  • Migration notes: an explicit @ApiBody beside a named @Body({ schema }) is dropped in favour of the schema; allow / allowControllers no longer exempt the schema-pipe check.

realtystack on bad0dfb: typecheck clean, 264/264.

Review fixes (commits 871648972a921e) — from the change request, plus an adversarial pass on the first fix:

  • A hidden column (dto: { response: false }) stays hidden on every PROJECTED response path — computed fields, JSON columns, exposed relations and operationResource outputs — through union, intersection, pipe (a top-level z.preprocess included), prefault, readonly, nonoptional and lazy (rebuilt; a recursive lazy no longer overflows the stack, including one whose cycle crosses a z.preprocess). .default() / .catch() hand their payload over without running the inner schema, so a hidden column below them (top-level or nested) is rejected at definition time, like discriminated union, tuple, record, map and set. A HAND-WRITTEN response schema (dto.response, operations.*.output, dto.paginated, userMetadata.responseSchema) is not projected and keeps its author-chosen component id, so a hidden field inside it is rejected at definition time with a pointer at .omit(); rockets-auth runs the same check on a consumer-supplied userCrud.model / roleCrud.model (signup, admin users, admin roles), which reach upstream CRUD directly. An op.sse() operation declares no output at all, so nothing is serialized — or stripped — there by design. A hand-written route's @SerializeOptions({ schema }) with a hidden field fails the boot through the route audit. Runtime regressions for the reported f.compute(z.union([nested, fallback])) case and each of the others; all fail against the previous code.
  • The "no open objects in a response" check walks the IN side of a pipe whenever its OUT passes values through (transform, any, unknown, custom — also behind wrappers, unions, arrays, object properties, record values, intersections and nested pipes); its memo caches only true, so the verdict never depends on visit order, so .passthrough().transform(v => v) is rejected; z.pipe(open, closed) strips on the way out and is accepted.
  • Node floor is 20.19.0 in every published engines, the docs and the CI matrix (20.19.0 + 22.x); both legs run the unit and package e2e suites, and release-readiness runs the example apps and the packed-consumer contract on exactly 20.19.0. The Vitest runner preloads @nestjs/core in a setup file: @nestjs/cqrs (CommonJS) requiring the ESM @nestjs/core while the runner's async import of it is still evaluating throws ERR_REQUIRE_CYCLE_MODULE on 20.19 (plain-Node orderings are fine; noted in the README for consumers whose own runner externalises ESM; filed upstream as @nestjs/cqrs (CommonJS) requiring the ESM @nestjs/core throws ERR_REQUIRE_CYCLE_MODULE on Node 20.19 under ESM-externalising test runners nestjs/nest#17583). fail-fast: false; module cache keyed by Node version.

alpha.10 pass (commits d26731dd72ccc9) — the three issues filed from this PR are fixed upstream, so the Rockets-side workarounds are deleted rather than kept. One commit per theme.

  • Bump to 8.0.0-alpha.10 (nestjs-modules#466 / #467 / #468).

  • Retired: RocketsCrudAdapter, restoreNamedRequestBodies, liftInlineRequestBodyDefinitions, crud-compat.ts, ConceptaRepositoryCompatModule, resolveConceptadevAppContext (→ upstream AppContextHost.from(), which throws where the local helper silently discarded a live transaction context), defineHook's HttpException pre-wrap, and the two mirrored metadata keys (→ upstream isAuthPublic() / isTransactional()). ROCKETS_DISABLE_GUARDS_TOKEN and RocketsCrudAdapter leave the public API; AuthServerGuard no longer injects Reflector. The served OpenAPI document is byte-identical — both example contracts unchanged.

  • Forced by the bump: TransactionManager.get() is gone (the Firestore dirty flag went with it — upstream removed the same pair from TypeOrmTransaction, and nothing read it), and propagation / TransactionRequiredException are gone, so there is no fail-closed transaction mode any more. A nested readOnly contradicting the scope it joins now throws instead of being ignored.

  • Security fix, found while auditing the adapter deletion: defineSubResource({ scope: false }) — and owner: false — dropped PathScopeGuard outright, and with it the ancestor-chain check. Ancestor route params are disabled: true and never reach buildWhere, so at three levels or more /parents/A/children/CHILD_OF_B/notes served CHILD_OF_B's rows where the scoped route answers 404. The guard now takes an optional ownerColumn and is attached to every sub-resource: the flags drop ownership only. Breaking for apps on either flag — a missing or hook-hidden parent is a 404, and a mismatched ancestor is refused. Four e2e tests including a depth-3 probe; each fails against the previous code.

  • Also closed: requestOverride.body / bodyBatch and resource-level request.body / bodyBatch now clear the same named-component bar as operations.X.input; a hand-written class @EntityHook has e2e coverage for 409/403/passthrough (the contract the removed pre-wrap used to work around); CONFIGURATION.md, AGENTS.md rule 16 and the changelogs no longer describe propagation, RocketsCrudAdapter or scope: false as "unscoped".

  • Nest leaves the 12 alpha line for stable 12.0.1 (commit 31e98eb). Not cosmetic: alpha.10 requires @nestjs/common / core ^12.0.1 and @nestjs/cqrs ^12.0.0, and a prerelease does not satisfy a stable caret range. A clean npm install of the published tarballs therefore nested a second copy under every upstream package — 13 copies of @nestjs/core, 16 of @nestjs/common, 2 of @nestjs/cqrs — and two copies means two class identities: RESULT_TYPE_SYMBOL is a unique symbol (TS2420 on the user-port queries) and ModuleRef stopped resolving (Nest can't resolve dependencies of the HookResolverService). Every @nestjs/* pin moves to the stable line across the packages, the examples, the root resolutions and the packed-consumer script. @nestjs/typeorm@12 is ESM, so one spec swaps vi.spyOn on a module namespace for vi.mock.

    This was caught by CI, not locally: the root resolutions block flattens the workspace tree, so every local suite ran against a shape the published consumer never sees. release:packages is the gate that models a real consumer, and it is now part of the local pre-push routine.

  • @nestjs/throttler is replaced by core's rate-limit port (commit 4c2f3f6). Its latest release (6.5.0, unchanged even on master) caps peers at Nest 11, so npm install @concepta/rockets-auth answered ERESOLVE on default npm — pre-existing on main, hidden by the consumer gate's --legacy-peer-deps. Core's @RateLimit / RateLimitGuard grew named dimensions enforced together (per-field merge by name, so a route tightening limit keeps the dimension's composite key); auth keeps its exact policy — per-IP ceiling no route overrides + per-(ip, account) fine limits, counters per route. The four pre-existing throttling e2e blocks pass unchanged against the new engine, and they caught two bugs in the swap itself (whole-dimension merge sharing the fine counter across accounts; cross-route counters draining the OTP allowance). Breaking: extras.throttling is now false | { ip?, default?, store? } with windowMs. The consumer gate installs with strict npm — no flags — as the enforced contract.

  • Generated recursive-definition names left the author namespace (commit b0d053d, from external review). The qualifier named a lifted z.lazy() inner object with a counter (TreeDtoRef0); an author schema carrying that exact id made the outcome depend on route scan order (author-first: silent rename; generated-first: document build aborted blaming a request/response split that does not exist). Names are now <ownerId>Ref_<8 hex of the definition's own JSON> — out of the author space by construction, stable while the shape is — and the residual deliberate collision throws a precise error naming the generating component, in either order. Both cross-conversion cases fail against the previous code.

  • The in-memory rate-limit store is bounded (commit d72ccc9, from adversarial review of the swap above). It never freed an entry, and its counter key carries an attacker-supplied account field — guards run BEFORE pipes, so that value is unvalidated and bounded only by the body parser. Every request on a public login / signup / recovery / OTP route inserted a permanent map entry, and the per-IP ceiling could not stop it: each admitted request carries a NEW account value, so the growth happened inside the policy. Not a regression (@nestjs/throttler's own map never evicted either — checked against the packed tarball) but ownership moved: it is Rockets' default store now. The store sweeps expired windows on write and enforces a hard key cap, dropping soonest-expiring entries first and warning when a live window has to go; the auth key function hashes any account value over 128 chars. Same commit: Retry-After now reports the latest reset among rejected dimensions (a client blocked for an hour by the ip ceiling was told to retry in 60s), the generated-name digest hashes canonical JSON (key order can't rename components), and the duplicate-copy tolerance is keyed by path rather than version.

Gates, the full CI set from a clean build: build, api:report:check-built, lint:all, typecheck:spec, test:config-native, test:ci (1126), test:e2e:cov (463), release:packages, samples:build, samples:test:e2e (191 / 49 / 8), both contract:check, and — on CI, which has the JDK 21 the emulator needs — the Firestore emulator suite.

Open follow-ups (not in this PR)

  • Upstream, fixed in 8.0.0-alpha.10: empty create body rejected with a bare 400 (nestjs-crud / nestjs-repository: a create body that validates to {} is rejected with a bare 400 nestjs-modules#466), CRUD request bodies inlined in the OpenAPI document (nestjs-crud: generated CRUD request bodies are inlined in the OpenAPI document instead of $ref'ing the named schema nestjs-modules#467), second TransactionScope.run on one context reusing the committed transaction (nestjs-repository: a second TransactionScope.run on the same context reuses the committed transaction ("No active transaction") nestjs-modules#468).
  • No fail-closed transaction mode exists after alpha.10 removed propagation. A boot assertion that each registered store has a transaction factory would replace it; that is a new guarantee, not a cleanup, so it is deliberately not in this PR.
  • The realtystack consumer run has not been repeated on alpha.10. Three public symbols and one constructor signature changed; a grep found no usage, but a grep is not a boot.
  • Component ids of nested relation projections are checked at document time only, not at plan time.
  • Rebuilding a schema to strip a hidden column drops what it cannot carry over: object-level .refine() checks, and the withOpenApi id of a nested named schema (it documents inline after the rebuild). Only schemas that actually contain a hidden column are affected.
  • Hand-written @ApiResponse({ standardSchema }) WITHOUT a serializer is documentation only; the audit report lists those routes (unserializedResponseSchemas), the boot check covers @SerializeOptions({ schema }).
  • /me claims the generic UserResponseDto / UserUpdateDto ids with no override.
  • An e2e flake with ROTATING victims at NORMAL duration (~20s) — one occurrence included Parse Error: Expected HTTP/, a protocol-level symptom. It does NOT match the documented memory-pressure signature (30s timeouts at ~20x duration), and every victim is green in isolation and on re-run. Bisected against the rate-limit guard's new setHeader path and exonerated: the earliest occurrence predates that code by 1h45, the failing spec (rockets-multi-auth-provider) uses no RateLimitGuard, and pool: 'forks' isolates each spec file in its own process. Prime remaining suspect is supertest ephemeral-port reuse across workers. Unresolved; worth a dedicated pass on an idle machine.
  • Examples were only booted against SQLite; a Postgres/MySQL boot is still due.

Not merged without review — leave it as a draft until you have gone through it.

🤖 Generated with Claude Code

tnramalho and others added 6 commits August 25, 2026 22:41
Rockets used @nestjs/config only for registerAs + ConfigModule.forFeature to
hand default settings to upstream's createSettingsProvider. Those defaults
are now plain Nest providers registered by each module (core, swagger-ui,
server, auth); ConfigModule is no longer re-exported.

Why now: the upstream 8.0.0-alpha.9 graph pins @nestjs/config@12.0.0-next.0,
which is ESM-only — a CJS require() from the Rockets dist fails at runtime
while tsc stays green. The root resolutions pin is removed so upstream keeps
the version it declares. rockets-server-auth keeps it as a devDependency for
the e2e ConfigService stub.

RFC #104, stage 1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t shim

`@concepta/rockets-core/standard-schema` and `/standard-schema/swagger`
had no consumer in Rockets or its examples and duplicated what Nest 12
ships natively (`@Body({ schema })` + `StandardSchemaValidationPipe`,
`@SerializeOptions({ schema })`, `ApiResponse({ standardSchema })`).

Generated DTOs no longer stamp `@Allow()` on their keys: the stamp only
existed to survive a foreign `ValidationPipe({ whitelist: true })`, a
pipe that must not sit in front of a schema-validated route at all.

`isStandardSchema` / `getCarriedStandardSchema` move to
`common/utils/standard-schema.util.ts` (internal). Public API report
updated: the two entry points and their 24 declarations are gone.

RFC #104, stage 2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`zod-swagger-golden.e2e-spec.ts`, `zod-parity.e2e-spec.ts` and their
hand-written control resources compared a class-DTO `defineResource` twin
against the zod resource. Class-DTO authoring is being retired, so the
twin goes; `zod-library.e2e-spec.ts` keeps every document and runtime
assertion against the zod author/book pair alone. `contract.json` is
unchanged (the controls were never mounted in the app).

RFC #104, stage 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…chemas

One schema engine. Every request body and response in Rockets is a named
zod schema (`withOpenApi(schema, id)`), validated per route by Nest's
native StandardSchemaValidationPipe with the Rockets exception factory
(`details[]` on every 400), serialized by the response schema (undeclared
columns never leave, Date -> ISO), and documented from the schema's own
JSON Schema bridge with a Rockets converter that `$ref`s named components.

Core: `defineResource` dto/input/output/paginated are schemas; zod
compiler emits `.zod.schemas`; `f.date()`, audit columns as `z.date()`;
response-exposed `z.iso.datetime()` rejected; optional fields admit the
`null` a nullable column reads back; `userMetadata` is
`{ entity, updateSchema, responseSchema }`; `SchemaValidatorConflictCheck`
refuses a global StandardSchemaValidationPipe; planner rejects two schema
instances under one component id; `createDocument` installs the converter
and lifts `definitions` out of upstream's inline CRUD request bodies;
exceptions filter vendors `mapHttpStatus` and handles alpha.9's
`RuntimeException extends HttpException` (hook 4xx no longer served as
500). Removed: createPaginatedDto, FreeFormJson, ROCKETS_TO_* options,
ZodBodyValidationInterceptor, whitelistedFromDto (-> validateWithSchema),
UserUpdateDto/UserResponseDto, BaseUser*Dto, PersistenceRow, ZodResourceDtos.

Server: `/me` rebuilt as `buildMeController(config)` (userMetadata null
before the first PATCH; hidden columns stay hidden). Auth: user/role/
invitation DTO classes are schemas composed from upstream schemas; admin,
signup, token, recovery and invitation routes on the engine; password
rotation runs consume + write in one transaction scope (the consume-only
scope left a finished transaction on the request context); new OTP login
e2e. `@concepta/nestjs-common` dropped. Examples and both contract.json
regenerated; docs updated.

Known gap (upstream): `CrudInitApiBody` inlines generated CRUD request
bodies, so `${Name}CreateDto`-style request components are inlined rather
than `$ref`'d (responses are).

RFC #104, stage 4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gine; legacy validators removed

Stages 5 and 6 of RFC #104, landed together: their agents ran in
parallel and share the regenerated contract.json / public API report.

Stage 5 — operationResource: compiled descriptors carry `inputSchema`,
`paramsSchema` and a schema `output` (no DTO classes). The generated
controller has a class-level StandardSchemaValidationPipe with the
Rockets factory; the body is `@Body({ schema })` behind a payload-shape
guard (missing body -> {}, array/scalar/Buffer -> 400 naming the whole
body), the query `@Query({ schema })` and the params `@Param({ schema })`
(raw params merged so extra path params still reach ctx.params). Output
is validated inline (null / mismatch -> 500). OpenAPI comes from the
schema bridge: named `<Base>Input` / `<Base>Output` components, query and
path params one per property. Defect fixed on the way: the generated
method exposed no `design:paramtypes`, so swagger documented no body,
query or params for any operation. Component-id uniqueness across CRUD
and operation resources is one planner check.

Stage 6 — hand-written routes: rockets-auth's OTP, change-password,
invitation revoke / acceptance-payload and admin role-assignment bodies,
sample-server's pet-share and sample-code-review's DTOs are named zod
schemas validated by each controller's own pipe (invitation-acceptance
`payload` is now validated). class-validator, class-transformer and
nestjs-zod are removed from every manifest; compileDtoClass / namedZodDto
are gone; the packed-consumer check installs no validation library beside
zod. Exception: rockets-auth keeps class-validator/class-transformer as
plain dependencies while nestjs-email/event (7.x) pull nestjs-common@7,
which requires them at import — the packed consumer proved it (locally
they resolved from a node_modules OUTSIDE the repo).

Also: sample-server scope hooks forward `ctx` to their repository
lookups (rule 16); docs, CHANGELOGs, api policy note.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review of the RFC #104 branch (adversarial pass) plus the first external
consumer run (realtystack) surfaced these; all fixed with a failing test
first.

- A create body that validates to `{}` is a valid create. Generated
  resources run on `RocketsCrudAdapter`, which drops the bare 400 the
  upstream adapter answered to an empty validated payload. An
  all-server-stamped sub-resource (`POST /parents/:id/stamps {}`) is the
  new e2e case.
- The fail-closed response check walks every wrapper (intersection,
  tuple, record/map/set value, readonly, catch, any single-child
  wrapper) and covers hand-supplied paginated envelopes.
- Hidden columns stay hidden at every depth of a computed field.
- One OpenAPI component describes one side: a schema used as both a
  request and a response is a document-build error.
- `f.date()` rejects null and booleans instead of storing 1970.
- Invitation acceptance validates `userMetadata` with the same default
  schema as signup and admin when the app configures none (a smuggled
  `userId` no longer reaches the row).
- A `@Body/@Query/@PARAM({ schema })` that no StandardSchemaValidationPipe
  reaches fails the boot (`requireSchemaPipe`); the route audit is
  always on, the route policy stays opt-in.
- Hand-written auth request bodies keep their component names
  (`LocalLoginDto`, `RefreshDto`, `Recovery*Dto`).
- Docs that still described the class-DTO era are rewritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tnramalho and others added 4 commits August 26, 2026 07:08
…name

The report records declarations reachable from public signatures; the
token/recovery controllers' body type aliases now point at the named
Rockets schemas (LocalLoginDto, RefreshDto, Recovery*Dto).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review of the branch plus the realtystack run; every item
landed with a test that failed first.

- Generated CRUD request bodies are $ref'd to their named component in
  the OpenAPI document again (`restoreNamedRequestBodies` drops the
  inline stamp upstream's CrudInitApiBody leaves where the route's
  @Body({ schema }) is named — upstream #467). Both contracts regenerated:
  every *CreateDto / *UpdateDto / *ReplaceDto component is back.
- The one-component-one-side rule covers nested named schemas: one nested
  id emitted with two shapes is an error, not a last-wins merge.
- Hand-written @SerializeOptions({ schema }) gets the fail-closed check
  at boot (`requireClosedResponse`); `requireSchemaPipe` has its own
  exemption list (`allowUnvalidatedSchema`) instead of riding on `allow`.
- rockets-auth: admin PATCH /admin/users/:id and /admin/roles/:id bodies
  are validated (the schema moved from the controller to the operation —
  upstream stamps the pipe from the operation only), the user update runs
  in one outermost transaction scope (it answered 500 on the finished
  transaction upstream leaves on the context, #468), and the metadata
  repository pins userId from the caller on the update branch.
- Migration note for the renamed/removed OpenAPI components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uard

- The /me metadata handlers forward the request context to every
  repository call and pin userId from the caller on the update branch;
  UpsertUserMetadataCommand(ctx, userId, data) and
  GetUserMetadataQuery(ctx, userId) take the context first (breaking for
  handler overrides; migration note in the changelogs).
- A generated CRUD body with no schema fails the boot under
  requireSchemaPipe — the structural guard for the admin-update defect
  (a controller-level request.body documents and validates nothing).
  Two e2e fixtures that relied on unvalidated bodies now declare theirs.
- allowUnvalidatedSchema gets the same more-than-one-match guard as allow.
- The audit report lists responses documented with standardSchema but
  serialized through no @SerializeOptions (unserializedResponseSchemas).
- Migration notes: explicit @ApiBody beside a named @Body is dropped;
  allow / allowControllers no longer exempt the schema-pipe check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… reader

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@leoafarias leoafarias left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The schema-unification direction is strong, and the route-scoped Standard Schema pipe is the right correction to the RFC's original global-pipe sketch. I am requesting changes because two response-safety guarantees and the published runtime compatibility contract fail with reproducible inputs.

I verified the branch with build, typecheck, API reports, lint, 1,074 unit tests, 440 package E2E tests, and the sample suites. Those gates pass, but they do not cover the wrapper shapes in the first two comments.

Please also update Issue #104 / the PR framing from one native execution path to one schema contract. operationResource deliberately retains inline output validation because Nest's serializer has different null/array semantics; that is sensible, but it means the stronger one-executor claim is not accurate.

Comment thread packages/rockets-core/src/zod/zod-projections.ts
Comment thread packages/rockets-core/src/common/utils/open-api-schema.util.ts Outdated
Comment thread packages/rockets-core/package.json Outdated
…pipe in-side, Node 20.19 floor

- Computed-field projection rebuilds every composite wrapper (union,
  intersection, pipe, default, catch, readonly, nonoptional, lazy) so a
  `dto: { response: false }` column below one never reaches the wire;
  a hidden column below a wrapper it cannot rebuild (discriminated
  union, tuple, record, map, set) fails at definition time. Runtime
  regression for `f.compute(z.union([nested, fallback]))`.
- `assertFailClosedResponse` walks BOTH sides of a pipe: an ordinary
  `.transform()` keeps its (open) object on the IN side. Regression for
  `.passthrough().transform(v => v)`.
- Node floor is 20.19.0 in every published `engines`, the docs and the
  CI matrix (the CommonJS build loads the ESM Nest 12 line through
  require(esm); 20.18 fails with ERR_REQUIRE_ESM).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tnramalho and others added 2 commits August 26, 2026 18:35
Loading vitest.config.mts straight into Node needs native TypeScript
stripping (Node 22+); the 20.19.0 leg exists to test the published
packages' floor (build, tests, packed consumer), not the repo tooling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…19 floor

Second pass on the PR #105 review, after an adversarial check of the
first one:

- `.default()` / `.catch()` hand their payload over without running the
  inner schema, so a hidden column below them cannot be stripped — they
  are rejected at definition time (they were wrongly "rebuilt").
- A recursive `z.lazy` with a hidden column overflowed the stack: the
  rebuilt lazy is memoized per source instance so walker identity holds.
- The strip now runs on all three response paths — computed fields, JSON
  columns and exposed relations — not only under `f.compute()`.
- The fail-closed walker reads a pipe's IN side only when its OUT is a
  transform; `z.pipe(open, closed)` strips on the way out and passes.
- The Vitest runner preloads `@nestjs/core` (setup file), which removes
  the ERR_REQUIRE_CYCLE_MODULE Node 20.19 raised when `@nestjs/cqrs`
  required the still-evaluating ESM module. Both CI legs now run the full
  suite; `fail-fast: false`; the module cache is keyed by Node version.

Regressions for each case fail against the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tnramalho and others added 7 commits August 26, 2026 23:01
…pass-through pipes

Third pass on the PR #105 review round, after a second adversarial seal:

- An `operationResource` output built from an entity schema strips its
  `dto: { response: false }` fields — the fourth response path, beside
  computed fields, JSON columns and exposed relations. E2E asserts the
  secret is absent from the HTTP body.
- A TOP-LEVEL `.default()` on a field with a hidden column is rejected
  at definition time (it was silently dropped, and the row then failed
  serialization at runtime); `.prefault()` is rebuilt — its payload does
  run through the inner schema.
- The fail-closed walker reads a pipe's IN side whenever its OUT passes
  values through (transform, any, unknown, custom), not only transforms.
- CI: one test-results artifact per matrix leg (the report reads the
  22.x one); release-readiness pins the exact 20.19.0 floor; the wording
  no longer claims the example apps run in the PR matrix.
- The cqrs/ESM runner gap is filed upstream (nestjs/nest#17583) and the
  preload file says when to remove it.

Regressions for each case fail against the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…level preprocess

Fourth pass on the PR #105 review round, after a third adversarial seal:

- A hand-written response schema (dto.response, operations.*.output,
  dto.paginated, userMetadata.responseSchema) is not projected and keeps
  the author's component id, so a `dto: { response: false }` field
  inside it is rejected at definition time with a pointer at `.omit()`
  (`assertNoHiddenFields`) instead of reaching the wire.
- The projected field is stripped WITH its wrappers (`stripHidden` on
  the field itself): a top-level `z.preprocess` is kept and rebuilt, and
  a top-level `.default()` / `.catch()` is rejected like a nested one —
  no more peel-then-partially-reapply.
- The fail-closed walker's pass-through test is recursive: `any`,
  `unknown`, `custom`, `transform` behind optional / nullable / readonly
  / lazy or as a union member also make a pipe's IN side walkable.
- Stale CI comment about the packed-consumer contract corrected.

Regressions for each case (six new) fail against the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…posites

Fifth pass on the PR #105 review round, after a fourth adversarial seal:

- rockets-auth hands `userCrud.model` / `roleCrud.model` straight to
  upstream CRUD serialization (no defineResource projection), so only
  the component name was checked. The signup, admin-users and
  admin-roles modules now also require a fail-closed schema with no
  `dto: { response: false }` field (`assertNoHiddenFields`, exported from
  core). Regression per module, each failing against the previous commit.
- The fail-closed walker's pass-through test reaches composites: an OUT
  that is an array / object / record / intersection / nested pipe holding
  `any` / `unknown` / `custom` / a transform hands input through, so the
  pipe's IN side is walked. Five new spec rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ail-closed guard

Sixth pass on the PR #105 review round, after a fifth adversarial seal:

- `passesThrough` descended through `schemaChildren`, whose pipe branch
  called `passesThrough` again with a fresh cycle set — a recursive lazy
  that crosses a `z.preprocess` overflowed the stack in every walker
  (fail-closed check, hidden-field detection, projection). It is now
  memoized per schema instance, with the in-progress entry reading
  `false`, so a cycle terminates. Regressions on all three entry points
  fail against the previous commit with RangeError.
- The fail-closed guard on rockets-auth's `userCrud.model` /
  `roleCrud.model` had no test: an open (`.catchall()`) model is now
  rejected by a regression per module.
- CONFIGURATION.md documents strip-vs-reject (projected vs hand-written
  response schemas) and the deliberate over-flag on pass-through pipes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ields in serializer schemas

Seventh pass on the PR #105 review round, after a sixth adversarial seal:

- The pass-through memo stored an in-progress `false` as a final answer,
  which made the fail-closed verdict depend on visit order and fail
  OPEN (the same schema reported nothing when the branch that closed a
  cycle was visited first). Only `true` is memoized now (the predicate is
  monotone); termination comes from a per-walk in-progress set threaded
  through the shared child walker. Regressions pin both field orders and
  a warm-then-probe run over shared instances; both fail against the
  previous commit.
- The route audit rejects a hand-written `@SerializeOptions({ schema })`
  that declares a `dto: { response: false }` field
  (`RouteAuditEntry.hiddenResponseField`, `requireClosedResponse`) — the
  last response path with neither strip nor reject.
- CONFIGURATION: `op.sse()` declares no `output` at all (not `output:
  false`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…memo

The previous commit said both memo regressions failed against c571386;
only the field-order one did — its warm-up case computed the cold
verdict first, which cached `true` on the shared node before the walk
that would have poisoned it. The warm-up now comes first, on the same
instances, with the transform inside the cycle; it fails on c571386.

Also: the two JSDoc blocks orphaned by the last insertions are back on
`assertNoHiddenFields` / `readOpenResponseSchema`, the open-response
reader reuses `readSerializerSchema`, and the CHANGELOG notes the
complexity trade of a walker with no `false` memo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…route audit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tnramalho
tnramalho marked this pull request as ready for review August 28, 2026 21:49
supertest's `send()` takes `string | object`; the case table was typed
`unknown`, which `yarn typecheck:spec` rejects. Vitest only transpiles,
so the e2e run stayed green while CI's typecheck step failed.
@tnramalho
tnramalho force-pushed the feat/schema-engine-104 branch from 591dd7f to a587d27 Compare August 28, 2026 21:50
Qualifying zod's positional `__schemaN` definitions with the owning
component id invented names like `TreeDtoRef0` without checking whether
the document already had one. Nothing forbids an author from calling a
schema `TreeDtoRef0`, and when both landed in the SAME conversion the
generated entry overwrote theirs in the batch: the recursive property was
then documented with the author's shape, silently, since no later
emit-time comparison ever saw two values under one name.

The qualifier now takes both the batch's own names and everything the
document has already emitted, and walks a suffix until the name is free.
Regression test fails against the previous code.
@tnramalho

Copy link
Copy Markdown
Collaborator Author

Request validation: where it landed

Rockets validates through Nest 12's native path — @Body({ schema }) on the parameter, StandardSchemaValidationPipe reading metadata.schema. Generated routes carry the same { schema } in route metadata. One form everywhere.

What was there before. A DTO carrier: a class holding the schema, discovered by a pipe subclass through the parameter's type. It was the right call when it was written — Nest did not accept a schema on the parameter yet. Its foundation survived and is what every route runs on today: sitting on Nest's native pipe instead of writing our own validation, and the exceptionFactory that attaches details[] to every 400.

What changed. Nest 12 accepts the schema directly, so the intermediate class had nothing left to carry. Both forms were compared in packages/rockets-core/src/__e2e__/schema-approaches.e2e-spec.ts, 11 cases with assertions: at runtime they are identical. Each has one silent hole — a mismatched type annotation on the native form, a missing emitDecoratorMetadata on the carrier — and both are closable. What decides is cost: native needs a lint rule, the carrier needs a pipe subclass, a factory, and an audit that understands two declaration forms. Native is also what Nest documents.

Fixes in this branch

  • A second recursive schema no longer aborts document generation. z.toJSONSchema names extracted definitions positionally (__schema0), restarting per conversion, so two unrelated recursive schemas both claimed that name.
  • A discriminated union is documented as one, with an explicit discriminator.mapping — the implicit form matches the tag against the component name, and 'circle' is not 'CircleDto'.
  • A qualified definition name never lands on one an author already owns. Reported by @leoafarias: TreeDtoRef0 invented next to an authored TreeDtoRef0 overwrote it inside the same batch, and the recursive property was documented with the wrong shape, silently. The qualifier now walks to a free name. Regression test fails against the previous code.

Follow-ups

  1. Boot check for a body/query/param that resolves no schema at all — hits both forms, not caught by the current requireSchemaPipe audit on hand-written routes.
  2. Lint rule for decorator schema vs type annotation mismatch.

@MrMaz

MrMaz commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

@concepta/nestjs-crud, @concepta/nestjs-repository, and @concepta/nestjs-core 8.0.0-alpha.10 are published, with fixes for the three issues filed from this PR: #466, #467, #468. Went through the branch at ac719397 looking for what's retirable — sharing in case it saves a pass.

Retirable once you bump to alpha.10:

  • rockets-core/src/infrastructure/crud/rockets-crud.adapter.ts (RocketsCrudAdapter) — RepositoryAdapter.prepare() no longer rejects a create body that validates to {} (#466). Check before deleting: your override's params merge is selective (if (field in merged) — a route param only overrides a key already present in the body), while upstream's CrudAdapter.prepareEntityBeforeSave merges unconditionally ({ ...dto, ...context.params }). Dropping the shim will start writing param values onto columns that previously stayed untouched for any route whose param names aren't already body keys — fine for a nested FK, a silent data change otherwise. Worth a diff of route params against entity columns first.
  • rockets-core/src/common/swagger-ui/restore-named-request-bodies.ts and most of lift-inline-definitions.ts — CRUD request bodies now $ref a named component the same way responses do (#467); CrudInitApiBody no longer stamps raw inline JSON Schema, so isInlineBodyParameter won't match anything to strip. Your swagger e2e assertions that pin the old inline-body behavior (rockets-core-zod-operation-io.e2e-spec.ts:16-19, the inverse case in rockets-core-schema-engine.e2e-spec.ts:409-416) will need updating in the same commit.
  • TransactionScope — no code workaround on your side for #468, so this one's a pure correctness win on upgrade, nothing to delete.

Retirable independent of the version bump:

  • rockets-core/src/infrastructure/crud-compat.ts — the three re-exported types (CrudParamOptionInterface, CrudRequestConfig, CrudResponseConfig) are already in 8.0.0-alpha.10's dist/index.d.ts.
  • rockets-server-auth/.../concepta-repository-compat.module.ts — already a self-declared no-op; still imported and registered in rockets-auth.module-definition.ts:118,411 though, so it's dead but wired.
  • rockets-core/.../resolve-conceptadev-app-context.tsAppContextHost.from() already ships in alpha.10 with equivalent instanceof-or-mint semantics, and it's actually stricter than your version: it throws on a non-empty non-host value, where yours silently mints a fresh host and discards whatever the caller passed. A few of your callers invoke this inside password/OTP/user handlers where that could quietly drop live transaction context. Worth switching to .from().
  • rockets-core/.../define-hook.ts:166-183 (and the matching bit in exceptions.filter.ts) — the originalError-loss bug this pre-wrap works around doesn't reproduce against the actual alpha.10 tarball; I tested it (RuntimeException sets context.originalError, RepositoryQueryException spreads this.context not super.context). Might be worth re-verifying on your end before trusting my read, but if it holds, the pre-wrap (and the "throw RepositoryQueryException directly" contract it imposes on hook authors) isn't needed.

Also in alpha.10: isTransactional(...targets) / getTransactionalOptions(...targets) from @concepta/nestjs-repository, and isAuthPublic(...targets) from @concepta/nestjs-authentication — pass a handler/class the same way you'd pass them to Nest's own Reflector.getAllAndOverride. These replace mirroring the metadata key strings (TRANSACTIONAL_KEY, AUTHENTICATION_MODULE_DISABLE_GUARDS_TOKEN stay unexported on purpose — the point is to stop coupling to how we store the metadata, not just hand you the string).

Also, re: #74 vs #104 — we independently landed on the same call you did (@Body({ schema }) + native StandardSchemaValidationPipe, no carrier class) before seeing this PR. crud-init-validation.decorator.ts resolves the schema through a parameter → operation → controller hierarchy on top of that, but it's the same native-form direction either way.

tnramalho and others added 13 commits August 31, 2026 14:20
Ships the fixes for the three issues filed from this PR:
nestjs-modules#466 (RepositoryAdapter.prepare rejected a create body that
validates to `{}`), #467 (CRUD request bodies bypassed the document
converter) and #468 (TransactionScope ran a query on a finished
transaction).

Two upstream removals land with it and are handled in the following
commits: `TransactionManager.get()` and `TransactionRunOptions.propagation`
(with `TransactionRequiredException`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
Every item the upstream maintainer flagged as retirable on this branch:

- `RocketsCrudAdapter` — upstream's `RepositoryAdapter.prepare()` no
  longer rejects a create body that validates to `{}` (#466), so
  generated resources run on `CrudAdapter` again. The override merged
  route params selectively where upstream merges unconditionally; on a
  generated route that difference is inert, because `context.params`
  only ever carries params present in the request URL and non-`disabled`
  — on a create route, the immediate parent's FK, which is a column on
  the child by construction.
- `restoreNamedRequestBodies` / `liftInlineRequestBodyDefinitions` —
  upstream stamps `ApiBody({ standardSchema })` and `$ref`s CRUD bodies
  like responses (#467), and Nest's own converter already lifts
  `$defs`/`definitions` and rewrites the refs. Both shims are no-ops.
  The two e2e comments that described them are rewritten; the served
  document is byte-identical (both sample contracts unchanged).
- `defineHook`'s `HttpException` pre-wrap — `RuntimeException` sets
  `context.originalError` and `RepositoryQueryException` preserves it,
  so a hook's `ConflictException` reaches the filter as a 409 on its
  own. The contract that told class-hook authors to throw
  `RepositoryQueryException` directly is dropped with it.
- `crud-compat.ts` — the three types are exported from nestjs-crud.
- `ConceptaRepositoryCompatModule` — a self-declared no-op still
  imported and registered by `rockets-auth`.
- `resolveConceptadevAppContext` — replaced by upstream
  `AppContextHost.from()`, which throws on a non-empty non-host value
  where the local helper silently minted a fresh host and discarded what
  the caller passed. Six auth handlers were on that path, including
  password, OTP and user flows where it could drop live transaction
  context.
- The two mirrored metadata keys — `AuthServerGuard` and the route audit
  read `AuthPublic` through upstream's `isAuthPublic()`, and the
  SSE/`Transactional()` conflict check uses `isTransactional(handler,
  controllerClass)`, which also fixes the override order: a route-level
  `Transactional(false)` under a resource-level `Transactional()` now
  reads as opted out. `ROCKETS_DISABLE_GUARDS_TOKEN` is removed and
  `AuthServerGuard` no longer injects `Reflector`.

Forced by the bump, outside that list:

- `TransactionManager.get()` is gone. The Firestore adapter's dirty flag
  went with it — upstream removed the same `markDirty`/`isDirty` pair
  from `TypeOrmTransaction` and `TypeOrmRepository`, and nothing ever
  read it.
- `TransactionRunOptions.propagation` and `TransactionRequiredException`
  are gone, so there is no fail-closed transaction mode; two specs drop
  it. A nested `readOnly` contradicting the scope it joins now throws
  `TransactionReadOnlyConflictException` instead of being ignored — a
  loud failure replacing a silent write, pinned by a rewritten spec plus
  a sibling covering the non-contradicting join.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
`scope: false` and `owner: false` dropped `PathScopeGuard` outright. The
guard does two separable things: it verifies the addressed chain (the
parent exists, is visible to its own hooks, and at three levels or more
actually contains the middle row) and it verifies ownership. Only the
second is an access-control opt-in — a request naming a row through a
parent that does not contain it is malformed whoever sends it.

Ancestor route params are declared `disabled: true`, so they never reach
`buildWhere` and cannot substitute for the check. The result was that
`/parents/A/children/CHILD_OF_B/notes` served `CHILD_OF_B`'s rows
whenever the deep resource opted out, where the scoped route answers 404.

`PathScopeGuard.for` now takes an optional `ownerColumn` and the guard is
attached to every sub-resource. Without it the guard skips the actor
requirement and the owner clause, but still performs the parent lookup
with the parent's hooks replayed. The actor overlay is declared whenever
the request carries one, independent of `ownerColumn`: that flag decides
whether the guard filters by owner, not whether the parent's own hooks
get to see who is asking.

Behaviour changes for apps on either flag: a missing parent is a 404
rather than an empty list, a parent hidden by its own hooks stays hidden,
and a mismatched ancestor is refused. An owner-less route still serves
actor-less requests.

Four e2e tests pin it, including a depth-3 probe that fails against the
previous code. The same fixture also pins what `scope: false` never
removed: reads stay FK-filtered and a cross-parent PATCH/DELETE is a 404,
because every verb resolves its row through `getOneOrFail` → `buildWhere`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
`operations.X.input` was checked with `assertNamedSchema`, but three
other paths reached the same request body unchecked:
`operations.X.requestOverride.body` / `bodyBatch` and the resource-level
`request.body` / `bodyBatch`. An unnamed schema on any of them documented
inline while every sibling body was a `$ref` — the exact asymmetry this
PR removed everywhere else.

Also pins the class-hook half of the error contract. Removing the
`defineHook` pre-wrap also removed the documented instruction for
class-hook authors to throw `RepositoryQueryException` directly, and
nothing tested that path: a hand-written `@EntityHook` class now has e2e
coverage for 409, 403 and the passthrough case.

That passthrough case exposed a fixture gap — `ZoteEntity` never carried
the `dateCreated` / `dateUpdated` columns its `baseEntity` schema
declares, so a successful create would have serialized to a 500. No test
had ever exercised a passing `/zotes` create.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
…e docs

Migration notes across the root and four package changelogs, plus the
public API report (three removed symbols, `AuthServerGuard`'s constructor,
`PathScopeGuard.for`'s optional `ownerColumn`, and the Firestore dirty
flag).

Two docs were describing behaviour that never existed:

- `AGENTS.md` rule 16 and `CONFIGURATION.md` §8a still documented
  `propagation` / `TransactionRequiredException` and claimed a nested
  `readOnly` is ignored. Upstream removed the first two, and the third
  now throws.
- `CONFIGURATION.md` called `defineSubResource({ scope: false })`
  "unscoped". It never was: the immediate parent's `:param` stays a CRUD
  route param whose `field` is the FK column, so upstream resolves every
  operation through it. The flag drops the `PathScopeHook` and the
  ownership clause — and, until the previous commit, silently dropped the
  ancestor-chain check with them.

Also drops the stale `RocketsCrudAdapter` and `ROCKETS_DISABLE_GUARDS_TOKEN`
references left behind in `CONFIGURATION.md`, `rockets-server`'s README
and the changelog entries that predate their removal — a reader following
those was told to import symbols this branch deletes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
The alpha.10 bump broke `release:packages` — the packed-consumer gate that
type-checks and boots a clean npm install of the published tarballs. It is
the one gate the workspace cannot fake, and the one I had not run.

Root cause: upstream `8.0.0-alpha.10` requires `@nestjs/common` / `core`
`^12.0.1` and `@nestjs/cqrs` `^12.0.0`, while this repo pinned
`12.0.0-alpha.6` and `^11.0.0`. A prerelease does not satisfy `^12.0.1`,
so npm nested a second copy under every upstream package — 13 copies of
`@nestjs/core`, 16 of `@nestjs/common`, 2 of `@nestjs/cqrs` in the
consumer tree. The root `resolutions` block flattens all of that in the
workspace, which is exactly why every local gate stayed green.

Two copies means two distinct class identities:

- `@nestjs/cqrs` — `RESULT_TYPE_SYMBOL` is a `unique symbol`, so the
  `Query<T>` our query classes extend was not the `Query<T>` the upstream
  interface referenced (TS2420 on both user-port queries).
- `@nestjs/core` — `ModuleRef` from one copy is not the token the other
  provides, so the consumer app failed to boot with
  `Nest can't resolve dependencies of the HookResolverService (?, Reflector)`.

Nest 12 is stable (12.0.1), so every `@nestjs/*` pin moves to the stable
line: common / core / platform-express / testing / swagger / typeorm /
jwt at 12.0.1, cqrs / passport / cli / schematics at 12.0.0, across the
packages, the examples, the root resolutions, and the consumer script's
own pinned install.

`@nestjs/typeorm@12` is ESM, so `vi.spyOn` on its module namespace throws
(`Module namespace is not configurable in ESM`). The single-instance spec
uses `vi.mock` with a test-controlled override instead.

Verified with the CI gate set, not a subset: build, api:report:check-built,
lint:all, typecheck:spec, test:config-native, test:ci (1116),
test:e2e:cov (460), release:packages, samples:build, samples:test:e2e
(191/49/8) and both contract:check. `test:firestore-emulator` needs JDK 21
and this machine has 19 — the only gate not run locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
…led against

Follow-up to 31e98eb, from an adversarial pass on it.

The Nest-stable bump fixed the copies I measured, not the class. A probe
install of `rockets-server-auth`'s published dependency block still nests
three Nest 11 copies: `@concepta/nestjs-email@7` and `-event@7` declare
`@nestjs/common` / `@nestjs/core` `^11.1.9` as HARD dependencies, so npm
must nest them and no consumer-side `resolutions` can flatten them. They
are not dormant — `EmailModule` is wired into the auth module graph — and
they are one upstream refactor away from reproducing the same `ModuleRef`
boot failure.

Nothing here can remove them, so this adds DETECTION instead:
`verify-packed-consumer.mjs` now walks the installed tree and fails when
`@nestjs/core`, `@nestjs/common` or `@nestjs/cqrs` resolve to more than
one version, tolerating the known v7-line copies by exact version so a
NEW duplicate fails the gate. Falsified by emptying the tolerated set:
the assertion reports both copies and their paths. This is the Nest
equivalent of `assert-single-typeorm.spec.ts`, which already tested this
class for typeorm and nothing else.

Also from the same pass:

- The gate's own fixture still installed `@nestjs/typeorm@11.0.3` while
  `rockets-repository-typeorm` now publishes a `^12.0.1` peer — it was
  installing a combination the package declares invalid.
- `rockets-repository-typeorm` and `-firestore` advertised
  `@nestjs/common: ^12.0.0-alpha.0` peers, inviting consumers onto the
  exact prerelease resolution that produced the duplicates.
- `@nestjs/config` was pinned to `12.0.0-next.0` in a repo that just left
  the prerelease line.
- `PathScopeGuard.ownerColumn` is `abstract`. `undefined` means "no
  ownership check", so the defaulted field meant a hand-written subclass
  that forgot it silently served an unguarded route — fail-open on an
  exported base class.
- The ancestor-chain guarantee has a hole one level up from the one just
  fixed: the guard replays the MIDDLE resource's hooks, so `scope: false`
  on the MIDDLE composes no `PathScopeHook` and nothing ties the middle
  row to `:parentId`. Probed and pinned as observed (the leaf route is
  served, while the middle's own route still 404s), and documented as the
  limit of the guarantee rather than presented as a design goal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
…peer-deps

The comment justified the flag with Nest 12 advertising Nest 11 peers.
That is settled — `@nestjs/core@12.0.1` advertises `^12` — and the stated
removal criterion had therefore fired without anyone acting on it.

The flag is still load-bearing, for a different and worse reason:
`@nestjs/throttler@6.5.0`, the latest published version, caps its peers at
`@nestjs/common ^11.0.0` while `rockets-auth` depends on Nest 12, so
`npm install @concepta/rockets-auth` answers ERESOLVE on default npm.
That is a consumer-facing defect, it predates this branch (`main`'s
`12.0.0-alpha.5` fails identically), and fixing it means replacing the
auth rate-limit engine — throttler is wired through
`ThrottlerModule.forRoot`, an `AuthAccountThrottlerGuard` subclass and
`@Throttle` decorators across 11 files.

Tracked separately so this branch does not grow a rate-limit refactor it
did not cause. The comment now says which change removes the flag, so the
next reader does not re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
A default `npm install @concepta/rockets-auth` resolves again.

`@nestjs/throttler@6.5.0` — the latest published version, unchanged even
on its master branch — caps its peers at `@nestjs/common ^11.0.0`, so the
install answered ERESOLVE on default npm against Nest 12. Pre-existing
(`main`'s `12.0.0-alpha.5` fails identically); hidden by the consumer
gate's `--legacy-peer-deps`. A strict-resolution probe of the dependency
block minus throttler resolves cleanly, so the flag comes off with it.

Core's rate-limit port grows the one capability the swap needed —
NAMED DIMENSIONS enforced together:

- `@RateLimit` accepts a policy (`{ [name]: { limit, windowMs, key? } }`;
  the flat form stays as sugar for `default`), and
  `RATE_LIMIT_DEFAULTS_TOKEN` supplies app-wide dimensions
  (`disabled: true` turns the guard off).
- Merging is PER FIELD by dimension name — handler over class over
  defaults — so a route override that only tightens `limit` keeps the
  dimension's `key`. The first cut merged whole dimension objects and the
  pre-existing e2e caught it: the account-composite key fell back to
  per-IP and the fine counter was shared across accounts, the exact
  lockout the composite key exists to prevent. Pinned by unit tests.
- Every dimension counts every attempt, including ones another dimension
  rejects — saturating the coarse ceiling must not keep the fine
  counters clean.

Auth swaps engines with behaviour preserved:

- `RocketsAuthRateLimitModule` registers the defaults, the store
  (`throttling.store`, default `InMemoryRateLimitStore` — same
  per-process scope the previous engine shipped) and `RateLimitGuard`,
  `@Global()` for the same reason `ThrottlerModule` was.
- Same policy: 1000/min per-IP ceiling no route overrides + fine
  per-`(ip, account)` limits per route; counters are per route, matching
  throttler's handler-derived storage keys — a global counter would let
  ten failed logins consume the OTP route's much smaller allowance
  (caught by the pre-existing OTP e2e).
- `AuthAccountThrottlerGuard` is deleted; its composite-tracker logic is
  `authAccountRateLimitKey`, a plain function.
- The four pre-existing throttling e2e blocks — declared limit,
  per-account isolation, proxy-aware forwarded-IP buckets,
  `throttling: false` — pass unchanged against the new engine.

BREAKING: `extras.throttling` is now
`false | { ip?: { limit, windowMs }, default?: { limit, windowMs },
store? }`, replacing the pass-through of throttler's option surface; a
custom counter store implements core's `RateLimitStoreInterface`.
`@nestjs/throttler` leaves the dependency tree. Core rate-limit store
keys gained a `<dimension>:` prefix, so counters reset once on upgrade.

The packed-consumer gate now installs with NO `--legacy-peer-deps` — a
consumer's default `npm install` of the published tarballs is the
enforced contract, on top of the duplicate-copy assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
An audit for stale references after the throttler removal, plus the two
test gaps the swap left:

- `rockets-auth`'s README carried a `Throttle({ …, ttl })` example that
  no longer compiles — now `RateLimit({ …, windowMs })`, with a line on
  what a route-level override inherits.
- The root README's Versions section was stale on three axes at once:
  upstream pins (said alpha.8, is alpha.10), Nest (said `12.0.0-alpha.5`
  core with satellites "until a Nest 12 line is published"; everything is
  on stable 12), and `@nestjs/throttler` listed as a dependency it no
  longer is.
- Two doc comments still pointing at `@Throttle` / "the auth throttler
  guard" as the worked example.

Tests, closing coverage the swap only had by accident:

- Per-route counter isolation is pinned EXPLICITLY: an account whose
  login bucket answers 429 still has its full OTP budget. The first cut
  of the swap shared one counter across routes and only the OTP suite's
  incidental failure caught it — that is now a named assertion, not a
  side effect.
- `throttling.store` had zero coverage for a public option: a
  reject-everything store proves the supplied class is what every
  counter routes through (the default in-memory store would admit the
  first request).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
…r namespace

From external review of the branch: the qualifier named a lifted
`z.lazy()` inner object with a counter (`TreeDtoRef0`), and an author
schema legitimately carrying that exact id made the outcome depend on
route scan order. Reproduced both ways before fixing:

- author converted first — the generated definition silently renamed
  itself aside (`TreeDtoRef0_2`); correct but only by ordering luck.
- generated first — the author's schema aborted the whole document build
  with the request/response two-shapes error: true words, wrong
  diagnosis, and a boot that fails or passes depending on module import
  order.

Generated names are now derived from the owning component AND the
definition's own JSON: `<ownerId>Ref_<8 hex of sha256(definition)>`. That
keeps them out of the author namespace by construction, stable for as
long as the recursive shape itself is, and unique per document (the
`taken` scan still suffixes a same-conversion hash collision). The
residual DELIBERATE collision — an author id equal to an
already-generated name — is unresolvable, because the generated `$ref`s
are already in Swagger's hands and the author's id is a wire contract; it
now throws a precise error naming the generating component and the fix,
in either conversion order.

Two new converter tests cover both cross-conversion orders and the
deliberate-collision error; the existing same-conversion guard from
ac71939 keeps its coverage. Both cross-conversion cases fail against the
previous code (one silently renamed, one threw the misdiagnosed error).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
The consumer loop (realtystack/api-rockets on this branch: tsc clean,
264/264, real tsx boot) surfaced nothing broken in the engine — and two
things worth fixing at the source:

- `SwaggerUiService.builder()` gets the warning at the point of
  temptation. The one external consumer fed `builder().build()` to
  `SwaggerModule.createDocument` itself, silently dropping the
  standard-schema converter: request bodies documented inline and 21 of
  its 26 components vanished (only the response schemas Nest's own
  `$defs` lifting rescues survived). `createDocument`'s own doc already
  warned about this drift — on the method the consumer skipped, which is
  exactly where a warning cannot work.

- The `@nestjs/config` devDependency and its e2e mock module are deleted
  from `rockets-auth`. Nothing injects `ConfigService` anywhere in
  `packages/*/src`, and no test consumed the mock — dead since the RFC
  stage that removed the runtime dependency. The consumer run prompted
  the audit: its tsx/CJS runtime cannot load `@nestjs/config@12`
  (ESM-only, no `require` condition), and checking why Rockets did not
  care revealed it should not be in the tree at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
…ow-ups

From an adversarial pass on the rate-limit swap. The engine swap itself
held up in every dimension checked (guard coverage 1:1, per-field merge,
count-all semantics, `disabled`/store-scope/`@Global()` parity); the
defect is one level below it, in the store the swap just made the default
for every auth deployment.

**`InMemoryRateLimitStore` never freed an entry, and its keys are
attacker-controlled.** `authAccountRateLimitKey` embeds `body.email ??
body.username`, and guards run BEFORE pipes, so that value is unvalidated
and bounded only by the body parser (100 kB default). Every request on a
public login / signup / recovery / OTP route therefore inserted a
permanent map entry — and the coarse per-IP ceiling cannot stop it,
precisely because every dimension counts every attempt: each of the 1000
admitted requests/minute carries a NEW account value and inserts a fresh
key. Growth INSIDE the policy, not from exceeding it.

Not a regression — `@nestjs/throttler@6.5.0`'s own storage map has no
`delete` either (checked against the packed tarball, not assumed) — but
ownership moved: it was upstream's unbounded map, fixable by a dependency
bump; it is now ours, shipped as the default.

Two bounds, both cheap: the store sweeps expired windows on write and
enforces a hard key cap (100k, overridable via
`RATE_LIMIT_MAX_KEYS_TOKEN`), dropping soonest-expiring entries first and
warning when a LIVE window has to go — that eviction resets a counter and
raises the effective limit, so it happens only at the ceiling and never
before expired entries are gone. The auth key function bounds the account
field, hashing anything over 128 chars. Three tests, all red without the
eviction call.

Four follow-ups from the same pass:

- `Retry-After` reported the first rejected dimension's reset. Two
  dimensions can reject with different windows — both read `remaining:
  0`, so the tie-break picked whichever came first, and a client blocked
  for an hour by the `ip` ceiling was told to retry in 60s. Now the
  latest reset among rejected dimensions.
- The generated-name digest hashes CANONICAL JSON (keys sorted at every
  depth). Plain `JSON.stringify` is insertion-ordered, so a zod release
  emitting the same schema with reordered keys would rename every
  generated component with no wire change. The collision error also now
  names the real escape hatch: naming the recursive node with
  `withOpenApi` means no name is generated for it at all.
- `TOLERATED_NEST_DUPLICATES` is keyed by PATH, not version — keyed on
  the version alone, a new package nesting the same Nest 11 build for an
  unrelated reason would pass the gate silently.
- `CONFIGURATION.md`'s sub-resource sample still said `scope: false`
  "would disable FK filter+stamp+guard entirely", contradicting the
  corrected prose 90 lines below since 5366bea.

Also: the count-all divergence from throttler is now stated in the auth
changelog, since "behaviour preserved" is claimed alongside it.

The unexplained e2e flake is bisected and the rate-limit guard is
EXONERATED: its earliest occurrence predates the guard's `setHeader` path
by 1h45, the failing spec uses no `RateLimitGuard`, and `pool: 'forks'`
isolates each spec file in its own process. Recorded in the PR
follow-ups; still unresolved, prime suspect is supertest port reuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JUVXjyjAihBuPfynu2r2S
.slice(0, 8);
const base = `${id}Ref_${digest}`;
let candidate = base;
for (let attempt = 2; taken.has(candidate); attempt += 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: document generation aborts for a recursive or z.json() named schema referenced from two routes.

taken includes every name already emitted, so on the second conversion of the same instance the generated definition is renamed to <id>Ref_<hex>_2, the owner's $ref moves with it, and the shape comparison below (:367-379) throws "emitted with two different shapes". Reproduced on this head:

const convert = createRocketsStandardSchemaConverter();
const J = withOpenApi(z.object({ id: z.string(), data: z.json() }), 'JsonDto');
convert(J, { schemaType: 'output' });                              // read route
convert(buildPaginatedSchema(J, 'ctx'), { schemaType: 'output' }); // list route: throws

z.json() is a z.lazy in zod 4.4.3, so any resource with a JSON column or a recursive field in its response cannot serve /api/docs-json. The converter spec only covers the non-recursive double conversion.

My take: when taken.has(base) because the already-emitted definition under that name has identical content, reuse the name instead of suffixing. A regression that converts a z.json() response for read and then for the paginated envelope would pin it.

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.

Reproduced, fixed — but the mechanism is not the one described, and the suggested fix would not have fired.

taken never collides here. The generated name was prefixed with the OWNER's id, so the same lifted definition got a different name per conversion:

owner JsonDto           -> JsonDtoRef_b3e672be
owner JsonDtoPaginated  -> JsonDtoPaginatedDtoRef_b3e672be   (same digest, other prefix)

JsonDto's own $ref moved with it, and that is what tripped the two-shapes check — so taken.has(base) is false and reusing on identical content never runs.

First fix dropped the owner prefix entirely (RocketsRef_<hash>, content only). That was wrong in a second way, found by an adversarial pass over the fix: a lifted definition is not self-contained. One can reference another, and the __schemaN it does that through is positional:

"__schema0": { "leaf": { "$ref": "#/definitions/__schema1" }, ... }

so two definitions with the same outer shape and different children shared a digest, and the document aborted — the same failure, newly reachable because the prefix had been hiding it. The digest is now transitive: each reference is substituted with the digest of what it points at, a back-edge with its distance up the stack. Equal digests mean equal meaning.

Both regressions are pinned in rockets-standard-schema.converter.spec.ts: two same-shaped parents with different leaves get four distinct components, and one shared leaf under two different parents becomes one shared component.

Comment on lines 149 to 150
if (cfg.responseOverride !== undefined)
next.response = cfg.responseOverride;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: responseOverride skips all three response checks.

output and paginated go through assertNamedSchema / assertFailClosedResponse / assertNoHiddenFields; this branch assigns the escape hatch as is, and buildOperationDecorators stamps it as the serializer. Reproduced: read: { responseOverride: { resource: withOpenApi(z.looseObject({ id: z.string() }), 'Leak') } } is accepted at definition time (an unnamed one too) where the same schema via output throws. This PR gave requestOverride.body the named-component bar; the response side did not get the same treatment.

Suggested change
if (cfg.responseOverride !== undefined)
next.response = cfg.responseOverride;
if (cfg.responseOverride !== undefined) {
// Same bar as `output` / `paginated`: the escape hatch is stamped as
// the serializer directly, so it must be a named, fail-closed
// component with no hidden column.
for (const slot of ['resource', 'paginated'] as const) {
const schema = cfg.responseOverride[slot];
if (schema === undefined) continue;
const context = `defineResource(${resourceKey}): operations.${label}.responseOverride.${slot}`;
assertNamedSchema(schema, context);
assertFailClosedResponse(schema, context);
assertNoHiddenFields(schema, context);
}
next.response = cfg.responseOverride;
}

collection probably wants the same bar; I left it out because I have not traced how upstream reads it.

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.

Applied as suggested — assertNamedSchema + assertFailClosedResponse + assertNoHiddenFields on both resource and paginated.

On collection: traced it rather than guessing. It is declared on upstream's CrudResponseConfig but read nowhere in @concepta/nestjs-crud's dist — the only hit is the .d.ts field declaration, no runtime read. So it reaches no response and is not checked; there is a comment at the call site saying to add it the moment upstream consumes it.

Pinned by three cases in define-resource.spec.ts: unnamed, open, and hidden-column overrides all throw with the operations.*.responseOverride.* context.

Comment on lines +132 to +146
// Still over: drop the soonest-expiring LIVE windows. This resets
// their counters — loud, because it means the limiter is admitting
// more than its policy for those keys.
const overflow = this.windows.size - this.maxKeys;
const byExpiry = [...this.windows.entries()].sort(
(a, b) => a[1].expiresAt - b[1].expiresAt,
);
for (const [key] of byExpiry.slice(0, overflow)) {
this.windows.delete(key);
}
this.logger.warn(
`Rate-limit key cap (${this.maxKeys}) reached; dropped ${overflow} live ` +
`window(s), whose counters restart. This means unique keys are being ` +
`created faster than they expire — use a shared, persistent store.`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: this eviction is both the CPU hot spot and the ceiling reset under the flood it exists for.

Two things, one rewrite:

  1. Once size > maxKeys, every consume runs the O(n) sweep above and, whenever nothing has expired, this O(n log n) copy-and-sort of 100k entries on the request path. Under a unique-account flood the map hovers at the cap, so each request pays the full scan. Rejected requests still mint keys (the guard consumes every dimension, rate-limit.guard.ts:126-159), so the 429s do not stop it. A windowMs of one hour reaches the cap at under 30 rps.
  2. expiresAt is anchored at a key's first request, so within one policy the soonest-expiring live window is the oldest-created one. For an attacker that is their ip: ceiling key, created on request one and only updated since. The account-rotation traffic the ceiling exists to stop is what evicts the ceiling, and admitted volume then scales with attack rate.

My take: evict by insertion order in an amortised batch (the Map already iterates oldest-first, so no copy and no sort), or refuse new keys at the cap instead of dropping live ones. Either way a regression that fills the store past maxKeys and asserts the ip: window survives would catch this. The warn below also fires once per request in steady state.

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.

Both points confirmed, and measured: 1.8 ms per consume at 20k keys with nothing expired, and the warn fired once per request in steady state.

One correction, on the proposed fix: evicting by insertion order makes point 2 worse. The ceiling key is created on request one of the flood, so it is also the oldest inserted — insertion order puts it at the head of the queue instead of the tail.

It is now LRU by last touch: consume deletes and re-inserts its key, so Map iteration order is recency order and eviction drops from the front, bounded by the overflow (one entry per request at the cap — no scan, no sort). A hot key is touched every request, so the ceiling survives by construction. Pinned: after a 200-key flood at maxKeys: 10, the ip: window still reports 1000 - 202. The live-drop warning is coalesced to one a minute, carrying the count.

The same pass found two more ways the store could be switched off, both introduced by fixes on this PR: a key function returning [] contributed no counter at all (the guard then answered true for every request — now falls back to the route's default key), and maxKeys at 0/NaN/Infinity made eviction drop the entry consume had just inserted (now refused at construction, integer >= 1).

if (schema instanceof z.ZodObject) {
for (const [key, field] of Object.entries(schema.shape)) {
const fieldPath = `${path}.${key}`;
if (unwrapField(field, fieldPath).meta.dto?.response === false) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth considering: the marker is only read on direct object properties after unwrapField's peels (optional, nullable, default, non-transform pipe). Anything else wrapping the leaf hides it, and the recursive fallback below cannot recover it because a bare string has no children. On this head, with secret = f.string({ dto: { response: false } }) inside a hand-written or f.compute object:

wrapper assertNoHiddenFields withHiddenFieldsRemoved
secret().readonly() accepted keeps pw
secret().nonoptional() accepted keeps pw
secret().prefault('x') accepted keeps pw
secret().catch('x') accepted keeps pw
z.array(secret()) accepted keeps pw
secret().transform(v => v) accepted keeps pw

.readonly() and .transform() are ordinary things to write on a response field. Checking the marker on every node the shared walker visits, rather than on direct properties only, would close all six at once.

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.

All six reproduced exactly as tabulated, and the diagnosis is right: the marker sits one level down where the recursive walk cannot recover it, because the marked node is a bare leaf with no children.

The marker is now read on every node the walker visits, and stripHiddenObject peels the wrapper chain (single-child wrappers via the shared innerType slot, plus arrays — a list of a hidden value is hidden) so the property is dropped rather than kept.

Two of the six do not drop, they now throw at definition time, which is the answer this file already gives for .default() / .catch(): .transform(), because its output cannot be rebuilt without the hidden input, and z.lazy(), which a later pass caught — the rebuilt getter used to run first at serialization, so that error arrived as a 500 on the first response the route served.

Table is pinned as a describe.each in zod-projections.spec.ts, both directions (assert rejects, projection drops).

if (seen.has(schema)) return undefined;
seen.add(schema);

if (schema instanceof z.ZodObject) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth considering: the only rejection is catchall on a ZodObject; pass-through leaves are accepted everywhere except under a pipe. passesThrough below knows any / unknown / custom / transform are leaky, but it is consulted only from the ZodPipe branch. On this head all of these pass assertFailClosedResponse:

  • z.record(z.string(), z.unknown()) as the whole response (ships every row column, hidden ones included)
  • z.object({ blob: z.any() }), z.object({ blob: z.unknown() })
  • z.custom(() => true), z.json(), z.function({ output: z.looseObject(...) })

The /me response has one: claims: z.record(z.string(), z.unknown()) in me.schemas.ts:35-38, so whatever the adapter puts there reaches the client. Either extend the check to pass-through values in output position, or narrow the "no open objects" wording in the changelog so it does not read as covering these.

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.

Took the first option — extended the check — but scoped narrower than the comment implies, and the /me half went the other way. Both deliberate:

Root is now a POSITION, not a node. z.unknown() / z.any() / z.custom() and a record/map of one are refused at a response root, and so is anything that wraps them without naming a key: optional, nullable, readonly, catch, lazy, an array's or set's element, a union branch, either side of an intersection, a pipe's out side. z.array(z.unknown()) ships each row verbatim exactly like the bare version, so it goes the same way.

It stops at an object or a tuple, and that is the part that is not in the comment: a nested z.record(z.string(), z.unknown()) is the shape of a JSON column (rockets-core-json-column.e2e-spec.ts has three), and rejecting it would make JSON columns unserializable. Inside a declared property the author named the key and chose the value type.

/me's claims stays z.unknown() — the second option offered. It is a declared property, so the new rule does not touch it; and narrowing it to z.json() was tried and reverted, because it adds a recursive component to every generated client without constraining anything the adapter puts there. Its description now says the values are the provider's and are not validated.

response.setHeader?.(
'X-RateLimit-Remaining',
String(verdict.result.remaining),
);
response.setHeader?.(
'X-RateLimit-Reset',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: this header and Retry-After can disagree. Retry-After correctly uses the latest reset among rejected dimensions; X-RateLimit-Reset comes from verdict, and when two dimensions reject both report remaining: 0, so the tie-break picks whichever came first. A client blocked for an hour by ip and a minute by default sees Retry-After: 3600 next to a reset one minute out. Using latestRejectedResetAt here on the rejected path would align them.

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.

Fixed. Both headers now state the same instant on a rejection: X-RateLimit-Reset uses latestRejectedResetAt when the verdict is a rejection, and the reported dimension's own resetAt when it is not.

Pinned in rate-limit.guard.spec.ts with two dimensions rejecting at now + 3.6M and now + 60s — the assertion picks the hour, not the minute, so a revert flips it.

result = await this.store.consume(key, options.limit, options.windowMs);
} catch (error) {
this.logger.error(
`Rate limit store failed for key "${key}": ${

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: key carries the raw account value and the client IP. During a store outage this logs every login / recovery / OTP attempt's account identifier at error level, into whatever aggregator the app ships to. Logging the dimension name plus a hash of the key would keep the diagnostic without the PII.

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.

Fixed. The message is now Rate limit store failed for key <dimension>:<8 hex> — the dimension name (which names no one) plus a SHA-256 prefix of the whole key, so two failures for the same counter still correlate without writing an address or an IP to the aggregator.

Pinned: the test drives a key containing 1.2.3.4 and victim@example.com and asserts neither string appears in the logged message.

// filter by the IMMEDIATE parent, so the guard's parent lookup —
// replaying the parent's own `PathScopeHook` — is the only thing that
// rejects a middle row addressed through the wrong ancestor.
const ScopeGuard = PathScopeGuard.for(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth considering: the chain guarantee depends on the middle level's PathScopeHook, so scope: false on a middle resource silently weakens every leaf under it. The guard verifies the immediate parent and replays its hooks; with no hook there is nothing tying the middle row to :parentId, and rockets-core-sub-resource.e2e-spec.ts pins the wrong-ancestor leaf request as 200 "as observed". For an access-control check I lean toward not pinning the hole: each link is a known (param, FK column) pair at materialisation time, so the guard could add Where.eq(middleFk, params[grandparentParam]) to the parent lookup structurally, without hook replay. Failing that, refusing (or at least warning) when a scope: false resource declares subResources would make the trade explicit.

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.

Agreed on the principle — a hole should not be pinned as observed behaviour — and the e2e now pins the 404 with the middle level scoped.

Went the cheaper way for the fix itself: defineResource refuses a scope: false sub-resource that declares subResources, naming the segment and the two ways out (scope that level, or move its children up). That closes the class at definition time rather than leaving the trade to be made by accident.

The structural fix suggested — the guard adding Where.eq(middleFk, params[grandparentParam]) from the materialisation-time (param, FK) pair, without hook replay — is not done and is the better long-term shape. It is named as such in the commit and the changelog rather than left implied, so it does not get lost.

// `scope: false` the route is deliberately not owner-scoped, so an
// actor-less request still gets its chain verified rather than a 401
// the resource never asked for.
if (this.ownerColumn !== undefined && !actorId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Behaviour change worth a changelog line: with ownerColumn unset the guard now runs for unauthenticated callers too, and answers 404 for a missing parent versus a normal response for an existing one. Before this PR no guard was attached on owner: false / scope: false, so an owner-less nested route was not a parent-existence oracle unless the app gates it with its own auth guard. Fine if intended, but it is new surface.

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.

Right, and it is new surface introduced by this PR rather than pre-existing. Recorded in both places a consumer would look:

  • CHANGELOG, on the scope: false / owner: false entry: with no ownerColumn the guard runs for unauthenticated callers too, so a public nested route answers 404 for a missing parent where before no guard was attached at all.
  • CONFIGURATION.md, on the owner: false bullet, with what to do about it — gate the route with your own auth guard if the distinction is sensitive.

return this.repo.update(
existing,
definedData as Partial<UserMetadataEntityInterface>,
{ ...definedData, userId } as Partial<UserMetadataEntityInterface>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth considering: userId is pinned, id and the audit columns are not. defineZodUserMetadata omits UPDATE_MANAGED_FIELDS from the update schema, but a hand-written updateSchema is the documented pattern and validateRocketsUserMetadataConfig never checks it. A schema declaring id: z.string().optional() lets PATCH /me hand a foreign primary key to repo.update(existing, ...). SignupUserHandler already strips identity fields for this reason; the same strip here (and in rockets-auth's user-metadata.repository.ts:45-52), or a boot-time rejection of an update schema that declares any managed field, would close it.

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.

Confirmed and closed at both ends.

validateRocketsUserMetadataConfig now rejects at boot an updateSchema declaring any of USER_METADATA_MANAGED_FIELDS (id, userId, dateCreated, dateUpdated, dateDeleted, version — exported from core so the list has one home), naming the fields and the .omit({ … }) to add.

Both write paths strip them regardless, for the reason the comment implies but the check cannot cover: the boot check can only read a plain object shape, so a union, an intersection or a pipe passes it. That is upsert-user-metadata.handler.ts and rockets-auth's user-metadata.repository.ts.

Worth reporting: the repo's own aggregate-resources.spec.ts fixture declared id in its update schema, and the new boot check caught it on the first run.

tnramalho and others added 4 commits September 3, 2026 22:13
Review follow-ups from @kauandotnet on #105, plus what an adversarial
pass over those fixes turned up.

- The in-memory store evicted by expiry, which put the coarse per-IP
  CEILING key at the head of the queue: created on request one of a
  flood and only updated after, it is the soonest to expire within one
  window length, so the account-rotation traffic the ceiling exists to
  stop was what reset the ceiling. It also swept and sorted the whole
  map on every request past the cap (1.8 ms at 20k keys, on the event
  loop). Now LRU: `consume` re-inserts its key at the back, eviction
  drops from the front bounded by the overflow. The live-drop warning
  is coalesced to one a minute; it fired per request in steady state.
- The auth account key read `email ?? username`, and guards run before
  pipes, so a decoy `email` on a `{ username, password }` login body
  minted a fresh counter per request — 10/min against one account
  became the 1000/min ceiling. `RateLimitOptions.key` may now return
  SEVERAL keys, counted independently under the dimension's own limit;
  the auth key returns one per account field present. The routes whose
  body names no account key that dimension on the IP explicitly, or the
  same decoy replaces their IP fallback.
- `X-RateLimit-Reset` came from the reported dimension while
  `Retry-After` used the latest rejected one, so one 429 could carry two
  answers to "when may I retry?".
- A store outage logged the counter key — client IP plus the account
  the request named — at error level. It now logs the dimension and a
  stable digest.
- `RocketsAuthRateLimitModule` claimed an app could share its store by
  providing `RATE_LIMIT_STORE_TOKEN`; a module-local provider wins in
  its own injector, so that yields two stores. `throttling.store` is
  the way, and `throttling.maxKeys` was added for the same reason.

Found while re-reviewing the above: an empty key array contributed no
counter at all (a dimension that cannot reject admits everything), and
`maxKeys` at zero or `NaN` made eviction drop the entry `consume` had
just inserted — a limiter switched off by config, silently. Both are
refused now. Route overrides are typed `RateLimitDimensionOverride` so
a route may swap only the `key`, which is what the account-less routes
need; a dimension left without `limit`/`windowMs` is rejected by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEfZ7ocqA2Lnr6aHEhsjAD
Review follow-ups from @kauandotnet on #105.

- `operations.*.responseOverride.resource/paginated` assigned their
  schema straight through while `output` went through the named /
  fail-closed / no-hidden-column checks — and `buildOperationDecorators`
  stamps the override as the serializer, so the one path meant for the
  hardest cases was the one that skipped every response check.
  (`collection` is declared by the upstream config type but read nowhere
  in `@concepta/nestjs-crud`, so it reaches no response.)
- `dto: { response: false }` was read only on direct object properties,
  through the wrappers `unwrapField` peels. `.readonly()`,
  `.nonoptional()`, `.prefault()`, `.catch()`, `z.array(...)` and
  `.transform()` all hid the marker one level down, where the recursive
  walk could not recover it either — the marked node is a bare leaf. All
  six were accepted and the column shipped. The marker is now read on
  every node; the projection drops the field through the five
  rebuildable wrappers and refuses the transform, like `.default()`.
  A hidden node under a `z.lazy()` is refused at definition time too:
  the rebuilt getter ran first at serialization, so that error arrived
  as a 500 on the first response the route served.
- `assertFailClosedResponse` only rejected `.passthrough()` /
  `.catchall()` on an object, so `z.record(z.string(), z.unknown())` —
  undeclared keys AND unconstrained values — passed as a whole response.
  A pass-through root is refused now, and "root" is a position: it
  survives every wrapper that names no key, so `z.array(z.unknown())`
  goes the same way. It ends at an object or a tuple, where the author
  named the key — a JSON column stays serializable.
- A hand-written `userMetadata.updateSchema` could declare `id`, and the
  payload reached `repo.update(existing, …)` with another row's primary
  key. Rejected at boot against `USER_METADATA_MANAGED_FIELDS`; both
  write paths strip them anyway, since the boot check can only read a
  plain object shape.

`/me`'s `claims` stays `z.record(z.string(), z.unknown())` on purpose —
its values are the identity provider's, and narrowing them to `z.json()`
adds a recursive component to every generated client without
constraining anything. Its description now says it is not validated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEfZ7ocqA2Lnr6aHEhsjAD
…scoped level

Review follow-ups from @kauandotnet on #105.

- A definition `z.toJSONSchema` had to extract was named after the
  OWNING component, so the same one reached through two owners was named
  twice — `read` and the paginated envelope of `list`, the ordinary case
  for a resource whose response carries a recursive field or a
  `z.json()` column. Nothing collided, but the owner's own `$ref` moved
  with it and the emitted-shape check aborted `/api/docs-json`, blaming
  a request/response split that does not exist. Generated names are now
  `RocketsRef_<hash>`, derived from content alone: one name per
  definition, and no longer dependent on which route was converted first
  (adding a route renamed a published component and churned every
  generated client). Reuse is sound because zod extracts one definition
  per cycle and inlines the rest, so a lifted definition's only `$ref`
  is to itself.
- The chain guarantee at depth three runs through the MIDDLE level's
  `PathScopeHook`. With `scope: false` there, nothing tied the middle row
  to `:parentId` and a leaf was readable through any existing
  grandparent id — a hole opened by a switch two levels up, on a
  resource whose own routes look correct. A `scope: false` sub-resource
  that declares `subResources` is refused at definition time; the e2e
  that pinned the hole as "the documented limit" pins the 404 instead.

The structural alternative — the guard adding `Where.eq(middleFk,
params[grandparentParam])` without hook replay — is the better long-term
fix and is not done here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEfZ7ocqA2Lnr6aHEhsjAD
CHANGELOG and CONFIGURATION for the twelve review items and the fixes
made on top of them, including the three that break a boot: an update
schema declaring a server-managed column, a `scope: false` level with
sub-resources, and a hand-written response schema whose root is a
pass-through or that hides a column behind a wrapper.

Also documents the behaviour change this PR already shipped: the
always-attached `PathScopeGuard` runs for unauthenticated callers on an
owner-less nested route, so a public nested route reports whether a
parent id exists.

Public API: `RateLimitOptions.key` may return several keys, route
overrides are `RateLimitDimensionOverride`, `USER_METADATA_MANAGED_FIELDS`
is exported, and `RocketsAuthThrottlingOptions` gains `maxKeys`. Both
sample contracts regenerate with the `/me` claims description only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEfZ7ocqA2Lnr6aHEhsjAD
tnramalho and others added 2 commits September 4, 2026 08:56
…h limits per route

Second round of adversarial review, on the fixes from the first one. Two
claims made in those commits were wrong; both are corrected here.

- **The converter's dedup invariant was false.** `196b240` asserted that
  a lifted definition is self-contained, so its own JSON could name it.
  It is not: one lifted definition can reference another (a recursive
  node whose field is a second recursive node), and the `__schemaN`
  names it does that through are positional. Two definitions with the
  same outer shape and different children therefore shared a digest, and
  the second to claim the name aborted the document — the exact failure
  that commit set out to remove, newly reachable because it also dropped
  the owner prefix that had been hiding it. The digest is now transitive:
  each reference is substituted with the digest of what it points at, a
  back-edge with its distance up the stack. Equal digests mean equal
  meaning, so one shared leaf under two parents is one component and two
  same-shaped parents with different leaves are two.

- **"A module-local provider wins over a global one" does not decide the
  auth store.** A guard resolves from the injector of the module that
  DECLARES its controller. That is the Rockets Auth module for
  token/recovery/OTP/me-password, so those were never at risk; invitation
  acceptance declares its own controller and now imports the one
  rate-limit registration instead of reading the global registry. Signup
  cannot: upstream generates its controller through
  `CrudModule.forFeature`, which accepts neither `imports` nor
  `providers`, so it resolves globally — where the FIRST registration
  wins. Documented as the boundary it is, in all three places that
  claimed otherwise, and pinned in both import orders by a new e2e that
  boots a competing `@Global` store.

The decoy-field class is closed structurally rather than per route. The
fine dimension keys on the IP by DEFAULT, and a route opts into
`(ip, account)` by naming the body fields it authenticates with
(`authAccountRateLimitKey(['username'])`). Patching the five shipped
account-less routes closed the instances; a route added later would have
inherited the trap. `PATCH /me/password` keys on the authenticated user —
`JwtGuard` runs before the limiter there, and 5/min per IP is one NAT'd
office locking itself out.

Also from the same pass: a dimension name carrying `:` could share a
counter with another dimension (the store key is `<dimension>:<key>`);
`maxKeys` must be an integer, `Infinity` included in the refusal; a
`z.set()` of a pass-through is refused at a response root like an array
is, with the tuple exception stated; and the CHANGELOG entries that still
described the owner-prefixed naming now describe what shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEfZ7ocqA2Lnr6aHEhsjAD
…ng in between

Plan review of the one item left from the last seal ("add a boot-time
check for an incomplete dimension") rejected the plan and found the
option I had written off as impossible.

`RateLimitDimensionOverride` was `Partial<RateLimitOptions>` — widened so
`/signup` could keep the app-wide numbers and swap the key. That went
further than the requirement in the same commit that documented it:
`{ limit }`, `{ windowMs }` and `{}` also compiled, and each describes a
dimension the author cannot complete, so the mistake surfaced only as a
throw on the first request to the route. The type is now the union the
merge actually needs — a complete dimension, or a `key` on its own — and
those three are back on the compiler, pinned by a typetest that fails
(unused `@ts-expect-error`) the moment the union is reverted. App-wide
dimensions are typed complete: they are the base a route merges onto.

The boot-time check itself is NOT built, and the reason is recorded in
`CONFIGURATION.md` §7d and the CHANGELOG so the next reviewer finds a
decision rather than an omission: `RouteAuditService` is provided by
`RocketsCoreModule` and would resolve one app-wide
`RATE_LIMIT_DEFAULTS_TOKEN`, while the guard resolves the one visible to
the module that declares each controller. The audit would abort the boot
of correct apps that register defaults in a feature module — the same
false-positive class the ACL query check already had to fix.

What the type cannot see stays a runtime throw and is now documented: a
key-only override naming a dimension no default registers. Verified in a
full `rockets-auth` app rather than assumed — it boots and answers 500,
so the window was never "bare-core apps only" as the plan claimed.

Also documents `@RateLimit({})`, which four controllers rely on and no
page explained: it opts a route in and overrides nothing, so in an app
that registers no defaults it means no limit at all. And corrects §7d,
which said a dimension is rejected when NEITHER field is supplied — the
guard rejects when either is missing — and still listed both as
unconditionally required, which had been the pre-widening contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEfZ7ocqA2Lnr6aHEhsjAD
@tnramalho

Copy link
Copy Markdown
Collaborator Author

@kauandotnet — all twelve reproduced, all twelve addressed. Replies are on the threads; this is the map and the three places the fix differs from the comment.

Commits (d72ccc9..4e77170)

6af8bc5 rate limit: LRU store, per-field keys, header agreement, key not logged, store/maxKeys wiring
bcfaae1 response safety: responseOverride checks, hidden marker through wrappers, pass-through root, /me managed fields
196b240 converter naming, scope: false + subResources refusal, the guard's new surface
b24a139 docs + API report
4374419 second pass over those fixes (see below)
4e77170 rate-limit override typed as an intent union instead of Partial<>

Where the fix differs from the comment

  1. Converter — the mechanism described (taken_2 suffix) does not fire; the collision was the owner prefix giving one definition two names across conversions, so "reuse the name when the content is identical" would not have run. Fixed by naming from transitive content. A second pass then caught that my first fix had introduced a real digest collision — a lifted definition is not self-contained, one can reference another.
  2. Store eviction — the suggested insertion-order eviction makes point 2 of the same comment worse: the ceiling key is the oldest inserted, so it would be dropped first. LRU by last touch instead.
  3. Fail-closed responses — extended the check, but the new rule stops at an object or a tuple: a nested z.record(z.string(), z.unknown()) is a JSON column and stays legal. /me's claims therefore stays z.unknown() (the other option offered), with a description that says it is not validated.

One thing not done, named rather than implied: the structural ancestor-chain check for sub-resources (Where.eq(middleFk, params[grandparentParam]) without hook replay). defineResource refuses scope: false with subResources instead, which closes the class; the structural version is the better long-term shape and is called out in the commit and the changelog.

Three breaking changes now labelled in the CHANGELOG, all boot-time: an updateSchema declaring a server-managed column, a scope: false level with sub-resources, and a hand-written response schema whose root is a pass-through or that hides a column behind a wrapper.

Adversarial passes over the fixes produced their own findings — two ways the limiter could be switched off by ordinary-looking config (key: () => [], maxKeys: 0), the account-less half of the decoy class, and the digest collision above. Those are in 4374419 and 4e77170.

Gates on 4e77170: build, api:report:check-built, typecheck:spec, 1185 unit, 468 package e2e, lint:all, the three sample suites, and release:packages.

@kauandotnet kauandotnet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

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.

4 participants