From 7b00d110d0e3fc15c48652d3328422ee79bdde20 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 8 Aug 2026 19:44:27 -0400 Subject: [PATCH 1/7] feat: define server composition APIs --- .../rockets-adapter-firebase/CHANGELOG.md | 9 + packages/rockets-adapter-firebase/README.md | 18 +- .../rockets-adapter-firebase/package.json | 3 +- .../__tests__/define-firebase-auth.spec.ts | 2 +- .../firebase-auth-guard.integration.spec.ts | 2 +- .../src/integration/define-firebase-auth.ts | 16 +- packages/rockets-core/CHANGELOG.md | 15 ++ packages/rockets-core/README.md | 20 +-- packages/rockets-core/package.json | 3 +- .../src/__e2e__/rockets-core.e2e-spec.ts | 6 + .../interfaces/auth-bootstrap.interface.ts | 17 ++ packages/rockets-core/src/index.ts | 7 +- .../auth/create-stub-auth-bootstrap.ts | 10 +- .../auth/define-auth-adapter.spec.ts | 49 ++++++ .../auth/define-auth-adapter.ts | 40 +++++ .../guards/auth-server.guard.ts | 7 +- .../src/rockets-core.module-definition.ts | 12 +- .../rockets-repository-typeorm/CHANGELOG.md | 12 ++ packages/rockets-repository-typeorm/README.md | 30 +++- .../rockets-repository-typeorm/package.json | 7 +- .../src}/define-typeorm-repository.spec.ts | 33 ++-- .../src}/define-typeorm-repository.ts | 8 +- .../rockets-repository-typeorm/src/index.ts | 1 + packages/rockets-server/CHANGELOG.md | 142 +++------------ packages/rockets-server/README.md | 98 ++++++----- packages/rockets-server/SWAGGER.md | 20 --- packages/rockets-server/package.json | 27 +-- .../src/__e2e__/create-server.e2e-spec.ts | 108 ++++++++++++ .../rockets-server/src/create-server.spec.ts | 29 +++ packages/rockets-server/src/create-server.ts | 21 +++ packages/rockets-server/src/index.ts | 5 +- .../src/rockets.module-definition.spec.ts | 166 ++++++++++++++++-- .../src/rockets.module-definition.ts | 104 ++++++++--- packages/rockets-server/src/typeorm.ts | 1 - packages/rockets-server/tsconfig.json | 3 +- 35 files changed, 735 insertions(+), 316 deletions(-) create mode 100644 packages/rockets-core/CHANGELOG.md create mode 100644 packages/rockets-core/src/infrastructure/auth/define-auth-adapter.spec.ts create mode 100644 packages/rockets-core/src/infrastructure/auth/define-auth-adapter.ts create mode 100644 packages/rockets-repository-typeorm/CHANGELOG.md rename packages/{rockets-server/src/repository => rockets-repository-typeorm/src}/define-typeorm-repository.spec.ts (60%) rename packages/{rockets-server/src/repository => rockets-repository-typeorm/src}/define-typeorm-repository.ts (72%) delete mode 100644 packages/rockets-server/SWAGGER.md create mode 100644 packages/rockets-server/src/__e2e__/create-server.e2e-spec.ts create mode 100644 packages/rockets-server/src/create-server.spec.ts create mode 100644 packages/rockets-server/src/create-server.ts delete mode 100644 packages/rockets-server/src/typeorm.ts diff --git a/packages/rockets-adapter-firebase/CHANGELOG.md b/packages/rockets-adapter-firebase/CHANGELOG.md index 37fc481ea..8d4cded7b 100644 --- a/packages/rockets-adapter-firebase/CHANGELOG.md +++ b/packages/rockets-adapter-firebase/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Changed + +- Synchronous `defineFirebaseAuth()` options are now flat + (`defineFirebaseAuth({ firebaseApp })`); asynchronous wiring remains the + explicit `{ forRootAsync }` variant. +- Node.js 20 is the minimum supported runtime. + ## 1.0.0-alpha.0 - Initial public alpha release of the Firebase auth adapter. diff --git a/packages/rockets-adapter-firebase/README.md b/packages/rockets-adapter-firebase/README.md index e8bc633c0..6615790db 100644 --- a/packages/rockets-adapter-firebase/README.md +++ b/packages/rockets-adapter-firebase/README.md @@ -8,8 +8,8 @@ > maps the decoded payload to `AuthorizedUser`, and plugs into the standard > `auth` chain. -**Status:** preview (`0.0.1-dev.0` on npm, dist-tag `alpha`). API expected to -stay shape-compatible through 1.0. +**Status:** pre-1.0 preview (`0.0.1-dev.0`, npm dist-tag `alpha`). Public +shapes may still change before 1.0. --- @@ -62,8 +62,8 @@ module wrap the SDK (the common case). ### Wire it into a Rockets app Use the `defineFirebaseAuth()` helper. It returns an `AuthBootstrap` that -`RocketsModule.forRoot({ auth })` consumes directly. When `forRoot()` / -`forRootAsync()` is set, core imports `FirebaseAuthModule` and injects +`createServer({ auth })` or `RocketsModule.forRoot({ auth })` consumes +directly. Core imports `FirebaseAuthModule` and injects `FirebaseAuthAdapter` from that module — the adapter is not double-registered. ```typescript @@ -80,7 +80,7 @@ const firebaseApp = initializeApp({ credential: applicationDefault() }); imports: [ RocketsModule.forRoot({ auth: defineFirebaseAuth({ - forRoot: { firebaseApp }, + firebaseApp, }), userMetadata: { /* entity, createDto, updateDto */ @@ -93,8 +93,8 @@ const firebaseApp = initializeApp({ credential: applicationDefault() }); export class AppModule {} ``` -Pass `forRootAsync` instead of `forRoot` to build options asynchronously (e.g. -inject `ConfigService`). See +Pass `{ forRootAsync: ... }` instead of flat sync options to build options +asynchronously (e.g. inject `ConfigService`). See [How-to › Build options asynchronously](#build-options-asynchronously). --- @@ -203,8 +203,8 @@ export class ProfileController { | Member | Purpose | |---|---| -| `defineFirebaseAuth(input)` | Returns an `AuthBootstrap` for `RocketsModule.forRoot({ auth })`. Accepts `{ forRoot }` for sync or `{ forRootAsync }` for async wiring. Auth-owned entities belong on app `resources[]`, not on this helper. | -| `DefineFirebaseAuthInput` | Discriminated input type — pass exactly one of `forRoot` / `forRootAsync`. | +| `defineFirebaseAuth(input)` | Returns an `AuthBootstrap` for `createServer({ auth })` or `RocketsModule.forRoot({ auth })`. Pass module options directly for sync wiring, or `{ forRootAsync }` for async wiring. | +| `DefineFirebaseAuthInput` | Input type for either flat sync options or the explicit `{ forRootAsync }` variant. | ### Module diff --git a/packages/rockets-adapter-firebase/package.json b/packages/rockets-adapter-firebase/package.json index 0aa5310b2..155cc19d1 100644 --- a/packages/rockets-adapter-firebase/package.json +++ b/packages/rockets-adapter-firebase/package.json @@ -24,8 +24,7 @@ "directory": "packages/rockets-adapter-firebase" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=20.0.0" }, "files": [ "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}", diff --git a/packages/rockets-adapter-firebase/src/__tests__/define-firebase-auth.spec.ts b/packages/rockets-adapter-firebase/src/__tests__/define-firebase-auth.spec.ts index b66d08b03..61bc37248 100644 --- a/packages/rockets-adapter-firebase/src/__tests__/define-firebase-auth.spec.ts +++ b/packages/rockets-adapter-firebase/src/__tests__/define-firebase-auth.spec.ts @@ -14,7 +14,7 @@ class FakeVerifier implements FirebaseTokenVerifierInterface { describe('defineFirebaseAuth', () => { it('returns AuthBootstrap with FirebaseAuthAdapter and forRoot (sync path)', () => { const bootstrap = defineFirebaseAuth({ - forRoot: { verifier: FakeVerifier }, + verifier: FakeVerifier, }); expect(bootstrap.adapter).toBe(FirebaseAuthAdapter); diff --git a/packages/rockets-adapter-firebase/src/__tests__/firebase-auth-guard.integration.spec.ts b/packages/rockets-adapter-firebase/src/__tests__/firebase-auth-guard.integration.spec.ts index ee97adee7..489480d7a 100644 --- a/packages/rockets-adapter-firebase/src/__tests__/firebase-auth-guard.integration.spec.ts +++ b/packages/rockets-adapter-firebase/src/__tests__/firebase-auth-guard.integration.spec.ts @@ -50,7 +50,7 @@ describe('FirebaseAuthAdapter + AuthServerGuard (integration)', () => { beforeAll(async () => { const bootstrap = defineFirebaseAuth({ - forRoot: { verifier: StubVerifier }, + verifier: StubVerifier, }); const moduleRef = await Test.createTestingModule({ diff --git a/packages/rockets-adapter-firebase/src/integration/define-firebase-auth.ts b/packages/rockets-adapter-firebase/src/integration/define-firebase-auth.ts index da1f04a9d..45b3e3e9f 100644 --- a/packages/rockets-adapter-firebase/src/integration/define-firebase-auth.ts +++ b/packages/rockets-adapter-firebase/src/integration/define-firebase-auth.ts @@ -9,19 +9,21 @@ import { FirebaseAuthModule } from '../modules/firebase-auth.module'; * Input for {@link defineFirebaseAuth}. * * Choose exactly one wiring shape: - * - `forRoot` — sync options (`FirebaseAuthModule.forRoot` payload). + * - flat sync options (`FirebaseAuthModule.forRoot` payload). * - `forRootAsync` — async options (`FirebaseAuthModule.forRootAsync` payload). * * Auth-owned entities belong in app `resources[]`, not here. */ export type DefineFirebaseAuthInput = - | Readonly<{ - forRoot: FirebaseAuthModuleOptions; + | (Readonly & { forRootAsync?: never; - }> + }) | Readonly<{ forRootAsync: FirebaseAuthModuleAsyncOptions; - forRoot?: never; + firebaseApp?: never; + verifier?: never; + userResolver?: never; + checkRevoked?: never; }>; /** @@ -33,8 +35,8 @@ export function defineFirebaseAuth( return { adapter: FirebaseAuthAdapter, forRoot: () => - input.forRootAsync !== undefined + 'forRootAsync' in input && input.forRootAsync !== undefined ? FirebaseAuthModule.forRootAsync(input.forRootAsync) - : FirebaseAuthModule.forRoot(input.forRoot), + : FirebaseAuthModule.forRoot(input), }; } diff --git a/packages/rockets-core/CHANGELOG.md b/packages/rockets-core/CHANGELOG.md new file mode 100644 index 000000000..c24603e78 --- /dev/null +++ b/packages/rockets-core/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## Unreleased + +### Added + +- `AuthBootstrapContributions`, allowing an auth integration to carry its owned + resources, metadata contract, repository, and guard preference. +- `defineAuthAdapter()`, which registers and exports a custom auth adapter from + a generated host module. + +### Changed + +- `AuthServerGuard` recognizes the upstream class-level public-route sentinel. +- Node.js 20 is the minimum supported runtime. diff --git a/packages/rockets-core/README.md b/packages/rockets-core/README.md index 62b937edf..d424a5cd3 100644 --- a/packages/rockets-core/README.md +++ b/packages/rockets-core/README.md @@ -7,7 +7,7 @@ > Configuration-driven composition layer: one options object → planner → > upstream `@concepta/nestjs-*` modules registered as Nest imports. -**Status:** stable (`0.0.1-dev.0` on npm, dist-tag `alpha`). +**Status:** pre-1.0 preview (`0.0.1-dev.0`, npm dist-tag `alpha`). --- @@ -105,16 +105,17 @@ import { APP_GUARD } from '@nestjs/core'; import { RocketsCoreModule, AuthServerGuard, + defineAuthAdapter, defineResource, } from '@concepta/rockets-core'; import { JwtAdapter } from './auth/jwt.adapter'; import { PetEntity } from './pet.entity'; -import { defineTypeOrmRepository } from './repository/define-typeorm-repository'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; @Module({ imports: [ RocketsCoreModule.forRoot({ - auth: JwtAdapter, + auth: defineAuthAdapter(JwtAdapter), repository: defineTypeOrmRepository({ type: 'sqlite', database: ':memory:', @@ -130,18 +131,15 @@ export class AppModule {} ### What just happened -- `auth: JwtAdapter` registered the adapter as a provider; core exposes the - ordered chain on `AUTH_ADAPTERS_TOKEN` for `AuthServerGuard`. +- `defineAuthAdapter(JwtAdapter)` registered and exported the adapter; core + exposes the ordered chain on `AUTH_ADAPTERS_TOKEN` for `AuthServerGuard`. - `repository: defineTypeOrmRepository(...)` is the only place that mentions TypeORM. The planner collects entities from `resources[]` and registers them. - `defineResource({ entity: PetEntity })` produced `GET/POST/PATCH/DELETE /pets` with validation and Swagger schema. No controller was written. -`defineTypeOrmRepository` is a small app-local `RepositoryBootstrap` wrapper -(TypeORM connection options + planner-derived entity list) around -`TypeOrmRepositoryModule` from `@concepta/rockets-repository-typeorm`. Keep the -helper in the sample app (or copy into yours) — do not pull TypeORM into -`@concepta/rockets-core` itself. +`defineTypeOrmRepository` is owned by +`@concepta/rockets-repository-typeorm`; core remains storage-agnostic. --- @@ -487,7 +485,7 @@ expect. | Option | Type | Required | Description | | -------------- | ---------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `auth` | `AuthBootstrap` or array | optional† | Auth wiring from `defineFirebaseAuth()`, `defineRocketsAuth()`, or app-local helpers. Each entry supplies `adapter` and optional `forRoot()` for external Nest modules. Entity rows belong in `resources[]`, not on the auth helper. | +| `auth` | `AuthBootstrap` or array | optional† | Auth wiring from `defineFirebaseAuth()`, `defineRocketsAuth()`, or app-local helpers. Each entry supplies an `adapter`, optional `forRoot()`, and optional integration-owned defaults through `contributes`; explicit app options win. | | `repository` | `RepositoryModuleInterface` or `RepositoryBootstrap` | optional | Default persistence adapter. A bootstrap owns both `forRoot(entities)` and `forFeature(entities)`. | | `userMetadata` | `RocketsUserMetadataConfig` | optional | Entity + DTOs for the metadata table joined to external users. | | `resources` | `ReadonlyArray` | optional | Mix of `defineResource`, `defineModuleResource`, and manual `RocketsResourceConfig`. | diff --git a/packages/rockets-core/package.json b/packages/rockets-core/package.json index f409cc097..ba732c60d 100644 --- a/packages/rockets-core/package.json +++ b/packages/rockets-core/package.json @@ -36,8 +36,7 @@ "directory": "packages/rockets-core" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=20.0.0" }, "files": [ "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}", diff --git a/packages/rockets-core/src/__e2e__/rockets-core.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core.e2e-spec.ts index 190024251..aa510bab5 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core.e2e-spec.ts @@ -28,7 +28,9 @@ import { USER_METADATA_MODULE_ENTITY_KEY, } from '../rockets-core.constants'; import { UpsertUserMetadataCommand } from '../application/commands/impl/upsert-user-metadata.command'; +import { UpsertUserMetadataHandler } from '../application/commands/handlers/upsert-user-metadata.handler'; import { GetUserMetadataQuery } from '../application/queries/impl/get-user-metadata.query'; +import { GetUserMetadataHandler } from '../application/queries/handlers/get-user-metadata.handler'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; @@ -175,6 +177,10 @@ describe('RocketsCoreModule (e2e)', () => { RocketsCoreModule.forRoot({ auth: createStubAuthBootstrap(MockAuthAdapter), providers: [MockAuthAdapter], + handlers: { + upsertUserMetadata: UpsertUserMetadataHandler, + getUserMetadata: GetUserMetadataHandler, + }, global: true, }), ], diff --git a/packages/rockets-core/src/domain/interfaces/auth-bootstrap.interface.ts b/packages/rockets-core/src/domain/interfaces/auth-bootstrap.interface.ts index d26bf8974..7037ea4bf 100644 --- a/packages/rockets-core/src/domain/interfaces/auth-bootstrap.interface.ts +++ b/packages/rockets-core/src/domain/interfaces/auth-bootstrap.interface.ts @@ -1,5 +1,20 @@ import type { DynamicModule, Type } from '@nestjs/common'; +import type { RepositoryModuleInterface } from '@concepta/nestjs-repository'; import type { AuthAdapterInterface } from './auth-adapter.interface'; +import type { RepositoryBootstrap } from './repository-bootstrap.interface'; +import type { RocketsUserMetadataConfig } from './rockets-user-metadata-config.interface'; +import type { ResourceInput } from '../../infrastructure/resource/planner/app-registration-plan.types'; + +/** + * Defaults an auth integration can contribute to the surrounding Rockets app. + * Explicit options on `RocketsModule` take precedence over every contribution. + */ +export interface AuthBootstrapContributions { + readonly resources?: ReadonlyArray; + readonly userMetadata?: RocketsUserMetadataConfig; + readonly repository?: RepositoryModuleInterface | RepositoryBootstrap; + readonly enableGlobalGuard?: boolean; +} /** * Light / external auth wiring. When `forRoot` is set, core imports the @@ -10,4 +25,6 @@ export interface AuthBootstrap< > { readonly adapter: Type; readonly forRoot?: () => DynamicModule; + /** Persistence and server defaults owned by this integration. */ + readonly contributes?: AuthBootstrapContributions; } diff --git a/packages/rockets-core/src/index.ts b/packages/rockets-core/src/index.ts index e6b713c49..b0786ee48 100644 --- a/packages/rockets-core/src/index.ts +++ b/packages/rockets-core/src/index.ts @@ -118,8 +118,13 @@ export { export type { DefineModuleResourceInput } from './infrastructure/resource/define-module-resource'; // Auth bootstrap (light / external auth wiring) -export type { AuthBootstrap } from './domain/interfaces/auth-bootstrap.interface'; +export type { + AuthBootstrap, + AuthBootstrapContributions, +} from './domain/interfaces/auth-bootstrap.interface'; export { createStubAuthBootstrap } from './infrastructure/auth/create-stub-auth-bootstrap'; +export { defineAuthAdapter } from './infrastructure/auth/define-auth-adapter'; +export type { DefineAuthAdapterOptions } from './infrastructure/auth/define-auth-adapter'; // Resource config & definition API export type { RocketsResourceConfig } from './domain/interfaces/rockets-resource.interface'; diff --git a/packages/rockets-core/src/infrastructure/auth/create-stub-auth-bootstrap.ts b/packages/rockets-core/src/infrastructure/auth/create-stub-auth-bootstrap.ts index b043e1088..9d433c3c1 100644 --- a/packages/rockets-core/src/infrastructure/auth/create-stub-auth-bootstrap.ts +++ b/packages/rockets-core/src/infrastructure/auth/create-stub-auth-bootstrap.ts @@ -1,17 +1,11 @@ import type { Type } from '@nestjs/common'; import type { AuthAdapterInterface } from '../../domain/interfaces/auth-adapter.interface'; import type { AuthBootstrap } from '../../domain/interfaces/auth-bootstrap.interface'; +import { defineAuthAdapter } from './define-auth-adapter'; /** Minimal AuthBootstrap for core e2e tests — exports adapter from a host module. */ export function createStubAuthBootstrap( adapter: Type, ): AuthBootstrap { - return { - adapter, - forRoot: () => ({ - module: class StubAuthHostModule {}, - providers: [adapter], - exports: [adapter], - }), - }; + return defineAuthAdapter(adapter); } diff --git a/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.spec.ts b/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.spec.ts new file mode 100644 index 000000000..e26fecfb3 --- /dev/null +++ b/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { Injectable } from '@nestjs/common'; + +import type { + AuthAdapterInterface, + AuthAttemptResult, + AuthRequest, +} from '../../domain/interfaces/auth-adapter.interface'; +import * as RocketsCore from '../../index'; + +@Injectable() +class SpecAuthAdapter implements AuthAdapterInterface { + async authenticate(_request: AuthRequest): Promise { + return { matched: false }; + } +} + +describe('defineAuthAdapter', () => { + it('exports a complete bootstrap for a custom adapter', () => { + const defineAuthAdapter = ( + RocketsCore as unknown as { + defineAuthAdapter: ( + adapter: typeof SpecAuthAdapter, + options?: { providers?: unknown[]; contributes?: { resources: [] } }, + ) => { + adapter: unknown; + contributes?: unknown; + forRoot?: () => { + providers?: unknown[]; + exports?: unknown[]; + }; + }; + } + ).defineAuthAdapter; + const dependency = { provide: 'AUTH_DEPENDENCY', useValue: true }; + const contributes = { resources: [] as [] }; + + const bootstrap = defineAuthAdapter(SpecAuthAdapter, { + providers: [dependency], + contributes, + }); + const module = bootstrap.forRoot!(); + + expect(bootstrap.adapter).toBe(SpecAuthAdapter); + expect(bootstrap.contributes).toBe(contributes); + expect(module.providers).toEqual([dependency, SpecAuthAdapter]); + expect(module.exports).toEqual([SpecAuthAdapter]); + }); +}); diff --git a/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.ts b/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.ts new file mode 100644 index 000000000..8788aa67c --- /dev/null +++ b/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.ts @@ -0,0 +1,40 @@ +import type { DynamicModule, Provider, Type } from '@nestjs/common'; + +import type { AuthAdapterInterface } from '../../domain/interfaces/auth-adapter.interface'; +import type { + AuthBootstrap, + AuthBootstrapContributions, +} from '../../domain/interfaces/auth-bootstrap.interface'; + +/** Optional Nest wiring and server defaults owned by a custom auth adapter. */ +export interface DefineAuthAdapterOptions { + readonly imports?: NonNullable; + readonly controllers?: NonNullable; + readonly providers?: ReadonlyArray; + readonly exports?: NonNullable; + readonly contributes?: AuthBootstrapContributions; +} + +/** + * Turn a custom adapter into a complete auth integration. + * + * The generated host module registers and exports the adapter so Rockets can + * inject it into the ordered guard chain. Additional Nest wiring remains + * private to that integration unless explicitly exported. + */ +export function defineAuthAdapter( + adapter: Type, + options: DefineAuthAdapterOptions = {}, +): AuthBootstrap { + return { + adapter, + contributes: options.contributes, + forRoot: () => ({ + module: class AuthAdapterHostModule {}, + imports: [...(options.imports ?? [])], + controllers: [...(options.controllers ?? [])], + providers: [...(options.providers ?? []), adapter], + exports: [adapter, ...(options.exports ?? [])], + }), + }; +} diff --git a/packages/rockets-core/src/infrastructure/guards/auth-server.guard.ts b/packages/rockets-core/src/infrastructure/guards/auth-server.guard.ts index ec4776ed2..ced00fbf2 100644 --- a/packages/rockets-core/src/infrastructure/guards/auth-server.guard.ts +++ b/packages/rockets-core/src/infrastructure/guards/auth-server.guard.ts @@ -39,12 +39,15 @@ export class AuthServerGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - const isDisabled = this.reflector.getAllAndOverride( + const isDisabled = this.reflector.getAllAndOverride( ROCKETS_DISABLE_GUARDS_TOKEN, [context.getHandler(), context.getClass()], ); - if (isDisabled === true) { + // Upstream AuthPublic({ classLevel: true }) deliberately stores the + // sentinel `classLevel` instead of `true` so its own guard can distinguish + // an explicit class-wide choice from an accidental one. + if (isDisabled === true || isDisabled === 'classLevel') { return true; } diff --git a/packages/rockets-core/src/rockets-core.module-definition.ts b/packages/rockets-core/src/rockets-core.module-definition.ts index 1a6979e33..832484902 100644 --- a/packages/rockets-core/src/rockets-core.module-definition.ts +++ b/packages/rockets-core/src/rockets-core.module-definition.ts @@ -201,6 +201,15 @@ function createCoreProviders(options: { providers.push(...buildAuthChainProviders(authBootstraps)); } + const userMetadataProviders: Provider[] = + options.extras?.userMetadata || options.extras?.handlers + ? [ + options.extras?.handlers?.upsertUserMetadata ?? + UpsertUserMetadataHandler, + options.extras?.handlers?.getUserMetadata ?? GetUserMetadataHandler, + ] + : []; + return [ ...providers, AuthServerGuard, @@ -214,8 +223,7 @@ function createCoreProviders(options: { // zod constraints — this interceptor fills the gap without importing nestjs-zod. { provide: APP_INTERCEPTOR, useClass: ZodBodyValidationInterceptor }, // Built-in user-metadata CQRS (override in `extras.handlers` if you need to customize storage) - options.extras?.handlers?.upsertUserMetadata ?? UpsertUserMetadataHandler, - options.extras?.handlers?.getUserMetadata ?? GetUserMetadataHandler, + ...userMetadataProviders, ...(options.extras?.providers ?? []), ...extractResourceProviders(options.plan.crudResources), ]; diff --git a/packages/rockets-repository-typeorm/CHANGELOG.md b/packages/rockets-repository-typeorm/CHANGELOG.md new file mode 100644 index 000000000..862b1deea --- /dev/null +++ b/packages/rockets-repository-typeorm/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + +### Added + +- `defineTypeOrmRepository()`, the adapter-owned `RepositoryBootstrap` that + combines connection options with the Rockets planner's entity list. + +### Changed + +- Node.js 20 is the minimum supported runtime. diff --git a/packages/rockets-repository-typeorm/README.md b/packages/rockets-repository-typeorm/README.md index 55825a37a..1c33daa40 100644 --- a/packages/rockets-repository-typeorm/README.md +++ b/packages/rockets-repository-typeorm/README.md @@ -8,10 +8,11 @@ TypeORM implementation of the Rockets dynamic repository contract. This is a **thin wrapper** over [`@concepta/nestjs-repository-typeorm`](https://www.npmjs.com/package/@concepta/nestjs-repository-typeorm): -the main entry re-exports the upstream package verbatim, so consumers depend -on a single `@concepta/*` package instead of reaching for the upstream one -directly. The Rockets-specific addition — the zod `SchemaEntityCompiler` — -lives at the `/zod` subpath and is the only code this package owns. +the main entry re-exports the upstream package, so consumers depend on a single +`@concepta/*` package instead of reaching for the upstream one directly. It +also owns the Rockets bootstrap that combines TypeORM connection options with +the planner-derived entity list. The zod `SchemaEntityCompiler` lives at the +`/zod` subpath. ## Install @@ -19,7 +20,24 @@ lives at the `/zod` subpath and is the only code this package owns. npm install @concepta/rockets-repository-typeorm typeorm ``` -## Repository module +## Repository bootstrap + +Use one bootstrap at the server boundary. Rockets derives the entity list from +the resources, so the connection never repeats it: + +```ts +import { createServer } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; + +const repository = defineTypeOrmRepository({ + type: 'postgres', + url: process.env.DATABASE_URL, +}); + +createServer({ repository, resources }); +``` + +For lower-level module registration, the upstream exports remain available: ```ts import { TypeOrmRepositoryModule } from '@concepta/rockets-repository-typeorm'; @@ -47,6 +65,8 @@ export const { zodResource, zodSubResource } = `zod` and `nestjs-zod` are **optional peers** — you only pay for them if you import the `/zod` subpath. +Requires Node.js 20 or newer. + ## License BSD-3-Clause diff --git a/packages/rockets-repository-typeorm/package.json b/packages/rockets-repository-typeorm/package.json index 780aeebfc..8ed8a8ff3 100644 --- a/packages/rockets-repository-typeorm/package.json +++ b/packages/rockets-repository-typeorm/package.json @@ -36,13 +36,13 @@ "directory": "packages/rockets-repository-typeorm" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=20.0.0" }, "files": [ "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}", "README.md", - "LICENSE.txt" + "LICENSE.txt", + "CHANGELOG.md" ], "scripts": { "clean": "rimraf dist tsconfig.tsbuildinfo", @@ -65,6 +65,7 @@ }, "peerDependencies": { "@nestjs/common": "^12.0.0-alpha.0", + "@nestjs/typeorm": "^11.0.0", "nestjs-zod": "^5.0.0", "reflect-metadata": "^0.1.13 || ^0.2.0", "typeorm": "^0.3.0", diff --git a/packages/rockets-server/src/repository/define-typeorm-repository.spec.ts b/packages/rockets-repository-typeorm/src/define-typeorm-repository.spec.ts similarity index 60% rename from packages/rockets-server/src/repository/define-typeorm-repository.spec.ts rename to packages/rockets-repository-typeorm/src/define-typeorm-repository.spec.ts index 5465430d4..e3cfd4802 100644 --- a/packages/rockets-server/src/repository/define-typeorm-repository.spec.ts +++ b/packages/rockets-repository-typeorm/src/define-typeorm-repository.spec.ts @@ -1,7 +1,8 @@ -import { vi, describe, it, expect, afterEach } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { TypeOrmModule, type TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { TypeOrmRepositoryModule } from '@concepta/rockets-repository-typeorm'; -import { defineTypeOrmRepository } from './define-typeorm-repository'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; + +import * as TypeOrmAdapter from './index'; type TypeOrmRepositoryFeatureInput = Parameters< typeof TypeOrmRepositoryModule.forFeature @@ -15,15 +16,23 @@ class TypeOrmRepositorySpecEntity { } describe('defineTypeOrmRepository', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => vi.restoreAllMocks()); - it('delegates feature registration to TypeOrmRepositoryModule', () => { - const repository = defineTypeOrmRepository({ - type: 'sqlite', - database: ':memory:', - }); + function define(connection: TypeOrmModuleOptions) { + return ( + TypeOrmAdapter as unknown as { + defineTypeOrmRepository: (options: TypeOrmModuleOptions) => { + forFeature: typeof TypeOrmRepositoryModule.forFeature; + forRoot: ( + entities: ReadonlyArray, + ) => unknown; + }; + } + ).defineTypeOrmRepository(connection); + } + + it('is owned by the TypeORM adapter package and delegates feature registration', () => { + const repository = define({ type: 'sqlite', database: ':memory:' }); const entities: TypeOrmRepositoryFeatureInput = [ { key: 'typeormRepositorySpec', entity: TypeOrmRepositorySpecEntity }, ]; @@ -44,7 +53,7 @@ describe('defineTypeOrmRepository', () => { database: ':memory:', synchronize: true, }; - const repository = defineTypeOrmRepository(connection); + const repository = define(connection); const forRoot = vi.spyOn(TypeOrmModule, 'forRoot'); repository.forRoot([TypeOrmRepositorySpecEntity]); diff --git a/packages/rockets-server/src/repository/define-typeorm-repository.ts b/packages/rockets-repository-typeorm/src/define-typeorm-repository.ts similarity index 72% rename from packages/rockets-server/src/repository/define-typeorm-repository.ts rename to packages/rockets-repository-typeorm/src/define-typeorm-repository.ts index 08e22075d..919c5aee9 100644 --- a/packages/rockets-server/src/repository/define-typeorm-repository.ts +++ b/packages/rockets-repository-typeorm/src/define-typeorm-repository.ts @@ -1,12 +1,16 @@ import type { DynamicModule, PlainLiteralObject, Type } from '@nestjs/common'; -import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { TypeOrmRepositoryModule } from '@concepta/rockets-repository-typeorm'; +import { TypeOrmModule, type TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; import type { RepositoryBootstrap } from '@concepta/rockets-core'; import type { DynamicRepositoryModule, RepositoryProviderOptions, } from '@concepta/nestjs-repository'; +/** + * TypeORM repository bootstrap whose root entity list is derived by the + * Rockets planner from resources and integration contributions. + */ export function defineTypeOrmRepository< Connection extends TypeOrmModuleOptions, >(connection: Connection): RepositoryBootstrap { diff --git a/packages/rockets-repository-typeorm/src/index.ts b/packages/rockets-repository-typeorm/src/index.ts index eef064d01..386c01ee4 100644 --- a/packages/rockets-repository-typeorm/src/index.ts +++ b/packages/rockets-repository-typeorm/src/index.ts @@ -4,3 +4,4 @@ // `@concepta/*` package. The Rockets-specific addition — the zod // `SchemaEntityCompiler` — lives at the `/zod` subpath, not here. export * from '@concepta/nestjs-repository-typeorm'; +export { defineTypeOrmRepository } from './define-typeorm-repository'; diff --git a/packages/rockets-server/CHANGELOG.md b/packages/rockets-server/CHANGELOG.md index 6374cc5e2..51734770e 100644 --- a/packages/rockets-server/CHANGELOG.md +++ b/packages/rockets-server/CHANGELOG.md @@ -11,132 +11,34 @@ and this project adheres to ### Added -- **`RocketsAuthIntegration`** (`ROCKETS_AUTH_INTEGRATION_KIND`, - `isRocketsAuthIntegration`) in `@concepta/rockets-core` for bundles - returned by `@concepta/rockets-auth` **`defineRocketsAuth()`**. - -- **Auth adapter chain** — `auth` in `RocketsModule.forRoot` / `forRootAsync` - now accepts an array: `auth: [AdapterA, AdapterB]`. The `AuthServerGuard` - iterates the chain and stops on the first conclusive result. Single `Type` - inputs continue to work (normalised to a one-element chain). - -- **`extractBearerToken(request: AuthRequest): string | null`** exported from - `@concepta/rockets-core` (and re-exported from `@concepta/rockets`). Replaces - the removed `BearerTokenAuthAdapter` helper. - -- **`AUTH_ADAPTERS_TOKEN`** exported from `@concepta/rockets-core`. Injects the - full ordered adapter chain as `ReadonlyArray`. - -- **`collectAdapters`** and **`resolveAuthChain`** exported from the respective - module-definition files for unit-testing. +- `createServer(definition)`, the canonical launch-facing facade that returns a + Nest entry module for direct use with `NestFactory.create()`. +- `defineAuthAdapter(Adapter, options?)`, a complete host module for custom + authentication adapters. +- Auth integrations can contribute owned resources, user metadata, a root + repository, and a default guard preference through `AuthBootstrap`. +- Executable coverage for direct server launch, ordered multi-credential auth, + private-by-default routes, and metadata-free micro apps. ### Changed -- **`RocketsModule`**: when `extras.auth` is a `RocketsAuthIntegration`, merges - `resources` and appends `nestImports` **after** - `RocketsCoreModule.forRootAsync` so repository rows exist before - `RocketsAuthModule` boots. Merges `userMetadata` / - `rocketsDefaults.enableGlobalGuard` from the integration when not set on - extras. - -- **`AuthAdapterInterface`** — the contract now has a single method - `authenticate(request: AuthRequest): Promise`. - `AuthAttemptResult` is a discriminated union; see below. - -- **`AuthAttemptResult.error`** is now `HttpException` instead of - `UnauthorizedException`, allowing adapters to return 403 and other status - codes. - -- **`AuthServerGuard`** now logs every adapter decision at `debug` level and - wraps unexpected thrown errors in a generic `401` (details are only emitted to - the server-side `Logger`). - -- **`authExternallyProvided`** is no longer a user-facing config field. The flag - is inferred internally by `resolveAuthChain` based on entry type - (`RocketsAuthIntegration` → externally provided; bare `Type` / - `AuthFeatureBundle` → auto-registered). +- `RocketsModule` resolves auth contributions before module registration. + Explicit app options win; conflicting integration defaults fail during + composition. +- `userMetadata` is optional. The `/me` controller and default metadata + handlers are registered only when a metadata contract exists. +- Class-wide `@AuthPublic({ classLevel: true })` metadata is honored by + `AuthServerGuard`. +- TypeORM bootstrap ownership moved to + `@concepta/rockets-repository-typeorm`; the server no longer depends on + TypeORM. +- Node.js 20 is the minimum supported runtime. ### Removed -- **`BearerTokenAuthAdapter`** abstract class — use `extractBearerToken` and - implement `AuthAdapterInterface` directly. - -- **`AUTH_ADAPTER_TOKEN`** (singular) — replaced by `AUTH_ADAPTERS_TOKEN` (the - full chain). `RocketsAuthProvider` alias on `@concepta/rockets` is also - removed. - -- **`AuthorizeUserInterface`** and **`ValidateTokenInterface`** — removed. - -### Migration guide - -#### Implement `authenticate` instead of `validateToken` - -**Before:** - -```typescript -@Injectable() -export class MyAdapter extends BearerTokenAuthAdapter { - async validateToken(token: string): Promise { - const decoded = await verify(token); - return { - id: decoded.sub, - sub: decoded.sub, - email: decoded.email, - userRoles: [], - claims: {}, - }; - } -} -``` - -**After:** - -```typescript -import { extractBearerToken } from '@concepta/rockets-core'; - -@Injectable() -export class MyAdapter implements AuthAdapterInterface { - async authenticate(request: AuthRequest): Promise { - const token = extractBearerToken(request); - if (token === null) return { matched: false }; - - try { - const decoded = await verify(token); - return { - matched: true, - user: { - id: decoded.sub, - sub: decoded.sub, - email: decoded.email, - userRoles: [], - claims: {}, - }, - }; - } catch { - return { - matched: true, - error: new UnauthorizedException('Authentication failed'), - }; - } - } -} -``` - -#### Replace `AUTH_ADAPTER_TOKEN` with `AUTH_ADAPTERS_TOKEN` - -**Before:** - -```typescript -providers: [{ provide: AUTH_ADAPTER_TOKEN, useClass: MyAdapter }]; -``` - -**After:** - -```typescript -// Remove the manual provider — Rockets registers adapters automatically via -// the `auth` option. Inject AUTH_ADAPTERS_TOKEN to read the chain. -const adapters = app.get(AUTH_ADAPTERS_TOKEN); -``` +- The `rockets-swagger` manifest entry. OpenAPI generation belongs to the + consumer application because only it owns the complete Nest graph and + document settings. ## [1.0.0-alpha.7] - 2026-02-19 diff --git a/packages/rockets-server/README.md b/packages/rockets-server/README.md index 15cb035d1..b104551e0 100644 --- a/packages/rockets-server/README.md +++ b/packages/rockets-server/README.md @@ -7,7 +7,7 @@ > External-auth NestJS server. One options object → adapter chain, global guard, > `/me`, declarative CRUD resources, swagger. -**Status:** stable (`0.0.1-dev.0` on npm, dist-tag `alpha`). +**Status:** pre-1.0 preview (`0.0.1-dev.0`, npm dist-tag `alpha`). **Stack context:** [Repository README](../../README.md#what-problem-each-layer-solves) — Concepta @@ -62,7 +62,8 @@ repositories, swagger registration) is re-exported from core. ### When NOT to use this package - You want a **complete built-in auth system** (signup, login, password - recovery, OTP, oauth, admin user CRUD) → use `@concepta/rockets-server-auth`. + recovery, OTP, invitations, admin user CRUD) → use + `@concepta/rockets-auth` alongside this package. - You want full composition control (no `/me`, no global guard) → drop to `@concepta/rockets-core`. @@ -124,8 +125,9 @@ them directly. ### Minimal working app -A single CRUD resource (`pets`) and a single JWT adapter. The `auth`, -`userMetadata`, and `resources` are the three options most apps care about. +A single CRUD resource (`pets`) and a single JWT adapter. `createServer()` is +the canonical launch-facing API; it returns a Nest entry module that can be +passed straight to `NestFactory.create()`. ```typescript // src/auth/jwt.adapter.ts @@ -135,6 +137,7 @@ import { AuthAdapterInterface, AuthAttemptResult, AuthRequest, + defineAuthAdapter, extractBearerToken, } from '@concepta/rockets'; @@ -157,37 +160,38 @@ export class JwtAdapter implements AuthAdapterInterface { } } } + +export const jwtAuth = defineAuthAdapter(JwtAdapter); ``` ```typescript -// src/app.module.ts -import { Module } from '@nestjs/common'; -import { RocketsModule, defineResource } from '@concepta/rockets'; -import { JwtAdapter } from './auth/jwt.adapter'; +// src/server.ts +import { NestFactory } from '@nestjs/core'; +import { createServer, defineResource } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; +import { jwtAuth } from './auth/jwt.adapter'; import { PetEntity } from './pet.entity'; import { UserMetadataEntity } from './user/user-metadata.entity'; import { UserMetadataCreateDto, UserMetadataUpdateDto } from './user/dto'; -import { defineTypeOrmRepository } from './repository/define-typeorm-repository'; - -@Module({ - imports: [ - RocketsModule.forRoot({ - auth: JwtAdapter, - userMetadata: { - entity: UserMetadataEntity, - createDto: UserMetadataCreateDto, - updateDto: UserMetadataUpdateDto, - }, - repository: defineTypeOrmRepository({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - }), - resources: [defineResource({ entity: PetEntity })], - }), - ], -}) -export class AppModule {} + +export const server = createServer({ + auth: jwtAuth, + userMetadata: { + entity: UserMetadataEntity, + createDto: UserMetadataCreateDto, + updateDto: UserMetadataUpdateDto, + }, + repository: defineTypeOrmRepository({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + }), + resources: [defineResource({ entity: PetEntity })], +}); + +// main.ts +const app = await NestFactory.create(server); +await app.listen(3000); ``` You now have: @@ -311,9 +315,7 @@ import { defineModuleResource } from '@concepta/rockets-core'; RocketsModule.forRoot({ auth: defineFirebaseAuth({ - forRoot: { - firebaseApp: admin.initializeApp({ credential: applicationDefault() }), - }, + firebaseApp: admin.initializeApp({ credential: applicationDefault() }), // forRootAsync: { useFactory: resolveFirebaseOptions, inject: [ConfigService] }, }), userMetadata, @@ -359,15 +361,24 @@ defineModuleResource({ | **This package** | `@concepta/rockets` | `RocketsModule`, `MeController`, `APP_GUARD` wiring | | Built-in identity (path B) | `@concepta/rockets-auth` | `defineRocketsAuth()` — **sibling**, not a dependency of this package; apps import both | -Path B: -`RocketsModule.forRoot({ auth: defineRocketsAuth(...), repository, resources })`. +Path B: `createServer({ auth: defineRocketsAuth(...), resources })`. The auth +integration contributes its persistence rows, root repository, metadata +contract, and guard preference; explicit server options still win. + +### `createServer(definition)` + +The canonical public facade. It accepts the same static definition as +`RocketsModule.forRoot()` and returns a Nest `DynamicModule`. Pass it directly +to `NestFactory.create()` or import it into a larger host module. Use +`RocketsModule.forRootAsync()` only when the host itself must build the whole +definition asynchronously. ### `RocketsModule.forRoot(options)` / `forRootAsync(options)` | Option | Type | Required | Description | | ------------------- | -------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `auth` | `AuthBootstrap \| ReadonlyArray` | yes | From `defineFirebaseAuth()`, `defineRocketsAuth()`, or app-local helpers. Pair entity rows on `resources[]`. | -| `userMetadata` | `RocketsUserMetadataConfig` | yes when `/me` is enabled | `{ entity, createDto, updateDto, responseDto?, repository? }`. Used by `MeController` and the default metadata handlers. | +| `auth` | `AuthBootstrap \| ReadonlyArray` | yes | From `defineFirebaseAuth()`, `defineRocketsAuth()`, or app-local helpers. Integrations may contribute owned defaults. | +| `userMetadata` | `RocketsUserMetadataConfig` | optional | When present, mounts `/me` and its metadata handlers. Explicit configuration overrides an auth contribution. | | `repository` | `RepositoryModuleInterface \| RepositoryBootstrap` | optional | Default persistence adapter forwarded to core. Omit if the auth integration registers everything. | | `resources` | `ReadonlyArray` | optional | Bundles from `defineResource` / `defineModuleResource` / hand-built `RocketsResourceConfig`. | | `enableGlobalGuard` | `boolean` (default `true`) | optional | Set `false` to skip the `APP_GUARD: AuthServerGuard` provider. | @@ -416,16 +427,13 @@ Everything most apps need: for back-compat). - Error helpers: `logAndGetErrorDetails`, `getErrorDetails`, `ErrorDetails`. -### Swagger generation - -The package ships a CLI helper to dump the OpenAPI spec to disk: - -```bash -yarn rockets-swagger > swagger.json -``` +### OpenAPI generation -Driven by the same `RocketsModule` so the spec reflects the running app's -resources. +OpenAPI is app-owned because only the application knows its complete entry +module, global prefixes, and document settings. Build the document from the +same `server` definition with Nest's `SwaggerModule.createDocument(app, +config)`. The runnable samples demonstrate both Swagger UI and document +post-processing; the packages do not expose a misleading standalone CLI. --- diff --git a/packages/rockets-server/SWAGGER.md b/packages/rockets-server/SWAGGER.md deleted file mode 100644 index 838a323fa..000000000 --- a/packages/rockets-server/SWAGGER.md +++ /dev/null @@ -1,20 +0,0 @@ -# Rockets Server API Documentation - -This document describes the API endpoints available in the Rockets Server -module. - -## Base URL - -- Development: `http://localhost:3000` - -## Endpoints - -_Endpoints will be added as the module is extended with specific functionality._ - -## Authentication - -_Authentication details will be added when auth modules are integrated._ - -## Error Handling - -_Error handling details will be added as the module is extended._ diff --git a/packages/rockets-server/package.json b/packages/rockets-server/package.json index e5a6142e5..ecf540b92 100644 --- a/packages/rockets-server/package.json +++ b/packages/rockets-server/package.json @@ -12,12 +12,7 @@ "require": "./dist/index.js", "import": "./dist/index.js" }, - "./package.json": "./package.json", - "./typeorm": { - "types": "./dist/typeorm.d.ts", - "require": "./dist/typeorm.js", - "import": "./dist/typeorm.js" - } + "./package.json": "./package.json" }, "sideEffects": false, "publishConfig": { @@ -45,16 +40,10 @@ "rest-api" ], "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "bin": { - "rockets-swagger": "./bin/generate-swagger.js" + "node": ">=20.0.0" }, "files": [ "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}", - "bin/generate-swagger.js", - "SWAGGER.md", "README.md", "LICENSE.txt", "CHANGELOG.md" @@ -68,36 +57,32 @@ "test:watch": "cd ../.. && ./node_modules/.bin/vitest --project unit packages/rockets-server", "test:e2e": "cd ../.. && ./node_modules/.bin/vitest run --project e2e-packages packages/rockets-server", "test:e2e:cov": "cd ../.. && ./node_modules/.bin/vitest run --project e2e-packages --coverage packages/rockets-server", - "test:cov": "cd ../.. && ./node_modules/.bin/vitest run --project unit --coverage packages/rockets-server", - "generate-swagger": "ts-node src/generate-swagger.ts" + "test:cov": "cd ../.. && ./node_modules/.bin/vitest run --project unit --coverage packages/rockets-server" }, "dependencies": { "@concepta/nestjs-core": "8.0.0-alpha.8", "@concepta/rockets-core": "workspace:^", - "@concepta/rockets-repository-typeorm": "workspace:^", "@nestjs/common": "12.0.0-alpha.5", "@nestjs/config": "^4.0.4", "@nestjs/core": "12.0.0-alpha.5", "@nestjs/cqrs": "^11.0.0", - "@nestjs/swagger": "12.0.0-alpha.2", - "@nestjs/typeorm": "^11.0.0" + "@nestjs/swagger": "12.0.0-alpha.2" }, "devDependencies": { "@concepta/nestjs-crud": "8.0.0-alpha.8", + "@concepta/rockets-repository-typeorm": "workspace:^", "@nestjs/platform-express": "12.0.0-alpha.5", "@nestjs/testing": "12.0.0-alpha.5", "@nestjs/typeorm": "^11.0.0", "@types/supertest": "^6.0.2", "sqlite3": "^5.1.6", "supertest": "^6.3.4", - "ts-node": "^10.9.2", "typeorm": "^0.3.31", "vitest": "^4.1.10" }, "peerDependencies": { "class-transformer": "*", "class-validator": "*", - "rxjs": "^7.1.0", - "typeorm": "^0.3.0" + "rxjs": "^7.1.0" } } diff --git a/packages/rockets-server/src/__e2e__/create-server.e2e-spec.ts b/packages/rockets-server/src/__e2e__/create-server.e2e-spec.ts new file mode 100644 index 000000000..bc0ca085b --- /dev/null +++ b/packages/rockets-server/src/__e2e__/create-server.e2e-spec.ts @@ -0,0 +1,108 @@ +import { afterAll, beforeAll, describe, it } from 'vitest'; +import { + Controller, + Get, + INestApplication, + Injectable, + Req, + UnauthorizedException, +} from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import request from 'supertest'; + +import { + defineModuleResource, + defineAuthAdapter, + extractBearerToken, + type AuthAdapterInterface, + type AuthAttemptResult, + type AuthBootstrap, + type AuthRequest, +} from '@concepta/rockets-core'; +import { createServer } from '../create-server'; + +@Injectable() +class SharedIdentityAdapter implements AuthAdapterInterface { + async authenticate(request: AuthRequest): Promise { + const token = extractBearerToken(request); + if (token === null) return { matched: false }; + if (token !== 'shared-user-token') { + return { matched: true, error: new UnauthorizedException() }; + } + return { + matched: true, + user: { id: 'user-1', sub: 'user-1', email: 'user@example.com' }, + }; + } +} + +@Injectable() +class ServiceKeyAdapter implements AuthAdapterInterface { + async authenticate(request: AuthRequest): Promise { + const key = request.headers['x-api-key']; + if (key === undefined) return { matched: false }; + if (key !== 'workflow-service-key') { + return { matched: true, error: new UnauthorizedException() }; + } + return { + matched: true, + user: { id: 'service-1', sub: 'service-1', claims: { type: 'service' } }, + }; + } +} + +function authBootstrap( + adapter: typeof SharedIdentityAdapter | typeof ServiceKeyAdapter, +): AuthBootstrap { + return defineAuthAdapter(adapter); +} + +@Controller('workflow') +@ApiTags('Workflow') +class WorkflowController { + @Get('actor') + @ApiOkResponse({ description: 'The authenticated workflow actor.' }) + actor(@Req() request: { user?: { id?: string } }): { actorId?: string } { + return { actorId: request.user?.id }; + } +} + +describe('createServer — launch-facing composition (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const entryModule = createServer({ + auth: [ + authBootstrap(SharedIdentityAdapter), + authBootstrap(ServiceKeyAdapter), + ], + resources: [defineModuleResource({ controllers: [WorkflowController] })], + }); + + app = await NestFactory.create(entryModule, { logger: false }); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('boots directly from one server definition without user metadata', async () => { + await request(app.getHttpServer()) + .get('/workflow/actor') + .set('Authorization', 'Bearer shared-user-token') + .expect(200, { actorId: 'user-1' }); + }); + + it('tries auth integrations in order and accepts a service credential fallback', async () => { + await request(app.getHttpServer()) + .get('/workflow/actor') + .set('x-api-key', 'workflow-service-key') + .expect(200, { actorId: 'service-1' }); + }); + + it('keeps the workflow private by default', async () => { + await request(app.getHttpServer()).get('/workflow/actor').expect(401); + }); +}); diff --git a/packages/rockets-server/src/create-server.spec.ts b/packages/rockets-server/src/create-server.spec.ts new file mode 100644 index 000000000..a69059794 --- /dev/null +++ b/packages/rockets-server/src/create-server.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import * as Rockets from './index'; +import { RocketsModule } from './rockets.module'; + +describe('createServer', () => { + it('is the canonical server-definition export', () => { + expect( + (Rockets as unknown as Record).createServer, + ).toBeTypeOf('function'); + }); + + it('materializes a Nest entry module from one Rockets definition', () => { + const createServer = ( + Rockets as unknown as { + createServer: (definition: Record) => { + module: unknown; + }; + } + ).createServer; + + const entryModule = createServer({ + enableGlobalGuard: false, + disableController: { me: true }, + }); + + expect(entryModule.module).toBe(RocketsModule); + }); +}); diff --git a/packages/rockets-server/src/create-server.ts b/packages/rockets-server/src/create-server.ts new file mode 100644 index 000000000..b07f6c8dc --- /dev/null +++ b/packages/rockets-server/src/create-server.ts @@ -0,0 +1,21 @@ +import type { DynamicModule } from '@nestjs/common'; + +import { RocketsModule } from './rockets.module'; +import type { RocketsOptions } from './rockets.module-definition'; + +/** The typed domain definition consumed by {@link createServer}. */ +export type RocketsServerDefinition = RocketsOptions; + +/** + * Turn one Rockets definition into the Nest entry module for the server. + * + * The result can be passed directly to `NestFactory.create()` or imported by a + * larger host module. `RocketsModule.forRoot()` remains available as the + * lower-level registration surface; this is the canonical definition-first + * facade used by launchers and platform tooling. + */ +export function createServer( + definition: RocketsServerDefinition, +): DynamicModule { + return RocketsModule.forRoot(definition); +} diff --git a/packages/rockets-server/src/index.ts b/packages/rockets-server/src/index.ts index fc20f95ff..ff7295952 100644 --- a/packages/rockets-server/src/index.ts +++ b/packages/rockets-server/src/index.ts @@ -32,6 +32,7 @@ export { Where, getDynamicRepositoryToken, RepositoryModule, + defineAuthAdapter, createStubAuthBootstrap, AuthUser, ActorCtx, @@ -74,6 +75,7 @@ export type { WithActor, EntityHookContext, RocketsCrudContext, + DefineAuthAdapterOptions, } from '@concepta/rockets-core'; export { isRepositoryBootstrap } from '@concepta/rockets-core'; @@ -88,6 +90,8 @@ export { ExceptionsFilter } from './infrastructure/filters/exceptions.filter'; // ── Server's own exports ── export { RocketsModule } from './rockets.module'; +export { createServer } from './create-server'; +export type { RocketsServerDefinition } from './create-server'; export type { RocketsOptions, RocketsAsyncOptions, @@ -100,7 +104,6 @@ export type { } from './infrastructure/config/interfaces/rockets-options-extras.interface'; export { UserModule } from './user.module'; export { MeController } from './gateways/http/me.controller'; -export { defineTypeOrmRepository } from './repository/define-typeorm-repository'; export { logAndGetErrorDetails, getErrorDetails, diff --git a/packages/rockets-server/src/rockets.module-definition.spec.ts b/packages/rockets-server/src/rockets.module-definition.spec.ts index 42b500dca..489fc9c21 100644 --- a/packages/rockets-server/src/rockets.module-definition.spec.ts +++ b/packages/rockets-server/src/rockets.module-definition.spec.ts @@ -1,42 +1,97 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { APP_GUARD } from '@nestjs/core'; -import { ROCKETS_CORE_SETTINGS_TOKEN } from '@concepta/rockets-core'; +import { + RocketsCoreModule, + ROCKETS_CORE_SETTINGS_TOKEN, + type AuthAdapterInterface, + type AuthAttemptResult, + type AuthBootstrap, + type AuthRequest, + type RepositoryModuleInterface, + type ResourceInput, + type RocketsUserMetadataConfig, + type UserMetadataCreatableInterface, + type UserMetadataModelUpdatableInterface, +} from '@concepta/rockets-core'; import { MeController } from './gateways/http/me.controller'; import { createRocketsControllers, + createRocketsImports, createRocketsProviders, createRocketsExports, } from './rockets.module-definition'; -import { RAW_OPTIONS_TOKEN } from './rockets.tokens'; +import { + RAW_OPTIONS_TOKEN, + ROCKETS_USER_METADATA_DTO_TOKEN, +} from './rockets.tokens'; + +class ContributedAuthAdapter implements AuthAdapterInterface { + authenticate(_request: AuthRequest): Promise { + return Promise.resolve({ matched: false }); + } +} +class ContributedMetadataEntity {} +class ContributedMetadataCreateDto implements UserMetadataCreatableInterface { + userId!: string; +} +class ContributedMetadataUpdateDto + implements UserMetadataModelUpdatableInterface +{ + id!: string; +} + +const contributedRepository = { + forFeature: vi.fn(), +} as unknown as RepositoryModuleInterface; +const contributedResource = { + key: 'auth-resource', +} as unknown as ResourceInput; +const contributedUserMetadata = { + entity: ContributedMetadataEntity, + createDto: ContributedMetadataCreateDto, + updateDto: ContributedMetadataUpdateDto, +} as RocketsUserMetadataConfig; + +function contributedAuth( + overrides: Record = {}, +): AuthBootstrap { + return { + adapter: ContributedAuthAdapter, + contributes: { + repository: contributedRepository, + resources: [contributedResource], + userMetadata: contributedUserMetadata, + enableGlobalGuard: false, + ...overrides, + }, + } as AuthBootstrap; +} describe('RocketsModuleDefinition', () => { describe('createRocketsControllers', () => { - it('should return MeController by default when no options provided', () => { + it('does not mount /me when no user-metadata contract is provided', () => { const result = createRocketsControllers({}); - expect(result).toContain(MeController); - expect(result).toHaveLength(1); + expect(result).toEqual([]); }); - it('should return MeController when extras is empty object', () => { + it('does not mount /me for empty extras', () => { const result = createRocketsControllers({ extras: {} }); - expect(result).toContain(MeController); - expect(result).toHaveLength(1); + expect(result).toEqual([]); }); - it('should return MeController when disableController is empty object', () => { + it('does not mount /me from disableController alone', () => { const result = createRocketsControllers({ extras: { disableController: {} }, }); - expect(result).toContain(MeController); - expect(result).toHaveLength(1); + expect(result).toEqual([]); }); - it('should return MeController when disableController.me is false', () => { + it('mounts /me when user metadata is configured', () => { const result = createRocketsControllers({ - extras: { disableController: { me: false } }, + extras: { userMetadata: contributedUserMetadata }, }); expect(result).toContain(MeController); @@ -45,7 +100,10 @@ describe('RocketsModuleDefinition', () => { it('should exclude MeController when disableController.me is true', () => { const result = createRocketsControllers({ - extras: { disableController: { me: true } }, + extras: { + disableController: { me: true }, + userMetadata: contributedUserMetadata, + }, }); expect(result).not.toContain(MeController); @@ -124,6 +182,84 @@ describe('RocketsModuleDefinition', () => { }); expect(result).toContain(CustomProvider); }); + + it('uses auth-contributed metadata and guard defaults', () => { + const result = createRocketsProviders({ + extras: { auth: contributedAuth() }, + }); + const metadataProvider = result.find( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === ROCKETS_USER_METADATA_DTO_TOKEN, + ) as { useValue: unknown }; + const guardProvider = result.find( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === APP_GUARD, + ); + + expect(metadataProvider.useValue).toEqual({ + updateDto: ContributedMetadataUpdateDto, + }); + expect(guardProvider).toBeUndefined(); + }); + }); + + describe('createRocketsImports', () => { + it('forwards auth-contributed persistence defaults to core', () => { + const forRootAsync = vi + .spyOn(RocketsCoreModule, 'forRootAsync') + .mockReturnValue({ module: RocketsCoreModule }); + + createRocketsImports({ + imports: [], + extras: { auth: contributedAuth() }, + }); + + expect(forRootAsync).toHaveBeenCalledWith( + expect.objectContaining({ + repository: contributedRepository, + resources: [contributedResource], + userMetadata: contributedUserMetadata, + }), + ); + forRootAsync.mockRestore(); + }); + + it('lets explicit app settings override auth defaults while retaining auth resources', () => { + const appRepository = { + forFeature: vi.fn(), + } as unknown as RepositoryModuleInterface; + const appResource = { key: 'app-resource' } as unknown as ResourceInput; + const appUserMetadata = { + ...contributedUserMetadata, + updateDto: class AppMetadataUpdateDto extends ContributedMetadataUpdateDto {}, + }; + const forRootAsync = vi + .spyOn(RocketsCoreModule, 'forRootAsync') + .mockReturnValue({ module: RocketsCoreModule }); + + createRocketsImports({ + imports: [], + extras: { + auth: contributedAuth(), + repository: appRepository, + resources: [appResource], + userMetadata: appUserMetadata, + }, + }); + + expect(forRootAsync).toHaveBeenCalledWith( + expect.objectContaining({ + repository: appRepository, + resources: [contributedResource, appResource], + userMetadata: appUserMetadata, + }), + ); + forRootAsync.mockRestore(); + }); }); describe('createRocketsExports', () => { diff --git a/packages/rockets-server/src/rockets.module-definition.ts b/packages/rockets-server/src/rockets.module-definition.ts index afb57169c..f37697547 100644 --- a/packages/rockets-server/src/rockets.module-definition.ts +++ b/packages/rockets-server/src/rockets.module-definition.ts @@ -5,7 +5,14 @@ import { Provider, } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; -import type { AuthBootstrap } from '@concepta/rockets-core'; +import type { + AuthBootstrap, + AuthBootstrapContributions, + RepositoryBootstrap, + RepositoryModuleInterface, + ResourceInput, + RocketsUserMetadataConfig, +} from '@concepta/rockets-core'; import { RocketsCoreModule, AuthServerGuard, @@ -40,6 +47,62 @@ export function normalizeAuthBootstraps( return [auth]; } +export interface ResolvedRocketsComposition { + readonly auth: ReadonlyArray; + readonly resources: ReadonlyArray; + readonly userMetadata?: RocketsUserMetadataConfig; + readonly repository?: RepositoryModuleInterface | RepositoryBootstrap; + readonly enableGlobalGuard?: boolean; +} + +function resolveSingleContribution< + Key extends Exclude, +>( + bootstraps: ReadonlyArray, + key: Key, +): AuthBootstrapContributions[Key] | undefined { + const values = bootstraps + .map((bootstrap) => bootstrap.contributes?.[key]) + .filter( + (value): value is NonNullable => + value !== undefined, + ); + if (values.length === 0) return undefined; + + const first = values[0]; + if (values.some((value) => value !== first)) { + throw new Error( + `RocketsModule: auth integrations contributed conflicting ${String( + key, + )} defaults. Set extras.${String( + key, + )} explicitly to resolve the conflict.`, + ); + } + return first; +} + +/** Resolve integration-owned defaults once before building the Nest module. */ +export function resolveRocketsComposition( + extras: RocketsOptionsExtrasInterface = {}, +): ResolvedRocketsComposition { + const auth = normalizeAuthBootstraps(extras.auth); + return { + auth, + resources: [ + ...auth.flatMap((bootstrap) => bootstrap.contributes?.resources ?? []), + ...(extras.resources ?? []), + ], + userMetadata: + extras.userMetadata ?? resolveSingleContribution(auth, 'userMetadata'), + repository: + extras.repository ?? resolveSingleContribution(auth, 'repository'), + enableGlobalGuard: + extras.enableGlobalGuard ?? + resolveSingleContribution(auth, 'enableGlobalGuard'), + }; +} + export const { ConfigurableModuleClass: RocketsModuleClass, OPTIONS_TYPE: ROCKETS_MODULE_OPTIONS_TYPE, @@ -92,6 +155,7 @@ export function createRocketsImports(options: { imports: NonNullable; extras?: RocketsOptionsExtrasInterface; }): NonNullable { + const composition = resolveRocketsComposition(options.extras); return [ ...options.imports, RocketsCoreModule.forRootAsync({ @@ -99,10 +163,10 @@ export function createRocketsImports(options: { useFactory: (opts: RocketsOptionsInterface) => ({ swagger: opts.swagger, }), - auth: normalizeAuthBootstraps(options.extras?.auth), - userMetadata: options.extras?.userMetadata, - repository: options.extras?.repository, - resources: options.extras?.resources ?? [], + auth: composition.auth, + userMetadata: composition.userMetadata, + repository: composition.repository, + resources: composition.resources, handlers: options.extras?.handlers, accessControl: options.extras?.accessControl, global: true, @@ -119,9 +183,10 @@ export function createRocketsControllers(options: { } const disableController = options.extras?.disableController ?? {}; + const composition = resolveRocketsComposition(options.extras); const controllers: DynamicModule['controllers'] = []; - if (!disableController.me) { + if (composition.userMetadata && !disableController.me) { controllers.push(MeController); } @@ -156,28 +221,21 @@ export function createRocketsProviders(options: { providers?: Provider[]; extras?: RocketsOptionsExtrasInterface; }): Provider[] { - const extrasUserMetadata = options.extras?.userMetadata; + const composition = resolveRocketsComposition(options.extras); + const extrasUserMetadata = composition.userMetadata; const providers: Provider[] = [ ...(options.providers ?? []), createRocketsSettingsProvider(), - { - provide: ROCKETS_USER_METADATA_DTO_TOKEN, - useFactory: () => { - if (extrasUserMetadata) { - return { - updateDto: extrasUserMetadata.updateDto, - }; - } - throw new Error( - 'RocketsModule: user-metadata config is required. Set ' + - '`extras.userMetadata` to a `RocketsUserMetadataConfig` with `entity`, ' + - '`createDto`, and `updateDto`.', - ); - }, - }, ]; - if (options.extras?.enableGlobalGuard !== false) { + if (extrasUserMetadata) { + providers.push({ + provide: ROCKETS_USER_METADATA_DTO_TOKEN, + useValue: { updateDto: extrasUserMetadata.updateDto }, + }); + } + + if (composition.enableGlobalGuard !== false) { providers.push({ provide: APP_GUARD, useClass: AuthServerGuard, diff --git a/packages/rockets-server/src/typeorm.ts b/packages/rockets-server/src/typeorm.ts deleted file mode 100644 index fdb40e56b..000000000 --- a/packages/rockets-server/src/typeorm.ts +++ /dev/null @@ -1 +0,0 @@ -export { defineTypeOrmRepository } from './repository/define-typeorm-repository'; diff --git a/packages/rockets-server/tsconfig.json b/packages/rockets-server/tsconfig.json index 22195f4bb..b6ecbb26f 100644 --- a/packages/rockets-server/tsconfig.json +++ b/packages/rockets-server/tsconfig.json @@ -23,7 +23,6 @@ "src/**/__e2e__/**" ], "references": [ - { "path": "../rockets-core" }, - { "path": "../rockets-repository-typeorm" } + { "path": "../rockets-core" } ] } From 15328942e42f2266061730d29635f6cdc9b06434 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 8 Aug 2026 19:52:21 -0400 Subject: [PATCH 2/7] chore: update composition lockfile --- yarn.lock | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index e8fa009c0..b61c9ed59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1006,6 +1006,7 @@ __metadata: zod: "npm:^4.4.3" peerDependencies: "@nestjs/common": ^12.0.0-alpha.0 + "@nestjs/typeorm": ^11.0.0 nestjs-zod: ^5.0.0 reflect-metadata: ^0.1.13 || ^0.2.0 typeorm: ^0.3.0 @@ -1037,16 +1038,12 @@ __metadata: "@types/supertest": "npm:^6.0.2" sqlite3: "npm:^5.1.6" supertest: "npm:^6.3.4" - ts-node: "npm:^10.9.2" typeorm: "npm:^0.3.31" vitest: "npm:^4.1.10" peerDependencies: class-transformer: "*" class-validator: "*" rxjs: ^7.1.0 - typeorm: ^0.3.0 - bin: - rockets-swagger: ./bin/generate-swagger.js languageName: unknown linkType: soft From 593b5115aa94cef38e2d1ab28b9d77f6f987b1b9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 8 Aug 2026 19:55:45 -0400 Subject: [PATCH 3/7] test: update TypeORM helper imports --- examples/sample-server/test/zod-full-coverage.e2e-spec.ts | 2 +- examples/sample-server/test/zod-parity.e2e-spec.ts | 2 +- examples/sample-server/test/zod-swagger-golden.e2e-spec.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/sample-server/test/zod-full-coverage.e2e-spec.ts b/examples/sample-server/test/zod-full-coverage.e2e-spec.ts index 958b0cb50..5ed5e63d2 100644 --- a/examples/sample-server/test/zod-full-coverage.e2e-spec.ts +++ b/examples/sample-server/test/zod-full-coverage.e2e-spec.ts @@ -18,8 +18,8 @@ import { z } from 'zod'; import { ExceptionsFilter, RocketsModule, - defineTypeOrmRepository, } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; import type { RocketsRepositoryModuleInterface, SchemaEntityCompiler, diff --git a/examples/sample-server/test/zod-parity.e2e-spec.ts b/examples/sample-server/test/zod-parity.e2e-spec.ts index 51b731477..e879c0a94 100644 --- a/examples/sample-server/test/zod-parity.e2e-spec.ts +++ b/examples/sample-server/test/zod-parity.e2e-spec.ts @@ -11,8 +11,8 @@ import request from 'supertest'; import { ExceptionsFilter, RocketsModule, - defineTypeOrmRepository, } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; import type { ResourceInput } from '@concepta/rockets'; import { UserMetadataCreateDto, diff --git a/examples/sample-server/test/zod-swagger-golden.e2e-spec.ts b/examples/sample-server/test/zod-swagger-golden.e2e-spec.ts index 7e6995356..25ba6ccd3 100644 --- a/examples/sample-server/test/zod-swagger-golden.e2e-spec.ts +++ b/examples/sample-server/test/zod-swagger-golden.e2e-spec.ts @@ -7,7 +7,8 @@ import { SwaggerModule, } from '@nestjs/swagger'; import { cleanupOpenApiDoc } from 'nestjs-zod'; -import { RocketsModule, defineTypeOrmRepository } from '@concepta/rockets'; +import { RocketsModule } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; import type { ResourceInput } from '@concepta/rockets'; import { UserMetadataCreateDto, From c24aa043f54838da920dee07846710d6c24aa5fe Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 9 Aug 2026 11:52:26 -0400 Subject: [PATCH 4/7] fix: close review findings on server composition APIs Gate each user-metadata CQRS handler on its own override, so a partial `handlers` config without `userMetadata` no longer registers a built-in handler that cannot resolve the user-metadata dynamic repository. Derive the Firebase async-branch exclusion from `keyof FirebaseAuthModuleOptions`. The hand-written `never` list already missed the inherited `imports`, which let a sync key ride along with `forRootAsync` and be silently dropped by `forRoot()`. Declare `@nestjs/typeorm` as a devDependency of `@concepta/rockets-repository-typeorm`, which compiles against it and previously resolved it only through workspace hoisting. Replace `as unknown as` namespace shadows in the new specs with typed index imports, so `typecheck:spec` actually covers `createServer`, `defineAuthAdapter`, and `defineTypeOrmRepository`. BREAKING CHANGE: `createStubAuthBootstrap()` is removed. It had become an alias for `defineAuthAdapter()`, which builds the same host module and also accepts imports, controllers, providers, exports, and `contributes`. Replace `createStubAuthBootstrap(Adapter)` with `defineAuthAdapter(Adapter)`. --- .../__tests__/define-firebase-auth.spec.ts | 31 +++++ .../src/integration/define-firebase-auth.ts | 15 +-- packages/rockets-core/CHANGELOG.md | 12 ++ .../rockets-core-access-control.e2e-spec.ts | 4 +- .../rockets-core-define-hook.e2e-spec.ts | 4 +- ...ckets-core-entity-hook-binding.e2e-spec.ts | 4 +- .../rockets-core-feature-module.e2e-spec.ts | 10 +- ...ets-core-metadata-handler-gate.e2e-spec.ts | 114 ++++++++++++++++++ .../rockets-core-persistence.e2e-spec.ts | 8 +- ...kets-core-repository-bootstrap.e2e-spec.ts | 12 +- .../rockets-core-resources.e2e-spec.ts | 6 +- .../rockets-core-sub-resource.e2e-spec.ts | 4 +- .../rockets-core-zod-security.e2e-spec.ts | 6 +- .../src/__e2e__/rockets-core.e2e-spec.ts | 4 +- packages/rockets-core/src/index.ts | 1 - .../auth/create-stub-auth-bootstrap.ts | 11 -- .../auth/define-auth-adapter.spec.ts | 57 ++++++--- .../src/rockets-core.module-definition.ts | 28 +++-- .../rockets-repository-typeorm/CHANGELOG.md | 2 + .../rockets-repository-typeorm/package.json | 1 + .../src/define-typeorm-repository.spec.ts | 25 ++-- packages/rockets-server/CHANGELOG.md | 3 + .../rockets-multi-auth-provider.e2e-spec.ts | 9 +- .../providers/e2e-auth-bootstrap.fixture.ts | 4 +- .../rockets-server/src/create-server.spec.ts | 18 +-- packages/rockets-server/src/index.ts | 1 - yarn.lock | 1 + 27 files changed, 278 insertions(+), 117 deletions(-) create mode 100644 packages/rockets-core/src/__e2e__/rockets-core-metadata-handler-gate.e2e-spec.ts delete mode 100644 packages/rockets-core/src/infrastructure/auth/create-stub-auth-bootstrap.ts diff --git a/packages/rockets-adapter-firebase/src/__tests__/define-firebase-auth.spec.ts b/packages/rockets-adapter-firebase/src/__tests__/define-firebase-auth.spec.ts index 61bc37248..19409b5aa 100644 --- a/packages/rockets-adapter-firebase/src/__tests__/define-firebase-auth.spec.ts +++ b/packages/rockets-adapter-firebase/src/__tests__/define-firebase-auth.spec.ts @@ -36,4 +36,35 @@ describe('defineFirebaseAuth', () => { const dynamicModule = bootstrap.forRoot!(); expect(dynamicModule.module).toBe(FirebaseAuthModule); }); + + it('accepts every sync option, including the inherited `imports`', () => { + class SyncSideModule {} + + const bootstrap = defineFirebaseAuth({ + verifier: FakeVerifier, + imports: [SyncSideModule], + }); + + expect(bootstrap.forRoot!().module).toBe(FirebaseAuthModule); + }); + + it('rejects sync options alongside `forRootAsync`', () => { + // `forRoot()` only forwards `input.forRootAsync`, so a sync key here would + // be silently dropped. The exclusion is derived from + // `keyof FirebaseAuthModuleOptions`, so it covers the inherited `imports` + // too — these two directives fail the build if that hole ever reopens. + // @ts-expect-error `imports` belongs to the sync branch + defineFirebaseAuth({ + forRootAsync: { useFactory: () => ({ verifier: FakeVerifier }) }, + imports: [class AsyncSideModule {}], + }); + + // @ts-expect-error `verifier` belongs to the sync branch + defineFirebaseAuth({ + forRootAsync: { useFactory: () => ({ verifier: FakeVerifier }) }, + verifier: FakeVerifier, + }); + + expect(true).toBe(true); + }); }); diff --git a/packages/rockets-adapter-firebase/src/integration/define-firebase-auth.ts b/packages/rockets-adapter-firebase/src/integration/define-firebase-auth.ts index 45b3e3e9f..9acec01f3 100644 --- a/packages/rockets-adapter-firebase/src/integration/define-firebase-auth.ts +++ b/packages/rockets-adapter-firebase/src/integration/define-firebase-auth.ts @@ -18,13 +18,14 @@ export type DefineFirebaseAuthInput = | (Readonly & { forRootAsync?: never; }) - | Readonly<{ - forRootAsync: FirebaseAuthModuleAsyncOptions; - firebaseApp?: never; - verifier?: never; - userResolver?: never; - checkRevoked?: never; - }>; + // The sync keys are derived from `FirebaseAuthModuleOptions` rather than + // listed by hand, so adding an option cannot silently leave a hole that + // lets a sync key ride along with `forRootAsync` and get dropped. + | Readonly< + { + forRootAsync: FirebaseAuthModuleAsyncOptions; + } & Partial> + >; /** * Build an {@link AuthBootstrap} that wires `FirebaseAuthModule` into core. diff --git a/packages/rockets-core/CHANGELOG.md b/packages/rockets-core/CHANGELOG.md index c24603e78..ceb698e72 100644 --- a/packages/rockets-core/CHANGELOG.md +++ b/packages/rockets-core/CHANGELOG.md @@ -12,4 +12,16 @@ ### Changed - `AuthServerGuard` recognizes the upstream class-level public-route sentinel. +- The built-in user-metadata CQRS handlers are registered per handler: each one + is used only when `userMetadata` is configured or that specific handler is + overridden through `handlers`. Previously, overriding one handler also pulled + in the other built-in, which fails to resolve the user-metadata repository + when no metadata contract exists. - Node.js 20 is the minimum supported runtime. + +### Removed + +- `createStubAuthBootstrap()`. It had become an alias for `defineAuthAdapter()`, + which produces the same host module and additionally accepts imports, + controllers, providers, exports, and `contributes`. Replace + `createStubAuthBootstrap(Adapter)` with `defineAuthAdapter(Adapter)`. diff --git a/packages/rockets-core/src/__e2e__/rockets-core-access-control.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-access-control.e2e-spec.ts index c22092bd4..b557c5d9d 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-access-control.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-access-control.e2e-spec.ts @@ -35,7 +35,7 @@ import { USER_METADATA_MODULE_ENTITY_KEY } from '../rockets-core.constants'; import { APP_GUARD } from '@nestjs/core'; import { AuthServerGuard } from '../infrastructure/guards/auth-server.guard'; import { defineResource } from '../infrastructure/resource/define-resource'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; // ── Fixtures ── @@ -147,7 +147,7 @@ describe('RocketsCoreModule — opt-in accessControl (e2e)', () => { }), MetaRepoModule, RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(RoleAuthProvider), + auth: defineAuthAdapter(RoleAuthProvider), providers: [RoleAuthProvider], repository: TypeOrmRepositoryModule, resources: [ diff --git a/packages/rockets-core/src/__e2e__/rockets-core-define-hook.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-define-hook.e2e-spec.ts index 79a0ac135..afca3d7f0 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-define-hook.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-define-hook.e2e-spec.ts @@ -48,7 +48,7 @@ import { USER_METADATA_MODULE_ENTITY_KEY } from '../rockets-core.constants'; import { AuthServerGuard } from '../infrastructure/guards/auth-server.guard'; import { RocketsCoreExceptionsFilter } from '../infrastructure/filters/exceptions.filter'; import { defineResource } from '../infrastructure/resource/define-resource'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; import { defineHook } from '../infrastructure/hooks/define-hook'; // ── Auth fixture ── @@ -200,7 +200,7 @@ describe('defineHook — functional entity hook (e2e)', () => { }), MetaModule, RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), providers: [StubAuthAdapter], repository: TypeOrmRepositoryModule, resources: [thingResource, otherResource], diff --git a/packages/rockets-core/src/__e2e__/rockets-core-entity-hook-binding.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-entity-hook-binding.e2e-spec.ts index 796d8ee87..eb335ba47 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-entity-hook-binding.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-entity-hook-binding.e2e-spec.ts @@ -48,7 +48,7 @@ import { USER_METADATA_MODULE_ENTITY_KEY } from '../rockets-core.constants'; import { AuthServerGuard } from '../infrastructure/guards/auth-server.guard'; import { defineResource } from '../infrastructure/resource/define-resource'; import { defineModuleResource } from '../infrastructure/resource/define-module-resource'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; import { InjectDynamicRepository } from '../common'; import { EntityHook, @@ -243,7 +243,7 @@ describe('@EntityHook({ entity }) — runtime binding (e2e)', () => { }), MetaModule, RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), providers: [StubAuthAdapter], repository: TypeOrmRepositoryModule, resources: [widgetResource, gadgetResource, widgetLogFeature], diff --git a/packages/rockets-core/src/__e2e__/rockets-core-feature-module.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-feature-module.e2e-spec.ts index 20c334242..0bff4b559 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-feature-module.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-feature-module.e2e-spec.ts @@ -33,7 +33,7 @@ import type { AuthRequest, } from '../domain/interfaces/auth-adapter.interface'; import { extractBearerToken } from '../infrastructure/auth/extract-bearer-token'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; import { InjectDynamicRepository } from '../common'; import type { UserMetadataCreatableInterface, @@ -173,7 +173,7 @@ describe('RocketsCoreModule + defineModuleResource (e2e)', () => { const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(FeatureE2eAuthAdapter), + auth: defineAuthAdapter(FeatureE2eAuthAdapter), providers: [FeatureE2eAuthAdapter], userMetadata: featureUserMetadataConfig, repository: DEFAULT_FAKE_ADAPTER, @@ -218,7 +218,7 @@ describe('RocketsCoreModule + defineModuleResource (e2e)', () => { const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(FeatureE2eAuthAdapter), + auth: defineAuthAdapter(FeatureE2eAuthAdapter), providers: [FeatureE2eAuthAdapter], userMetadata: featureUserMetadataConfig, repository: DEFAULT_FAKE_ADAPTER, @@ -255,7 +255,7 @@ describe('RocketsCoreModule + defineModuleResource (e2e)', () => { const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(FeatureE2eAuthAdapter), + auth: defineAuthAdapter(FeatureE2eAuthAdapter), providers: [FeatureE2eAuthAdapter], userMetadata: featureUserMetadataConfig, repository: DEFAULT_FAKE_ADAPTER, @@ -294,7 +294,7 @@ describe('RocketsCoreModule + defineModuleResource (e2e)', () => { const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(FeatureE2eAuthAdapter), + auth: defineAuthAdapter(FeatureE2eAuthAdapter), providers: [FeatureE2eAuthAdapter], userMetadata: featureUserMetadataConfig, repository: DEFAULT_FAKE_ADAPTER, diff --git a/packages/rockets-core/src/__e2e__/rockets-core-metadata-handler-gate.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-metadata-handler-gate.e2e-spec.ts new file mode 100644 index 000000000..eea59d26b --- /dev/null +++ b/packages/rockets-core/src/__e2e__/rockets-core-metadata-handler-gate.e2e-spec.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { INestApplication, Injectable } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; + +import type { + AuthAdapterInterface, + AuthAttemptResult, + AuthRequest, +} from '../domain/interfaces/auth-adapter.interface'; +import type { UserMetadataEntityInterface } from '../domain/interfaces/user-metadata.interface'; +import { RocketsCoreModule } from '../rockets-core.module'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; +import { AbstractUpsertUserMetadataHandler } from '../application/commands/handlers/abstract-upsert-user-metadata.handler'; +import { AbstractGetUserMetadataHandler } from '../application/queries/handlers/abstract-get-user-metadata.handler'; +import { UpsertUserMetadataCommand } from '../application/commands/impl/upsert-user-metadata.command'; +import { GetUserMetadataQuery } from '../application/queries/impl/get-user-metadata.query'; + +@Injectable() +class GateAuthAdapter implements AuthAdapterInterface { + async authenticate(_request: AuthRequest): Promise { + return { matched: false }; + } +} + +function metadataFor(userId: string): UserMetadataEntityInterface { + return { + id: userId, + userId, + dateCreated: new Date(0), + dateUpdated: new Date(0), + dateDeleted: null, + version: 1, + }; +} + +/** + * Stores metadata in memory so it depends on nothing the planner registers. + * The built-in handlers inject the user-metadata dynamic repository, which is + * only registered when `userMetadata` is configured. + */ +@Injectable() +class InMemoryUpsertHandler extends AbstractUpsertUserMetadataHandler { + async execute( + command: UpsertUserMetadataCommand, + ): Promise { + return metadataFor(command.userId); + } +} + +@Injectable() +class InMemoryGetHandler extends AbstractGetUserMetadataHandler { + async execute( + query: GetUserMetadataQuery, + ): Promise { + return metadataFor(query.userId); + } +} + +async function bootWithHandlers( + handlers: Parameters[0]['handlers'], +): Promise { + const moduleRef = await Test.createTestingModule({ + imports: [ + RocketsCoreModule.forRoot({ + auth: defineAuthAdapter(GateAuthAdapter), + handlers, + global: true, + }), + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + await app.init(); + return app; +} + +describe('RocketsCoreModule — user-metadata handler gate (e2e)', () => { + let app: INestApplication | undefined; + + afterEach(async () => { + await app?.close(); + app = undefined; + }); + + it('boots with no metadata contract and no handlers at all', async () => { + app = await bootWithHandlers(undefined); + + expect(app).toBeDefined(); + }); + + it('boots when only the upsert handler is overridden', async () => { + // The default GetUserMetadataHandler must NOT be pulled in here: it injects + // the user-metadata dynamic repository, which is unregistered without a + // `userMetadata` contract, and Nest would fail to resolve it at init. + app = await bootWithHandlers({ upsertUserMetadata: InMemoryUpsertHandler }); + + const result = await app + .get(CommandBus) + .execute(new UpsertUserMetadataCommand('user-1', {})); + + expect(result).toEqual(metadataFor('user-1')); + }); + + it('boots when only the get handler is overridden', async () => { + app = await bootWithHandlers({ getUserMetadata: InMemoryGetHandler }); + + const result = await app + .get(QueryBus) + .execute(new GetUserMetadataQuery('user-2')); + + expect(result).toEqual(metadataFor('user-2')); + }); +}); diff --git a/packages/rockets-core/src/__e2e__/rockets-core-persistence.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-persistence.e2e-spec.ts index f46e157aa..070ceba1e 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-persistence.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-persistence.e2e-spec.ts @@ -34,7 +34,7 @@ import { USER_METADATA_MODULE_ENTITY_KEY } from '../rockets-core.constants'; import { defineModuleResource } from '../infrastructure/resource/define-module-resource'; import { UpsertUserMetadataCommand } from '../application/commands/impl/upsert-user-metadata.command'; import { GetUserMetadataQuery } from '../application/queries/impl/get-user-metadata.query'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; import type { UserMetadataCreatableInterface, UserMetadataModelUpdatableInterface, @@ -170,7 +170,7 @@ describe('RocketsCoreModule — top-level repository + module resources (e2e)', const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(MockAuthAdapter), + auth: defineAuthAdapter(MockAuthAdapter), providers: [MockAuthAdapter], userMetadata: coreE2eUserMetadataConfig, repository: FakeRepositoryModule, @@ -200,7 +200,7 @@ describe('RocketsCoreModule — top-level repository + module resources (e2e)', const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(MockAuthAdapter), + auth: defineAuthAdapter(MockAuthAdapter), providers: [MockAuthAdapter], userMetadata: coreE2eUserMetadataConfig, repository: FakeRepositoryModule, @@ -232,7 +232,7 @@ describe('RocketsCoreModule — top-level repository + module resources (e2e)', const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(MockAuthAdapter), + auth: defineAuthAdapter(MockAuthAdapter), providers: [MockAuthAdapter], userMetadata: coreE2eUserMetadataConfig, repository: FakeRepositoryModule, diff --git a/packages/rockets-core/src/__e2e__/rockets-core-repository-bootstrap.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-repository-bootstrap.e2e-spec.ts index d313b0e18..4e4ac9a72 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-repository-bootstrap.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-repository-bootstrap.e2e-spec.ts @@ -25,7 +25,7 @@ import { extractBearerToken } from '../infrastructure/auth/extract-bearer-token' import type { RepositoryBootstrap } from '../domain/interfaces/repository-bootstrap.interface'; import { RocketsCoreModule } from '../rockets-core.module'; import { defineModuleResource } from '../infrastructure/resource/define-module-resource'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; // ──────────────────────────────────────────────────────────────────── // Fixtures @@ -169,7 +169,7 @@ describe('RocketsCoreModule — RepositoryBootstrap.forRoot wiring (e2e)', () => const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), repository: fakeBootstrap, userMetadata: metadataConfig, }), @@ -193,7 +193,7 @@ describe('RocketsCoreModule — RepositoryBootstrap.forRoot wiring (e2e)', () => const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), repository: fakeBootstrap, userMetadata: metadataConfig, resources: [widgetFeature, gadgetFeature], @@ -232,7 +232,7 @@ describe('RocketsCoreModule — RepositoryBootstrap.forRoot wiring (e2e)', () => const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), repository: fakeBootstrap, userMetadata: metadataConfig, resources: [widgetFeature, analyticsFeature], @@ -289,7 +289,7 @@ describe('RocketsCoreModule — RepositoryBootstrap.forRoot wiring (e2e)', () => const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), repository: fakeBootstrap, userMetadata: metadataConfig, resources: [widgetFeature, analyticsFeature], @@ -311,7 +311,7 @@ describe('RocketsCoreModule — RepositoryBootstrap.forRoot wiring (e2e)', () => const moduleRef = await Test.createTestingModule({ imports: [ RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), repository: altAdapter, userMetadata: metadataConfig, }), diff --git a/packages/rockets-core/src/__e2e__/rockets-core-resources.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-resources.e2e-spec.ts index 74c6d32b4..38a74b6be 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-resources.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-resources.e2e-spec.ts @@ -30,7 +30,7 @@ import { import { APP_GUARD } from '@nestjs/core'; import { AuthServerGuard } from '../infrastructure/guards/auth-server.guard'; import { defineResource } from '../infrastructure/resource/define-resource'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; // ── Fixtures ── @@ -107,7 +107,7 @@ describe('RocketsCoreModule — resources + resourcePersistence (e2e)', () => { }), MetaRepoModule, RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(SimpleAuthProvider), + auth: defineAuthAdapter(SimpleAuthProvider), providers: [SimpleAuthProvider], repository: TypeOrmRepositoryModule, resources: [ @@ -178,7 +178,7 @@ describe('RocketsCoreModule.forRootAsync (e2e)', () => { RocketsCoreModule.forRootAsync({ useFactory: () => ({}), // `auth` + `providers` are sync extras (alongside useFactory). - auth: createStubAuthBootstrap(SimpleAuthProvider), + auth: defineAuthAdapter(SimpleAuthProvider), providers: [SimpleAuthProvider], global: true, }), diff --git a/packages/rockets-core/src/__e2e__/rockets-core-sub-resource.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-sub-resource.e2e-spec.ts index b0673b6b5..fcd7efb11 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-sub-resource.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-sub-resource.e2e-spec.ts @@ -50,7 +50,7 @@ import { defineResource } from '../infrastructure/resource/define-resource'; import { defineSubResource } from '../infrastructure/resource/define-sub-resource'; import { AfterCreateReloadHook } from '../infrastructure/hooks/after-create-reload.hook'; import { OwnerStampHook } from '../infrastructure/hooks/owner-stamp.hook'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; // ── Auth fixture ── @@ -310,7 +310,7 @@ describe('RocketsCoreModule + defineSubResource + AfterCreateReloadHook (e2e)', }), MetaModule, RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), providers: [StubAuthAdapter], repository: TypeOrmRepositoryModule, resources: [categoryResource, parentResource, plainItemResource], diff --git a/packages/rockets-core/src/__e2e__/rockets-core-zod-security.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core-zod-security.e2e-spec.ts index c30088284..5ee0f8781 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core-zod-security.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core-zod-security.e2e-spec.ts @@ -47,7 +47,7 @@ import { extractBearerToken } from '../infrastructure/auth/extract-bearer-token' import { RocketsCoreModule } from '../rockets-core.module'; import { USER_METADATA_MODULE_ENTITY_KEY } from '../rockets-core.constants'; import { AuthServerGuard } from '../infrastructure/guards/auth-server.guard'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; import { EntityHook, PassthroughEntityHookBase, @@ -166,7 +166,7 @@ describe('zod security HIGHs (e2e RED) — owner scope + response exposure', () }), MetaModule, RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), providers: [StubAuthAdapter], repository: TypeOrmRepositoryModule, resources: [noteResource], @@ -356,7 +356,7 @@ describe('HIGH CWE-284 — PathScope with consumer hooks still enforces (e2e)', }), MetaModule, RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(StubAuthAdapter), + auth: defineAuthAdapter(StubAuthAdapter), providers: [StubAuthAdapter], repository: TypeOrmRepositoryModule, resources: [secParentResource], diff --git a/packages/rockets-core/src/__e2e__/rockets-core.e2e-spec.ts b/packages/rockets-core/src/__e2e__/rockets-core.e2e-spec.ts index aa510bab5..cf05ecd77 100644 --- a/packages/rockets-core/src/__e2e__/rockets-core.e2e-spec.ts +++ b/packages/rockets-core/src/__e2e__/rockets-core.e2e-spec.ts @@ -33,7 +33,7 @@ import { GetUserMetadataQuery } from '../application/queries/impl/get-user-metad import { GetUserMetadataHandler } from '../application/queries/handlers/get-user-metadata.handler'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; -import { createStubAuthBootstrap } from '../infrastructure/auth/create-stub-auth-bootstrap'; +import { defineAuthAdapter } from '../infrastructure/auth/define-auth-adapter'; import { AuthUser } from '../common'; // ──────────────────────────────────────────────────────────────────── @@ -175,7 +175,7 @@ describe('RocketsCoreModule (e2e)', () => { imports: [ TestMetadataRepoModule, RocketsCoreModule.forRoot({ - auth: createStubAuthBootstrap(MockAuthAdapter), + auth: defineAuthAdapter(MockAuthAdapter), providers: [MockAuthAdapter], handlers: { upsertUserMetadata: UpsertUserMetadataHandler, diff --git a/packages/rockets-core/src/index.ts b/packages/rockets-core/src/index.ts index b0786ee48..b2697225c 100644 --- a/packages/rockets-core/src/index.ts +++ b/packages/rockets-core/src/index.ts @@ -122,7 +122,6 @@ export type { AuthBootstrap, AuthBootstrapContributions, } from './domain/interfaces/auth-bootstrap.interface'; -export { createStubAuthBootstrap } from './infrastructure/auth/create-stub-auth-bootstrap'; export { defineAuthAdapter } from './infrastructure/auth/define-auth-adapter'; export type { DefineAuthAdapterOptions } from './infrastructure/auth/define-auth-adapter'; diff --git a/packages/rockets-core/src/infrastructure/auth/create-stub-auth-bootstrap.ts b/packages/rockets-core/src/infrastructure/auth/create-stub-auth-bootstrap.ts deleted file mode 100644 index 9d433c3c1..000000000 --- a/packages/rockets-core/src/infrastructure/auth/create-stub-auth-bootstrap.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Type } from '@nestjs/common'; -import type { AuthAdapterInterface } from '../../domain/interfaces/auth-adapter.interface'; -import type { AuthBootstrap } from '../../domain/interfaces/auth-bootstrap.interface'; -import { defineAuthAdapter } from './define-auth-adapter'; - -/** Minimal AuthBootstrap for core e2e tests — exports adapter from a host module. */ -export function createStubAuthBootstrap( - adapter: Type, -): AuthBootstrap { - return defineAuthAdapter(adapter); -} diff --git a/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.spec.ts b/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.spec.ts index e26fecfb3..7ccc3f04f 100644 --- a/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.spec.ts +++ b/packages/rockets-core/src/infrastructure/auth/define-auth-adapter.spec.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { Injectable } from '@nestjs/common'; +import { Injectable, type Provider } from '@nestjs/common'; import type { AuthAdapterInterface, AuthAttemptResult, AuthRequest, } from '../../domain/interfaces/auth-adapter.interface'; -import * as RocketsCore from '../../index'; +import type { AuthBootstrapContributions } from '../../domain/interfaces/auth-bootstrap.interface'; +// Imported from the package index (not the module directly) so this spec fails +// to compile if `defineAuthAdapter` stops being part of the public surface. +import { defineAuthAdapter } from '../../index'; @Injectable() class SpecAuthAdapter implements AuthAdapterInterface { @@ -17,23 +20,11 @@ class SpecAuthAdapter implements AuthAdapterInterface { describe('defineAuthAdapter', () => { it('exports a complete bootstrap for a custom adapter', () => { - const defineAuthAdapter = ( - RocketsCore as unknown as { - defineAuthAdapter: ( - adapter: typeof SpecAuthAdapter, - options?: { providers?: unknown[]; contributes?: { resources: [] } }, - ) => { - adapter: unknown; - contributes?: unknown; - forRoot?: () => { - providers?: unknown[]; - exports?: unknown[]; - }; - }; - } - ).defineAuthAdapter; - const dependency = { provide: 'AUTH_DEPENDENCY', useValue: true }; - const contributes = { resources: [] as [] }; + const dependency: Provider = { + provide: 'AUTH_DEPENDENCY', + useValue: true, + }; + const contributes: AuthBootstrapContributions = { resources: [] }; const bootstrap = defineAuthAdapter(SpecAuthAdapter, { providers: [dependency], @@ -46,4 +37,32 @@ describe('defineAuthAdapter', () => { expect(module.providers).toEqual([dependency, SpecAuthAdapter]); expect(module.exports).toEqual([SpecAuthAdapter]); }); + + it('registers and exports the adapter with no options', () => { + const module = defineAuthAdapter(SpecAuthAdapter).forRoot!(); + + expect(module.imports).toEqual([]); + expect(module.controllers).toEqual([]); + expect(module.providers).toEqual([SpecAuthAdapter]); + expect(module.exports).toEqual([SpecAuthAdapter]); + }); + + it('keeps extra wiring private unless explicitly exported', () => { + class SideModule {} + class SideController {} + const internal: Provider = { provide: 'INTERNAL', useValue: 1 }; + const shared: Provider = { provide: 'SHARED', useValue: 2 }; + + const module = defineAuthAdapter(SpecAuthAdapter, { + imports: [SideModule], + controllers: [SideController], + providers: [internal, shared], + exports: ['SHARED'], + }).forRoot!(); + + expect(module.imports).toEqual([SideModule]); + expect(module.controllers).toEqual([SideController]); + expect(module.providers).toEqual([internal, shared, SpecAuthAdapter]); + expect(module.exports).toEqual([SpecAuthAdapter, 'SHARED']); + }); }); diff --git a/packages/rockets-core/src/rockets-core.module-definition.ts b/packages/rockets-core/src/rockets-core.module-definition.ts index 832484902..68121a301 100644 --- a/packages/rockets-core/src/rockets-core.module-definition.ts +++ b/packages/rockets-core/src/rockets-core.module-definition.ts @@ -201,14 +201,26 @@ function createCoreProviders(options: { providers.push(...buildAuthChainProviders(authBootstraps)); } - const userMetadataProviders: Provider[] = - options.extras?.userMetadata || options.extras?.handlers - ? [ - options.extras?.handlers?.upsertUserMetadata ?? - UpsertUserMetadataHandler, - options.extras?.handlers?.getUserMetadata ?? GetUserMetadataHandler, - ] - : []; + // Gate each handler on its own override, falling back to the built-in only + // when a metadata contract exists. The defaults inject the user-metadata + // dynamic repository, which is registered only via `userMetadata` — pulling + // one in because the *other* handler was overridden would fail DI at boot. + const hasUserMetadata = options.extras?.userMetadata !== undefined; + const userMetadataProviders: Provider[] = []; + + const upsertUserMetadata = + options.extras?.handlers?.upsertUserMetadata ?? + (hasUserMetadata ? UpsertUserMetadataHandler : undefined); + if (upsertUserMetadata !== undefined) { + userMetadataProviders.push(upsertUserMetadata); + } + + const getUserMetadata = + options.extras?.handlers?.getUserMetadata ?? + (hasUserMetadata ? GetUserMetadataHandler : undefined); + if (getUserMetadata !== undefined) { + userMetadataProviders.push(getUserMetadata); + } return [ ...providers, diff --git a/packages/rockets-repository-typeorm/CHANGELOG.md b/packages/rockets-repository-typeorm/CHANGELOG.md index 862b1deea..f974090d6 100644 --- a/packages/rockets-repository-typeorm/CHANGELOG.md +++ b/packages/rockets-repository-typeorm/CHANGELOG.md @@ -9,4 +9,6 @@ ### Changed +- `@nestjs/typeorm` is now a required peer dependency, because + `defineTypeOrmRepository()` builds the TypeORM root module directly. - Node.js 20 is the minimum supported runtime. diff --git a/packages/rockets-repository-typeorm/package.json b/packages/rockets-repository-typeorm/package.json index 8ed8a8ff3..d7998e3a7 100644 --- a/packages/rockets-repository-typeorm/package.json +++ b/packages/rockets-repository-typeorm/package.json @@ -58,6 +58,7 @@ "devDependencies": { "@nestjs/common": "12.0.0-alpha.5", "@nestjs/testing": "12.0.0-alpha.5", + "@nestjs/typeorm": "11.0.3", "typeorm": "^0.3.31", "typescript": "^5.4.0", "vitest": "^4.1.10", diff --git a/packages/rockets-repository-typeorm/src/define-typeorm-repository.spec.ts b/packages/rockets-repository-typeorm/src/define-typeorm-repository.spec.ts index e3cfd4802..61e8cafc8 100644 --- a/packages/rockets-repository-typeorm/src/define-typeorm-repository.spec.ts +++ b/packages/rockets-repository-typeorm/src/define-typeorm-repository.spec.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { TypeOrmModule, type TypeOrmModuleOptions } from '@nestjs/typeorm'; import { TypeOrmRepositoryModule } from '@concepta/nestjs-repository-typeorm'; -import * as TypeOrmAdapter from './index'; +// Imported from the package index (not the module directly) so this spec fails +// to compile if `defineTypeOrmRepository` stops being part of the public +// surface — the bootstrap is owned by this package, not the server. +import { defineTypeOrmRepository } from './index'; type TypeOrmRepositoryFeatureInput = Parameters< typeof TypeOrmRepositoryModule.forFeature @@ -18,21 +21,11 @@ class TypeOrmRepositorySpecEntity { describe('defineTypeOrmRepository', () => { afterEach(() => vi.restoreAllMocks()); - function define(connection: TypeOrmModuleOptions) { - return ( - TypeOrmAdapter as unknown as { - defineTypeOrmRepository: (options: TypeOrmModuleOptions) => { - forFeature: typeof TypeOrmRepositoryModule.forFeature; - forRoot: ( - entities: ReadonlyArray, - ) => unknown; - }; - } - ).defineTypeOrmRepository(connection); - } - it('is owned by the TypeORM adapter package and delegates feature registration', () => { - const repository = define({ type: 'sqlite', database: ':memory:' }); + const repository = defineTypeOrmRepository({ + type: 'sqlite', + database: ':memory:', + }); const entities: TypeOrmRepositoryFeatureInput = [ { key: 'typeormRepositorySpec', entity: TypeOrmRepositorySpecEntity }, ]; @@ -53,7 +46,7 @@ describe('defineTypeOrmRepository', () => { database: ':memory:', synchronize: true, }; - const repository = define(connection); + const repository = defineTypeOrmRepository(connection); const forRoot = vi.spyOn(TypeOrmModule, 'forRoot'); repository.forRoot([TypeOrmRepositorySpecEntity]); diff --git a/packages/rockets-server/CHANGELOG.md b/packages/rockets-server/CHANGELOG.md index 51734770e..f699f9aa7 100644 --- a/packages/rockets-server/CHANGELOG.md +++ b/packages/rockets-server/CHANGELOG.md @@ -39,6 +39,9 @@ and this project adheres to - The `rockets-swagger` manifest entry. OpenAPI generation belongs to the consumer application because only it owns the complete Nest graph and document settings. +- The `createStubAuthBootstrap()` re-export, now that `defineAuthAdapter()` + covers the same wiring. Replace `createStubAuthBootstrap(Adapter)` with + `defineAuthAdapter(Adapter)`. ## [1.0.0-alpha.7] - 2026-02-19 diff --git a/packages/rockets-server/src/__e2e__/rockets-multi-auth-provider.e2e-spec.ts b/packages/rockets-server/src/__e2e__/rockets-multi-auth-provider.e2e-spec.ts index 470cefcd8..9e087dc2d 100644 --- a/packages/rockets-server/src/__e2e__/rockets-multi-auth-provider.e2e-spec.ts +++ b/packages/rockets-server/src/__e2e__/rockets-multi-auth-provider.e2e-spec.ts @@ -9,10 +9,7 @@ import type { AuthAttemptResult, AuthRequest, } from '@concepta/rockets-core'; -import { - createStubAuthBootstrap, - extractBearerToken, -} from '@concepta/rockets-core'; +import { defineAuthAdapter, extractBearerToken } from '@concepta/rockets-core'; import type { UserMetadataCreatableInterface, UserMetadataModelUpdatableInterface, @@ -150,7 +147,7 @@ function buildApp(adapter: Type) { return Test.createTestingModule({ imports: [ RocketsModule.forRoot({ - auth: createStubAuthBootstrap(adapter), + auth: defineAuthAdapter(adapter), userMetadata: { entity: StubUserMetadataEntity, createDto: UserMetadataCreateDto, @@ -166,7 +163,7 @@ function buildChainApp(adapters: ReadonlyArray>) { return Test.createTestingModule({ imports: [ RocketsModule.forRoot({ - auth: adapters.map((entry) => createStubAuthBootstrap(entry)), + auth: adapters.map((entry) => defineAuthAdapter(entry)), userMetadata: { entity: StubUserMetadataEntity, createDto: UserMetadataCreateDto, diff --git a/packages/rockets-server/src/__fixtures__/providers/e2e-auth-bootstrap.fixture.ts b/packages/rockets-server/src/__fixtures__/providers/e2e-auth-bootstrap.fixture.ts index 3901d72ff..7de3ddfe2 100644 --- a/packages/rockets-server/src/__fixtures__/providers/e2e-auth-bootstrap.fixture.ts +++ b/packages/rockets-server/src/__fixtures__/providers/e2e-auth-bootstrap.fixture.ts @@ -1,6 +1,6 @@ import type { Type } from '@nestjs/common'; import { - createStubAuthBootstrap, + defineAuthAdapter, type AuthAdapterInterface, type AuthBootstrap, } from '@concepta/rockets-core'; @@ -9,5 +9,5 @@ import { export function e2eAuthBootstrap( adapter: Type, ): AuthBootstrap { - return createStubAuthBootstrap(adapter); + return defineAuthAdapter(adapter); } diff --git a/packages/rockets-server/src/create-server.spec.ts b/packages/rockets-server/src/create-server.spec.ts index a69059794..d16da826b 100644 --- a/packages/rockets-server/src/create-server.spec.ts +++ b/packages/rockets-server/src/create-server.spec.ts @@ -1,24 +1,12 @@ import { describe, expect, it } from 'vitest'; -import * as Rockets from './index'; +// Imported from the package index (not the module directly) so this spec fails +// to compile if `createServer` stops being part of the public surface. +import { createServer } from './index'; import { RocketsModule } from './rockets.module'; describe('createServer', () => { - it('is the canonical server-definition export', () => { - expect( - (Rockets as unknown as Record).createServer, - ).toBeTypeOf('function'); - }); - it('materializes a Nest entry module from one Rockets definition', () => { - const createServer = ( - Rockets as unknown as { - createServer: (definition: Record) => { - module: unknown; - }; - } - ).createServer; - const entryModule = createServer({ enableGlobalGuard: false, disableController: { me: true }, diff --git a/packages/rockets-server/src/index.ts b/packages/rockets-server/src/index.ts index ff7295952..8aafb784f 100644 --- a/packages/rockets-server/src/index.ts +++ b/packages/rockets-server/src/index.ts @@ -33,7 +33,6 @@ export { getDynamicRepositoryToken, RepositoryModule, defineAuthAdapter, - createStubAuthBootstrap, AuthUser, ActorCtx, getActor, diff --git a/yarn.lock b/yarn.lock index b61c9ed59..54732e7a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1000,6 +1000,7 @@ __metadata: "@concepta/rockets-core": "workspace:^" "@nestjs/common": "npm:12.0.0-alpha.5" "@nestjs/testing": "npm:12.0.0-alpha.5" + "@nestjs/typeorm": "npm:11.0.3" typeorm: "npm:^0.3.31" typescript: "npm:^5.4.0" vitest: "npm:^4.1.10" From 3f6b6ce0dfd419cc2b59c168169c58d4a71df801 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 9 Aug 2026 11:56:55 -0400 Subject: [PATCH 5/7] refactor: remove bare type aliases from the server surface `RocketsServerDefinition` was a bare alias of `RocketsOptions` with one consumer: the `createServer()` parameter it annotated. `createServer()` now takes `RocketsOptions` directly. `RocketsAuthInput` was a `@deprecated` bare alias of `RocketsAuthOption` with no consumers at all. It was also the only name for that union on the public surface, so `RocketsAuthOption` is exported in its place. Checked the other alias-shaped declarations in the packages this branch touches and kept the ones that earn their name: `EntityHookContext` has ~70 consumers and is the documented name in the hooks subsystem, `WireRow` pairs with `PersistenceRow` as a domain distinction, and the `InjectDynamicRepository` / `InjectCrudAdapter` wrappers widen the upstream string-only decorators to accept an entity class. BREAKING CHANGE: the `RocketsServerDefinition` and `RocketsAuthInput` type exports are removed. Use `RocketsOptions` and `RocketsAuthOption`. --- packages/rockets-server/CHANGELOG.md | 4 ++++ packages/rockets-server/src/create-server.ts | 7 +------ packages/rockets-server/src/index.ts | 3 +-- .../config/interfaces/rockets-options-extras.interface.ts | 3 --- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/rockets-server/CHANGELOG.md b/packages/rockets-server/CHANGELOG.md index f699f9aa7..b368adbf5 100644 --- a/packages/rockets-server/CHANGELOG.md +++ b/packages/rockets-server/CHANGELOG.md @@ -42,6 +42,10 @@ and this project adheres to - The `createStubAuthBootstrap()` re-export, now that `defineAuthAdapter()` covers the same wiring. Replace `createStubAuthBootstrap(Adapter)` with `defineAuthAdapter(Adapter)`. +- `RocketsServerDefinition`, a bare alias of `RocketsOptions`. `createServer()` + takes `RocketsOptions` directly; use that name. +- `RocketsAuthInput`, a bare alias of `RocketsAuthOption`. The canonical + `RocketsAuthOption` is now exported in its place. ## [1.0.0-alpha.7] - 2026-02-19 diff --git a/packages/rockets-server/src/create-server.ts b/packages/rockets-server/src/create-server.ts index b07f6c8dc..316a01800 100644 --- a/packages/rockets-server/src/create-server.ts +++ b/packages/rockets-server/src/create-server.ts @@ -3,9 +3,6 @@ import type { DynamicModule } from '@nestjs/common'; import { RocketsModule } from './rockets.module'; import type { RocketsOptions } from './rockets.module-definition'; -/** The typed domain definition consumed by {@link createServer}. */ -export type RocketsServerDefinition = RocketsOptions; - /** * Turn one Rockets definition into the Nest entry module for the server. * @@ -14,8 +11,6 @@ export type RocketsServerDefinition = RocketsOptions; * lower-level registration surface; this is the canonical definition-first * facade used by launchers and platform tooling. */ -export function createServer( - definition: RocketsServerDefinition, -): DynamicModule { +export function createServer(definition: RocketsOptions): DynamicModule { return RocketsModule.forRoot(definition); } diff --git a/packages/rockets-server/src/index.ts b/packages/rockets-server/src/index.ts index 8aafb784f..da9568a60 100644 --- a/packages/rockets-server/src/index.ts +++ b/packages/rockets-server/src/index.ts @@ -90,7 +90,6 @@ export { ExceptionsFilter } from './infrastructure/filters/exceptions.filter'; // ── Server's own exports ── export { RocketsModule } from './rockets.module'; export { createServer } from './create-server'; -export type { RocketsServerDefinition } from './create-server'; export type { RocketsOptions, RocketsAsyncOptions, @@ -99,7 +98,7 @@ export type { RocketsOptionsInterface } from './infrastructure/config/interfaces export type { RocketsOptionsExtrasInterface, DisableControllerOptionsInterface, - RocketsAuthInput, + RocketsAuthOption, } from './infrastructure/config/interfaces/rockets-options-extras.interface'; export { UserModule } from './user.module'; export { MeController } from './gateways/http/me.controller'; diff --git a/packages/rockets-server/src/infrastructure/config/interfaces/rockets-options-extras.interface.ts b/packages/rockets-server/src/infrastructure/config/interfaces/rockets-options-extras.interface.ts index 3a7345e8b..98e4eaad4 100644 --- a/packages/rockets-server/src/infrastructure/config/interfaces/rockets-options-extras.interface.ts +++ b/packages/rockets-server/src/infrastructure/config/interfaces/rockets-options-extras.interface.ts @@ -16,9 +16,6 @@ export interface DisableControllerOptionsInterface { export type RocketsAuthOption = AuthBootstrap | ReadonlyArray; -/** @deprecated Prefer {@link RocketsAuthOption}. */ -export type RocketsAuthInput = RocketsAuthOption; - export interface RocketsOptionsExtrasInterface extends Pick { enableGlobalGuard?: boolean; From 090cad26c0cc05c8d81dbb52fda248eab63c1c89 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 9 Aug 2026 12:04:08 -0400 Subject: [PATCH 6/7] docs: drop changelog entry for an alias that never shipped --- packages/rockets-server/CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/rockets-server/CHANGELOG.md b/packages/rockets-server/CHANGELOG.md index b368adbf5..de97e2095 100644 --- a/packages/rockets-server/CHANGELOG.md +++ b/packages/rockets-server/CHANGELOG.md @@ -42,8 +42,6 @@ and this project adheres to - The `createStubAuthBootstrap()` re-export, now that `defineAuthAdapter()` covers the same wiring. Replace `createStubAuthBootstrap(Adapter)` with `defineAuthAdapter(Adapter)`. -- `RocketsServerDefinition`, a bare alias of `RocketsOptions`. `createServer()` - takes `RocketsOptions` directly; use that name. - `RocketsAuthInput`, a bare alias of `RocketsAuthOption`. The canonical `RocketsAuthOption` is now exported in its place. From 4d36c52da0097896fcd83889a020a95b93a3cbac Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Mon, 10 Aug 2026 13:56:12 -0300 Subject: [PATCH 7/7] fix: contributed guard opt-out must declare a replacement guard A contribution may swap the global guard, never remove it: honoring a contributed enableGlobalGuard: false with no replacement silently publishes every route. resolveRocketsComposition now throws unless the integration declares contributes.providesAppGuard (guard swap) or the app itself sets enableGlobalGuard: false (intentionally public API). Also covers the two previously untested composition claims: conflicting contributions throw, and explicit options beat contributed defaults. Co-Authored-By: Claude Fable 5 --- .../interfaces/auth-bootstrap.interface.ts | 12 ++++ .../src/rockets.module-definition.spec.ts | 59 +++++++++++++++++++ .../src/rockets.module-definition.ts | 24 +++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/packages/rockets-core/src/domain/interfaces/auth-bootstrap.interface.ts b/packages/rockets-core/src/domain/interfaces/auth-bootstrap.interface.ts index 7037ea4bf..3e0278735 100644 --- a/packages/rockets-core/src/domain/interfaces/auth-bootstrap.interface.ts +++ b/packages/rockets-core/src/domain/interfaces/auth-bootstrap.interface.ts @@ -13,7 +13,19 @@ export interface AuthBootstrapContributions { readonly resources?: ReadonlyArray; readonly userMetadata?: RocketsUserMetadataConfig; readonly repository?: RepositoryModuleInterface | RepositoryBootstrap; + /** + * Contribute `false` only together with `providesAppGuard: true` — an + * integration may swap the global guard, never remove it. Removing the + * guard entirely is an app-level decision (`enableGlobalGuard: false` on + * the module options). + */ readonly enableGlobalGuard?: boolean; + /** + * Declares that this integration registers its own app-wide guard + * (`APP_GUARD`), so contributing `enableGlobalGuard: false` is a guard + * swap rather than leaving the app unguarded. + */ + readonly providesAppGuard?: boolean; } /** diff --git a/packages/rockets-server/src/rockets.module-definition.spec.ts b/packages/rockets-server/src/rockets.module-definition.spec.ts index 489fc9c21..90c55937b 100644 --- a/packages/rockets-server/src/rockets.module-definition.spec.ts +++ b/packages/rockets-server/src/rockets.module-definition.spec.ts @@ -19,6 +19,7 @@ import { createRocketsImports, createRocketsProviders, createRocketsExports, + resolveRocketsComposition, } from './rockets.module-definition'; import { RAW_OPTIONS_TOKEN, @@ -62,6 +63,7 @@ function contributedAuth( resources: [contributedResource], userMetadata: contributedUserMetadata, enableGlobalGuard: false, + providesAppGuard: true, ...overrides, }, } as AuthBootstrap; @@ -205,6 +207,63 @@ describe('RocketsModuleDefinition', () => { }); expect(guardProvider).toBeUndefined(); }); + + it('explicit enableGlobalGuard: true beats a contributed false', () => { + const result = createRocketsProviders({ + extras: { auth: contributedAuth(), enableGlobalGuard: true }, + }); + const guardProvider = result.find( + (provider) => + typeof provider === 'object' && + 'provide' in provider && + provider.provide === APP_GUARD, + ); + + expect(guardProvider).toBeDefined(); + }); + }); + + describe('resolveRocketsComposition', () => { + it('throws when integrations contribute conflicting userMetadata', () => { + const otherUserMetadata = { + ...contributedUserMetadata, + updateDto: class OtherMetadataUpdateDto extends ContributedMetadataUpdateDto {}, + }; + + expect(() => + resolveRocketsComposition({ + auth: [ + contributedAuth(), + contributedAuth({ userMetadata: otherUserMetadata }), + ], + }), + ).toThrow(/conflicting userMetadata/); + }); + + it('throws when a contribution disables the guard without providing one', () => { + expect(() => + resolveRocketsComposition({ + auth: contributedAuth({ providesAppGuard: undefined }), + }), + ).toThrow(/providesAppGuard/); + }); + + it('accepts a contributed guard swap (enableGlobalGuard false + providesAppGuard)', () => { + const composition = resolveRocketsComposition({ + auth: contributedAuth(), + }); + + expect(composition.enableGlobalGuard).toBe(false); + }); + + it('accepts an explicit app-level opt-out even without a replacement guard', () => { + const composition = resolveRocketsComposition({ + auth: contributedAuth({ providesAppGuard: undefined }), + enableGlobalGuard: false, + }); + + expect(composition.enableGlobalGuard).toBe(false); + }); }); describe('createRocketsImports', () => { diff --git a/packages/rockets-server/src/rockets.module-definition.ts b/packages/rockets-server/src/rockets.module-definition.ts index f37697547..b3aacb7f6 100644 --- a/packages/rockets-server/src/rockets.module-definition.ts +++ b/packages/rockets-server/src/rockets.module-definition.ts @@ -87,6 +87,26 @@ export function resolveRocketsComposition( extras: RocketsOptionsExtrasInterface = {}, ): ResolvedRocketsComposition { const auth = normalizeAuthBootstraps(extras.auth); + const enableGlobalGuard = + extras.enableGlobalGuard ?? + resolveSingleContribution(auth, 'enableGlobalGuard'); + + // A contribution may swap the global guard, never remove it: honoring a + // contributed `false` with no declared replacement would silently publish + // every route. Opting out of a guard entirely is the app's call alone. + if ( + enableGlobalGuard === false && + extras.enableGlobalGuard !== false && + !auth.some((bootstrap) => bootstrap.contributes?.providesAppGuard === true) + ) { + throw new Error( + 'RocketsModule: an auth integration contributed enableGlobalGuard: ' + + 'false without declaring a replacement guard ' + + '(contributes.providesAppGuard). For an intentionally public API, ' + + 'set enableGlobalGuard: false explicitly on the module options.', + ); + } + return { auth, resources: [ @@ -97,9 +117,7 @@ export function resolveRocketsComposition( extras.userMetadata ?? resolveSingleContribution(auth, 'userMetadata'), repository: extras.repository ?? resolveSingleContribution(auth, 'repository'), - enableGlobalGuard: - extras.enableGlobalGuard ?? - resolveSingleContribution(auth, 'enableGlobalGuard'), + enableGlobalGuard, }; }