#104: one schema engine (upstream alpha.9, zod everywhere) - #105
#104: one schema engine (upstream alpha.9, zod everywhere)#105tnramalho wants to merge 43 commits into
Conversation
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>
…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
left a comment
There was a problem hiding this comment.
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.
…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>
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>
…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>
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.
591dd7f to
a587d27
Compare
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.
|
Request validation: where it landed Rockets validates through Nest 12's native path — 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 What changed. Nest 12 accepts the schema directly, so the intermediate class had nothing left to carry. Both forms were compared in Fixes in this branch
Follow-ups
|
|
Retirable once you bump to alpha.10:
Retirable independent of the version bump:
Also in alpha.10: Also, re: #74 vs #104 — we independently landed on the same call you did ( |
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) { |
There was a problem hiding this comment.
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: throwsz.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.
There was a problem hiding this comment.
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.
| if (cfg.responseOverride !== undefined) | ||
| next.response = cfg.responseOverride; |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
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.
| // 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.`, | ||
| ); |
There was a problem hiding this comment.
Blocking: this eviction is both the CPU hot spot and the ceiling reset under the flood it exists for.
Two things, one rewrite:
- Once
size > maxKeys, everyconsumeruns 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. AwindowMsof one hour reaches the cap at under 30 rps. expiresAtis 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 theirip: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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}": ${ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: falseentry: with noownerColumnthe guard runs for unauthenticated callers too, so a public nested route answers404for a missing parent where before no guard was attached at all. CONFIGURATION.md, on theowner: falsebullet, 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>, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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
…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
|
@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 (
Where the fix differs from the comment
One thing not done, named rather than implied: the structural ancestor-chain check for sub-resources ( Three breaking changes now labelled in the CHANGELOG, all boot-time: an Adversarial passes over the fixes produced their own findings — two ways the limiter could be switched off by ordinary-looking config ( Gates on |
Implements RFC #104: one schema engine for the whole stack.
What changes, in plain terms
operationResourcekeeps its own inline output validation on purpose (Nest's serializer letsnull/undefinedthrough 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.@concepta/nestjs-*moves to8.0.0-alpha.10and Nest to stable12.0.1(the upstream CRUD engine already works this way; this PR adopts it).@nestjs/configis no longer needed by Rockets.operationResource,/me, the built-in auth routes and all three examples run on the same engine.What you get
details(which field, which message).TagResponseDto,TagResponseDtoPaginatedDto, …) instead of inlining shapes.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.mdhas 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:
201now, not an unexplained400. Generated resources run onRocketsCrudAdapter..readonly(),.catch(), …) and the paginated envelope; hidden columns stay hidden at every depth of a computed field.f.date()rejectsnulland booleans (they used to become 1970).userMetadatawith the same default as signup and admin when the app configures none.requireSchemaPipe). The route audit is always on; the route policy stays opt-in.LocalLoginDto,RefreshDto,Recovery*Dto).Checked against realtystack (re-run on this branch's head): typecheck clean, 264/264, plus a real boot via
tsxwith a single@nestjs/core@12.0.1resolved, auth answering 401 and/api/docs-jsonserving 44 paths. Its only change beyond version pins was routing document generation throughSwaggerUiService.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:
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.@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 onallow.rockets-auth: the adminPATCH /admin/users/:idand/admin/roles/:idbodies 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 pinsuserIdfrom the caller on updates.AuthenticationResponse,RocketsAuthUserDto,RocketsAuthRoleDto, per-resourcePaginatedDto,CrudInvalidResponseDtogone).realtystack on this commit: typecheck clean, 264/264.
Third review pass (commits bad0dfb, f3098e5)
/memetadata handlers now forward the request context to every repository call (hooks run, the write joins the request transaction) and pinuserIdfrom the caller on updates — the same fixrockets-authgot, 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).allow; the audit report lists responses documented withstandardSchemabut not serialized (reported, not enforced).@ApiBodybeside a named@Body({ schema })is dropped in favour of the schema;allow/allowControllersno longer exempt the schema-pipe check.realtystack on bad0dfb: typecheck clean, 264/264.
Review fixes (commits 8716489 → 72a921e) — from the change request, plus an adversarial pass on the first fix:
dto: { response: false }) stays hidden on every PROJECTED response path — computed fields, JSON columns, exposed relations andoperationResourceoutputs — through union, intersection, pipe (a top-levelz.preprocessincluded), prefault, readonly, nonoptional and lazy (rebuilt; a recursive lazy no longer overflows the stack, including one whose cycle crosses az.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-authruns the same check on a consumer-supplieduserCrud.model/roleCrud.model(signup, admin users, admin roles), which reach upstream CRUD directly. Anop.sse()operation declares nooutputat 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 reportedf.compute(z.union([nested, fallback]))case and each of the others; all fail against the previous code.transform,any,unknown,custom— also behind wrappers, unions, arrays, object properties, record values, intersections and nested pipes); its memo caches onlytrue, 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.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/corein a setup file:@nestjs/cqrs(CommonJS) requiring the ESM@nestjs/corewhile the runner's async import of it is still evaluating throwsERR_REQUIRE_CYCLE_MODULEon 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 d26731d → d72ccc9) — 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(→ upstreamAppContextHost.from(), which throws where the local helper silently discarded a live transaction context),defineHook'sHttpExceptionpre-wrap, and the two mirrored metadata keys (→ upstreamisAuthPublic()/isTransactional()).ROCKETS_DISABLE_GUARDS_TOKENandRocketsCrudAdapterleave the public API;AuthServerGuardno longer injectsReflector. 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 fromTypeOrmTransaction, and nothing read it), andpropagation/TransactionRequiredExceptionare gone, so there is no fail-closed transaction mode any more. A nestedreadOnlycontradicting the scope it joins now throws instead of being ignored.Security fix, found while auditing the adapter deletion:
defineSubResource({ scope: false })— andowner: false— droppedPathScopeGuardoutright, and with it the ancestor-chain check. Ancestor route params aredisabled: trueand never reachbuildWhere, so at three levels or more/parents/A/children/CHILD_OF_B/notesservedCHILD_OF_B's rows where the scoped route answers 404. The guard now takes an optionalownerColumnand 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/bodyBatchand resource-levelrequest.body/bodyBatchnow clear the same named-component bar asoperations.X.input; a hand-written class@EntityHookhas e2e coverage for 409/403/passthrough (the contract the removed pre-wrap used to work around);CONFIGURATION.md,AGENTS.mdrule 16 and the changelogs no longer describepropagation,RocketsCrudAdapterorscope: falseas "unscoped".Nest leaves the 12 alpha line for stable
12.0.1(commit 31e98eb). Not cosmetic: alpha.10 requires@nestjs/common/core^12.0.1and@nestjs/cqrs^12.0.0, and a prerelease does not satisfy a stable caret range. A cleannpm installof 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_SYMBOLis aunique symbol(TS2420 on the user-port queries) andModuleRefstopped resolving (Nest can't resolve dependencies of the HookResolverService). Every@nestjs/*pin moves to the stable line across the packages, the examples, the rootresolutionsand the packed-consumer script.@nestjs/typeorm@12is ESM, so one spec swapsvi.spyOnon a module namespace forvi.mock.This was caught by CI, not locally: the root
resolutionsblock flattens the workspace tree, so every local suite ran against a shape the published consumer never sees.release:packagesis the gate that models a real consumer, and it is now part of the local pre-push routine.@nestjs/throttleris replaced by core's rate-limit port (commit 4c2f3f6). Its latest release (6.5.0, unchanged even on master) caps peers at Nest 11, sonpm install @concepta/rockets-authanswered ERESOLVE on default npm — pre-existing onmain, hidden by the consumer gate's--legacy-peer-deps. Core's@RateLimit/RateLimitGuardgrew named dimensions enforced together (per-field merge by name, so a route tighteninglimitkeeps 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.throttlingis nowfalse | { ip?, default?, store? }withwindowMs. 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-Afternow reports the latest reset among rejected dimensions (a client blocked for an hour by theipceiling 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), bothcontract:check, and — on CI, which has the JDK 21 the emulator needs — the Firestore emulator suite.Open follow-ups (not in this PR)
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), secondTransactionScope.runon 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).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..refine()checks, and thewithOpenApiid of a nested named schema (it documents inline after the rebuild). Only schemas that actually contain a hidden column are affected.@ApiResponse({ standardSchema })WITHOUT a serializer is documentation only; the audit report lists those routes (unserializedResponseSchemas), the boot check covers@SerializeOptions({ schema })./meclaims the genericUserResponseDto/UserUpdateDtoids with no override.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 newsetHeaderpath and exonerated: the earliest occurrence predates that code by 1h45, the failing spec (rockets-multi-auth-provider) uses noRateLimitGuard, andpool: '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.Not merged without review — leave it as a draft until you have gone through it.
🤖 Generated with Claude Code