From 8786d8365486ccd0091552f65264b59d36183b02 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Mon, 13 Jul 2026 16:05:10 +0200 Subject: [PATCH 01/21] fix(better-auth): extract DI tokens to break module/service circular import (SWC TDZ crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The better-auth DI tokens were split across two files that imported each other: BETTER_AUTH_INSTANCE was declared in core-better-auth.module.ts, while BETTER_AUTH_CONFIG and BETTER_AUTH_COOKIE_DOMAIN were declared in core-better-auth.service.ts. The service imported BETTER_AUTH_INSTANCE from the module, and the module imported the other two tokens (plus the service class) back from the service — a genuine import cycle. Under tsc/CommonJS this happened to work by evaluation-order luck. Under SWC (`nest start -b swc`) it crashes at startup: ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization The tokens are dereferenced at module-evaluation time (`@Inject(...)` in the CoreBetterAuthService constructor is evaluated when the class is defined), so the cycle lands in the temporal dead zone. Fix: move all three tokens into a dedicated leaf module, core-better-auth.constants.ts, which imports nothing. Module and service both import the tokens from there, making the graph acyclic and the initialization order deterministic for every compiler. Backward compatible: both core-better-auth.module.ts and core-better-auth.service.ts re-export the tokens, so existing deep imports (`from '.../core-better-auth.module'` / `'.../core-better-auth.service'`) and the package barrel keep working unchanged. No public API change. Verified by compiling src/ with SWC (legacy decorators + decoratorMetadata, matching the Nest CLI's SWC builder) and requiring the module: the ReferenceError reproduces before this change and is gone after it. madge reports the better-auth cycles dropping from 9 to 1; the remaining guard -> module cycle is benign because it only dereferences CoreBetterAuthModule lazily inside a method body, never at eval time. Build, lint, format, 691 unit tests and 1380 e2e tests all pass. --- .../better-auth/core-better-auth.constants.ts | 34 +++++++++++++++++++ .../better-auth/core-better-auth.module.ts | 10 +++--- .../better-auth/core-better-auth.service.ts | 17 +++------- src/core/modules/better-auth/index.ts | 1 + 4 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 src/core/modules/better-auth/core-better-auth.constants.ts diff --git a/src/core/modules/better-auth/core-better-auth.constants.ts b/src/core/modules/better-auth/core-better-auth.constants.ts new file mode 100644 index 00000000..eb8b68b2 --- /dev/null +++ b/src/core/modules/better-auth/core-better-auth.constants.ts @@ -0,0 +1,34 @@ +/** + * Dependency-injection tokens of the better-auth module. + * + * These live in a dedicated file — instead of core-better-auth.module.ts / + * core-better-auth.service.ts — so that module and service never have to import + * each other. The tokens used to be split across both files + * (BETTER_AUTH_INSTANCE in the module, BETTER_AUTH_CONFIG / + * BETTER_AUTH_COOKIE_DOMAIN in the service), which made the two files import + * each other in a cycle. That cycle happened to work under tsc/CommonJS by + * evaluation-order luck, but crashed SWC-compiled builds (`nest start -b swc`) + * with a temporal-dead-zone error: + * + * ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization + * + * Keeping the tokens in a leaf module with no imports of its own makes the + * dependency graph acyclic and the initialization order deterministic for every + * compiler. + */ + +/** + * Token for injecting the better-auth instance + */ +export const BETTER_AUTH_INSTANCE = 'BETTER_AUTH_INSTANCE'; + +/** + * Injection token for resolved BetterAuth configuration + */ +export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG'; + +/** + * Injection token for resolved cross-subdomain cookie domain. + * Set during Better-Auth instance creation, undefined if disabled. + */ +export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN'; diff --git a/src/core/modules/better-auth/core-better-auth.module.ts b/src/core/modules/better-auth/core-better-auth.module.ts index 38e48c75..451d678d 100644 --- a/src/core/modules/better-auth/core-better-auth.module.ts +++ b/src/core/modules/better-auth/core-better-auth.module.ts @@ -31,12 +31,12 @@ import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper'; import { CoreBetterAuthController } from './core-better-auth.controller'; import { CoreBetterAuthMiddleware } from './core-better-auth.middleware'; import { CoreBetterAuthResolver } from './core-better-auth.resolver'; -import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, CoreBetterAuthService } from './core-better-auth.service'; +import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; +import { CoreBetterAuthService } from './core-better-auth.service'; -/** - * Token for injecting the better-auth instance - */ -export const BETTER_AUTH_INSTANCE = 'BETTER_AUTH_INSTANCE'; +// Re-exported for backward compatibility: the tokens are declared in +// core-better-auth.constants.ts so that module and service stay acyclic (SWC-safe). +export { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; /** * Options for CoreBetterAuthModule.forRoot() diff --git a/src/core/modules/better-auth/core-better-auth.service.ts b/src/core/modules/better-auth/core-better-auth.service.ts index 38bf4c51..7c9794e4 100644 --- a/src/core/modules/better-auth/core-better-auth.service.ts +++ b/src/core/modules/better-auth/core-better-auth.service.ts @@ -13,7 +13,7 @@ import { resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helpe import { BetterAuthInstance } from './better-auth.config'; import { BetterAuthSessionUser } from './core-better-auth-user.mapper'; import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper'; -import { BETTER_AUTH_INSTANCE } from './core-better-auth.module'; +import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; /** * Result of a session validation @@ -28,6 +28,10 @@ export interface SessionResult { user: BetterAuthSessionUser | null; } +// Re-exported for backward compatibility: the tokens are declared in +// core-better-auth.constants.ts so that module and service stay acyclic (SWC-safe). +export { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; + /** * CoreBetterAuthService provides a NestJS-friendly wrapper around the better-auth instance. * @@ -51,17 +55,6 @@ export interface SessionResult { * } * ``` */ -/** - * Injection token for resolved BetterAuth configuration - */ -export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG'; - -/** - * Injection token for resolved cross-subdomain cookie domain. - * Set during Better-Auth instance creation, undefined if disabled. - */ -export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN'; - @Injectable() export class CoreBetterAuthService implements OnModuleInit { private readonly logger = new Logger(CoreBetterAuthService.name); diff --git a/src/core/modules/better-auth/index.ts b/src/core/modules/better-auth/index.ts index 0d918c7d..cbdc5608 100644 --- a/src/core/modules/better-auth/index.ts +++ b/src/core/modules/better-auth/index.ts @@ -39,6 +39,7 @@ export * from './core-better-auth-signup-validator.service'; export * from './core-better-auth-token.helper'; export * from './core-better-auth-user.mapper'; export * from './core-better-auth-web.helper'; +export * from './core-better-auth.constants'; export * from './core-better-auth.controller'; export * from './core-better-auth.middleware'; export * from './core-better-auth.module'; From 0ec3ea9d9dba5de19417d3f7439cd6930b040c14 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 19:34:51 +0200 Subject: [PATCH 02/21] fix(core): merge FilterInput and CombinedFilterInput into one module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two classes are mutually recursive and lived in two files that imported each other. FilterInput referenced CombinedFilterInput EAGERLY — in a decorator argument (`type: CombinedFilterInput`) and in the `design:type` metadata that emitDecoratorMetadata emits for `combinedFilter?: CombinedFilterInput`. Both are evaluated at class-definition time, i.e. while the module is still initializing, so on the cycle they read a class binding still in its temporal dead zone. This was not latent. It crashed, today, in shipped code: require('dist/core/common/inputs/combined-filter.input.js') -> ReferenceError: Cannot access 'CombinedFilterInput' before initialization It stayed hidden only because entering through the package barrel pulls filter.input in first via other importers (filter.args, filter.helper). A vendor-mode deep import, a unit test importing the input directly, or a reordering of src/index.ts would all have hit it. A lazy thunk does NOT fix this: `type: () => CombinedFilterInput` still leaves the eager design:type metadata behind, and SWC's `typeof` guard does not protect the member expression it compiles to. The only fix is to remove the import edge, so both classes now live in filter.input.ts with CombinedFilterInput declared FIRST (that ordering is load-bearing). combined-filter.input.ts stays as a re-export, so every existing import path still resolves. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../common/inputs/combined-filter.input.ts | 67 ++----------- src/core/common/inputs/filter.input.ts | 93 ++++++++++++++++++- 2 files changed, 102 insertions(+), 58 deletions(-) diff --git a/src/core/common/inputs/combined-filter.input.ts b/src/core/common/inputs/combined-filter.input.ts index 90345367..d81c04f2 100644 --- a/src/core/common/inputs/combined-filter.input.ts +++ b/src/core/common/inputs/combined-filter.input.ts @@ -1,57 +1,10 @@ -import { InputType } from '@nestjs/graphql'; - -import { Restricted } from '../decorators/restricted.decorator'; -import { UnifiedField } from '../decorators/unified-field.decorator'; -import { LogicalOperatorEnum } from '../enums/logical-operator.enum'; -import { RoleEnum } from '../enums/role.enum'; -import { maps } from '../helpers/model.helper'; -import { CoreInput } from './core-input.input'; -import { FilterInput } from './filter.input'; - -@InputType({ - description: 'Combination of multiple filters via logical operator', -}) -@Restricted(RoleEnum.S_EVERYONE) -export class CombinedFilterInput extends CoreInput { - /** - * Logical Operator to combine filters - */ - @UnifiedField({ - description: 'Logical Operator to combine filters', - enum: LogicalOperatorEnum, - roles: RoleEnum.S_EVERYONE, - }) - logicalOperator: LogicalOperatorEnum = undefined; - - /** - * Filters to combine via logical operator - */ - @UnifiedField({ - description: 'Filters to combine via logical operator', - isOptional: true, - roles: RoleEnum.S_EVERYONE, - type: () => FilterInput, - }) - filters: FilterInput[] = undefined; - - // =================================================================================================================== - // Methods - // =================================================================================================================== - - /** - * Mapping for Subtypes - */ - override map( - data: Partial | Record, - options: { - cloneDeep?: boolean; - funcAllowed?: boolean; - mapId?: boolean; - } = {}, - ): this { - super.map(data, options); - this.filters = maps(data.filters, FilterInput, options.cloneDeep); - Object.keys(this).forEach((key) => this[key] === undefined && delete this[key]); - return this; - } -} +/** + * `CombinedFilterInput` is declared in `./filter.input` — the two classes are mutually recursive + * and must live in one module, or the cycle between them crashes SWC-compiled builds with + * `ReferenceError: Cannot access 'CombinedFilterInput' before initialization`. See the docblock in + * `./filter.input` for the full analysis. + * + * This file remains only so existing deep imports of `.../inputs/combined-filter.input` keep + * resolving. Do NOT move the class back here. + */ +export { CombinedFilterInput } from './filter.input'; diff --git a/src/core/common/inputs/filter.input.ts b/src/core/common/inputs/filter.input.ts index e062e4d6..ca52c8d0 100644 --- a/src/core/common/inputs/filter.input.ts +++ b/src/core/common/inputs/filter.input.ts @@ -1,12 +1,103 @@ +/** + * Filter inputs. + * + * `CombinedFilterInput` and `FilterInput` are mutually recursive: a filter may contain a combined + * filter, and a combined filter contains a list of filters. They therefore live in ONE module, and + * `CombinedFilterInput` is declared FIRST — that ordering is load-bearing, not cosmetic. + * + * They used to sit in two files that imported each other. `FilterInput` referenced + * `CombinedFilterInput` EAGERLY — in a decorator argument (`type: CombinedFilterInput`) and in the + * `design:type` metadata `emitDecoratorMetadata` emits for `combinedFilter?: CombinedFilterInput`. + * Decorator arguments and that metadata are evaluated at CLASS-DEFINITION time, i.e. while the + * module is still initializing. On a cycle that reads a class binding still in its temporal dead + * zone, and under SWC → CommonJS it threw: + * + * ReferenceError: Cannot access 'CombinedFilterInput' before initialization + * + * That was not hypothetical — `require('.../combined-filter.input.js')` crashed. It only stayed + * hidden because the package barrel happens to pull `filter.input` in first via other importers + * (`filter.args`, `filter.helper`), so entering through the barrel masked it. A deep import, a unit + * test importing the input directly, or a reordering of `src/index.ts` would all have detonated it. + * + * Making the reference lazy does NOT fix it: `type: () => CombinedFilterInput` still leaves the + * eager `design:type` metadata behind, and SWC's `typeof` guard does not protect the member + * expression it compiles to. The only real fix is to remove the import edge — hence one module. + * + * Within a single module the problem disappears: `FilterInput` reads `CombinedFilterInput` at + * definition time, and by then it is already initialized. The reverse direction is safe because + * `CombinedFilterInput` only reaches `FilterInput` lazily (a `() => FilterInput` thunk and a method + * body), never at definition time. + * + * `combined-filter.input.ts` remains as a re-export so existing deep imports keep working. + * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)" for the general rule. + */ import { InputType } from '@nestjs/graphql'; import { Restricted } from '../decorators/restricted.decorator'; import { UnifiedField } from '../decorators/unified-field.decorator'; +import { LogicalOperatorEnum } from '../enums/logical-operator.enum'; import { RoleEnum } from '../enums/role.enum'; -import { CombinedFilterInput } from './combined-filter.input'; +import { maps } from '../helpers/model.helper'; import { CoreInput } from './core-input.input'; import { SingleFilterInput } from './single-filter.input'; +/** + * Combination of multiple filters via logical operator. + * + * Declared BEFORE `FilterInput` on purpose — `FilterInput` dereferences this class at + * class-definition time, so it must already be initialized. See the module docblock. + */ +@InputType({ + description: 'Combination of multiple filters via logical operator', +}) +@Restricted(RoleEnum.S_EVERYONE) +export class CombinedFilterInput extends CoreInput { + /** + * Logical Operator to combine filters + */ + @UnifiedField({ + description: 'Logical Operator to combine filters', + enum: LogicalOperatorEnum, + roles: RoleEnum.S_EVERYONE, + }) + logicalOperator: LogicalOperatorEnum = undefined; + + /** + * Filters to combine via logical operator + * + * The `() => FilterInput` thunk is required: `FilterInput` is declared below, so an eager + * reference here would read it before initialization. + */ + @UnifiedField({ + description: 'Filters to combine via logical operator', + isOptional: true, + roles: RoleEnum.S_EVERYONE, + type: () => FilterInput, + }) + filters: FilterInput[] = undefined; + + // =================================================================================================================== + // Methods + // =================================================================================================================== + + /** + * Mapping for Subtypes + */ + override map( + data: Partial | Record, + options: { + cloneDeep?: boolean; + funcAllowed?: boolean; + mapId?: boolean; + } = {}, + ): this { + super.map(data, options); + this.filters = maps(data.filters, FilterInput, options.cloneDeep); + Object.keys(this).forEach((key) => this[key] === undefined && delete this[key]); + return this; + } +} + /** * Input for filtering. The `singleFilter` will be ignored if the `combinedFilter` is set. */ From 588523be4f877415e892617958dc7d6076cc90ec Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 19:35:04 +0200 Subject: [PATCH 03/21] fix(core): take restricted.decorator off every import cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restricted.decorator sat on TWO runtime cycles at once: restricted.decorator -> db.helper -> input.helper -> restricted.decorator restricted.decorator -> tenant/core-tenant.helpers -> config.service -> input.helper -> restricted.decorator input.helper therefore evaluated while restricted.decorator was still initializing. Nothing crashed, but only because every cross-cycle dereference happened to sit inside a function body. A single top-level line in input.helper — a module-level alias of checkRestricted, an @Restricted-decorated class, design:type metadata — would have thrown under SWC -> CommonJS: ReferenceError: Cannot access 'checkRestricted' before initialization …in the file that drives field-level access control, on a compiler the default build never runs, with a fully green test suite. Two leaf extractions remove both edges: - id.helper.ts <- the ID cluster (equalIds, getIncludedIds, getStringIds, getObjectIds) out of db.helper. Depends only on mongoose Types, lodash and a type. - clone.helper.ts <- clone / deepFreeze out of input.helper, so config.service can reach them without importing input.helper. Both are true leaves (no framework imports), and db.helper / input.helper re-export their former members, so every existing import path keeps working. Removing one edge is not the same as removing the cycle: the id.helper extraction alone left the config.service path fully intact. restricted.decorator now appears in ZERO cycles; repo-wide cycles drop from 10 to 6, and most of the remainder are type-only imports that madge reports but both compilers erase. Its three exports are additionally converted from `const` arrows to hoisted function declarations. With the cycles gone this is defense in depth: hoisted declarations are initialized before any module body runs, so they stay TDZ-immune whatever the import graph does next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../common/decorators/restricted.decorator.ts | 41 +++- src/core/common/helpers/clone.helper.ts | 110 ++++++++++ src/core/common/helpers/db.helper.ts | 175 ++-------------- src/core/common/helpers/id.helper.ts | 198 ++++++++++++++++++ src/core/common/helpers/input.helper.ts | 96 ++------- src/core/common/services/config.service.ts | 5 +- 6 files changed, 371 insertions(+), 254 deletions(-) create mode 100644 src/core/common/helpers/clone.helper.ts create mode 100644 src/core/common/helpers/id.helper.ts diff --git a/src/core/common/decorators/restricted.decorator.ts b/src/core/common/decorators/restricted.decorator.ts index 75542560..6580ce66 100644 --- a/src/core/common/decorators/restricted.decorator.ts +++ b/src/core/common/decorators/restricted.decorator.ts @@ -1,10 +1,35 @@ +/** + * NOTE ON `export function` VS `export const` (do not "modernize" these to arrow functions). + * + * `Restricted`, `getRestricted` and `checkRestricted` are declared as FUNCTION DECLARATIONS on + * purpose. This file sits on an import cycle: + * + * restricted.decorator → helpers/db.helper → helpers/input.helper → restricted.decorator + * + * so when `input.helper`'s module body runs, THIS module is still mid-evaluation. A `const` arrow is + * a temporal-dead-zone binding: any evaluation-time read of it from `input.helper` (a top-level + * alias, an `@Restricted`-decorated class, `design:type` metadata) would throw under SWC → CommonJS: + * + * ReferenceError: Cannot access 'checkRestricted' before initialization + * + * Function declarations are HOISTED and fully initialized before any module body runs, so they are + * immune. `input.helper` today only calls `checkRestricted` from inside a function body, which is + * why nothing has crashed — but that is one careless top-level line away, in the file that drives + * field-level access control. The hoisting removes the hazard rather than relying on vigilance. + * + * The proper fix is to break the cycle (extract the ID helpers out of `db.helper` into a leaf), and + * that is worth doing. Until then, `pnpm run check:swc-tdz` is the mechanical guard. + * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)". + */ import { UnauthorizedException } from '@nestjs/common'; import 'reflect-metadata'; import _ = require('lodash'); import { ProcessType } from '../enums/process-type.enum'; import { RoleEnum } from '../enums/role.enum'; -import { equalIds, getIncludedIds } from '../helpers/db.helper'; +// Import from the id.helper LEAF, never from db.helper: db.helper imports input.helper, which +// imports this file back — that cycle is what the extraction removed. See id.helper's docblock. +import { equalIds, getIncludedIds } from '../helpers/id.helper'; import { RequestContext } from '../services/request-context.service'; import { RequireAtLeastOne } from '../types/required-at-least-one.type'; import { checkRoleAccess } from '../../modules/tenant/core-tenant.helpers'; @@ -40,9 +65,9 @@ export type RestrictedType = ( * properties of the processed item, which is specified by the value of `memberOf` * Via processType the restriction can be set for input or output only */ -export const Restricted = (...rolesOrMember: RestrictedType): ClassDecorator & PropertyDecorator => { +export function Restricted(...rolesOrMember: RestrictedType): ClassDecorator & PropertyDecorator { return Reflect.metadata(restrictedMetaKey, rolesOrMember); -}; +} /** * Cache for Restricted metadata — decorators are static, metadata never changes at runtime. @@ -60,7 +85,7 @@ const restrictedMetadataCache = new WeakMap> /** * Get restricted data for (property of) object */ -export const getRestricted = (object: unknown, propertyKey?: string): RestrictedType => { +export function getRestricted(object: unknown, propertyKey?: string): RestrictedType { if (!object) { return null; } @@ -94,13 +119,13 @@ export const getRestricted = (object: unknown, propertyKey?: string): Restricted classCache.set(cacheKey, metadata); return metadata; -}; +} /** * Check data for restricted properties (properties with `Restricted` decorator) * For special Roles and member of group checking the dbObject must be set in options */ -export const checkRestricted = ( +export function checkRestricted( data: any, user: { emailVerified?: any; @@ -125,7 +150,7 @@ export const checkRestricted = ( throwError?: boolean; } = {}, processedObjects: WeakSet = new WeakSet(), -) => { +) { // Act like Roles handling: checkObjectItself = false & mergeRoles = true // For Input: throwError = true // For Output: throwError = false @@ -394,4 +419,4 @@ export const checkRestricted = ( // Return processed data return data; -}; +} diff --git a/src/core/common/helpers/clone.helper.ts b/src/core/common/helpers/clone.helper.ts new file mode 100644 index 00000000..52bcf8b0 --- /dev/null +++ b/src/core/common/helpers/clone.helper.ts @@ -0,0 +1,110 @@ +/** + * Cloning / freezing helpers. + * + * These live in their own file — instead of `input.helper.ts` — so that `config.service` can reach + * them without importing `input.helper`, which imports `restricted.decorator` behind it. + * + * That edge closed a runtime cycle: + * + * restricted.decorator → tenant/core-tenant.helpers → config.service → input.helper + * → restricted.decorator + * + * so `input.helper` evaluated while `restricted.decorator` was still initializing. Nothing crashed, + * but only because every cross-cycle dereference sat inside a function body. A single top-level line + * in `input.helper` — a module-level alias of `checkRestricted`, an `@Restricted`-decorated class, + * `design:type` metadata — would have thrown under SWC → CommonJS: + * + * ReferenceError: Cannot access 'checkRestricted' before initialization + * + * …in the file that drives field-level access control, on a compiler this repo's default build never + * runs, with a green test suite. Moving these two pure functions out removes the edge, so the cycle + * no longer exists rather than merely being disarmed. + * + * This file imports only Node built-ins, lodash and rfdc — no framework code — so it can never be + * part of a cycle itself. Keep it that way. + * + * `input.helper` re-exports both functions, so every existing import path keeps working. + * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)". + */ +import * as inspector from 'inspector'; +import _ = require('lodash'); +import rfdc = require('rfdc'); +import * as util from 'util'; + +/** + * Get clone of object + * + * @param object Object to clone + * @param options Options for cloning + * @param options.checkResult Whether to check the result of the cloning process + * @param options.circles Keeping track of circular references will slow down performance with an additional 25% overhead. + * Even if an object doesn't have any circular references, the tracking overhead is the cost. + * By default if an object with a circular reference is passed to rfdc, it will throw + * (similar to how JSON.stringify would throw). Use the circles option to detect and preserve + * circular references in the object. If performance is important, try removing the circular + * reference from the object (set to undefined) and then add it back manually after cloning + * instead of using this option. + * @param options.proto Copy prototype properties as well as own properties into the new object. + * It's marginally faster to allow enumerable properties on the prototype to be copied into the + * cloned object (not onto it's prototype, directly onto the object). + */ +export function clone(object: any, options?: { checkResult?: boolean; circles?: boolean; proto?: boolean }) { + const config = { + checkResult: true, + circles: true, + debug: inspector.url() !== undefined, + proto: false, + ...options, + }; + + try { + const cloned = rfdc(config)(object); + if (config.checkResult && !util.isDeepStrictEqual(object, cloned)) { + throw new Error('Cloned object differs from original object'); + } + return cloned; + } catch (e) { + if (!config.circles) { + if (config.debug) { + console.debug(e, config, object, 'automatic try to use rfdc with circles'); + } + try { + const clonedWithCircles = rfdc({ + ...config, + circles: true, + })(object); + if (config.checkResult && !util.isDeepStrictEqual(object, clonedWithCircles)) { + throw new Error('Cloned object differs from original object', { cause: e }); + } + return clonedWithCircles; + } catch (innerError) { + if (config.debug) { + console.debug(innerError, 'rfcd with circles did not work => automatic use of _.clone!'); + } + return _.cloneDeep(object); + } + } else { + if (config.debug) { + console.debug(e, config, object, 'automatic try to use _.clone instead rfdc'); + } + return _.cloneDeep(object); + } + } +} + +/** + * Get deep frozen object + */ +export function deepFreeze(object: any, visited: WeakSet = new WeakSet()) { + if (!object || typeof object !== 'object') { + return object; + } + if (visited.has(object)) { + return object; + } + visited.add(object); + for (const [key, value] of Object.entries(object)) { + object[key] = deepFreeze(value, visited); + } + return Object.freeze(object); +} diff --git a/src/core/common/helpers/db.helper.ts b/src/core/common/helpers/db.helper.ts index 15796b8a..37dca316 100644 --- a/src/core/common/helpers/db.helper.ts +++ b/src/core/common/helpers/db.helper.ts @@ -1,5 +1,4 @@ import { FieldNode, GraphQLResolveInfo, SelectionNode } from 'graphql'; -import _ = require('lodash'); import { Document, Model, PopulateOptions, Query, Types } from 'mongoose'; import { ResolveSelector } from '../interfaces/resolve-selector.interface'; @@ -7,8 +6,19 @@ import { CoreModel } from '../models/core-model.model'; import { FieldSelection } from '../types/field-selection.type'; import { IdsType } from '../types/ids.type'; import { StringOrObjectId } from '../types/string-or-object-id.type'; +import { equalIds, getObjectIds, getStringIds } from './id.helper'; import { removePropertiesDeep } from './input.helper'; +/** + * The ID helpers moved to `./id.helper` and are re-exported here so every existing import path keeps + * working. They were extracted because `restricted.decorator` needs `equalIds` / `getIncludedIds`, + * and pulling them from HERE put that decorator on a runtime import cycle + * (restricted.decorator → db.helper → input.helper → restricted.decorator) that is one top-level + * line away from an SWC temporal-dead-zone crash. `id.helper` imports no framework code, so it can + * never be part of a cycle. Do NOT move them back. + */ +export { equalIds, getIncludedIds, getObjectIds, getStringIds } from './id.helper'; + // ===================================================================================================================== // Export functions // ===================================================================================================================== @@ -71,7 +81,9 @@ export function addIds( // Convert array if (['object', 'string'].includes(convert as string)) { for (let i = 0; i < result.length; i++) { - result[i] = convert === 'string' ? getStringId(result[i]) : getObjectIds(result[i]); + // getStringIds() delegates to the (module-private) getStringId() for a non-array argument, so + // this is the same conversion the removed private helper performed — result[i] is a single ID. + result[i] = convert === 'string' ? getStringIds(result[i]) : getObjectIds(result[i]); } } @@ -97,20 +109,6 @@ export function checkStringIds(ids: string | string[]): boolean { return false; } -/** - * Checks if all IDs are equal - */ -export function equalIds(...ids: IdsType[]): boolean { - if (!ids) { - return false; - } - const compare = getStringIds(ids[0]); - if (!compare) { - return false; - } - return ids.every((id) => getStringIds(id) === compare); -} - /** * Get (and remove) elements with specific IDs from array */ @@ -160,48 +158,6 @@ export function getElementsViaIds( // Return elements return elements; } -/** - * Get included ids - * @param includes IdsType, which should be checked if it contains the ID - * @param ids IdsType, which should be included - * @param convert If set the result array will be converted to pure type String array or ObjectId array - * @return IdsType with IDs which are included, undefined if includes or ids are missing or null if none is included - */ -export function getIncludedIds(includes: IdsType, ids: IdsType, convert?: 'string'): string[]; -export function getIncludedIds(includes: IdsType, ids: IdsType, convert?: 'object'): Types.ObjectId[]; - -export function getIncludedIds( - includes: IdsType, - ids: IdsType | T, - convert?: 'object' | 'string', -): null | T[] | undefined { - if (!includes || !ids) { - return undefined; - } - - if (!Array.isArray(includes)) { - includes = [includes]; - } - - if (!Array.isArray(ids)) { - ids = [ids]; - } - - let result = []; - const includesStrings = getStringIds(includes); - for (const id of ids) { - if (includesStrings.includes(getStringIds(id))) { - result.push(id); - } - } - - if (convert) { - result = convert === 'string' ? getStringIds(result) : getObjectIds(result); - } - - return result.length ? result : null; -} - /** * Get indexes of IDs in an array */ @@ -252,18 +208,6 @@ export function getNextFieldNodes(nodes: readonly SelectionNode[]): FieldNode[] return result; } -/** - * Convert string(s) to ObjectId(s) - */ -export function getObjectIds(ids: any[]): Types.ObjectId[]; -export function getObjectIds(ids: any): Types.ObjectId; -export function getObjectIds(ids: T): Types.ObjectId | Types.ObjectId[] { - if (Array.isArray(ids)) { - return ids.map((id) => new Types.ObjectId(getStringId(id))); - } - return new Types.ObjectId(getStringId(ids)); -} - /** * Get populate options from GraphQL resolve info */ @@ -356,55 +300,6 @@ export function getPopulatOptionsFromSelections(selectionNodes: readonly Selecti return populateOptions; } -/** - * Get IDs from string of ObjectId array in a flat string array - */ -export function getStringIds(elements: any[], options?: { deep?: boolean; unique?: boolean }): string[]; - -export function getStringIds(elements: any, options?: { deep?: boolean; unique?: boolean }): string; - -export function getStringIds( - elements: T, - options?: { deep?: boolean; unique?: boolean }, -): string | string[] { - // Process options - const { deep, unique } = { - deep: false, - unique: false, - ...options, - }; - - // Check elements - if (!elements) { - return elements as any; - } - - // Init ids - let ids = []; - - // Process non array - if (!Array.isArray(elements)) { - return getStringId(elements); - } - - // Process array - for (const element of elements) { - if (Array.isArray(element)) { - if (deep) { - ids = ids.concat(getStringIds(element, { deep })); - } - } else { - const id = getStringId(element); - if (id) { - ids.push(id); - } - } - } - - // Return (unique) ID array - return unique ? _.uniq(ids) : ids; -} - /** * Convert all ObjectIds to strings */ @@ -664,45 +559,3 @@ export async function setPopulates>( // ===================================================================================================================== // Not exported helper functions // ===================================================================================================================== -/** - * Get ID of element as string - */ -function getStringId(element: any): string { - // Check element - if (!element) { - return element; - } - - // Buffer handling - if (element instanceof Buffer) { - return element.toString(); - } - - // String handling - if (typeof element === 'string') { - return element; - } - - // Object handling - if (typeof element === 'object') { - if (element instanceof Types.ObjectId) { - return element.toHexString(); - } - - if (element.id) { - if (element.id instanceof Buffer && element.toHexString) { - return element.toHexString(); - } - return getStringId(element.id); - } else if (element._id) { - return getStringId(element._id); - } - } - - // Other types - if (typeof element.toString === 'function') { - return element.toString(); - } - - return undefined; -} diff --git a/src/core/common/helpers/id.helper.ts b/src/core/common/helpers/id.helper.ts new file mode 100644 index 00000000..fda911d1 --- /dev/null +++ b/src/core/common/helpers/id.helper.ts @@ -0,0 +1,198 @@ +/** + * ID helpers. + * + * These live in their own file — instead of `db.helper.ts` — so that files which need nothing but + * ID comparison can reach them without importing `db.helper`, which drags in GraphQL, Mongoose + * models and `input.helper` behind it. + * + * That was not an aesthetic choice. `restricted.decorator.ts` needs exactly two of these functions + * (`equalIds`, `getIncludedIds`), and importing them from `db.helper` put it on a real runtime + * cycle: + * + * restricted.decorator → db.helper → input.helper → restricted.decorator + * + * `input.helper` therefore evaluated while `restricted.decorator` was still initializing. Nothing + * crashed, but only because every cross-cycle dereference happened to sit inside a function body: + * a single top-level line in `input.helper` (a module-level alias, an `@Restricted`-decorated class, + * `design:type` metadata) would have thrown under SWC → CommonJS: + * + * ReferenceError: Cannot access 'checkRestricted' before initialization + * + * …in the file that drives field-level access control, on a compiler this repo's default build never + * runs. Moving the ID cluster into this leaf removes the `restricted.decorator → db.helper` edge + * outright, so the cycle no longer exists rather than merely being disarmed. + * + * This file imports only Mongoose's `Types`, lodash, and a type — no framework code — so it can + * never be part of a cycle itself. Keep it that way. + * + * `db.helper` re-exports all four public functions, so every existing import path keeps working. + * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)". + */ +import _ = require('lodash'); +import { Types } from 'mongoose'; + +import { IdsType } from '../types/ids.type'; + +/** + * Checks if all IDs are equal + */ +export function equalIds(...ids: IdsType[]): boolean { + if (!ids) { + return false; + } + const compare = getStringIds(ids[0]); + if (!compare) { + return false; + } + return ids.every((id) => getStringIds(id) === compare); +} + +/** + * Get included ids + * @param includes IdsType, which should be checked if it contains the ID + * @param ids IdsType, which should be included + * @param convert If set the result array will be converted to pure type String array or ObjectId array + * @return IdsType with IDs which are included, undefined if includes or ids are missing or null if none is included + */ +export function getIncludedIds(includes: IdsType, ids: IdsType, convert?: 'string'): string[]; +export function getIncludedIds(includes: IdsType, ids: IdsType, convert?: 'object'): Types.ObjectId[]; + +export function getIncludedIds( + includes: IdsType, + ids: IdsType | T, + convert?: 'object' | 'string', +): null | T[] | undefined { + if (!includes || !ids) { + return undefined; + } + + if (!Array.isArray(includes)) { + includes = [includes]; + } + + if (!Array.isArray(ids)) { + ids = [ids]; + } + + let result = []; + const includesStrings = getStringIds(includes); + for (const id of ids) { + if (includesStrings.includes(getStringIds(id))) { + result.push(id); + } + } + + if (convert) { + result = convert === 'string' ? getStringIds(result) : getObjectIds(result); + } + + return result.length ? result : null; +} + +/** + * Convert string(s) to ObjectId(s) + */ +export function getObjectIds(ids: any[]): Types.ObjectId[]; +export function getObjectIds(ids: any): Types.ObjectId; +export function getObjectIds(ids: T): Types.ObjectId | Types.ObjectId[] { + if (Array.isArray(ids)) { + return ids.map((id) => new Types.ObjectId(getStringId(id))); + } + return new Types.ObjectId(getStringId(ids)); +} + +/** + * Get IDs from string of ObjectId array in a flat string array + */ +export function getStringIds(elements: any[], options?: { deep?: boolean; unique?: boolean }): string[]; + +export function getStringIds(elements: any, options?: { deep?: boolean; unique?: boolean }): string; + +export function getStringIds( + elements: T, + options?: { deep?: boolean; unique?: boolean }, +): string | string[] { + // Process options + const { deep, unique } = { + deep: false, + unique: false, + ...options, + }; + + // Check elements + if (!elements) { + return elements as any; + } + + // Init ids + let ids = []; + + // Process non array + if (!Array.isArray(elements)) { + return getStringId(elements); + } + + // Process array + for (const element of elements) { + if (Array.isArray(element)) { + if (deep) { + ids = ids.concat(getStringIds(element, { deep })); + } + } else { + const id = getStringId(element); + if (id) { + ids.push(id); + } + } + } + + // Return (unique) ID array + return unique ? _.uniq(ids) : ids; +} + +// ===================================================================================================================== +// Not exported helper functions +// ===================================================================================================================== + +/** + * Get ID of element as string + */ +function getStringId(element: any): string { + // Check element + if (!element) { + return element; + } + + // Buffer handling + if (element instanceof Buffer) { + return element.toString(); + } + + // String handling + if (typeof element === 'string') { + return element; + } + + // Object handling + if (typeof element === 'object') { + if (element instanceof Types.ObjectId) { + return element.toHexString(); + } + + if (element.id) { + if (element.id instanceof Buffer && element.toHexString) { + return element.toHexString(); + } + return getStringId(element.id); + } else if (element._id) { + return getStringId(element._id); + } + } + + // Other types + if (typeof element.toString === 'function') { + return element.toString(); + } + + return undefined; +} diff --git a/src/core/common/helpers/input.helper.ts b/src/core/common/helpers/input.helper.ts index 3cb9ba5e..62b6d309 100644 --- a/src/core/common/helpers/input.helper.ts +++ b/src/core/common/helpers/input.helper.ts @@ -3,16 +3,23 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { ValidatorOptions } from 'class-validator/types/validation/ValidatorOptions'; import { Kind } from 'graphql/index'; -import * as inspector from 'inspector'; -import _ = require('lodash'); -import rfdc = require('rfdc'); -import * as util from 'util'; import { checkRestricted } from '../decorators/restricted.decorator'; import { ProcessType } from '../enums/process-type.enum'; import { RoleEnum } from '../enums/role.enum'; +import { clone } from './clone.helper'; import { merge } from './config.helper'; -import { equalIds } from './db.helper'; +import { equalIds } from './id.helper'; + +/** + * `clone` and `deepFreeze` moved to `./clone.helper` and are re-exported here so every existing + * import path keeps working. They were extracted because `config.service` needs `clone`/`deepFreeze`, + * and pulling them from HERE put it on a runtime import cycle + * (restricted.decorator → core-tenant.helpers → config.service → input.helper → restricted.decorator) + * that is one top-level line away from an SWC temporal-dead-zone crash. `clone.helper` imports no + * framework code, so it can never be part of a cycle. Do NOT move them back. + */ +export { clone, deepFreeze } from './clone.helper'; /** * Helper class for inputs @@ -341,68 +348,6 @@ export function checkAndGetDate(input: any): Date { return date; } -/** - * Clone object - * @param object Any object - * @param options Finetuning of rfdc cloning - * @param options.checkResult Whether to compare object and cloned object via JSON.stringify and try alternative cloning - * methods if they are not equal - * @param options.circles Keeping track of circular references will slow down performance with an additional 25% overhead. - * Even if an object doesn't have any circular references, the tracking overhead is the cost. - * By default if an object with a circular reference is passed to rfdc, it will throw - * (similar to how JSON.stringify would throw). Use the circles option to detect and preserve - * circular references in the object. If performance is important, try removing the circular - * reference from the object (set to undefined) and then add it back manually after cloning - * instead of using this option. - * @param options.debug Whether to shoe console.debug messages - * @param options.proto Copy prototype properties as well as own properties into the new object. - * It's marginally faster to allow enumerable properties on the prototype to be copied into the - * cloned object (not onto it's prototype, directly onto the object). - */ -export function clone(object: any, options?: { checkResult?: boolean; circles?: boolean; proto?: boolean }) { - const config = { - checkResult: true, - circles: true, - debug: inspector.url() !== undefined, - proto: false, - ...options, - }; - - try { - const cloned = rfdc(config)(object); - if (config.checkResult && !util.isDeepStrictEqual(object, cloned)) { - throw new Error('Cloned object differs from original object'); - } - return cloned; - } catch (e) { - if (!config.circles) { - if (config.debug) { - console.debug(e, config, object, 'automatic try to use rfdc with circles'); - } - try { - const clonedWithCircles = rfdc({ - ...config, - circles: true, - })(object); - if (config.checkResult && !util.isDeepStrictEqual(object, clonedWithCircles)) { - throw new Error('Cloned object differs from original object', { cause: e }); - } - return clonedWithCircles; - } catch (innerError) { - if (config.debug) { - console.debug(innerError, 'rfcd with circles did not work => automatic use of _.clone!'); - } - return _.cloneDeep(object); - } - } else { - if (config.debug) { - console.debug(e, config, object, 'automatic try to use _.clone instead rfdc'); - } - return _.cloneDeep(object); - } - } -} - /** * Combines objects to a new single plain object and ignores undefined */ @@ -410,23 +355,6 @@ export function combinePlain(...args: Record[]): any { return assignPlain({}, ...args); } -/** - * Get deep frozen object - */ -export function deepFreeze(object: any, visited: WeakSet = new WeakSet()) { - if (!object || typeof object !== 'object') { - return object; - } - if (visited.has(object)) { - return object; - } - visited.add(object); - for (const [key, value] of Object.entries(object)) { - object[key] = deepFreeze(value, visited); - } - return Object.freeze(object); -} - /** * Standard error function */ diff --git a/src/core/common/services/config.service.ts b/src/core/common/services/config.service.ts index 3719393c..61f7ff5c 100644 --- a/src/core/common/services/config.service.ts +++ b/src/core/common/services/config.service.ts @@ -4,7 +4,10 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { merge } from '../helpers/config.helper'; -import { clone, deepFreeze } from '../helpers/input.helper'; +// Import from the clone.helper LEAF, never from input.helper: input.helper imports +// restricted.decorator, which reaches back here via core-tenant.helpers — that cycle is what the +// extraction removed. See clone.helper's docblock. +import { clone, deepFreeze } from '../helpers/clone.helper'; import { IServerOptions } from '../interfaces/server-options.interface'; /** From 15b9ac14239a875f538c5e8cdd2e98ba7d89d57b Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 19:35:21 +0200 Subject: [PATCH 04/21] fix(better-auth): remove the last module cycle and the reset() leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DI-token extraction broke module <-> service, but a structurally identical cycle survived in the same module: better-auth-roles.guard imported CoreBetterAuthModule for the static getTokenServiceInstance() accessor, while the module imported the guard to register it as APP_GUARD. It was benign only because both sides dereference each other lazily inside method bodies — one static field initializer or one typed constructor parameter away from the very crash this branch fixes. core-better-auth.registry.ts now holds the BetterAuthTokenService reference. It uses `import type` only, which both tsc and SWC erase, so it emits no require() at all and is a true leaf. The guard reads the registry; the module writes it; CoreBetterAuthModule.getTokenServiceInstance() stays as a thin delegate, so the public API is unchanged (core-ai-mcp.controller keeps working). The better-auth module now has zero cycles. Also fixes a test-isolation leak found on the way: CoreBetterAuthModule.reset() cleared serviceInstance and userMapperInstance but never the token service, so a token service from a previous testing module survived into the next one and BetterAuthRolesGuard verified tokens against a stale instance. Additionally: - The constants docblock claimed the change "makes the dependency graph acyclic". That was false while the guard cycle survived, and a maintainer reading it would reasonably have hoisted the guard's lazy lookup into a static field — recreating the crash in good faith. Corrected, and the real rule is spelled out: a cycle is only fatal when dereferenced at module-evaluation time. - The backward-compat re-exports are narrowed to exactly the symbols each file exported before, instead of widening the surface to three valid import paths per token — which is precisely how the original cycle was born. - Token JSDoc now states the injected type; they are untyped string tokens, so the docblock is the only type signal a consumer gets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../better-auth/better-auth-roles.guard.ts | 16 ++++-- .../better-auth/core-better-auth.constants.ts | 51 +++++++++++++++--- .../better-auth/core-better-auth.module.ts | 44 +++++++++++---- .../better-auth/core-better-auth.registry.ts | 53 +++++++++++++++++++ .../better-auth/core-better-auth.service.ts | 20 +++++-- src/core/modules/better-auth/index.ts | 1 + 6 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 src/core/modules/better-auth/core-better-auth.registry.ts diff --git a/src/core/modules/better-auth/better-auth-roles.guard.ts b/src/core/modules/better-auth/better-auth-roles.guard.ts index 63e453a4..83812eeb 100644 --- a/src/core/modules/better-auth/better-auth-roles.guard.ts +++ b/src/core/modules/better-auth/better-auth-roles.guard.ts @@ -13,7 +13,7 @@ import { ErrorCode } from '../error-code'; import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../tenant/core-tenant.helpers'; import { BetterAuthTokenService } from './better-auth-token.service'; import { BetterAuthenticatedUser } from './better-auth.types'; -import { CoreBetterAuthModule } from './core-better-auth.module'; +import { getBetterAuthTokenService } from './core-better-auth.registry'; /** * BetterAuth Roles Guard @@ -33,7 +33,8 @@ import { CoreBetterAuthModule } from './core-better-auth.module'; * IMPORTANT: This guard has NO constructor dependencies. This is intentional because * NestJS DI has issues resolving Reflector/ModuleRef for APP_GUARD providers in dynamic modules. * Instead, we use Reflect.getMetadata directly to read decorator metadata, and access - * BetterAuthTokenService via CoreBetterAuthModule static reference. + * BetterAuthTokenService via the core-better-auth.registry leaf module — NOT via + * CoreBetterAuthModule, which would make guard and module import each other. */ @Injectable() export class BetterAuthRolesGuard implements CanActivate { @@ -41,12 +42,17 @@ export class BetterAuthRolesGuard implements CanActivate { private tokenService: BetterAuthTokenService | null = null; /** - * Get BetterAuthTokenService lazily from CoreBetterAuthModule - * This avoids DI issues while still allowing token verification + * Get BetterAuthTokenService lazily from the better-auth registry. + * This avoids DI issues while still allowing token verification. + * + * The lookup goes through core-better-auth.registry.ts rather than + * CoreBetterAuthModule on purpose: importing the module here would make guard + * and module import each other, and that cycle is one decorator away from the + * SWC temporal-dead-zone crash documented in core-better-auth.constants.ts. */ private getTokenService(): BetterAuthTokenService | null { if (!this.tokenService) { - this.tokenService = CoreBetterAuthModule.getTokenServiceInstance(); + this.tokenService = getBetterAuthTokenService(); } return this.tokenService; } diff --git a/src/core/modules/better-auth/core-better-auth.constants.ts b/src/core/modules/better-auth/core-better-auth.constants.ts index eb8b68b2..f2099bbe 100644 --- a/src/core/modules/better-auth/core-better-auth.constants.ts +++ b/src/core/modules/better-auth/core-better-auth.constants.ts @@ -12,23 +12,62 @@ * * ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization * - * Keeping the tokens in a leaf module with no imports of its own makes the - * dependency graph acyclic and the initialization order deterministic for every - * compiler. + * The lethal ingredient is not the cycle by itself — it is a cycle PLUS a read of + * the cyclic binding at module-evaluation time. `@Inject(BETTER_AUTH_INSTANCE)` is + * a constructor-parameter decorator, and decorator arguments are evaluated when the + * class is defined, i.e. while the module is still initializing. On a cycle the + * importing side then reads a `const` that has not been initialized yet. + * + * This file imports nothing, so it can never be mid-evaluation when someone + * imports it — in any module system, under any compiler. That makes the + * initialization order of the tokens deterministic everywhere. + * + * WHY THIS MATTERS FOR FUTURE CHANGES + * ----------------------------------- + * `tsc` does NOT catch a regression here, and neither does the test suite: vitest + * runs SWC through Vite's module runner, whose getter-based live bindings tolerate + * cycles. Only the SWC → CommonJS → `require()` path fails, which is exactly what + * `pnpm run check:swc-tdz` exercises. If you move a token back into the module or + * the service, everything stays green locally and breaks for consumers. + * + * The rule, in short: **DI tokens belong in an import-free leaf file.** + * See .claude/rules/better-auth.md §6. */ /** - * Token for injecting the better-auth instance + * Token for injecting the better-auth instance. + * + * Injected type: `BetterAuthInstance | null` — null when better-auth is disabled. + * Declared `@Optional()` in the CoreBetterAuthService constructor. + * + * @example + * ```typescript + * import { Inject, Injectable, Optional } from '@nestjs/common'; + * import { BETTER_AUTH_INSTANCE, BetterAuthInstance } from '@lenne.tech/nest-server'; + * + * @Injectable() + * export class MyService { + * constructor( + * @Optional() @Inject(BETTER_AUTH_INSTANCE) private readonly auth: BetterAuthInstance | null, + * ) {} + * } + * ``` */ export const BETTER_AUTH_INSTANCE = 'BETTER_AUTH_INSTANCE'; /** - * Injection token for resolved BetterAuth configuration + * Injection token for the resolved BetterAuth configuration. + * + * Injected type: `IBetterAuth | null` — null when better-auth is disabled. + * Declared `@Optional()` in the CoreBetterAuthService constructor. */ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG'; /** - * Injection token for resolved cross-subdomain cookie domain. + * Injection token for the resolved cross-subdomain cookie domain. * Set during Better-Auth instance creation, undefined if disabled. + * + * Injected type: `string | null | undefined`. + * Declared `@Optional()` in the CoreBetterAuthService constructor. */ export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN'; diff --git a/src/core/modules/better-auth/core-better-auth.module.ts b/src/core/modules/better-auth/core-better-auth.module.ts index 451d678d..54189321 100644 --- a/src/core/modules/better-auth/core-better-auth.module.ts +++ b/src/core/modules/better-auth/core-better-auth.module.ts @@ -28,15 +28,34 @@ import { CoreBetterAuthRateLimitMiddleware } from './core-better-auth-rate-limit import { CoreBetterAuthRateLimiter } from './core-better-auth-rate-limiter.service'; import { CoreBetterAuthSignUpValidatorService } from './core-better-auth-signup-validator.service'; import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper'; +import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; import { CoreBetterAuthController } from './core-better-auth.controller'; import { CoreBetterAuthMiddleware } from './core-better-auth.middleware'; +import { + getBetterAuthTokenService, + resetBetterAuthRegistry, + setBetterAuthTokenService, +} from './core-better-auth.registry'; import { CoreBetterAuthResolver } from './core-better-auth.resolver'; -import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; import { CoreBetterAuthService } from './core-better-auth.service'; -// Re-exported for backward compatibility: the tokens are declared in -// core-better-auth.constants.ts so that module and service stay acyclic (SWC-safe). -export { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; +/** + * @deprecated Import from `./core-better-auth.constants` instead. Re-exported only + * so existing deep imports keep working; it will be dropped in a future MINOR. + * + * Do NOT import this token from here inside the better-auth module itself — the + * token must come from the constants leaf, or module and service start importing + * each other again and SWC builds crash with a temporal-dead-zone ReferenceError + * (see core-better-auth.constants.ts). + * + * NOTE: the `@deprecated` tag above is documentation only — TypeScript does not + * honor JSDoc on an `export … from` declaration. Do NOT try to "fix" that by + * re-declaring the token as a local `const`: that would give the barrel two + * distinct declarations of the same name and turn its `export *` into a TS2308 + * ambiguity error. The mechanical guards are `pnpm run check:swc-tdz` and + * `tests/unit/better-auth-di-tokens.spec.ts`. + */ +export { BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; /** * Options for CoreBetterAuthModule.forRoot() @@ -239,10 +258,11 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { // Safety Net: Track if forRoot() has already been called to detect duplicate registration private static forRootCalled = false; private static cachedDynamicModule: DynamicModule | null = null; - // Static references for lazy GraphQL driver (autoRegister: false) and BetterAuthRolesGuard + // Static references for lazy GraphQL driver (autoRegister: false). + // The BetterAuthTokenService reference lives in core-better-auth.registry.ts instead, + // so BetterAuthRolesGuard can read it without importing this module (cycle-free). private static serviceInstance: CoreBetterAuthService | null = null; private static userMapperInstance: CoreBetterAuthUserMapper | null = null; - private static tokenServiceInstance: BetterAuthTokenService | null = null; private static resolvedCookieDomain: string | undefined = undefined; /** @@ -279,11 +299,14 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { /** * Gets the static BetterAuthTokenService instance. - * Used by BetterAuthRolesGuard for token verification when user isn't already on request. * Safe because guards are invoked only after module initialization. + * + * Delegates to the core-better-auth.registry leaf module, which is the single + * source of truth. BetterAuthRolesGuard reads the registry directly instead of + * calling this method, so that guard and module never import each other. */ static getTokenServiceInstance(): BetterAuthTokenService | null { - return this.tokenServiceInstance; + return getBetterAuthTokenService(); } /** @@ -315,7 +338,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { CoreBetterAuthModule.userMapperInstance = this.userMapper; } if (this.tokenService) { - CoreBetterAuthModule.tokenServiceInstance = this.tokenService; + setBetterAuthTokenService(this.tokenService); } // Configure rate limiter with stored config @@ -747,6 +770,9 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { this.serviceInstance = null; this.userMapperInstance = null; this.resolvedCookieDomain = undefined; + // Reset the BetterAuthTokenService reference used by BetterAuthRolesGuard. + // Without this a token service from a previous testing module leaks into the next one. + resetBetterAuthRegistry(); // Reset shared RolesGuard registry (shared with CoreAuthModule) RolesGuardRegistry.reset(); } diff --git a/src/core/modules/better-auth/core-better-auth.registry.ts b/src/core/modules/better-auth/core-better-auth.registry.ts new file mode 100644 index 00000000..24917b59 --- /dev/null +++ b/src/core/modules/better-auth/core-better-auth.registry.ts @@ -0,0 +1,53 @@ +/** + * Runtime registry for better-auth singletons that must be reachable from files + * which cannot import core-better-auth.module.ts. + * + * `BetterAuthRolesGuard` deliberately has no constructor dependencies (see + * .claude/rules/better-auth.md §5), so it cannot receive `BetterAuthTokenService` + * through DI and has to look it up statically. Reading it from + * `CoreBetterAuthModule` would make guard and module import each other — the same + * cycle shape that crashed SWC builds when the DI tokens still lived in the module + * and the service (see core-better-auth.constants.ts). + * + * This file therefore holds the reference instead. It uses only `import type`, + * which both tsc and SWC erase entirely, so it emits no `require()` at all and is + * a true leaf: it can never be mid-evaluation when someone imports it, in any + * module system, under any compiler. + * + * @internal Populated by CoreBetterAuthModule.onModuleInit(). Read it through the + * public `CoreBetterAuthModule.getTokenServiceInstance()` unless you are in a file + * that the module itself imports — importing the module from there would re-create + * the cycle. + */ + +import type { BetterAuthTokenService } from './better-auth-token.service'; + +let tokenServiceInstance: BetterAuthTokenService | null = null; + +/** + * Stores the BetterAuthTokenService singleton. + * Called by CoreBetterAuthModule during module initialization. + * @internal + */ +export function setBetterAuthTokenService(service: BetterAuthTokenService | null): void { + tokenServiceInstance = service; +} + +/** + * Returns the BetterAuthTokenService singleton, or null when better-auth is + * disabled or the module has not initialized yet. + * + * Safe to call from guards: they run only after module initialization. + */ +export function getBetterAuthTokenService(): BetterAuthTokenService | null { + return tokenServiceInstance; +} + +/** + * Clears the registry. Called by CoreBetterAuthModule.reset() so tests do not + * leak a token service from a previous testing module into the next one. + * @internal + */ +export function resetBetterAuthRegistry(): void { + tokenServiceInstance = null; +} diff --git a/src/core/modules/better-auth/core-better-auth.service.ts b/src/core/modules/better-auth/core-better-auth.service.ts index 7c9794e4..d96bf9ac 100644 --- a/src/core/modules/better-auth/core-better-auth.service.ts +++ b/src/core/modules/better-auth/core-better-auth.service.ts @@ -28,9 +28,23 @@ export interface SessionResult { user: BetterAuthSessionUser | null; } -// Re-exported for backward compatibility: the tokens are declared in -// core-better-auth.constants.ts so that module and service stay acyclic (SWC-safe). -export { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; +/** + * @deprecated Import from `./core-better-auth.constants` instead. Re-exported only + * so existing deep imports keep working; it will be dropped in a future MINOR. + * + * Do NOT import these tokens from here inside the better-auth module itself — they + * must come from the constants leaf, or module and service start importing each + * other again and SWC builds crash with a temporal-dead-zone ReferenceError + * (see core-better-auth.constants.ts). + * + * NOTE: the `@deprecated` tag above is documentation only — TypeScript does not + * honor JSDoc on an `export … from` declaration. Do NOT try to "fix" that by + * re-declaring the tokens as local `const`s: that would give the barrel two + * distinct declarations of the same name and turn its `export *` into a TS2308 + * ambiguity error. The mechanical guards are `pnpm run check:swc-tdz` and + * `tests/unit/better-auth-di-tokens.spec.ts`. + */ +export { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN } from './core-better-auth.constants'; /** * CoreBetterAuthService provides a NestJS-friendly wrapper around the better-auth instance. diff --git a/src/core/modules/better-auth/index.ts b/src/core/modules/better-auth/index.ts index cbdc5608..c007c87b 100644 --- a/src/core/modules/better-auth/index.ts +++ b/src/core/modules/better-auth/index.ts @@ -43,5 +43,6 @@ export * from './core-better-auth.constants'; export * from './core-better-auth.controller'; export * from './core-better-auth.middleware'; export * from './core-better-auth.module'; +export * from './core-better-auth.registry'; export * from './core-better-auth.resolver'; export * from './core-better-auth.service'; From 87333cbadc6739e1e550e550760e4d9ae660fd47 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 19:35:34 +0200 Subject: [PATCH 05/21] fix(better-auth): stop a consumer key from silently stripping Secure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createBetterAuthInstance pins `advanced.useSecureCookies: false` so BetterAuth's native handlers read the same unprefixed session-cookie name the cookie helper writes, and restores the Secure attribute via `advanced.defaultCookieAttributes` on an https baseURL. But a consumer's `options.advanced` was merged SHALLOWLY — so a project setting defaultCookieAttributes for an entirely unrelated reason wholesale-replaced the framework's `{ secure: true }`: options.advanced.defaultCookieAttributes = { partitioned: true } -> Set-Cookie: iam.session_token=…; HttpOnly; SameSite=Lax (Secure GONE) On an https deployment that ships session cookies in the clear on every flow BetterAuth handles natively — 2FA verify, social callback, magic link, passkey — all of which ESTABLISH a session. No error, no warning. `sameSite: 'none'` would self-detect (browsers reject it without Secure), but `{ partitioned: true }` or `{ domain: … }` strip it silently. That one key is now deep-merged: `secure: true` is the base and the consumer's keys spread over it, so an EXPLICIT `secure: false` still wins while an unrelated key can no longer clobber it. Tests: the existing spec only asserted the CONFIG shape. It would have stayed green through this bug, and through a BetterAuth upgrade that reordered the spread in createCookieGetter (where our Secure flag rests entirely on defaultCookieAttributes being spread AFTER `secure: !!secureCookiePrefix`). The new tests call BetterAuth's own getCookies() on a constructed instance and assert the DERIVED attributes, plus sendWebResponse() — the helper a prior review accused of dropping Secure — forwards it to Express verbatim. Derivation -> forwarding -> wire is now pinned end to end. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modules/better-auth/better-auth.config.ts | 16 ++ tests/unit/better-auth-secure-cookies.spec.ts | 165 ++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/src/core/modules/better-auth/better-auth.config.ts b/src/core/modules/better-auth/better-auth.config.ts index 3e673e6e..52c9b171 100644 --- a/src/core/modules/better-auth/better-auth.config.ts +++ b/src/core/modules/better-auth/better-auth.config.ts @@ -476,6 +476,22 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea finalConfig.advanced = { ...(betterAuthConfig.advanced as Record), ...(optionsAdvanced as Record), + // `defaultCookieAttributes` needs a DEEP merge, not the shallow spread above. + // It is the key that carries our restored `secure: true` (see the `useSecureCookies: false` + // block), so a consumer setting it for an entirely unrelated reason — `{ partitioned: true }`, + // `{ domain: … }` — would wholesale-replace the object and silently strip `Secure` from every + // session cookie the native handlers forward (2FA verify, social callback, magic link) on an + // https deployment. Session-establishing cookies, in the clear, with no error anywhere. + // Re-apply `secure: true` as the BASE and let the consumer's keys spread over it, so an + // EXPLICIT `secure: false` still wins while an unrelated key can no longer clobber it. + ...(secureCookies && { + defaultCookieAttributes: { + secure: true, + ...((optionsAdvanced as Record).defaultCookieAttributes as + | Record + | undefined), + }, + }), }; } } else { diff --git a/tests/unit/better-auth-secure-cookies.spec.ts b/tests/unit/better-auth-secure-cookies.spec.ts index e539f660..d975ff1b 100644 --- a/tests/unit/better-auth-secure-cookies.spec.ts +++ b/tests/unit/better-auth-secure-cookies.spec.ts @@ -23,9 +23,11 @@ * needed and this stays a pure unit test. */ +import { getCookies } from 'better-auth/cookies'; import { describe, expect, it } from 'vitest'; import { createBetterAuthInstance } from '../../src/core/modules/better-auth/better-auth.config'; +import { sendWebResponse } from '../../src/core/modules/better-auth/core-better-auth-web.helper'; // Minimal MongoDB `Db` stand-in. `mongodbAdapter(db)` only stores the reference; // nothing here is called during `createBetterAuthInstance()`. @@ -154,3 +156,166 @@ describe('BetterAuth advanced.useSecureCookies', () => { expect(advanced.defaultCookieAttributes).toEqual({ secure: true }); }); }); + +/** + * The assertions above prove our CONFIG carries `defaultCookieAttributes: { secure: true }`. + * They do NOT prove Better-Auth honours it — and that distinction is the whole ballgame. + * + * Better-Auth derives every cookie in `createCookieGetter()` (better-auth/dist/cookies/index.mjs): + * + * attributes: { + * secure: !!secureCookiePrefix, // false — because we pin useSecureCookies: false + * … + * ...options.advanced?.defaultCookieAttributes, // { secure: true } — spread AFTER, so it wins + * … + * } + * + * The Secure flag on production session cookies therefore rests entirely on that SPREAD ORDER, + * inside a third-party library, and nothing in this repo pinned it. If a Better-Auth upgrade moved + * `defaultCookieAttributes` above the `secure:` line — or stopped applying it to the session cookie — + * the config-shape tests above would stay green while every session cookie from the NATIVE-forwarded + * path (2FA verify, social callback, magic link, passkey) silently shipped over HTTPS without + * `Secure`. Session-establishing flows, transmitted in the clear on any downgraded request. + * + * So these tests call Better-Auth's OWN `getCookies()` — the exact function its handlers use — on + * the ACTUAL options of a constructed instance, and assert the DERIVED attributes. This is a guard + * against the library, not against ourselves. + */ +describe('BetterAuth session cookie: attributes Better-Auth actually derives', () => { + /** Build a real instance, then ask Better-Auth what session cookie it would emit. */ + function derivedSessionCookie(config: Record) { + const result = createBetterAuthInstance({ config: config as any, db: fakeDb }); + expect(result).not.toBeNull(); + return getCookies((result!.instance as any).options).sessionToken; + } + + it('emits Secure=true AND an unprefixed name on an https baseURL', () => { + const sessionToken = derivedSessionCookie({ + baseUrl: 'https://api.example.com', + enabled: true, + secret: VALID_SECRET, + }); + + // The transport flag — without this, native-forwarded session cookies go out in the clear. + expect(sessionToken.attributes.secure).toBe(true); + + // …while the NAME stays unprefixed. Both must hold at once: a `__Secure-` prefix here would + // resurrect the 401 split-brain (native handlers reading a cookie the helper never writes). + expect(sessionToken.name).toBe('iam.session_token'); + expect(sessionToken.name).not.toContain('__Secure-'); + + // Defense in depth: these should never regress either. + expect(sessionToken.attributes.httpOnly).toBe(true); + expect(sessionToken.attributes.sameSite.toLowerCase()).toBe('lax'); + }); + + it('does NOT set Secure on an http baseURL (would break local dev — browsers drop Secure cookies on http)', () => { + const sessionToken = derivedSessionCookie({ enabled: true, secret: VALID_SECRET }); + + expect(sessionToken.attributes.secure).toBe(false); + expect(sessionToken.name).toBe('iam.session_token'); + }); + + it('still emits Secure=true when a consumer overrides useSecureCookies on https', () => { + const sessionToken = derivedSessionCookie({ + baseUrl: 'https://api.example.com', + enabled: true, + options: { advanced: { useSecureCookies: true } }, + secret: VALID_SECRET, + }); + + expect(sessionToken.attributes.secure).toBe(true); + // Consumer opted into Better-Auth managing cookies entirely → the __Secure- prefix is expected. + expect(sessionToken.name).toBe('__Secure-iam.session_token'); + }); + + // --------------------------------------------------------------------------------------------- + // The framework's Secure restoration lives INSIDE the `defaultCookieAttributes` key. A shallow + // spread of a consumer's `options.advanced` therefore let them replace that whole object — and a + // consumer setting it for a completely unrelated reason (cookie partitioning, a custom domain) + // silently dropped `Secure` from every native-forwarded session cookie on https. The merge is now + // deep for that one key: `secure: true` is the base, consumer keys spread over it. + // --------------------------------------------------------------------------------------------- + + it('keeps Secure when a consumer sets an UNRELATED defaultCookieAttributes key on https', () => { + const sessionToken = derivedSessionCookie({ + baseUrl: 'https://api.example.com', + enabled: true, + options: { advanced: { defaultCookieAttributes: { partitioned: true } } }, + secret: VALID_SECRET, + }); + + // The consumer's key is honoured … + expect((sessionToken.attributes as Record).partitioned).toBe(true); + // … and Secure survives. Before the deep merge this was `false` — session cookies in the clear. + expect(sessionToken.attributes.secure).toBe(true); + }); + + it('lets an EXPLICIT secure:false override win (deliberate opt-out is still the consumer’s call)', () => { + const sessionToken = derivedSessionCookie({ + baseUrl: 'https://api.example.com', + enabled: true, + options: { advanced: { defaultCookieAttributes: { secure: false } } }, + secret: VALID_SECRET, + }); + + // Explicit beats implicit: we protect against silent clobbering, not against an informed choice. + expect(sessionToken.attributes.secure).toBe(false); + }); +}); + +/** + * The last link in the chain. + * + * The tests above prove Better-Auth DERIVES a session cookie carrying `Secure`. They do not prove it + * reaches the wire. Session-establishing flows that Better-Auth handles NATIVELY — 2FA verify, + * social callback, magic link, passkey — never pass through `BetterAuthCookieHelper`; their + * `Set-Cookie` is forwarded by our own `sendWebResponse()`. That helper is exactly the link a prior + * security review accused of dropping the `Secure` attribute. + * + * So: derivation (`getCookies`, above) → forwarding (`sendWebResponse`, here) → wire. Both halves + * are now pinned, and the accusation is refuted at the precise point it was aimed. + */ +describe('sendWebResponse: native-forwarded Set-Cookie reaches Express verbatim', () => { + /** Minimal Express `Response` stand-in — only what sendWebResponse touches. */ + function fakeExpressRes() { + const headers: Record = {}; + return { + end: () => undefined, + getHeader: (name: string) => headers[name.toLowerCase()], + headers, + send: () => undefined, + setHeader: (name: string, value: unknown) => { + headers[name.toLowerCase()] = value; + }, + status: () => undefined, + } as any; + } + + it('preserves the Secure attribute on a natively-forwarded session cookie', async () => { + const cookie = 'iam.session_token=abc123; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax'; + const webResponse = new Response(null, { headers: { 'set-cookie': cookie }, status: 200 }); + const res = fakeExpressRes(); + + await sendWebResponse(res, webResponse); + + const forwarded = res.getHeader('set-cookie') as string[]; + expect(forwarded).toContain(cookie); + // The whole point: the attribute survives the hop into Express untouched. + expect(forwarded.join('; ')).toContain('Secure'); + }); + + it('does not drop cookies that were already set before the native handler ran', async () => { + const preExisting = 'compat.token=xyz; Path=/'; + const fromBetterAuth = 'iam.session_token=abc123; Path=/; HttpOnly; Secure; SameSite=Lax'; + + const res = fakeExpressRes(); + res.setHeader('set-cookie', [preExisting]); + + await sendWebResponse(res, new Response(null, { headers: { 'set-cookie': fromBetterAuth }, status: 200 })); + + const forwarded = res.getHeader('set-cookie') as string[]; + expect(forwarded).toContain(preExisting); + expect(forwarded).toContain(fromBetterAuth); + }); +}); From 2da23b2dba59006fd72303ffc692d207280cfb2d Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 19:35:52 +0200 Subject: [PATCH 06/21] test(core): add the SWC/TDZ guard that CI was missing entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This bug class had the worst possible failure profile: it passed a fully green CI and crashed only for consumers. Nothing in the pipeline could see it. - `nest build` / `pnpm run build` use tsc, whose emit tolerates these cycles. - vitest DOES run SWC — but through Vite's module runner, whose getter-based live bindings resolve a cycle lazily, so the temporal dead zone never opens. 2000+ green tests proved nothing here. - oxlint has no `import/no-cycle` rule (checked; it is not implemented). Only SWC -> CommonJS -> Node's require() fails, and no job ran that. `pnpm run check:swc-tdz` compiles with SWC and loads the output under the CJS loader. It is wired into check:raw (so `pnpm run check` picks it up) and into the build workflow, which today runs neither `check` nor a server boot and is therefore weaker than the local gate. It loads EVERY compiled module as its own entry point, not just the barrel: whether such a cycle throws depends on which module the graph is entered through, and a barrel-only load reported the (real, crashing) combined-filter.input bug as green. Sharded across 4 forked children — capped at 4 because each child pays a full node_modules cold load, and 12 shards measured slower than running single-threaded. It deliberately does NOT narrow the entry set to "files in a cycle": that would make the guard's correctness depend on a static cycle analysis, and a blind spot there would not produce a wrong answer but a GREEN one, on the one check that exists because everything else is already blind. Two structural specs pin what the guard cannot see. check:swc-tdz catches the CRASH; it does not catch the DISARMING of a safety property. Each invariant below can be reverted by a well-meaning refactor — an "organize imports", a "modernize to arrow functions" — with nothing turning red, because the cycle would be present-but-disarmed again: - the DI-token and registry files stay import-free leaves - the guard reaches the token service without importing the module - the token STRING VALUES (public API; consumers may write @Inject('BETTER_AUTH_INSTANCE') as a literal — a typo would be invisible in-repo, since provide/inject/@Inject all read the same imported symbol) - restricted.decorator takes its helpers from the leaves, and its exports stay hoisted function declarations - id.helper / clone.helper import no framework code - the AI services' load-bearing `import type` stays type-only Every one of these was mutation-tested: the bug was reintroduced and the test watched go red. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 9 + package.json | 7 +- scripts/check-swc-tdz.mjs | 217 +++++++++++++++++++++ tests/unit/better-auth-di-tokens.spec.ts | 213 ++++++++++++++++++++ tests/unit/import-cycle-invariants.spec.ts | 180 +++++++++++++++++ 5 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 scripts/check-swc-tdz.mjs create mode 100644 tests/unit/better-auth-di-tokens.spec.ts create mode 100644 tests/unit/import-cycle-invariants.spec.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25c7344d..2679105f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,15 @@ jobs: - name: Optimize and check run: pnpm run prepublishOnly + # Compiles with SWC to CommonJS and require()s the barrel under Node's CJS + # loader. This is the ONLY step that can catch a temporal-dead-zone crash from + # an import cycle: `nest build` uses tsc, and vitest runs SWC through Vite's + # module runner, whose getter-based live bindings tolerate cycles. Consumers + # running `nest start -b swc` do not get that safety net. + # Runs before Build so the subsequent tsc build cleanly recreates dist/. + - name: SWC module-graph guard (TDZ) + run: pnpm run check:swc-tdz + - name: Build run: pnpm run build diff --git a/package.json b/package.json index b9542eeb..2ac90ffd 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,10 @@ "build:dev": "pnpm run build", "c": "pnpm run check", "check": "node scripts/check.mjs", - "check:raw": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm test && pnpm run build && bash scripts/check-server-start.sh", - "check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm test && pnpm run build && bash scripts/check-server-start.sh", - "check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm test && pnpm run build && bash scripts/check-server-start.sh", + "check:raw": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm test && pnpm run check:swc-tdz && pnpm run build && bash scripts/check-server-start.sh", + "check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm test && pnpm run check:swc-tdz && pnpm run build && bash scripts/check-server-start.sh", + "check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm test && pnpm run check:swc-tdz && pnpm run build && bash scripts/check-server-start.sh", + "check:swc-tdz": "nest build -b swc && node scripts/check-swc-tdz.mjs", "cf": "pnpm run check:fix", "cnaf": "pnpm run check:naf", "docs": "pnpm run docs:ci && open http://127.0.0.1:8080/ && open ./public/index.html && compodoc -p tsconfig.json -s ", diff --git a/scripts/check-swc-tdz.mjs b/scripts/check-swc-tdz.mjs new file mode 100644 index 00000000..05123a4f --- /dev/null +++ b/scripts/check-swc-tdz.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node +/** + * SWC temporal-dead-zone guard. + * + * Loads the SWC-compiled output under Node's CommonJS loader — the one execution path where an + * import cycle actually explodes, and the only one nothing else in this repo exercises: + * + * - `nest build` / `pnpm run build` use **tsc**, whose emit tolerates these cycles. + * - vitest runs SWC through **Vite's module runner**, whose getter-based live bindings resolve a + * cycle lazily, so the temporal dead zone never opens. 2000+ green tests prove nothing here. + * - oxlint has no `import/no-cycle` rule. + * + * Consumers running `nest start -b swc` DO hit this path. A cycle that carries a class/const + * dereferenced at module-evaluation time (a decorator argument, `design:type` metadata, a static + * field initializer) throws: + * + * ReferenceError: Cannot access 'X' before initialization + * + * WHY EVERY FILE IS LOADED SEPARATELY, NOT JUST THE BARREL + * ------------------------------------------------------- + * Whether such a cycle throws depends on WHICH module the graph is entered through. Requiring only + * the barrel is not enough — and this is not theoretical: `filter.input` ↔ `combined-filter.input` + * crashed on a direct `require('.../combined-filter.input.js')` while the barrel loaded fine, + * because the barrel happened to pull `filter.input` in first. A vendor-mode deep import, or a unit + * test importing the input directly, would have hit the crash that a barrel-only check called green. + * + * So each compiled file is required as its OWN entry point, with our modules evicted from the + * require cache in between. `node_modules` stays cached — the cycles we care about are inside this + * repo, and re-evaluating the whole dependency tree per file would make the check unusably slow. + * + * WHY IT IS SHARDED, AND WHY IT IS NOT "SMART" + * -------------------------------------------- + * Re-evaluating our module graph once per entry point is inherently O(files × closure). The work is + * embarrassingly parallel and each shard is independent, so the file list is split across a few + * forked children — a pure speedup, since every file is still loaded as its own entry point in a + * pristine registry. The SEMANTICS are identical to a single-threaded sweep (verified: the sharded + * run still catches the `combined-filter.input` crash). + * + * The obvious "real" optimisation would be to only use files that PARTICIPATE IN A CYCLE as entry + * points — statically compute the strongly-connected components and test ~15 files instead of ~320. + * That is deliberately NOT done. It would make this guard's correctness depend on a static cycle + * analysis, and a blind spot in that analysis would not produce a wrong answer — it would produce a + * GREEN one, on the single check that exists precisely because everything else in the pipeline is + * already blind to this bug class. A guard whose job is to be unfoolable must not be clever. + * Brute force is the feature. + */ +import { fork } from 'node:child_process'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { availableParallelism } from 'node:os'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SELF = fileURLToPath(import.meta.url); +const ROOT = join(dirname(SELF), '..'); +const DIST = join(ROOT, 'dist'); + +/** + * Two kinds of file are deliberately not loaded. Both are excluded because requiring them proves + * nothing about import cycles — NOT because they are inconvenient. Everything else that fails to + * load is a real finding, and the skips are printed so "all green" can never quietly mean + * "we didn't look". + * + * - `/templates/` — code templates copied verbatim into consumer projects (`build:copy-templates`). + * They import `@lenne.tech/nest-server` by package name, which cannot resolve inside this repo, + * and they are never `require()`d here. + * - `main.js` — the server bootstrap. Its last line calls `bootstrap()`, so requiring it does not + * load a module graph, it STARTS A NEST SERVER (opens a Mongo connection, binds a port). Its + * entire import graph is covered anyway: every module it pulls in is checked individually. + */ +const EXCLUDED_DIR_SEGMENTS = ['/templates/']; +const EXCLUDED_FILES = [join(DIST, 'main.js')]; + +function isExcluded(file) { + if (EXCLUDED_FILES.includes(file)) { + return true; + } + const posix = file.split(sep).join('/'); + return EXCLUDED_DIR_SEGMENTS.some((segment) => posix.includes(segment)); +} + +function collectJsFiles(dir) { + const found = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + found.push(...collectJsFiles(full)); + } else if (entry.endsWith('.js')) { + found.push(full); + } + } + return found; +} + +// --------------------------------------------------------------------------------------------- +// Child: load an assigned slice, each file as its own entry point, and report failures over IPC. +// --------------------------------------------------------------------------------------------- +if (process.send && process.env.SWC_TDZ_SHARD) { + const require = createRequire(SELF); + const files = JSON.parse(process.env.SWC_TDZ_SHARD); + const failures = []; + + /** Evict only OUR modules, so the next require re-evaluates them from a pristine registry. */ + const evictOwnModules = () => { + for (const key of Object.keys(require.cache)) { + if (key.startsWith(DIST)) { + delete require.cache[key]; + } + } + }; + + for (const file of files) { + evictOwnModules(); + try { + require(file); + } catch (error) { + failures.push({ file: relative(ROOT, file), message: error?.message ?? String(error) }); + } + } + + process.send({ failures }); + process.exit(0); +} + +// --------------------------------------------------------------------------------------------- +// Parent: shard the file list, fan out, aggregate. +// --------------------------------------------------------------------------------------------- +if (!existsSync(DIST)) { + process.stderr.write('[swc-tdz] dist/ not found — run the SWC build first.\n'); + process.exit(1); +} + +const allFiles = collectJsFiles(DIST).sort(); +const excluded = allFiles.filter(isExcluded); +const files = allFiles.filter((file) => !isExcluded(file)); + +// Never let an exclusion pass silently — a skipped file must be visible, or "all green" is a lie. +for (const file of excluded) { + process.stdout.write(`[swc-tdz] skipped (not a loadable library module): ${relative(ROOT, file)}\n`); +} + +/** + * Capped at 4 on purpose, not set to the core count. + * + * Each child pays a full cold load of `node_modules` (Nest, Mongoose, GraphQL) before it can start + * its slice — a fixed, CPU- and IO-heavy cost that every extra shard pays AGAIN. Past ~4 children + * that duplicated warm-up dominates and they start fighting for cores: measured on a 12-core box, + * 12 shards (17s) was slower than running single-threaded (6s). Median wall-clock: + * + * 1 shard: 6.3s (unstable, 4.5–12s) 4 shards: 3.7s (stable, 3.5–4.5s) 12 shards: 17s + * + * Four is also the point where the sweep stops being the check's variance hot-spot, which matters + * more than the raw seconds. Override with SWC_TDZ_SHARDS to re-measure on other hardware. + */ +const shardCount = Math.max( + 1, + Math.min(Number(process.env.SWC_TDZ_SHARDS) || 4, availableParallelism(), files.length), +); +const shards = Array.from({ length: shardCount }, () => []); +files.forEach((file, index) => shards[index % shardCount].push(file)); + +const results = await Promise.all( + shards.map( + (shard) => + new Promise((resolve, reject) => { + // stdio: discard the children's own output, keep only the IPC channel. + // + // Do NOT use `silent: true` here. That pipes child stdout/stderr to the parent, and loading + // ~320 modules makes each child emit a torrent of Nest logger output ("Configured for: …", + // guard registrations, config banners). Nothing in the parent drains those pipes, so once a + // child fills the ~64 KB pipe buffer it BLOCKS on write, forever — the whole check hangs + // with no error and no output. Discarding the streams outright removes the buffer, and the + // results come back over IPC where they cannot be confused with log noise anyway. + const child = fork(SELF, [], { + env: { ...process.env, SWC_TDZ_SHARD: JSON.stringify(shard) }, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + + let reported = null; + child.on('message', (message) => { + reported = message; + }); + child.on('error', reject); + child.on('exit', (code) => { + if (reported) { + resolve(reported.failures); + } else { + // A child that dies without reporting is itself a finding: some module in its slice took + // the process down (a top-level crash, an `process.exit`, an OOM). Never swallow it. + reject(new Error(`shard died without reporting (exit code ${code})`)); + } + }); + }), + ), +); + +const failures = results.flat(); + +if (failures.length) { + process.stderr.write( + `\n[swc-tdz] ${failures.length} of ${files.length} modules failed to load under SWC/CommonJS:\n\n`, + ); + for (const { file, message } of failures) { + process.stderr.write(` ✗ ${file}\n ${message}\n`); + } + process.stderr.write( + '\n A "Cannot access \'X\' before initialization" means an import cycle is dereferenced at\n' + + ' module-evaluation time (decorator argument, design:type metadata, static field initializer).\n' + + ' Break the cycle — a lazy thunk is usually NOT enough, because emitDecoratorMetadata still\n' + + ' emits an eager design:type. See .claude/rules/architecture.md.\n\n', + ); + process.exit(1); +} + +process.stdout.write( + `[swc-tdz] ${files.length} modules load clean as standalone entry points (${shardCount} shards)\n`, +); diff --git a/tests/unit/better-auth-di-tokens.spec.ts b/tests/unit/better-auth-di-tokens.spec.ts new file mode 100644 index 00000000..c599bdc6 --- /dev/null +++ b/tests/unit/better-auth-di-tokens.spec.ts @@ -0,0 +1,213 @@ +/** + * Unit Tests: the better-auth DI-token contract and the leaf-module invariant that protects it. + * + * Two things are pinned here, and both are load-bearing for reasons that no other test covers. + * + * 1. THE TOKEN VALUES ARE PUBLIC API. + * The three tokens are plain strings exported through `src/index.ts`. NestJS matches + * `provide:` against `@Inject()` by string VALUE, not by binding identity — so a consumer + * (especially a vendor-mode project) may legitimately write `@Inject('BETTER_AUTH_INSTANCE')` + * as a literal. Inside this repo a value change would be invisible: `provide`, `inject`, + * `@Inject()` and `moduleRef.get()` all reference the same imported symbol, so renaming the + * value flips every side at once and DI still resolves. Every test would stay green while + * every literal-string consumer breaks. This spec is the only thing standing between a typo + * and a silent downstream outage. + * + * 2. THE LEAF INVARIANT IS THE FIX. + * `core-better-auth.constants.ts` exists to be import-free. When the tokens still lived in + * core-better-auth.module.ts / core-better-auth.service.ts, those two files imported each + * other, and `@Inject(BETTER_AUTH_INSTANCE)` — a constructor-parameter decorator, evaluated + * at class-definition time — read the token while the cycle was still initializing. Under + * SWC → CommonJS that throws: + * + * ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization + * + * A file with zero imports can never be mid-evaluation when someone imports it, in any module + * system, under any compiler. Adding a single runtime import to the constants file re-opens + * that door, and NOTHING else in the suite would notice: tsc compiles the cycle happily, and + * vitest runs SWC through Vite's module runner, whose getter-based live bindings tolerate + * cycles. (`pnpm run check:swc-tdz` catches the actual crash; this spec catches the structural + * cause, in milliseconds, with a message that says what to do about it.) + * + * The same applies to `core-better-auth.registry.ts`, which lets BetterAuthRolesGuard reach + * BetterAuthTokenService without importing CoreBetterAuthModule. It may hold `import type` + * only — those are erased by both tsc and SWC and emit no `require()`. + * + * See .claude/rules/better-auth.md §6. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + BETTER_AUTH_CONFIG, + BETTER_AUTH_COOKIE_DOMAIN, + BETTER_AUTH_INSTANCE, +} from '../../src/core/modules/better-auth/core-better-auth.constants'; +import { CoreBetterAuthModule } from '../../src/core/modules/better-auth/core-better-auth.module'; +import { + getBetterAuthTokenService, + resetBetterAuthRegistry, + setBetterAuthTokenService, +} from '../../src/core/modules/better-auth/core-better-auth.registry'; + +const MODULE_DIR = join(__dirname, '..', '..', 'src', 'core', 'modules', 'better-auth'); + +/** + * Remove comments without mangling string literals. + * + * A naive `//` strip would corrupt any specifier containing `//` (a URL, say), and the constants + * file's own JSDoc contains an `@example` block with real `import` statements — which a regex over + * the raw source would happily report as imports. Both failure modes are silent, so the scanner + * walks the source instead of pattern-matching it. + */ +function stripComments(source: string): string { + let out = ''; + let i = 0; + let quote: string | null = null; + + while (i < source.length) { + const char = source[i]; + const next = source[i + 1]; + + if (quote) { + if (char === '\\') { + out += char + (next ?? ''); + i += 2; + continue; + } + if (char === quote) { + quote = null; + } + out += char; + i += 1; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + quote = char; + out += char; + i += 1; + continue; + } + + if (char === '/' && next === '/') { + while (i < source.length && source[i] !== '\n') { + i += 1; + } + continue; + } + + if (char === '/' && next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { + i += 1; + } + i += 2; + continue; + } + + out += char; + i += 1; + } + + return out; +} + +/** + * Module specifiers that survive compilation, i.e. that emit a `require()` and therefore create an + * edge in the runtime import graph. `import type` / `export type` are excluded: both compilers + * erase them entirely. + */ +function runtimeImports(fileName: string): string[] { + const code = stripComments(readFileSync(join(MODULE_DIR, fileName), 'utf8')); + const specifiers: string[] = []; + + // `import ... from 'x'` and `export ... from 'x'`, but not the `type` variants. + const fromRe = /\b(?:import|export)\s+(?!type\b)[^;]*?\bfrom\s*['"]([^'"]+)['"]/g; + for (const match of code.matchAll(fromRe)) { + specifiers.push(match[1]); + } + + // Bare side-effect imports: `import 'x'`. + const bareRe = /\bimport\s*['"]([^'"]+)['"]/g; + for (const match of code.matchAll(bareRe)) { + specifiers.push(match[1]); + } + + // CommonJS escape hatch. + const requireRe = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + for (const match of code.matchAll(requireRe)) { + specifiers.push(match[1]); + } + + return specifiers; +} + +describe('BetterAuth DI tokens', () => { + describe('token values (public API — consumers may inject them as string literals)', () => { + it('pins the exact token strings', () => { + // Do NOT "fix" a failure here by updating the expectation: the value IS the contract. + // Changing it silently breaks every consumer using @Inject('BETTER_AUTH_INSTANCE'). + expect(BETTER_AUTH_INSTANCE).toBe('BETTER_AUTH_INSTANCE'); + expect(BETTER_AUTH_CONFIG).toBe('BETTER_AUTH_CONFIG'); + expect(BETTER_AUTH_COOKIE_DOMAIN).toBe('BETTER_AUTH_COOKIE_DOMAIN'); + }); + + it('keeps the tokens distinct', () => { + const tokens = [BETTER_AUTH_INSTANCE, BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN]; + expect(new Set(tokens).size).toBe(tokens.length); + }); + }); + + describe('leaf-module invariant (SWC temporal-dead-zone protection)', () => { + it('core-better-auth.constants.ts imports nothing at all', () => { + expect(runtimeImports('core-better-auth.constants.ts')).toEqual([]); + }); + + it('core-better-auth.registry.ts emits no runtime import (type-only imports are erased)', () => { + expect(runtimeImports('core-better-auth.registry.ts')).toEqual([]); + }); + + it('the guard reaches the token service without importing the module', () => { + // better-auth-roles.guard.ts must not import core-better-auth.module.ts: that edge, plus the + // module's own import of the guard, is the cycle shape that crashes SWC as soon as either + // side gains an evaluation-time dereference (a decorator argument, a static field + // initializer). It reads core-better-auth.registry.ts instead. + expect(runtimeImports('better-auth-roles.guard.ts')).not.toContain('./core-better-auth.module'); + }); + }); + + describe('registry lifecycle', () => { + beforeEach(() => { + resetBetterAuthRegistry(); + }); + + afterEach(() => { + resetBetterAuthRegistry(); + }); + + it('CoreBetterAuthModule.getTokenServiceInstance() reads the registry', () => { + const tokenService = {} as never; + setBetterAuthTokenService(tokenService); + + // The module's public static getter must stay a thin delegate to the registry — that is what + // lets BetterAuthRolesGuard bypass the module entirely without changing the public API. + expect(CoreBetterAuthModule.getTokenServiceInstance()).toBe(tokenService); + expect(getBetterAuthTokenService()).toBe(tokenService); + }); + + it('CoreBetterAuthModule.reset() clears the token service (test-isolation leak)', () => { + setBetterAuthTokenService({} as never); + expect(getBetterAuthTokenService()).not.toBeNull(); + + CoreBetterAuthModule.reset(); + + // Before the registry extraction, reset() cleared serviceInstance and userMapperInstance but + // NOT the token service — so a token service from a previous testing module survived into the + // next one, and BetterAuthRolesGuard silently verified tokens against a stale instance. + expect(getBetterAuthTokenService()).toBeNull(); + expect(CoreBetterAuthModule.getTokenServiceInstance()).toBeNull(); + }); + }); +}); diff --git a/tests/unit/import-cycle-invariants.spec.ts b/tests/unit/import-cycle-invariants.spec.ts new file mode 100644 index 00000000..d76a72fa --- /dev/null +++ b/tests/unit/import-cycle-invariants.spec.ts @@ -0,0 +1,180 @@ +/** + * Unit Tests: the structural invariants that keep SWC-compiled builds from crashing. + * + * THE BUG CLASS + * ------------- + * An import cycle is survivable on its own. It becomes FATAL when a TDZ-subject binding + * (`const` / `class` / `let`) that crosses the cycle is dereferenced at MODULE-EVALUATION time — + * in a decorator argument, in `design:type` / `design:paramtypes` metadata (which + * `emitDecoratorMetadata` emits eagerly), in a static field initializer, or in a top-level + * statement. Under SWC → CommonJS → Node's `require()` that reads a binding still in its temporal + * dead zone: + * + * ReferenceError: Cannot access 'X' before initialization + * + * WHY THIS FILE EXISTS AND WHY IT IS NOT REDUNDANT + * ------------------------------------------------ + * `pnpm run check:swc-tdz` catches the CRASH. It does not catch the DISARMING of a safety + * property. Each invariant below is currently the only thing standing between a live cycle and a + * crash, and every one of them can be reverted by a well-meaning refactor — an "organize imports", + * a "modernize to arrow functions", a "split this file up" — WITHOUT anything turning red: + * the code still compiles, all tests still pass, and `check:swc-tdz` still goes green, because the + * cycle is disarmed-but-present rather than armed. The crash only appears later, for a consumer, + * on a compiler this repo's default build never runs. + * + * So these tests assert the SAFETY PROPERTY itself, not its consequence. They are deliberately + * structural. If one fails, do not "fix" it by relaxing the assertion — read the docblock it points + * at. + * + * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)". + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const SRC = join(__dirname, '..', '..', 'src'); + +function read(...segments: string[]): string { + return readFileSync(join(SRC, ...segments), 'utf8'); +} + +describe('SWC/TDZ import-cycle invariants', () => { + describe('restricted.decorator — the leaf imports that took it off every cycle', () => { + /** + * `restricted.decorator` used to sit on TWO runtime cycles at once: + * + * restricted.decorator → db.helper → input.helper → restricted.decorator + * restricted.decorator → tenant/core-tenant.helpers → config.service → input.helper + * → restricted.decorator + * + * `input.helper` therefore evaluated while `restricted.decorator` was mid-initialization, and + * only the fact that every cross-cycle dereference happened to sit inside a function body kept + * it from throwing. One top-level line in `input.helper` — a module-level alias of + * `checkRestricted`, an `@Restricted`-decorated class, `design:type` metadata — would have + * crashed SWC-compiled builds, in the file that drives field-level access control. + * + * Both cycles are gone now, and these two imports are why: + * - the ID helpers moved to the `id.helper` leaf (kills the db.helper edge) + * - `clone` / `deepFreeze` moved to the `clone.helper` leaf (kills the config.service edge) + * + * Point either import back at the fat helper and the cycle returns instantly — with nothing + * turning red, because the cycle would be present-but-disarmed again. That is what these tests + * are for. + */ + const restricted = read('core', 'common', 'decorators', 'restricted.decorator.ts'); + const configService = read('core', 'common', 'services', 'config.service.ts'); + + it('restricted.decorator takes the ID helpers from the id.helper leaf, not db.helper', () => { + expect(restricted).toMatch(/from '\.\.\/helpers\/id\.helper'/); + expect(restricted).not.toMatch(/from '\.\.\/helpers\/db\.helper'/); + }); + + it('config.service takes clone/deepFreeze from the clone.helper leaf, not input.helper', () => { + expect(configService).toMatch(/from '\.\.\/helpers\/clone\.helper'/); + expect(configService).not.toMatch(/from '\.\.\/helpers\/input\.helper'/); + }); + + /** + * Defense in depth. With both cycles removed, a `const` arrow here would no longer be reachable + * from a mid-evaluation module — but `restricted.decorator` is imported by half the framework + * and a new cycle through it is exactly the kind of thing that reappears. Hoisted function + * declarations are initialized before any module body runs, so they stay TDZ-immune whatever the + * import graph does next. It costs nothing to keep. + */ + it.each(['Restricted', 'getRestricted', 'checkRestricted'])( + '%s is a hoisted function declaration, not a const arrow (TDZ-immunity)', + (name) => { + expect(restricted).toMatch(new RegExp(`^export function ${name}\\b`, 'm')); + expect(restricted).not.toMatch(new RegExp(`^export const ${name}\\s*=`, 'm')); + }, + ); + }); + + describe('the helper leaves must stay leaves', () => { + /** + * `id.helper` and `clone.helper` exist for exactly one reason: to be importable from files that + * must not reach `db.helper` / `input.helper`. The moment either grows a framework import, it + * stops being a leaf, and the cycles it was carved out to break come straight back. + * + * Node built-ins, lodash, rfdc, mongoose `Types` and type-only imports are fine — none of them + * can reach back into this codebase. + */ + const ALLOWED = /^(node:)?(inspector|util|lodash|rfdc|mongoose)$/; + + it.each([ + ['id.helper.ts', ['../types/ids.type']], + ['clone.helper.ts', []], + ])('%s imports no framework code', (file, allowedRelative) => { + const source = read('core', 'common', 'helpers', file); + const specifiers = [...source.matchAll(/^import\s+(?:type\s+)?[^;]*?['"]([^'"]+)['"]/gm)].map((m) => m[1]); + + for (const specifier of specifiers) { + const isAllowedPackage = ALLOWED.test(specifier); + const isAllowedRelative = (allowedRelative as string[]).includes(specifier); + expect( + isAllowedPackage || isAllowedRelative, + `${file} may not import "${specifier}" — it must stay a leaf`, + ).toBe(true); + } + }); + }); + + describe('filter inputs — mutually recursive classes must share one module', () => { + /** + * `FilterInput` references `CombinedFilterInput` EAGERLY: in a decorator argument + * (`type: CombinedFilterInput`) and in the `design:type` metadata emitted for + * `combinedFilter?: CombinedFilterInput`. Split across two files that import each other, a + * direct `require()` of `combined-filter.input` crashed: + * + * ReferenceError: Cannot access 'CombinedFilterInput' before initialization + * + * It stayed hidden because entering through the package barrel pulls `filter.input` in first. + * A lazy thunk does NOT fix this — `emitDecoratorMetadata` still emits an eager `design:type`, + * and SWC's `typeof` guard does not protect the member expression it compiles to. The only fix + * is to remove the import edge, so both classes live in `filter.input.ts` with + * `CombinedFilterInput` declared FIRST. + */ + const filterInput = read('core', 'common', 'inputs', 'filter.input.ts'); + const combinedFilterInput = read('core', 'common', 'inputs', 'combined-filter.input.ts'); + + it('both classes are declared in filter.input.ts', () => { + expect(filterInput).toMatch(/^export class CombinedFilterInput\b/m); + expect(filterInput).toMatch(/^export class FilterInput\b/m); + }); + + it('CombinedFilterInput is declared BEFORE FilterInput (FilterInput reads it at definition time)', () => { + const combinedAt = filterInput.indexOf('export class CombinedFilterInput'); + const filterAt = filterInput.indexOf('export class FilterInput'); + + expect(combinedAt).toBeGreaterThanOrEqual(0); + expect(filterAt).toBeGreaterThanOrEqual(0); + expect(combinedAt).toBeLessThan(filterAt); + }); + + it('filter.input.ts does not import combined-filter.input.ts (that edge was the cycle)', () => { + expect(filterInput).not.toMatch(/from '\.\/combined-filter\.input'/); + }); + + it('combined-filter.input.ts is a re-export shim only — no class declaration', () => { + expect(combinedFilterInput).toMatch(/export \{ CombinedFilterInput \} from '\.\/filter\.input'/); + expect(combinedFilterInput).not.toMatch(/^export class\b/m); + }); + }); + + describe('AI services — the type-only import is load-bearing', () => { + /** + * `core-ai-interaction.service` ↔ `core-ai.service` is kept off a real runtime cycle by ONE + * thing: `AiInteractionRecord` is pulled in with `import type`, which both tsc and SWC erase. + * `core-ai.service` has a constructor `@Inject`, so `decoratorMetadata` emits `design:paramtypes` + * at top level — an evaluation-time deref. Widen that `import type` to a value import (an IDE + * "organize imports" or a lint autofix will do it without asking) and the cycle becomes real and + * armed at the same instant. + */ + it('core-ai-interaction.service.ts imports from core-ai.service with `import type`', () => { + const source = read('core', 'modules', 'ai', 'services', 'core-ai-interaction.service.ts'); + const valueImport = /^import\s+(?!type\b)[^;]*from\s+'\.\/core-ai\.service'/m; + + expect(source).not.toMatch(valueImport); + }); + }); +}); From b7c680f33d9ac1466c9f251e9c081adfed3a3963 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 19:36:10 +0200 Subject: [PATCH 07/21] test: refuse to drop a database that is not recognizably a test database The global setup drops whatever MONGODB_URI points at, and that variable is not always a test database: a running `lt dev` session exports it pointing at the project's DEVELOPMENT database. Running the suite from that shell would silently wipe the developer's data. Both the setup and the stale-run cleanup now require the database name to look like a test database before anything is dropped. CI does not set MONGODB_URI, so the guard never triggers there, and the run databases the suite creates (nest-server-e2e-run-*, nest-server-ci-*) satisfy it. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/db-lifecycle.reporter.ts | 13 ++++++++++++- tests/global-setup.ts | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/db-lifecycle.reporter.ts b/tests/db-lifecycle.reporter.ts index fee7d36a..c8661a64 100644 --- a/tests/db-lifecycle.reporter.ts +++ b/tests/db-lifecycle.reporter.ts @@ -8,6 +8,16 @@ import { MongoClient } from 'mongodb'; */ export const RUN_DB_PATTERN = /-run-(\d+)-p(\d+)$/; +/** + * A database name MUST match this before anything here is willing to drop it. + * + * The safety net for a MONGODB_URI that does not point where the test setup + * assumes: a running `lt dev` session exports it pointing at the project's + * DEVELOPMENT database, so without this guard a test run started from that + * shell would silently wipe the developer's data. + */ +export const SAFE_TEST_DB_PATTERN = /(e2e|ci|test|acctest)/i; + /** * Age limit for stale run databases. Normally staleness is detected via a dead * PID; this cap only exists for the rare case of PID recycling (the old PID now @@ -137,7 +147,8 @@ export default class DbLifecycleReporter { const legacy = name.match(legacyTimestamped); stale = legacy ? Date.now() - Number(legacy[1]) > 60 * 60 * 1000 : false; } - if (stale) { + // Belt and braces: never drop anything that is not recognizably a test database. + if (stale && SAFE_TEST_DB_PATTERN.test(name)) { await connection.db(name).dropDatabase(); dropped.push(name); } diff --git a/tests/global-setup.ts b/tests/global-setup.ts index f802df88..5512554e 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -1,7 +1,7 @@ import { MongoClient } from 'mongodb'; import envConfig from '../src/config.env'; -import { splitMongoUri } from './db-lifecycle.reporter'; +import { SAFE_TEST_DB_PATTERN, splitMongoUri } from './db-lifecycle.reporter'; /** * Vitest global setup: give every test run its OWN database. @@ -24,12 +24,27 @@ import { splitMongoUri } from './db-lifecycle.reporter'; * * An externally provided MONGODB_URI (e.g. CI service container) opts out of * the unique-name scheme: that URI is used as-is and dropped up front, exactly - * like the previous behavior. + * like the previous behavior — but only if it names a disposable test database + * (see the guard below). */ export async function setup() { if (process.env.MONGODB_URI) { const connection = await MongoClient.connect(process.env.MONGODB_URI); const db = connection.db(); + + // Never drop a database that is not recognizably a test database. This branch drops + // whatever MONGODB_URI points at, and that variable is not always a test DB: a running + // `lt dev` session exports it pointing at the project's DEVELOPMENT database, so without + // this guard, running the suite from that shell silently wipes the developer's data. + if (!SAFE_TEST_DB_PATTERN.test(db.databaseName)) { + await connection.close(); + throw new Error( + `Refusing to dropDatabase("${db.databaseName}"): not a recognized test database ` + + `(expected a name matching ${SAFE_TEST_DB_PATTERN}). ` + + 'MONGODB_URI must point at a disposable test database.', + ); + } + await db.dropDatabase(); console.info(`Dropped externally configured test database: ${db.databaseName}`); await connection.close(); From f0c43bd21482af1b13f7a192d16c5b4f4d66711a Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 19:36:11 +0200 Subject: [PATCH 08/21] docs: write down the leaf-file rule so this bug class cannot come back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constraint this branch establishes existed nowhere except the source files it fixed — and the footgun path stayed open: importing a token from core-better-auth.module inside the module still compiles, still passes every test, and only crashes under SWC. The same shape is still latent in tus.module.ts and in nine AI service files. - .claude/rules/better-auth.md gains a §6 on DI-token placement: the rule, the exact error, the mistakes table, and — most importantly — a table of which tools can and cannot see a regression here. A green `pnpm test` is NO evidence. - .claude/rules/architecture.md generalises it to all core modules, lists which modules are already safe and which are one back-import away, and records the lesson that cost the most time: removing one edge is not removing the cycle. Re-run madge and check the file appears in ZERO cycles. - migration-guides/11.27.6-to-11.27.7.md covers the SWC startup crash (with the error string, so an affected consumer searching for it lands here), the CombinedFilterInput deep-import crash, the cookie Secure fix, and the vendor-mode note that the BetterAuth change is an atomic file set — a partial core sync that omits the new leaf leaves an unresolvable import. - CLAUDE.md: document check:swc-tdz, and correct the stale claim that this repo lints with ESLint and formats with Prettier (it uses oxlint / oxfmt). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lt-dev-backend-reviewer/MEMORY.md | 1 + .../project_swc-tdz-import-cycles.md | 57 +++++ .../lt-dev-docs-reviewer/MEMORY.md | 3 +- .../framework-api-generator-allowlist.md | 12 +- ...atch-release-migration-guide-convention.md | 14 + .../lt-dev-performance-reviewer/MEMORY.md | 3 + .../swc-cjs-tdz-and-ci-gap.md | 45 ++++ .../lt-dev-security-reviewer/MEMORY.md | 7 +- ...tterauth-di-failclosed-and-cycle-triage.md | 29 +++ ...ect-betterauth-native-cookie-forwarding.md | 52 +++- .../project-e2e-node-env-trap.md | 14 + .../lt-dev-test-reviewer/MEMORY.md | 1 + .../vitest-blind-to-swc-cjs-tdz.md | 22 ++ .claude/rules/architecture.md | 56 ++++ .claude/rules/better-auth.md | 79 ++++++ CLAUDE.md | 10 +- migration-guides/11.27.6-to-11.27.7.md | 242 ++++++++++++++++++ 17 files changed, 635 insertions(+), 12 deletions(-) create mode 100644 .claude/agent-memory/lt-dev-backend-reviewer/project_swc-tdz-import-cycles.md create mode 100644 .claude/agent-memory/lt-dev-docs-reviewer/patch-release-migration-guide-convention.md create mode 100644 .claude/agent-memory/lt-dev-performance-reviewer/swc-cjs-tdz-and-ci-gap.md create mode 100644 .claude/agent-memory/lt-dev-security-reviewer/project-betterauth-di-failclosed-and-cycle-triage.md create mode 100644 .claude/agent-memory/lt-dev-security-reviewer/project-e2e-node-env-trap.md create mode 100644 .claude/agent-memory/lt-dev-test-reviewer/vitest-blind-to-swc-cjs-tdz.md create mode 100644 migration-guides/11.27.6-to-11.27.7.md diff --git a/.claude/agent-memory/lt-dev-backend-reviewer/MEMORY.md b/.claude/agent-memory/lt-dev-backend-reviewer/MEMORY.md index 20797f30..9997b897 100644 --- a/.claude/agent-memory/lt-dev-backend-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-backend-reviewer/MEMORY.md @@ -1,2 +1,3 @@ - [Core ErrorCode in framework repo](project_core-errorcode.md) — src/core has its own ErrorCode registry; modern core modules use it, but the baseline is mixed (core-user still raw strings). - [AI module prompt/slot service raw exceptions](project_ai-module-prompt-service-errors.md) — most of the AI module routes through ErrorCode, but CoreAiPromptService + CoreAiSlotService still throw raw-string ForbiddenExceptions; consistency cleanup, not a security gap. +- [SWC TDZ import cycles](project_swc-tdz-import-cycles.md) — cycles crash under `-b swc` only when deref'd at module-eval time; full 9-cycle audit: only 3 are real, `filter.input ↔ combined-filter.input` already throws on deep import. diff --git a/.claude/agent-memory/lt-dev-backend-reviewer/project_swc-tdz-import-cycles.md b/.claude/agent-memory/lt-dev-backend-reviewer/project_swc-tdz-import-cycles.md new file mode 100644 index 00000000..1c986992 --- /dev/null +++ b/.claude/agent-memory/lt-dev-backend-reviewer/project_swc-tdz-import-cycles.md @@ -0,0 +1,57 @@ +--- +name: swc-tdz-import-cycles +description: Import cycles in src/core only TDZ-crash under SWC when a binding is dereferenced at module-EVAL time (param decorators, static fields); check/CI has no SWC or madge step, so these are invisible to green CI. +metadata: + type: project +--- + +Circular imports inside `src/core/**` are a live crash class under SWC (`nest start -b swc`), +but **only** when one side dereferences the other's binding during **module evaluation**. + +**Why:** SWC's ESM→CJS emit exposes each export as a getter over a TDZ'd `const`/`class` binding. +On a cycle, the second `require()` returns a partially-populated exports object; touching a +property then fires the getter into the TDZ → `ReferenceError: Cannot access 'X' before initialization`. +tsc/CommonJS survives the same graph by evaluation-order luck. Fixed 2026-07 in +`better-auth` (commit 8786d83): `@Inject(BETTER_AUTH_INSTANCE)` sat in a **constructor-parameter +decorator**, which SWC evaluates at class-definition time (top-level statement). + +**How to apply — the triage question is always "eval-time or lazy?":** +- CRASHES: constructor param decorators (`@Inject(X)`), class-level decorator *arguments*, + `static` field initializers, top-level `const`, and `design:paramtypes` metadata + (`typeof _mod.X` still fires the CJS getter). +- SAFE (latent): references only inside **method bodies** — resolved at request time, long after + both modules finish evaluating. + +To settle a verdict, compile the two files with `@swc/core` (commonjs + legacyDecorator + +decoratorMetadata) and grep the emit: a deref at column 0 / inside `_ts_decorate([...])` is a +crash; one inside a method body is not. + +**Known latent (not crashing) as of 2026-07:** `better-auth-roles.guard.ts ↔ core-better-auth.module.ts` +(guard touches `CoreBetterAuthModule.getTokenServiceInstance()` only inside a method body; the module +touches the guard only inside `createDeferredModule()`). Repo-wide `madge --circular src/` reports +~10 cycles — treat that count as the baseline. + +**Full 9-cycle audit (2026-07-13, SWC-emit verified).** Only 3 of madge's 9 are RUNTIME cycles; +#3/#6/#7/#8 emit an empty CJS file (type-only) and #5/#9 have a type-only return edge — all madge +false positives. Configure madge `skipTypeImports` to cut the noise. +- **`filter.input.ts ↔ combined-filter.input.ts` is ALREADY BROKEN** — `filter.input.ts:25` + (`type: CombinedFilterInput`, eager) + its `design:type` metadata deref inside a top-level + `_ts_decorate`. Deep-importing `combined-filter.input` FIRST throws today, zero source changes. + The barrel survives only because 3 modules (`filter.args`, `filter.helper`, `single-filter.input`) + pull `FilterInput` in first. **A `type: () => X` thunk does NOT fix it** — the crash just moves to + the `_ts_metadata("design:type", …)` line, which `decoratorMetadata` emits eagerly and userland + cannot make lazy. Only structural de-cycling (merge the mutually-recursive pair into one module) works. +- `restricted.decorator → db.helper → input.helper`: benign today; the exposed edge is the RETURN one + (`input.helper` dereffing restricted's TDZ `const` arrows), not the one reviewers keep checking. +- `core-ai.service.ts:106` has a constructor-param `design:paramtypes` deref (the literal better-auth + shape); the ONLY thing defusing it is `import type` on `core-ai-interaction.service.ts:8` — + one IDE "organize imports" autofix away from a crash. + +**Load-bearing `import type`:** in this repo `import type` is not cosmetic — it is what keeps several +cycles off the runtime graph. Never let a lint autofix widen one to a value import. + +**Blind spot:** `pnpm run check` → `scripts/check.mjs` runs `nest build` (tsc) only. There is **no +SWC build and no madge/circular step** in check.mjs, check-server-start.sh, or .github/workflows/. +So this entire bug class keeps CI green and only detonates for consumers running `-b swc` — +especially **vendor-mode** consumers who copy `src/core/` into their own tree. +See [[core-errorcode]] for the other core-module consistency baseline. diff --git a/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md b/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md index 3608d36b..60abc30f 100644 --- a/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md @@ -1,4 +1,5 @@ -- [FRAMEWORK-API generator interface allowlist](framework-api-generator-allowlist.md) — FRAMEWORK-API.md only documents config interfaces hardcoded in generate-framework-api.ts; new interfaces silently omitted +- [FRAMEWORK-API generator interface allowlist](framework-api-generator-allowlist.md) — only allowlisted config interfaces are expanded; consts/DI tokens/helpers are out of scope, and a date-only diff is build churn +- [Patch-release migration-guide convention](patch-release-migration-guide-convention.md) — every 11.27.x patch has a guide, incl. zero-effort internal bugfixes; don't auto-grade "bugfix → no guide" as N/A - [Doc places to check for config features](doc-surfaces-for-config-features.md) — the full set of doc surfaces a new configurable feature must update in this repo - [AI module doc coverage gaps](ai-module-doc-coverage-gaps.md) — AI features exported in index.ts (hooks, tool-grants, modes, attachments, claudeCli, compaction) but missing from user-facing docs - [Migration-guide behavior-change count trap](migration-guide-behavior-change-count-trap.md) — derive behavior changes from better-auth.config.ts/cookies.helper.ts diffs; guide Overview counts have under-reported before diff --git a/.claude/agent-memory/lt-dev-docs-reviewer/framework-api-generator-allowlist.md b/.claude/agent-memory/lt-dev-docs-reviewer/framework-api-generator-allowlist.md index 96cee6fa..282faa52 100644 --- a/.claude/agent-memory/lt-dev-docs-reviewer/framework-api-generator-allowlist.md +++ b/.claude/agent-memory/lt-dev-docs-reviewer/framework-api-generator-allowlist.md @@ -1,6 +1,6 @@ --- name: framework-api-generator-allowlist -description: FRAMEWORK-API.md only expands config interfaces named in a hardcoded list in scripts/generate-framework-api.ts; new interfaces are silently dropped +description: FRAMEWORK-API.md only expands config interfaces named in a hardcoded list in scripts/generate-framework-api.ts; new interfaces are silently dropped, and non-interface changes are structurally out of scope metadata: type: project --- @@ -10,3 +10,13 @@ metadata: **Why:** The CLAUDE.md framework-compatibility rule claims FRAMEWORK-API is "auto-generated... includes the new interface and all fields" — but that is only true if the interface name was added to the allowlist. A new top-level config interface (e.g. `IAi`, `IAiRateLimit`, `IAiDefaultConnection`) appears only as a `field?: boolean | IName` reference under its parent, never expanded, unless someone edits the generator. **How to apply:** When reviewing a branch that adds a new config interface to `server-options.interface.ts`, do NOT treat "FRAMEWORK-API.md was regenerated" as sufficient. Grep FRAMEWORK-API.md for a dedicated `### ` heading. If absent, flag it AND flag the missing edit to the generator's `targetInterfaces` array — a one-time regeneration won't fix it. + +## The generator's TOTAL scope (everything else is legitimately N/A) + +It emits exactly five things: (1) `CoreModule.forRoot()` overload signatures, (2) the allowlisted config interfaces, (3) `ServiceOptions`, (4) `CrudService` public method signatures, (5) a core-modules table built by scanning `src/core/modules/*` dirs for the mere *presence* of README.md / INTEGRATION-CHECKLIST.md. + +Consequence: **exported `const`s, DI tokens, helpers, guards, middleware and new non-interface files can never appear in FRAMEWORK-API.md.** For such a change, FRAMEWORK-API.md being absent from the diff is CORRECT — do not flag it as a missed Living-Documentation update. Adding a new `.ts` file inside an existing module dir also changes nothing (the table only checks whether the two doc files exist). + +## Date-stamp churn is not a doc gap + +Line 3 is `> Auto-generated from source code on YYYY-MM-DD (vX.Y.Z)`. Because the date is stamped fresh on every run, re-running the generator on an unchanged API produces a **1-line, date-only diff** — which shows up as a dirty `FRAMEWORK-API.md` in `git status`. That is build churn, not a pending documentation update. Verify with `git diff -- FRAMEWORK-API.md`: if the only hunk is the date line, the API surface is genuinely unchanged. Run it standalone via `npx tsx scripts/generate-framework-api.ts` (it is the last step of `pnpm run build`, so a full build is not needed to check). diff --git a/.claude/agent-memory/lt-dev-docs-reviewer/patch-release-migration-guide-convention.md b/.claude/agent-memory/lt-dev-docs-reviewer/patch-release-migration-guide-convention.md new file mode 100644 index 00000000..48111b5e --- /dev/null +++ b/.claude/agent-memory/lt-dev-docs-reviewer/patch-release-migration-guide-convention.md @@ -0,0 +1,14 @@ +--- +name: patch-release-migration-guide-convention +description: nest-server ships a migration guide for EVERY patch release incl. zero-effort internal bugfixes — the observed convention is stricter than the written rule in .claude/rules/migration-guides.md +metadata: + type: project +--- + +`.claude/rules/migration-guides.md` says a guide is "Not required if purely internal bugfixes with no user action needed". **The repo's actual practice contradicts this**: `migration-guides/` has an unbroken chain for every patch in the 11.27.x line (11.27.0→.1→.2→.3→.4→.5→.6), including guides whose own Overview says *"Migration Effort: 0 minutes (automatic) — `pnpm update` is enough"* and *"No source-code or config changes are required in consuming projects"* (see `11.27.2-to-11.27.3.md`, a pure internal unhandled-rejection fix, and `11.24.0-to-11.24.1.md`). + +**Why:** Consumers use the guides as the release-notes surface, not just as migration instructions. A guide for a "silent" bugfix still earns its keep when the bug had a *user-visible symptom* — the reader who googles the crash/error string needs to land on "fixed in vX". Grading "no guide needed, it's just a bugfix" as 100% N/A therefore under-reports. + +**How to apply:** When a branch is a pure internal bugfix, do NOT auto-grade the migration-guide dimension as N/A. Ask: (1) did the bug have a symptom a consumer could observe (startup crash, 401, error string)? (2) does the fix require anything of **vendor-mode** consumers, who never run `pnpm update` and instead sync `src/core/` via the `lt-dev:nest-server-core-updater` agent — e.g. a multi-file atomic change where a partial sync breaks the build? If either is yes, a guide is warranted. + +**Timing nuance (do not mis-attribute):** release commits are titled `11.27.X: ` and bundle the version bump + migration guide + FRAMEWORK-API regen. Feature/fix branches use conventional commits (`fix(better-auth): …`) and leave `package.json` version alone. So "no version bump / no guide on a fix branch" is *normal*; flag the guide as a **release-time deliverable**, not a branch blocker. See [[framework-api-generator-allowlist]] and [[doc-surfaces-for-config-features]]. diff --git a/.claude/agent-memory/lt-dev-performance-reviewer/MEMORY.md b/.claude/agent-memory/lt-dev-performance-reviewer/MEMORY.md index cc4e4776..51f82b99 100644 --- a/.claude/agent-memory/lt-dev-performance-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-performance-reviewer/MEMORY.md @@ -2,3 +2,6 @@ ## Project - [AI Module Performance Profile](ai-module-perf.md) — per-prompt DB query budget + memory characteristics of src/core/modules/ai; what to re-check vs what's already correct. + +## Build & Startup +- [SWC/CJS TDZ + CI Gap](swc-cjs-tdz-and-ci-gap.md) — circular-import crashes hit `nest start -b swc` but NOT CI (vitest's unplugin-swc misses it); includes the cycle-triage rule. diff --git a/.claude/agent-memory/lt-dev-performance-reviewer/swc-cjs-tdz-and-ci-gap.md b/.claude/agent-memory/lt-dev-performance-reviewer/swc-cjs-tdz-and-ci-gap.md new file mode 100644 index 00000000..0dc771fb --- /dev/null +++ b/.claude/agent-memory/lt-dev-performance-reviewer/swc-cjs-tdz-and-ci-gap.md @@ -0,0 +1,45 @@ +--- +name: swc-cjs-tdz-and-ci-gap +description: Circular-import TDZ crashes only reproduce under SWC->CJS + Node require (nest start -b swc); vitest's unplugin-swc does NOT cover it, so CI is green while dev crashes. Includes the cycle-triage rule. +metadata: + type: project +--- + +Circular imports in this repo can crash `nest start -b swc` with +`ReferenceError: Cannot access 'X' before initialization` while **CI stays green**. + +**Why:** `nest build` uses **tsc** (`nest-cli.json` has no `"builder": "swc"`), so the +published `dist/` never sees SWC semantics. SWC *is* used in two places — the dev +scripts (`start:dev:swc`, `start:local:swc`) and **vitest** (both `vitest.config.ts` +and `vitest-e2e.config.ts` use `unplugin-swc`). But vitest runs SWC output through +**Vite's module runner** (ESM live bindings via getters, cycle-tolerant), *not* +through Node's CJS `require()` on SWC's emitted CJS. The TDZ only manifests in the +**SWC-CJS + Node-CJS-loader** combination that `nest start -b swc` produces, and +**no CI job runs that**. This is how the `BETTER_AUTH_INSTANCE` TDZ bug shipped to +develop with a green pipeline. + +**Cycle-triage rule** — a cycle is only fatal when it carries a **TDZ-subject binding** +(`const` / `class` / `let`) that is **dereferenced during module evaluation**: + +| Binding in the cycle | Deref timing | Fatal? | +|---|---|---| +| `const` (e.g. a DI token string) | module-eval, e.g. inside an `@Inject()` **parameter decorator** | **YES** — this was the bug | +| `class` | deferred into a method / factory body | No, but **latent** — moving the deref to a decorator arg or static initializer reintroduces the crash | +| `export function` | any | No — function declarations are **hoisted**, TDZ-immune | +| type-only (`Type`, interfaces) | erased at compile | No — not a runtime cycle at all (madge reports these as false positives) | + +**How to reproduce/verify a suspected cycle** (cheap, no MongoDB needed): +SWC-compile with `module: { type: "commonjs" }` + legacy decorators + decoratorMetadata, +then plain `node -e "require('/core/modules//.js')"`. Throws on a fatal +cycle, silent otherwise. Diff the same require against a `git worktree` of the base branch +to get a clean A/B. + +**How to apply:** When reviewing anything that touches the import graph in `src/core/`, +do NOT trust green CI as evidence that the SWC path is safe. Run `madge --circular +--extensions ts src/core/...`, then triage each cycle with the table above — most are +benign (`function` / type-only), so report only cycles carrying `const`/`class` bindings. +Known latent one (pre-existing, NOT yet fixed): `better-auth-roles.guard.ts` ↔ +`core-better-auth.module.ts` — a `class` cycle, currently benign only because both +dereference sites sit in deferred function bodies. + +Related: [[ai-module-perf]] diff --git a/.claude/agent-memory/lt-dev-security-reviewer/MEMORY.md b/.claude/agent-memory/lt-dev-security-reviewer/MEMORY.md index c9ac7c35..39eea571 100644 --- a/.claude/agent-memory/lt-dev-security-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-security-reviewer/MEMORY.md @@ -6,4 +6,9 @@ - [feedback-typeof-detection.md](feedback-typeof-detection.md) — typeof detection for CoreModule.forRoot() signature; security classification of Type - [project-ai-module-secret-stripping.md](project-ai-module-secret-stripping.md) — AI module 3-layer apiKeyEncrypted protection + secretFields override fragility; MCP PKCE enforced by SDK - [project-ai-mcp-oauth-refresh-token-binding.md](project-ai-mcp-oauth-refresh-token-binding.md) — CoreAiMcpOAuthService.exchangeRefreshToken ignores rotating client_id; cross-client token theft risk when ai.mcp.oauth=true -- [project-betterauth-native-cookie-forwarding.md](project-betterauth-native-cookie-forwarding.md) — BetterAuth native-handler paths forward Set-Cookie verbatim, bypass the cookie helper's Secure flag; useSecureCookies:false (11.27.6) drops Secure on 2FA/social/magic-link session cookies +- [project-betterauth-native-cookie-forwarding.md](project-betterauth-native-cookie-forwarding.md) — BetterAuth's helper vs native-forward cookie paths; SEC-001 ("useSecureCookies:false strips Secure") is FIXED — do not re-report +- [project-betterauth-di-failclosed-and-cycle-triage.md](project-betterauth-di-failclosed-and-cycle-triage.md) — @Optional() auth-instance injection degrades fail-CLOSED (401, not bypass); how to triage madge cycles as TDZ-risky (decorator-arg) vs benign (method-body) + +## Review Methodology + +- [project-e2e-node-env-trap.md](project-e2e-node-env-trap.md) — e2e without NODE_ENV=e2e fabricates 5 bogus BetterAuth "Invalid credentials" failures; reproduces on base branch too, so a control-diff won't catch it diff --git a/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-di-failclosed-and-cycle-triage.md b/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-di-failclosed-and-cycle-triage.md new file mode 100644 index 00000000..35408dda --- /dev/null +++ b/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-di-failclosed-and-cycle-triage.md @@ -0,0 +1,29 @@ +--- +name: project-betterauth-di-failclosed-and-cycle-triage +description: BetterAuth DI degrades fail-closed (null authInstance -> 401, never bypass); criterion for triaging madge import cycles as TDZ-risky vs benign +metadata: + type: project +--- + +Two durable conclusions from auditing the BetterAuth DI-token extraction (`core-better-auth.constants.ts`, 11.27.6+). + +## 1. BetterAuth DI failure is FAIL-CLOSED, never fail-open + +`CoreBetterAuthService` injects the auth instance with `@Optional() @Inject(BETTER_AUTH_INSTANCE)`. A silently-unresolved token degrades to `authInstance = null`, and that propagates **closed**: + +`isEnabled()` returns `false` -> every session path (`getSession`, `getSessionByToken`, JWT verify) returns `{ session: null, user: null }` -> middleware never sets `req.user` -> `BetterAuthRolesGuard.canActivate()` hits `if (!user) throw new UnauthorizedException(ErrorCode.UNAUTHORIZED)`. + +**Why:** `@Optional()` on a security-critical token *looks* like a fail-open hazard, and that is the first instinct on every review. It is not one here. The worst case is a total 401 outage (availability), not an auth bypass. `S_EVERYONE` endpoints stay public — which is correct, they are public by design. + +**How to apply:** When reviewing any change to BetterAuth DI wiring, injection tokens, or `isEnabled()`, do NOT classify `@Optional()` auth-instance injection as a bypass risk without tracing it. Classify a broken-injection scenario as availability/DoS (Medium at most), not Critical. Re-verify the chain only if someone changes `isEnabled()` to default-true, or makes the guard treat a missing user as permissive. + +## 2. Triaging madge import cycles: decorator-arg vs method-body + +`pnpm dlx madge --circular --extensions ts src/core/modules/better-auth/` reports 4 cycles. Only some are dangerous. The discriminator is **where the cross-cycle binding is dereferenced**: + +- **TDZ-risky (real bug):** binding referenced at *module-evaluation time* — most importantly as a **decorator argument**, since decorators execute at class-definition time. This was the actual SWC crash: `@Inject(BETTER_AUTH_INSTANCE)` in the service's constructor, with the token declared in the module that imports the service. Under tsc/CommonJS it survived on evaluation-order luck (partial-exports gives `undefined`, not a throw); under SWC it raised `ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization`. Note the CommonJS variant is the *scarier* one — a silent `@Inject(undefined)` rather than a loud crash. +- **Benign:** binding referenced only inside a **method/function body**, dereferenced at call time long after both modules finished evaluating. Example: `better-auth-roles.guard.ts > core-better-auth.module.ts` — the guard touches `CoreBetterAuthModule.getTokenServiceInstance()` only inside `getTokenService()`, and the module touches `BetterAuthRolesGuard` only inside `createDeferredModule()`. Safe in both directions; leaving it is fine. + +**Watch item:** the 3 remaining pre-existing cycles include `restricted.decorator.ts > db.helper.ts > input.helper.ts` — that one touches a *security* decorator. Currently benign (`equalIds`/`getIncludedIds` are called inside function bodies), but if anything there ever moves to a decorator argument or top-level const, it becomes the same class of SWC-TDZ bug on a security-critical path. Re-check it whenever `restricted.decorator.ts` is edited. + +Related: [[project-betterauth-native-cookie-forwarding]], [[project-e2e-node-env-trap]]. diff --git a/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-native-cookie-forwarding.md b/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-native-cookie-forwarding.md index 18cdf03a..617f415f 100644 --- a/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-native-cookie-forwarding.md +++ b/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-native-cookie-forwarding.md @@ -1,17 +1,55 @@ --- name: project-betterauth-native-cookie-forwarding -description: BetterAuth native-handler paths forward Set-Cookie verbatim and bypass BetterAuthCookieHelper's Secure flag; useSecureCookies:false drops Secure on those session paths +description: BetterAuth's two cookie-writing paths (helper vs native-forward) and why the "useSecureCookies:false strips Secure" finding (SEC-001) is FIXED — do not re-report it metadata: type: project --- -BetterAuth session cookies in nest-server are written by TWO distinct paths with different transport-security guarantees. This split is durable architecture, not a one-off. +BetterAuth session cookies in nest-server are written by TWO distinct paths. This split is durable +architecture and worth knowing — but the security finding once attached to it (SEC-001) is **RESOLVED**. -- **Helper path** — `BetterAuthCookieHelper.setSessionCookies()` (`core-better-auth-cookie.helper.ts`) sets `secure: isProductionLikeEnv(env)`. Used by the email/password sign-in success path (`processCookies`) and the passkey middleware re-set (`setSessionCookiesFromWebResponse`, `core-better-auth-api.middleware.ts:288`). These get `Secure` in prod. -- **Native-forward path** — `handleBetterAuthPlugins` catch-all (`core-better-auth.controller.ts:897`) and the middleware fallbacks call `authInstance.handler()` then `sendWebResponse()` (`core-better-auth-web.helper.ts:208`), which forwards BetterAuth's `Set-Cookie` **verbatim** (merges, never re-secures). This path serves **2FA verify, social login callback, magic link** (all session-ESTABLISHING) and the READ handlers (2FA enable/disable, passkey register/list, `/token`). +## The two paths (verified, still true) -The reader (`resolveBetterAuthSessionCookieName`, `better-auth-cookie-prefix.helper.ts:57`) only ever resolves the UNPREFIXED `.session_token` — it is NOT `__Secure-`-aware. BetterAuth's own reader IS (`node_modules/better-auth/dist/cookies/index.mjs:212` reads `__Secure- ?? `). +- **Helper path** — `BetterAuthCookieHelper.setSessionCookies()` (`core-better-auth-cookie.helper.ts`), + reached via `processCookies` in `core-better-auth.controller.ts`. Serves only + `CONTROLLER_HANDLED_PATHS` = `/features`, `/sign-in/email`, `/sign-up/email`, `/sign-out`, `/session` + (`core-better-auth-api.middleware.ts:24`). +- **Native-forward path** — everything else: 2FA verify, social callback, magic link, passkey, `/token`. + `authInstance.handler()` → `sendWebResponse()` (`core-better-auth-web.helper.ts:217-221`) forwards + `getSetCookie()` **verbatim** — it never re-secures. So BetterAuth's own cookie attributes ARE the wire bytes. -**Why:** In 11.27.6 this name mismatch was "fixed" by pinning `advanced.useSecureCookies: false` in `better-auth.config.ts`. In BetterAuth's `createCookieGetter` (cookies/index.mjs:21-32) `secure: !!secureCookiePrefix`, so `useSecureCookies:false` sets `secure:false` AND unprefixes — it does NOT merely rename. Result: native-forwarded session cookies ship WITHOUT `Secure` over https in production. The email/password path is unaffected (helper still sets Secure), which is why the code comment / migration guide claimed "confidentiality unaffected" / "Security Updates: None" — accurate only for the helper path. +## SEC-001 is FIXED — do NOT re-report it -**How to apply:** When reviewing any BetterAuth cookie/`Secure`/`SameSite` change, check BOTH paths. A change that looks helper-only (which sets Secure correctly) can still leave native-forwarded session-establishment (2FA/social/magic-link/passkey) insecure. The robust fix is making the reader/writer `__Secure-`-aware in prod (mirror BetterAuth's dual-read) rather than disabling `useSecureCookies` globally. Related: [[project-ai-module-secret-stripping]]. +**Claim (false as of 11.27.6+):** "pinning `advanced.useSecureCookies: false` strips `Secure` from +native-forwarded session cookies over https." + +**Why it is false:** in BetterAuth's `createCookieGetter()` (`node_modules/better-auth/dist/cookies/index.mjs`), +`secure: !!secureCookiePrefix` (line **32**) is only a DEFAULT inside an object literal, and +`...options.advanced?.defaultCookieAttributes` (line **37**) is spread **after** it in the SAME literal — so it +WINS. The cookie NAME (line 30) is derived from `secureCookiePrefix` independently. Name and Secure-attribute are +therefore **decoupled**, which is exactly what lets `better-auth.config.ts:420` inject +`...(secureCookies && { defaultCookieAttributes: { secure: true } })` on an `https://` baseURL: unprefixed NAME +(fixes the 401 split-brain) + `Secure` TRANSPORT flag (preserves confidentiality). + +Runtime-proven `Set-Cookie` from `auth.handler()` (https baseURL, production): +`iam.session_token=…; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax` + +Regression tests: `tests/unit/better-auth-secure-cookies.spec.ts` — incl. a test literally named +`(SEC-001)`, plus library-guard tests that call BetterAuth's own `getCookies()` to pin the spread-order +dependency. + +**Why the stale finding existed:** SEC-001 was real against an INTERMEDIATE state of commit 83b59e1 (only the +pin, no `defaultCookieAttributes`). The fix was folded into the SAME commit before it landed. The review was +never re-verified, so an already-remediated finding survived in memory as if open. + +## Real residual footgun (LOW, still open) + +`better-auth.config.ts:476-479` shallow-merges a consumer's `options.advanced` over the framework's, so a project +setting `advanced.defaultCookieAttributes` for ANY reason (e.g. `{ partitioned: true }`) **wholesale-replaces** +the framework's `{ secure: true }` → `secure:false` on https. Runtime-confirmed. Deep-merging that one key +(`defaultCookieAttributes: { secure: true, ...consumer }`) would close it. + +**How to apply:** when reviewing BetterAuth cookie/`Secure`/`SameSite` changes, still check BOTH paths — but +verify against `createCookieGetter`'s FULL object literal (through line 39), not just the `secure:` line. Before +re-reporting ANY finding carried in memory, re-verify against current code: this one was fixed in the same +commit that introduced it. Related: [[project-betterauth-di-failclosed-and-cycle-triage]]. diff --git a/.claude/agent-memory/lt-dev-security-reviewer/project-e2e-node-env-trap.md b/.claude/agent-memory/lt-dev-security-reviewer/project-e2e-node-env-trap.md new file mode 100644 index 00000000..88ecde35 --- /dev/null +++ b/.claude/agent-memory/lt-dev-security-reviewer/project-e2e-node-env-trap.md @@ -0,0 +1,14 @@ +--- +name: project-e2e-node-env-trap +description: Running the e2e suite without NODE_ENV=e2e fabricates 5 bogus BetterAuth "Invalid credentials" failures — a false-positive CRITICAL trap for security reviewers +metadata: + type: project +--- + +Never invoke the e2e suite as bare `npx vitest run --config vitest-e2e.config.ts`. Always use `pnpm run test:e2e` (or prefix `NODE_ENV=e2e` manually) — the package script is `NODE_ENV=e2e vitest run --config vitest-e2e.config.ts`. + +**Why:** without `NODE_ENV=e2e`, `getEnvironmentConfig()` falls back to `config.local`, and `tests/stories/better-auth-api.story.test.ts` then fails **5 auth tests** with `UnauthorizedException: #LTNS_0010: Invalid credentials` (sign-in, sign-out, 2FA, session-token). These look exactly like a genuine authentication regression and are extremely tempting to report as a Critical/High finding. They are pure invocation artifacts — the same suite is 39/39 green under `NODE_ENV=e2e`. The tell is the generated DB name: `nest-server-local-run-*` instead of an e2e-run DB. + +**How to apply:** In any security review that runs e2e tests, use the package script. If auth tests fail, before writing the finding: (1) check the DB name in the output for `-local-run-`, (2) re-run with `NODE_ENV=e2e`, (3) only then run the base branch as a control. Diffing against the base branch alone does NOT catch this — the bogus failures reproduce identically on `develop`, which makes them look like a real "pre-existing broken auth" issue rather than an artifact. + +Related: [[project-betterauth-di-failclosed-and-cycle-triage]]. diff --git a/.claude/agent-memory/lt-dev-test-reviewer/MEMORY.md b/.claude/agent-memory/lt-dev-test-reviewer/MEMORY.md index c9fc6ebc..7256ae84 100644 --- a/.claude/agent-memory/lt-dev-test-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-test-reviewer/MEMORY.md @@ -1,3 +1,4 @@ - [E2E Test Isolation Model](e2e-isolation-model.md) — how the Vitest e2e suite shares one MongoDB across parallel forks and what that means for collection-wide deleteMany in tests. - [ConfigService Singleton in Tests](configservice-singleton-in-tests.md) — per-file fork isolation makes static ConfigService safe between files; within a file mergeConfig (lodash merge) means arrays don't clear via [] — check test ORDER, not "reset". - [AI Module Test Coverage](ai-module-test-coverage.md) — coverage gaps observed in the AI module review (OAuth 2.1 stores, MCP HTTP session lifecycle, sessionStart/stop hooks, search_tools/ask_user_question execute bodies); brittle regex-on-message assertions caused by mixed raw-string vs ErrorCode throws. +- [Vitest Is Blind to SWC/CJS TDZ](vitest-blind-to-swc-cjs-tdz.md) — vitest (even with unplugin-swc) can NEVER catch import-cycle TDZ crashes; only an SWC→CJS build+require does. Plus: 9 pre-existing cycles, so "zero cycles" tests are a non-starter. diff --git a/.claude/agent-memory/lt-dev-test-reviewer/vitest-blind-to-swc-cjs-tdz.md b/.claude/agent-memory/lt-dev-test-reviewer/vitest-blind-to-swc-cjs-tdz.md new file mode 100644 index 00000000..ac18c9f0 --- /dev/null +++ b/.claude/agent-memory/lt-dev-test-reviewer/vitest-blind-to-swc-cjs-tdz.md @@ -0,0 +1,22 @@ +--- +name: vitest-blind-to-swc-cjs-tdz +description: Why the nest-server vitest suite CANNOT catch SWC/CommonJS TDZ crashes from import cycles — and the cheap build+require guard that can +metadata: + type: project +--- + +The vitest suite is **structurally incapable** of catching `ReferenceError: Cannot access 'X' before initialization` crashes caused by circular imports, even though both vitest configs use `unplugin-swc`. + +**Why:** `unplugin-swc` runs SWC as a *Vite transform*. Vite's module runner rewrites imports into lazy, getter-based `__vite_ssr_import__` accessors, so a cycle resolves through live bindings evaluated on *access*. The TDZ window never opens. By contrast `nest start -b swc` / `nest build -b swc` emit **CommonJS**, and Node's CJS loader executes a partially-initialized module on a cycle → TDZ throw on the `const`. Verified empirically (2026-07-13): the crash trace goes through `Module._compile (node:internal/modules/cjs/loader)`. + +**Consequence:** adding more vitest tests — including a DI-resolution test — can never guard this bug class. Only code compiled to CJS and *executed* reproduces it. `nest build -b swc` alone is useless: it **exits 0** (SWC does no cycle/type checking). The graph must be *loaded*. + +**The cheap guard (verified as a perfect binary discriminator):** +``` +npx nest build -b swc && node -e "require('./dist/index.js')" +``` +Pre-fix → throws the exact production ReferenceError in seconds. Post-fix → loads clean. No MongoDB, no port, no server lifecycle. `@swc/core` + `@swc/cli` are already devDependencies. Must be ordered BEFORE `pnpm run build` in the `check:raw` chain, because `nest build` has `deleteOutDir: true` and `check-server-start.sh` needs the tsc `dist/`. + +**Cycle inventory is NOT zero** — `madge --circular src/core/` reports 9 cycles (2026-07-13). A "zero cycles" assertion is therefore impossible, and a baseline/allowlist test has poor signal: most cycles are benign (type-only or deferred method-body access). The bug is never "a cycle" — it is **a cycle PLUS a module-evaluation-time access** of the cyclic binding. Decorators (`@Inject(TOKEN)`) are evaluated at class-definition = module-eval time, which is exactly what makes them TDZ-lethal; a lazy `Foo.bar()` inside a method body in the same cycle is harmless. + +**How to apply when reviewing:** never accept "all tests green" as evidence that an import-cycle/module-init fix works. Ask for the SWC-CJS load guard. And when grading a cycle, check *where the binding is read* (decorator/top-level = dangerous; method body = safe), not merely whether a cycle exists. See [[e2e-isolation-model]] for the DB-sharing counterpart. diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 4506b2e8..b0cb63ce 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -60,6 +60,62 @@ Key areas: JWT, MongoDB, GraphQL, email, security, static assets - `CheckResponseInterceptor` - Filters restricted fields - `CheckSecurityInterceptor` - Processes `securityCheck()` methods +## DI Token Placement (SWC-Safe) + +**Rule: DI tokens belong in an import-free leaf file (`*.constants.ts` / `*.enums.ts`) — never in `*.module.ts` or `*.service.ts`.** + +A token declared in a module that the service imports (or vice versa) makes the two files import each other. That cycle compiles fine under tsc, but `@Inject(TOKEN)` is a constructor-parameter decorator evaluated at **class-definition time** — so on the cycle it reads a `const` that is still in its temporal dead zone. Under SWC → CommonJS (`nest start -b swc`) the app dies at startup: + +``` +ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization +``` + +**This is invisible to `tsc`, to `pnpm test` (vitest runs SWC through Vite's cycle-tolerant module runner) and to `oxlint` (which has no `import/no-cycle` rule).** It is caught only by `pnpm run check:swc-tdz`. + +### The general rule: a cycle is fatal only when dereferenced at evaluation time + +The cycle alone is survivable. What kills it is **reading a TDZ-subject binding (`const` / `class` / `let`) while the module is still initializing**: + +| Deref location | Evaluated | Danger | +|----------------|-----------|--------| +| Decorator argument (`@UnifiedField({ type: X })`, `@Inject(TOKEN)`) | class-definition time | ☠️ **fatal on a cycle** | +| `design:type` / `design:paramtypes` metadata (from `emitDecoratorMetadata`) | class-definition time | ☠️ **fatal on a cycle** — and userland cannot make it lazy | +| Static / class field initializer | class-definition time | ☠️ **fatal on a cycle** | +| Top-level `const alias = X` | module-evaluation time | ☠️ **fatal on a cycle** | +| Inside a function or method body | call time | ✅ safe (both modules are done by then) | +| `export function` declaration | hoisted | ✅ TDZ-immune — prefer over `const` arrows on cycle-adjacent files | +| `import type` | erased | ✅ not a runtime edge at all | + +**A lazy thunk is often NOT enough.** `type: () => X` defers the decorator argument, but `emitDecoratorMetadata` still emits an eager `design:type` for the property, and SWC's `typeof` guard does not protect the member expression it compiles to. To be safe you must remove the **import edge** — merge the modules, or extract the shared binding into a leaf. + +### `check:swc-tdz` loads every module as its own entry point + +Whether such a cycle throws depends on **which module the graph is entered through**. A barrel-only check is not enough: `filter.input` ↔ `combined-filter.input` crashed on a direct `require()` of `combined-filter.input` while the barrel loaded green, because the barrel happened to pull `filter.input` in first. So the guard requires each compiled file separately (`scripts/check-swc-tdz.mjs`). + +### Status per module + +Repo-wide cycles went from **10 → 6** while fixing this. The six that remain are, per an SWC-emit audit, almost all **not runtime cycles at all** (type-only imports that madge reports but both compilers erase — their emits are empty). + +| Module | Status | +|--------|--------| +| `better-auth` | ✅ `core-better-auth.constants.ts` (tokens) + `core-better-auth.registry.ts` (static service refs, `import type` only) | +| `tenant` | ✅ `core-tenant.enums.ts` | +| `auth` | ✅ `interfaces/auth-provider.interface.ts` | +| `common/inputs` | ✅ `FilterInput` + `CombinedFilterInput` merged into `filter.input.ts` (declaration order is load-bearing). Split apart, a direct `require()` of `combined-filter.input` **crashed** — it was a live bug, masked by the barrel. | +| `common/helpers` | ✅ `id.helper.ts` (ID cluster, extracted from `db.helper`) + `clone.helper.ts` (`clone`/`deepFreeze`, extracted from `input.helper`). Both are true leaves. | +| `common/decorators` | ✅ `restricted.decorator` is now on **zero** cycles. It took BOTH leaves: `id.helper` killed `→ db.helper → input.helper →`, `clone.helper` killed `→ core-tenant.helpers → config.service → input.helper →`. Its three exports are additionally hoisted `function` declarations (TDZ-immune) as defense in depth. | +| `ai` | ⚠️ `core-ai-interaction.service` ↔ `core-ai.service` is kept off a real cycle **only** by an `import type` on `AiInteractionRecord`. An IDE "organize imports" could silently widen it to a value import and arm a `design:paramtypes` deref. Pinned by `tests/unit/import-cycle-invariants.spec.ts`; moving the type to a leaf would settle it. | +| `tus` | ⚠️ `TUS_CONFIG` declared in `tus.module.ts` — currently acyclic, but unguarded | +| `ai` (tokens) | ⚠️ `AI_*_CLASS` / `AI_*_MODEL` declared in 9 `ai/services/*.service.ts` — currently acyclic, but unguarded | + +The ⚠️ entries are each one refactor away from the identical crash. `check:swc-tdz` will catch it if they are ever armed — but move the binding to a leaf when you touch them. + +### The lesson that cost the most time + +Removing one edge is not the same as removing the cycle. `restricted.decorator` was on **two** cycles through different paths; extracting the ID helpers out of `db.helper` felt like the fix and left the second one (via `config.service`) fully intact — with `madge` happily reporting the file as still cyclic. Always re-run `npx madge --circular --extensions ts src/` after an extraction and check the file appears in **zero** cycles, rather than assuming the edge you removed was the only one. + +Full background, failure analysis and the mistakes table: `.claude/rules/better-auth.md` §6. + ## Model Inheritance - `CorePersistenceModel` - Base for database entities diff --git a/.claude/rules/better-auth.md b/.claude/rules/better-auth.md index 97c3c6d5..a972daba 100644 --- a/.claude/rules/better-auth.md +++ b/.claude/rules/better-auth.md @@ -250,6 +250,84 @@ Both guards implement identical security logic: 2. **New system roles** → Add to BOTH guards 3. **Token verification changes** → Update `BetterAuthTokenService` (shared by both) 4. **Testing** → Test both Legacy Mode and IAM-Only Mode +5. **Reaching services from `BetterAuthRolesGuard`** → go through `core-better-auth.registry.ts`, + **never** through `CoreBetterAuthModule` (see §6 — importing the module from the guard + re-creates an import cycle) + +## 6. DI Token Placement (SWC-Safe) + +### The rule + +**DI tokens and static service references belong in an import-free leaf file — never in +`*.module.ts` or `*.service.ts`.** + +| File | Contents | Imports | +|------|----------|---------| +| `core-better-auth.constants.ts` | `BETTER_AUTH_INSTANCE`, `BETTER_AUTH_CONFIG`, `BETTER_AUTH_COOKIE_DOMAIN` | **none** | +| `core-better-auth.registry.ts` | `BetterAuthTokenService` reference for `BetterAuthRolesGuard` | **`import type` only** (erased by tsc and SWC) | + +### The problem + +The tokens used to live in the module (`BETTER_AUTH_INSTANCE`) and the service (`BETTER_AUTH_CONFIG`, +`BETTER_AUTH_COOKIE_DOMAIN`), so module and service imported each other. + +**Error (only under `nest start -b swc` / `nest build -b swc`):** + +``` +ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization +``` + +The lethal ingredient is **not the cycle by itself** — it is a cycle **plus a read of the cyclic +binding at module-evaluation time**. `@Inject(BETTER_AUTH_INSTANCE)` is a constructor-parameter +decorator, and decorator arguments are evaluated when the class is *defined*, i.e. while the module +is still initializing. On a cycle, the importing side then reads a `const` that is still in its +temporal dead zone. + +This is why the same cycle is harmless when both sides only dereference each other **inside method +bodies** (deferred to call time) — and why such a cycle is nonetheless a loaded gun: hoisting a +lazy lookup into a static field, or adding a typed constructor parameter (which emits +`design:paramtypes` at top level), weaponizes it instantly. + +### The solution + +```typescript +// core-better-auth.constants.ts — imports NOTHING +export const BETTER_AUTH_INSTANCE = 'BETTER_AUTH_INSTANCE'; + +// core-better-auth.module.ts AND core-better-auth.service.ts +import { BETTER_AUTH_INSTANCE } from './core-better-auth.constants'; +``` + +A file with zero imports can never be mid-evaluation when someone imports it — in any module +system, under any compiler. + +### Why you cannot rely on the test suite here + +This bug class is **invisible** to everything except one specific step: + +| Tool | Sees it? | Why | +|------|:--------:|-----| +| `tsc` / `pnpm run build` | ❌ | Compiles the cycle without complaint | +| `pnpm test` (vitest) | ❌ | vitest runs SWC through **Vite's module runner**, whose getter-based live bindings tolerate cycles | +| `oxlint` | ❌ | oxlint does **not** implement `import/no-cycle` | +| `pnpm run check:swc-tdz` | ✅ | SWC → CommonJS → `require()`: the exact path consumers hit | +| `tests/unit/better-auth-di-tokens.spec.ts` | ✅ | Asserts the leaf invariant structurally | + +The bug shipped to `develop` with a fully green CI. Treat a green `pnpm test` as **no evidence** on +this question. + +### Common mistakes + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Importing a token from `./core-better-auth.module` or `./core-better-auth.service` inside the better-auth module | Green tsc + green tests; `ReferenceError` for consumers on SWC | Import from `./core-better-auth.constants` | +| Adding any runtime `import` to `core-better-auth.constants.ts` | `better-auth-di-tokens.spec.ts` fails | Keep it a leaf; move whatever you needed elsewhere | +| Importing `CoreBetterAuthModule` into `better-auth-roles.guard.ts` | Re-creates the guard ↔ module cycle | Use `getBetterAuthTokenService()` from `./core-better-auth.registry` | +| Hoisting `getBetterAuthTokenService()` into a static field / class property initializer | Evaluation-time deref → TDZ crash returns | Keep the lookup inside the method body | + +> The backward-compat re-exports in `core-better-auth.module.ts` / `core-better-auth.service.ts` are +> marked `@deprecated` and exist **only** for external deep importers. Never use them from inside +> the module — that is precisely the path that re-creates the cycle. ## Summary @@ -260,3 +338,4 @@ Both guards implement identical security logic: | Testing | Full coverage, all tests pass, security tests included | | Customization | Use correct registration pattern, re-declare Resolver decorators | | Guards | Maintain both RolesGuard and BetterAuthRolesGuard in sync | +| DI Tokens | Import-free leaf file only — never in `*.module.ts` / `*.service.ts` (§6) | diff --git a/CLAUDE.md b/CLAUDE.md index 8891d220..bc512465 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,9 +114,15 @@ npx vitest run --config vitest-e2e.config.ts --reporter=hanging-process # Debu pnpm run test:cleanup # Remove leftover test artifacts (.txt, .bin) # Linting & Formatting -pnpm run lint # ESLint check +pnpm run lint # oxlint check pnpm run lint:fix # Auto-fix -pnpm run format # Prettier format +pnpm run format # oxfmt format + +# Import-cycle / SWC safety (part of `check`) +pnpm run check:swc-tdz # SWC→CJS build, loads EVERY module as its own entry point. + # The only step that catches a temporal-dead-zone crash from an + # import cycle — tsc, vitest and oxlint are all blind to it. + # See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)" # Package Development pnpm run build:dev # Build for local development (use with pnpm link) diff --git a/migration-guides/11.27.6-to-11.27.7.md b/migration-guides/11.27.6-to-11.27.7.md new file mode 100644 index 00000000..e9a6edc9 --- /dev/null +++ b/migration-guides/11.27.6-to-11.27.7.md @@ -0,0 +1,242 @@ +# Migration Guide: 11.27.6 → 11.27.7 + +## Overview + +| Category | Details | +|----------|---------| +| **Breaking Changes** | None | +| **New Features** | None (two new internal leaf modules: `core-better-auth.constants.ts`, `core-better-auth.registry.ts`) | +| **Bugfixes** | **(1)** Applications compiled with **SWC** (`nest start -b swc`, `nest build -b swc`) no longer crash at startup with `ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization` when BetterAuth is enabled. **(2)** A direct import of `core/common/inputs/combined-filter.input` no longer crashes under SWC with `ReferenceError: Cannot access 'CombinedFilterInput' before initialization`. **(3)** A consumer setting `betterAuth.options.advanced.defaultCookieAttributes` no longer silently strips the `Secure` flag from session cookies on an `https://` deployment. | +| **Security** | See bugfix (3). If your project sets `advanced.defaultCookieAttributes` for any reason (cookie partitioning, a custom domain) **and** runs on an `https://` baseURL, your session cookies were being sent without `Secure`. Updating fixes this with no action required. | +| **Deprecations** | The BetterAuth DI tokens re-exported from `core-better-auth.module` / `core-better-auth.service` are now marked deprecated in their docblocks. Import them from `core-better-auth.constants` (or the package root) instead. Both paths keep working. | +| **Migration Effort** | **npm mode:** 0 minutes (automatic) — `pnpm update` is enough. **Vendor mode:** see the note below — the BetterAuth change is an atomic file set. | + +This is a **startup-crash + cookie-security bugfix release**. No source-code or +config changes are required in consuming projects. + +--- + +## Quick Migration + +No code changes required. + +```bash +# Update package +pnpm add @lenne.tech/nest-server@11.27.7 + +# Verify build +pnpm run build + +# Run tests +pnpm test +``` + +--- + +## What's Fixed in 11.27.7 + +### Startup crash under the SWC compiler + +**The symptom.** With BetterAuth enabled and the app started or built via SWC: + +``` +ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization + at core-better-auth.module.ts + at core-better-auth.service.ts +``` + +This affected anyone using `nest start -b swc` / `nest build -b swc` — including +the `start:dev:swc` and `start:local:swc` scripts shipped in the starter. Projects +building with the default `tsc` builder were **not** affected. + +**The bug.** The three BetterAuth dependency-injection tokens were split across two +files that imported each other: + +- `BETTER_AUTH_INSTANCE` was declared in `core-better-auth.module.ts` +- `BETTER_AUTH_CONFIG` and `BETTER_AUTH_COOKIE_DOMAIN` were declared in `core-better-auth.service.ts` + +The service imported the first token from the module, and the module imported the +other two — plus the service class itself — back from the service. A genuine import +cycle. + +The cycle alone would have been survivable. What made it fatal is *when* the token +is read: `@Inject(BETTER_AUTH_INSTANCE)` is a **constructor-parameter decorator**, +and decorator arguments are evaluated when the class is *defined* — that is, while +the module is still initializing. On a cycle, the importing side reads a `const` +that is still in its temporal dead zone. + +Under `tsc`/CommonJS the evaluation order happened to work out. Under SWC it did +not, and the app died at startup. + +**The fix.** All three tokens moved into a new **import-free leaf module**, +`core-better-auth.constants.ts`. A file with zero imports can never be +mid-evaluation when someone imports it — in any module system, under any compiler — +so the initialization order is now deterministic everywhere. + +A second leaf, `core-better-auth.registry.ts`, does the same for the +`BetterAuthTokenService` reference that `BetterAuthRolesGuard` looks up statically, +removing the last import cycle inside the module. As a side effect this also fixes a +test-isolation leak: `CoreBetterAuthModule.reset()` previously did not clear that +reference, so a token service could survive into the next testing module. + +--- + +## Deprecations + +The tokens are still re-exported from their old locations, so **every existing import +keeps working**: + +```typescript +// Still works (now @deprecated) +import { BETTER_AUTH_INSTANCE } from '@lenne.tech/nest-server/dist/core/modules/better-auth/core-better-auth.module'; +import { BETTER_AUTH_CONFIG } from '@lenne.tech/nest-server/dist/core/modules/better-auth/core-better-auth.service'; + +// Preferred +import { BETTER_AUTH_CONFIG, BETTER_AUTH_INSTANCE } from '@lenne.tech/nest-server'; +``` + +Importing from the package root (`@lenne.tech/nest-server`) was already the +recommended path and is unaffected — **most projects need to change nothing.** + +The deprecated re-exports will be removed in a future MINOR. + +> **The token string values are unchanged** (`'BETTER_AUTH_INSTANCE'`, +> `'BETTER_AUTH_CONFIG'`, `'BETTER_AUTH_COOKIE_DOMAIN'`), so `@Inject('BETTER_AUTH_INSTANCE')` +> written as a string literal keeps resolving. A new unit test pins these values +> precisely because they are part of the public contract. + +--- + +## Vendor Mode: the change is an atomic 4-file set + +Projects that vendor the framework core (`projects/api/src/core/`) sync via the +`/lt-dev:backend:update-nest-server-core` agent rather than `pnpm update`. This +change touches four files that **must be adopted together**: + +| File | Change | +|------|--------| +| `core/modules/better-auth/core-better-auth.constants.ts` | **new** — the token leaf | +| `core/modules/better-auth/core-better-auth.registry.ts` | **new** — the token-service leaf | +| `core/modules/better-auth/core-better-auth.module.ts` | imports tokens from the constants leaf; delegates the token-service lookup to the registry | +| `core/modules/better-auth/core-better-auth.service.ts` | imports tokens from the constants leaf | +| `core/modules/better-auth/better-auth-roles.guard.ts` | reads the registry instead of importing the module | +| `core/modules/better-auth/index.ts` | re-exports both new leaves | + +A partial sync that takes the edits to `core-better-auth.module.ts` / +`core-better-auth.service.ts` **without** creating +`core-better-auth.constants.ts` leaves an unresolvable import and a broken build. +If your sync is interrupted, verify both new files exist before building. + +--- + +## Also fixed: `CombinedFilterInput` crashed on a direct import + +Same bug class, different module. `FilterInput` and `CombinedFilterInput` lived in +two files that imported each other, and `FilterInput` referenced +`CombinedFilterInput` **eagerly** — in a decorator argument and in the `design:type` +metadata `emitDecoratorMetadata` emits. Under SWC: + +``` +require('.../core/common/inputs/combined-filter.input.js') +→ ReferenceError: Cannot access 'CombinedFilterInput' before initialization +``` + +This only stayed hidden because importing from the package root pulls `filter.input` +in first. A **deep import** of `combined-filter.input` — plausible in vendor mode or +in a unit test — hit the crash. + +Both classes now live in `filter.input.ts` (declaration order matters), and +`combined-filter.input.ts` re-exports `CombinedFilterInput`. **Every existing import +path still resolves**, including deep imports of either file. + +--- + +## Also fixed (security): `Secure` could be stripped from session cookies + +If a project sets `betterAuth.options.advanced.defaultCookieAttributes` — for cookie +partitioning, a custom domain, anything — that object **wholesale-replaced** the +framework's own `{ secure: true }`, because the merge was shallow. On an `https://` +baseURL the result was session cookies without the `Secure` attribute, forwarded +verbatim by BetterAuth's native handlers (2FA verify, social callback, magic link). +No error, no warning. + +```ts +// Before 11.27.7 — on an https baseURL: +betterAuth: { options: { advanced: { defaultCookieAttributes: { partitioned: true } } } } +// → Set-Cookie: iam.session_token=…; HttpOnly; SameSite=Lax ← Secure GONE +``` + +The merge is now deep for that one key: `secure: true` is the base and your keys +spread over it. An **explicit** `{ secure: false }` still wins — this guards against +silent clobbering, not against a deliberate choice. + +**No action required.** If you set `defaultCookieAttributes` and run on HTTPS, the +fix applies automatically on update. + +--- + +## New: SWC regression guard + +The bug had the worst possible failure profile — **it passed a fully green CI and +crashed only for consumers.** `nest build` uses `tsc`, and vitest runs SWC through +Vite's module runner, whose getter-based live bindings tolerate import cycles. So +neither the build nor 2000+ tests could see it. + +A new check step closes that hole: + +```bash +pnpm run check:swc-tdz # nest build -b swc && node scripts/check-swc-tdz.mjs +``` + +It compiles with SWC to CommonJS and loads **every compiled module as its own entry +point** under Node's CJS loader — the exact path that fails. + +Loading only the package barrel is *not* sufficient, and that is not a theoretical +point: whether such a cycle throws depends on which module the graph is entered +through. The `CombinedFilterInput` crash above was invisible to a barrel-only load, +because the barrel happens to pull `filter.input` in first. Requiring each file +separately is what surfaces it. + +It is wired into `check:raw` (and therefore into `pnpm run check`) and into the CI +workflow. **Worth copying into your own project** if you build or run with SWC: it +costs a few seconds, needs no database, and is the only thing that catches this bug +class. + +--- + +## Compatibility Notes + +| Pattern | Status | +|---------|--------| +| Importing tokens from `@lenne.tech/nest-server` (package root) | ✅ Unchanged | +| Deep-importing tokens from `core-better-auth.module` / `.service` | ✅ Works, now `@deprecated` | +| `@Inject('BETTER_AUTH_INSTANCE')` as a string literal | ✅ Unchanged (values pinned by test) | +| Custom `{ provide: BETTER_AUTH_INSTANCE, useValue: ... }` overrides | ✅ Unchanged | +| Extending `BetterAuthRolesGuard` | ✅ Unchanged public behavior | +| `CoreBetterAuthModule.getTokenServiceInstance()` | ✅ Unchanged (now delegates to the registry) | +| Building with `tsc` (default) | ✅ Was never affected | +| Building with SWC | ✅ **Fixed** | + +--- + +## Troubleshooting + +**`ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization`** +You are still on ≤ 11.27.6 with an SWC build. Update to 11.27.7. + +**Vendor mode: `Cannot find module './core-better-auth.constants'`** +The sync did not create the new leaf file(s). Re-run the core update and confirm +both `core-better-auth.constants.ts` and `core-better-auth.registry.ts` exist in +`src/core/modules/better-auth/`. + +**Deprecation warnings on the token imports** +Expected. Switch the import to the package root (`@lenne.tech/nest-server`) or to +`core-better-auth.constants`. No behavior change either way. + +--- + +## Module Documentation + +- [BetterAuth README](../src/core/modules/better-auth/README.md) +- [BetterAuth Integration Checklist](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) +- [BetterAuth Customization](../src/core/modules/better-auth/CUSTOMIZATION.md) From 13e8860d73dbc6fed1db00cca81419d1743d7d34 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 20:36:47 +0200 Subject: [PATCH 09/21] fix(check): make the SWC guard side-effect free, fail-fast and unfoolable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the guard as first committed — one of which made it capable of reporting GREEN while checking nothing at all. 1. It built into the REAL dist/. `nest-cli.json` sets `deleteOutDir: true`, so `pnpm run check:swc-tdz` wiped the tsc-built artifact and left a partial SWC build in its place — the directory `pnpm start:prod` runs. Inside the check chain the next step rebuilt it, which is why it went unnoticed; standalone it silently destroyed the user's build. It now builds into `dist-swc-tdz/` via its own tsconfig: removed on success, KEPT on failure, because the emitted JS is the evidence (it shows whether the cyclic binding is dereferenced at top level or inside a function body). 2. It could pass having loaded zero modules. An empty or mis-emitted output directory meant `files = []`, no shard had anything to do, no failure was reported, and it printed "0 modules load clean" and exited 0. The one check that exists because tsc, vitest and oxlint are all blind to this bug class could itself go quietly blind. There is now a floor: if fewer than 200 loadable modules are found it fails instead of reporting success. 3. Nothing bounded a forked shard. A module that blocks inside require() — a socket, stdin, or a server boot if an exclusion ever stops matching — would have left the parent in Promise.all forever: a hang with no output and no error. Each shard now has a hard timeout, and `main.js` (whose last line calls `bootstrap()`) is excluded by BASENAME rather than by an absolute path, which would silently stop matching if the emit layout ever shifted. `process.send()` also waits for the flush before exiting, so a healthy shard cannot be misread as a dead one. Being side-effect free also removes the ordering constraint, so the guard moves AHEAD of the test step: a cycle now fails in ~10s instead of after the full suite. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 13 +++--- .gitignore | 3 ++ package.json | 10 ++-- scripts/check-swc-tdz.mjs | 92 ++++++++++++++++++++++++++++++++----- tsconfig.swc-tdz.json | 16 +++++++ 5 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 tsconfig.swc-tdz.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2679105f..0f016f59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,12 +38,13 @@ jobs: - name: Optimize and check run: pnpm run prepublishOnly - # Compiles with SWC to CommonJS and require()s the barrel under Node's CJS - # loader. This is the ONLY step that can catch a temporal-dead-zone crash from - # an import cycle: `nest build` uses tsc, and vitest runs SWC through Vite's - # module runner, whose getter-based live bindings tolerate cycles. Consumers - # running `nest start -b swc` do not get that safety net. - # Runs before Build so the subsequent tsc build cleanly recreates dist/. + # Compiles with SWC to CommonJS and loads every module under Node's CJS loader. + # This is the ONLY step that can catch a temporal-dead-zone crash from an import + # cycle: `nest build` uses tsc, and vitest runs SWC through Vite's module runner, + # whose getter-based live bindings tolerate cycles. Consumers running + # `nest start -b swc` do not get that safety net. + # Builds into its own throwaway dir, so it cannot disturb the dist/ that Build + # produces and Save build uploads. - name: SWC module-graph guard (TDZ) run: pnpm run check:swc-tdz diff --git a/.gitignore b/.gitignore index 05ea2308..f202ecda 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,6 @@ permissions.md # Keep .gitkeep files !/**/.gitkeep + +# Throwaway SWC build used by check:swc-tdz (kept only on failure, for inspecting the emit) +dist-swc-tdz diff --git a/package.json b/package.json index 2ac90ffd..65118875 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lenne.tech/nest-server", - "version": "11.27.6", + "version": "11.27.7", "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).", "keywords": [ "node", @@ -23,10 +23,10 @@ "build:dev": "pnpm run build", "c": "pnpm run check", "check": "node scripts/check.mjs", - "check:raw": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm test && pnpm run check:swc-tdz && pnpm run build && bash scripts/check-server-start.sh", - "check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm test && pnpm run check:swc-tdz && pnpm run build && bash scripts/check-server-start.sh", - "check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm test && pnpm run check:swc-tdz && pnpm run build && bash scripts/check-server-start.sh", - "check:swc-tdz": "nest build -b swc && node scripts/check-swc-tdz.mjs", + "check:raw": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh", + "check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh", + "check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh", + "check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs", "cf": "pnpm run check:fix", "cnaf": "pnpm run check:naf", "docs": "pnpm run docs:ci && open http://127.0.0.1:8080/ && open ./public/index.html && compodoc -p tsconfig.json -s ", diff --git a/scripts/check-swc-tdz.mjs b/scripts/check-swc-tdz.mjs index 05123a4f..f5e62952 100644 --- a/scripts/check-swc-tdz.mjs +++ b/scripts/check-swc-tdz.mjs @@ -45,15 +45,25 @@ * Brute force is the feature. */ import { fork } from 'node:child_process'; -import { existsSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'; import { createRequire } from 'node:module'; import { availableParallelism } from 'node:os'; -import { dirname, join, relative, sep } from 'node:path'; +import { basename, dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const SELF = fileURLToPath(import.meta.url); const ROOT = join(dirname(SELF), '..'); -const DIST = join(ROOT, 'dist'); + +/** + * A THROWAWAY output dir, deliberately not `dist/`. + * + * `dist/` is the real artifact: tsc-built, plus the copied types, templates and type references that + * `pnpm run build` adds, and it is what `pnpm start:prod` runs. Writing the SWC build there meant a + * standalone `pnpm run check:swc-tdz` silently replaced the user's build with a partial SWC one, and + * it coupled the check chain to a specific step order for no reason. With its own directory the + * guard is side-effect free and order-independent. See tsconfig.swc-tdz.json. + */ +const DIST = join(ROOT, 'dist-swc-tdz'); /** * Two kinds of file are deliberately not loaded. Both are excluded because requiring them proves @@ -69,10 +79,30 @@ const DIST = join(ROOT, 'dist'); * entire import graph is covered anyway: every module it pulls in is checked individually. */ const EXCLUDED_DIR_SEGMENTS = ['/templates/']; -const EXCLUDED_FILES = [join(DIST, 'main.js')]; + +// Matched by BASENAME, not by an absolute path. A path literal (`join(DIST, 'main.js')`) silently +// stops matching the moment the emit layout shifts (e.g. to `/src/main.js`) — and the failure +// mode of missing this one is not a red check, it is a child that BOOTS A NEST SERVER and hangs. +const EXCLUDED_BASENAMES = ['main.js']; + +/** + * A floor on how many modules must actually be loaded. + * + * Without it, an empty or mis-emitted output directory means `files = []`, every shard gets nothing + * to do, no failure is reported, and the script prints "0 modules load clean" and exits 0. The one + * check that exists because everything else is blind to this bug class would itself go quietly + * blind. The number is a sanity floor, not a target — the tree has ~325 modules. + */ +const MIN_EXPECTED_MODULES = 200; + +/** + * Hard ceiling per shard. A healthy shard finishes in a couple of seconds; this only ever fires when + * a module blocks inside `require()`, which must fail loudly rather than hang the whole check. + */ +const SHARD_TIMEOUT_MS = 120_000; function isExcluded(file) { - if (EXCLUDED_FILES.includes(file)) { + if (EXCLUDED_BASENAMES.includes(basename(file))) { return true; } const posix = file.split(sep).join('/'); @@ -118,15 +148,19 @@ if (process.send && process.env.SWC_TDZ_SHARD) { } } - process.send({ failures }); - process.exit(0); + // Exit only once the message is actually flushed. `process.exit()` immediately after `send()` can + // truncate it on some platforms, and the parent treats a missing report as a dead shard — a + // flaky red on a healthy run. + process.send({ failures }, () => process.exit(0)); } // --------------------------------------------------------------------------------------------- // Parent: shard the file list, fan out, aggregate. // --------------------------------------------------------------------------------------------- if (!existsSync(DIST)) { - process.stderr.write('[swc-tdz] dist/ not found — run the SWC build first.\n'); + process.stderr.write( + `[swc-tdz] ${relative(ROOT, DIST)}/ not found — run \`nest build -b swc -p tsconfig.swc-tdz.json\` first.\n`, + ); process.exit(1); } @@ -139,6 +173,17 @@ for (const file of excluded) { process.stdout.write(`[swc-tdz] skipped (not a loadable library module): ${relative(ROOT, file)}\n`); } +// A guard that checked nothing must not report success. See MIN_EXPECTED_MODULES. +if (files.length < MIN_EXPECTED_MODULES) { + process.stderr.write( + `\n[swc-tdz] only ${files.length} loadable modules found in ${relative(ROOT, DIST)}/ ` + + `(expected at least ${MIN_EXPECTED_MODULES}).\n` + + ' Refusing to report success on a build that looks empty or mis-emitted — this guard is the\n' + + ' only thing that sees SWC temporal-dead-zone crashes, so it must never pass by accident.\n\n', + ); + process.exit(1); +} + /** * Capped at 4 on purpose, not set to the core count. * @@ -176,17 +221,35 @@ const results = await Promise.all( stdio: ['ignore', 'ignore', 'ignore', 'ipc'], }); + // A child can BLOCK inside require() — a module that opens a socket, waits on stdin, or + // (if an exclusion ever stops matching) boots a server. With no timer the parent would sit + // in Promise.all forever, and the check would hang with no output and no error: exactly the + // failure mode this guard exists to make impossible. Fail loudly instead. + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject( + new Error( + `shard timed out after ${SHARD_TIMEOUT_MS / 1000}s — a module is blocking inside require(). ` + + 'Loading it must not start a server, open a socket, or wait on input.', + ), + ); + }, SHARD_TIMEOUT_MS); + let reported = null; child.on('message', (message) => { reported = message; }); - child.on('error', reject); + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); child.on('exit', (code) => { + clearTimeout(timer); if (reported) { resolve(reported.failures); } else { // A child that dies without reporting is itself a finding: some module in its slice took - // the process down (a top-level crash, an `process.exit`, an OOM). Never swallow it. + // the process down (a top-level crash, a `process.exit`, an OOM). Never swallow it. reject(new Error(`shard died without reporting (exit code ${code})`)); } }); @@ -207,11 +270,18 @@ if (failures.length) { '\n A "Cannot access \'X\' before initialization" means an import cycle is dereferenced at\n' + ' module-evaluation time (decorator argument, design:type metadata, static field initializer).\n' + ' Break the cycle — a lazy thunk is usually NOT enough, because emitDecoratorMetadata still\n' + - ' emits an eager design:type. See .claude/rules/architecture.md.\n\n', + ' emits an eager design:type. See .claude/rules/architecture.md.\n\n' + + ` The failing SWC build is kept in ${relative(ROOT, DIST)}/ so you can inspect the emit.\n\n`, ); + // Deliberately NOT cleaned up on failure: the emitted JS is the evidence — it shows exactly where + // the cyclic binding is dereferenced (top-level statement vs. inside a function body). process.exit(1); } +// Clean up on success: this directory is a throwaway, and leaving a second build tree around invites +// someone to mistake it for the real one. +rmSync(DIST, { force: true, recursive: true }); + process.stdout.write( `[swc-tdz] ${files.length} modules load clean as standalone entry points (${shardCount} shards)\n`, ); diff --git a/tsconfig.swc-tdz.json b/tsconfig.swc-tdz.json new file mode 100644 index 00000000..25dcef5c --- /dev/null +++ b/tsconfig.swc-tdz.json @@ -0,0 +1,16 @@ +{ + // Throwaway build config for `pnpm run check:swc-tdz`. + // + // The guard compiles with SWC and loads the result under Node's CommonJS loader. It must NOT write + // into `dist/`: that directory is the real, tsc-built artifact (plus the copied types, templates + // and type references that `pnpm run build` adds), and `pnpm start:prod` runs `dist/main.js`. + // Pointing the SWC build at `dist/` meant a standalone `pnpm run check:swc-tdz` silently replaced + // the user's build with a partial SWC one — and it forced the check chain into a specific order + // (guard before build) for no good reason. + // + // With its own outDir the guard is side-effect free and order-independent. + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-swc-tdz" + } +} From eb9f5399b2052ae770049cc233841e3ce80ea07e Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 20:37:07 +0200 Subject: [PATCH 10/21] fix(better-auth): keep the token-service registry out of the public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry was re-exported through the module barrel, which put `setBetterAuthTokenService()` on the package's public surface — letting a consumer swap or null the very token service `BetterAuthRolesGuard` makes its authorization decisions with. In a PATCH release, no less. It is module-internal plumbing: it exists only so the guard can reach BetterAuthTokenService without importing CoreBetterAuthModule (the cycle). Nothing outside the module needs it, and consumers that want the instance already have the public, read-only `CoreBetterAuthModule.getTokenServiceInstance()`. With it dropped, the package root exports exactly what it did on develop — 472 symbols, zero added, zero removed. Verified by building both refs and diffing. Also pins the backward-compat re-exports. Every symbol moved to a leaf in this branch stays reachable only because its old home re-exports it, and this package has no `exports` map — consumers deep-import these files directly. Those re-export lines carry a public contract while looking exactly like dead code to anyone tidying up imports, and deleting one would break every deep importer with nothing turning red: the symbol is still exported from the barrel, so the package-root tests stay green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/modules/better-auth/index.ts | 9 +++- tests/unit/import-cycle-invariants.spec.ts | 51 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/core/modules/better-auth/index.ts b/src/core/modules/better-auth/index.ts index c007c87b..52e97b27 100644 --- a/src/core/modules/better-auth/index.ts +++ b/src/core/modules/better-auth/index.ts @@ -43,6 +43,13 @@ export * from './core-better-auth.constants'; export * from './core-better-auth.controller'; export * from './core-better-auth.middleware'; export * from './core-better-auth.module'; -export * from './core-better-auth.registry'; +// NOT exported: './core-better-auth.registry'. +// +// It is module-internal plumbing that only exists so BetterAuthRolesGuard can reach +// BetterAuthTokenService without importing CoreBetterAuthModule (see .claude/rules/better-auth.md +// §6). Re-exporting it would put `setBetterAuthTokenService()` on the public API — letting a +// consumer swap or null the very token service the guard makes its authorization decisions with. +// Consumers that need the instance already have the public, read-only +// `CoreBetterAuthModule.getTokenServiceInstance()`. export * from './core-better-auth.resolver'; export * from './core-better-auth.service'; diff --git a/tests/unit/import-cycle-invariants.spec.ts b/tests/unit/import-cycle-invariants.spec.ts index d76a72fa..8c16d1e9 100644 --- a/tests/unit/import-cycle-invariants.spec.ts +++ b/tests/unit/import-cycle-invariants.spec.ts @@ -161,6 +161,57 @@ describe('SWC/TDZ import-cycle invariants', () => { }); }); + describe('the backward-compat re-exports must not be "cleaned up"', () => { + /** + * Moving a symbol to a leaf only stays non-breaking because its old home re-exports it. This + * package has no `exports` map and ships `src/**` as well as `dist/**`, so consumers CAN and DO + * deep-import these files directly — `from '@lenne.tech/nest-server/dist/core/common/helpers/db.helper'` + * resolves. + * + * Every one of these re-export lines therefore carries a public contract, while looking exactly + * like dead code to anyone tidying up imports. Deleting one is a silent breaking change for every + * deep importer, and nothing else in the suite would notice: the symbol is still exported from + * the package root, so the barrel tests stay green. + */ + it.each([ + ['helpers/db.helper.ts', ['equalIds', 'getIncludedIds', 'getObjectIds', 'getStringIds'], './id.helper'], + ['helpers/input.helper.ts', ['clone', 'deepFreeze'], './clone.helper'], + ])('%s still re-exports %j', (file, symbols, from) => { + const source = read('core', 'common', ...file.split('/')); + const reExport = new RegExp(`export \\{[^}]*\\} from '${from.replace('.', '\\.')}'`); + const line = source.match(reExport)?.[0] ?? ''; + + expect(line, `${file} must re-export from ${from}`).not.toBe(''); + for (const symbol of symbols as string[]) { + expect(line, `${file} dropped the re-export of ${symbol}`).toContain(symbol); + } + }); + + it.each([ + ['core-better-auth.module.ts', ['BETTER_AUTH_INSTANCE']], + ['core-better-auth.service.ts', ['BETTER_AUTH_CONFIG', 'BETTER_AUTH_COOKIE_DOMAIN']], + ])('%s still re-exports its original tokens (and only those)', (file, symbols) => { + const source = read('core', 'modules', 'better-auth', file); + const line = source.match(/export \{[^}]*\} from '\.\/core-better-auth\.constants'/)?.[0] ?? ''; + + expect(line, `${file} must keep its compat token re-export`).not.toBe(''); + for (const symbol of symbols as string[]) { + expect(line, `${file} dropped the re-export of ${symbol}`).toContain(symbol); + } + // …and only those: widening this back out gives three valid import paths per token, which is + // precisely how the original module <-> service cycle was born. + const exported = line.match(/\{([^}]*)\}/)?.[1].split(',').map((s) => s.trim()) ?? []; + expect(exported.sort()).toEqual([...(symbols as string[])].sort()); + }); + + it('the registry stays OUT of the better-auth barrel (setBetterAuthTokenService is not public API)', () => { + // Exporting it would let a consumer swap or null the token service that BetterAuthRolesGuard + // makes its authorization decisions with. + const barrel = read('core', 'modules', 'better-auth', 'index.ts'); + expect(barrel).not.toMatch(/^export \* from '\.\/core-better-auth\.registry'/m); + }); + }); + describe('AI services — the type-only import is load-bearing', () => { /** * `core-ai-interaction.service` ↔ `core-ai.service` is kept off a real runtime cycle by ONE From aba4bd5e8c8a06f14c8677abf0784a3b095177c4 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 20:37:07 +0200 Subject: [PATCH 11/21] docs: fix the rule that still showed the forbidden pattern, complete the guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .claude/rules/better-auth.md §5 still showed `CoreBetterAuthModule.getTokenServiceInstance()` inside the BetterAuthRolesGuard sample, while §6 — added in this same branch — lists importing the module into the guard as the mistake that re-creates the cycle. An agent copying §5 would have re-armed the bug the branch just fixed. The sample now reads the registry, with a note on when the static accessor is still fine (everywhere except the guard). - The migration guide documented ONE atomic file set for vendor-mode consumers. There are three: the BetterAuth tokens, the core helpers (id.helper / clone.helper — a sync that takes db.helper without id.helper gives an unresolvable import, exactly the hazard the guide already warned about elsewhere), and the filter inputs. All three are now listed, along with an explicit warning not to "clean up" the re-export lines that hold backward compatibility together. - The guide called the new leaves "internal". Now that the registry is genuinely internal (not barrel-exported) and the others are not, it says which is which — and records that the public API is unchanged, verified by diffing the export surface of both versions. FRAMEWORK-API.md regenerated for the version bump. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/rules/better-auth.md | 11 +++- FRAMEWORK-API.md | 2 +- migration-guides/11.27.6-to-11.27.7.md | 84 ++++++++++++++++++++------ 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/.claude/rules/better-auth.md b/.claude/rules/better-auth.md index a972daba..5a4a16bf 100644 --- a/.claude/rules/better-auth.md +++ b/.claude/rules/better-auth.md @@ -199,6 +199,8 @@ The BetterAuth module provides two RolesGuard implementations: **Solution:** `BetterAuthRolesGuard` with NO constructor dependencies: ```typescript +import { getBetterAuthTokenService } from './core-better-auth.registry'; + @Injectable() export class BetterAuthRolesGuard implements CanActivate { // NO constructor dependencies - avoids mixin DI conflict @@ -207,14 +209,19 @@ export class BetterAuthRolesGuard implements CanActivate { // Use Reflect.getMetadata directly (not NestJS Reflector) const roles = Reflect.getMetadata('roles', context.getHandler()); - // Access services via static module reference - const tokenService = CoreBetterAuthModule.getTokenServiceInstance(); + // Read the token service from the registry LEAF — never from CoreBetterAuthModule. + // Importing the module here re-creates the guard <-> module import cycle (see §6). + const tokenService = getBetterAuthTokenService(); // ... role checking logic identical to RolesGuard } } ``` +> The guard must NOT do `CoreBetterAuthModule.getTokenServiceInstance()`. That static accessor is +> still public API and still works — but calling it *from the guard* means importing the module, +> which is exactly the cycle §6 exists to prevent. Everywhere else, the static accessor is fine. + ### Guard Selection Logic In `CoreBetterAuthModule.createDeferredModule()`: diff --git a/FRAMEWORK-API.md b/FRAMEWORK-API.md index 7f966530..8ae0287a 100644 --- a/FRAMEWORK-API.md +++ b/FRAMEWORK-API.md @@ -1,6 +1,6 @@ # @lenne.tech/nest-server — Framework API Reference -> Auto-generated from source code on 2026-07-11 (v11.27.6) +> Auto-generated from source code on 2026-07-14 (v11.27.7) > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code ## CoreModule.forRoot() diff --git a/migration-guides/11.27.6-to-11.27.7.md b/migration-guides/11.27.6-to-11.27.7.md index e9a6edc9..90de52b3 100644 --- a/migration-guides/11.27.6-to-11.27.7.md +++ b/migration-guides/11.27.6-to-11.27.7.md @@ -5,7 +5,8 @@ | Category | Details | |----------|---------| | **Breaking Changes** | None | -| **New Features** | None (two new internal leaf modules: `core-better-auth.constants.ts`, `core-better-auth.registry.ts`) | +| **New Features** | None. Four new leaf modules (`core-better-auth.constants.ts`, `core-better-auth.registry.ts`, `id.helper.ts`, `clone.helper.ts`) — the tokens and helpers they hold were already public and stay reachable from their old locations. The registry is module-internal and deliberately **not** exported. | +| **Public API** | Unchanged. Verified by diffing the full export surface of `src/index.ts` on both versions: **nothing removed, nothing renamed**. | | **Bugfixes** | **(1)** Applications compiled with **SWC** (`nest start -b swc`, `nest build -b swc`) no longer crash at startup with `ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization` when BetterAuth is enabled. **(2)** A direct import of `core/common/inputs/combined-filter.input` no longer crashes under SWC with `ReferenceError: Cannot access 'CombinedFilterInput' before initialization`. **(3)** A consumer setting `betterAuth.options.advanced.defaultCookieAttributes` no longer silently strips the `Secure` flag from session cookies on an `https://` deployment. | | **Security** | See bugfix (3). If your project sets `advanced.defaultCookieAttributes` for any reason (cookie partitioning, a custom domain) **and** runs on an `https://` baseURL, your session cookies were being sent without `Secure`. Updating fixes this with no action required. | | **Deprecations** | The BetterAuth DI tokens re-exported from `core-better-auth.module` / `core-better-auth.service` are now marked deprecated in their docblocks. Import them from `core-better-auth.constants` (or the package root) instead. Both paths keep working. | @@ -107,25 +108,50 @@ The deprecated re-exports will be removed in a future MINOR. --- -## Vendor Mode: the change is an atomic 4-file set +## Vendor Mode: three atomic file sets Projects that vendor the framework core (`projects/api/src/core/`) sync via the -`/lt-dev:backend:update-nest-server-core` agent rather than `pnpm update`. This -change touches four files that **must be adopted together**: +`/lt-dev:backend:update-nest-server-core` agent rather than `pnpm update`. + +Every fix in this release works the same way: a symbol moved into a new **leaf** +file, and its old home now re-exports it. That makes each fix an **atomic set** — +adopt the edits without the new file and you get an unresolvable import and a broken +build. **Four files are new.** If a sync is interrupted, verify all four exist before +building. + +**Set 1 — BetterAuth DI tokens** | File | Change | |------|--------| -| `core/modules/better-auth/core-better-auth.constants.ts` | **new** — the token leaf | -| `core/modules/better-auth/core-better-auth.registry.ts` | **new** — the token-service leaf | -| `core/modules/better-auth/core-better-auth.module.ts` | imports tokens from the constants leaf; delegates the token-service lookup to the registry | -| `core/modules/better-auth/core-better-auth.service.ts` | imports tokens from the constants leaf | +| `core/modules/better-auth/core-better-auth.constants.ts` | 🆕 the token leaf | +| `core/modules/better-auth/core-better-auth.registry.ts` | 🆕 the token-service leaf | +| `core/modules/better-auth/core-better-auth.module.ts` | takes tokens from the constants leaf; delegates the token-service lookup to the registry | +| `core/modules/better-auth/core-better-auth.service.ts` | takes tokens from the constants leaf | | `core/modules/better-auth/better-auth-roles.guard.ts` | reads the registry instead of importing the module | -| `core/modules/better-auth/index.ts` | re-exports both new leaves | +| `core/modules/better-auth/index.ts` | exports the constants leaf (the registry is deliberately **not** exported — it is module-internal) | + +**Set 2 — core helpers** (this is what takes `restricted.decorator` off its cycles) + +| File | Change | +|------|--------| +| `core/common/helpers/id.helper.ts` | 🆕 the ID cluster (`equalIds`, `getIncludedIds`, `getStringIds`, `getObjectIds`) | +| `core/common/helpers/clone.helper.ts` | 🆕 `clone`, `deepFreeze` | +| `core/common/helpers/db.helper.ts` | ID cluster removed; **re-exports it** from `id.helper` | +| `core/common/helpers/input.helper.ts` | `clone`/`deepFreeze` removed; **re-exports them** from `clone.helper`; takes `equalIds` from `id.helper` | +| `core/common/services/config.service.ts` | takes `clone`/`deepFreeze` from `clone.helper` | +| `core/common/decorators/restricted.decorator.ts` | takes the ID helpers from `id.helper`; its three exports become hoisted `function` declarations | + +**Set 3 — filter inputs** + +| File | Change | +|------|--------| +| `core/common/inputs/filter.input.ts` | now declares **both** `CombinedFilterInput` (first) and `FilterInput` — the order is load-bearing | +| `core/common/inputs/combined-filter.input.ts` | reduced to a re-export shim | -A partial sync that takes the edits to `core-better-auth.module.ts` / -`core-better-auth.service.ts` **without** creating -`core-better-auth.constants.ts` leaves an unresolvable import and a broken build. -If your sync is interrupted, verify both new files exist before building. +> The re-export lines in `db.helper.ts`, `input.helper.ts`, `combined-filter.input.ts`, +> `core-better-auth.module.ts` and `core-better-auth.service.ts` look like dead code +> and are not: they are what keeps every existing deep import working. **Do not +> "clean them up".** The framework pins them with a test for exactly this reason. --- @@ -185,7 +211,8 @@ neither the build nor 2000+ tests could see it. A new check step closes that hole: ```bash -pnpm run check:swc-tdz # nest build -b swc && node scripts/check-swc-tdz.mjs +pnpm run check:swc-tdz +# = nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs ``` It compiles with SWC to CommonJS and loads **every compiled module as its own entry @@ -197,10 +224,31 @@ through. The `CombinedFilterInput` crash above was invisible to a barrel-only lo because the barrel happens to pull `filter.input` in first. Requiring each file separately is what surfaces it. -It is wired into `check:raw` (and therefore into `pnpm run check`) and into the CI -workflow. **Worth copying into your own project** if you build or run with SWC: it -costs a few seconds, needs no database, and is the only thing that catches this bug -class. +It builds into its own throwaway directory (`dist-swc-tdz/`, removed on success, +kept on failure so you can inspect the emit), so it never touches your real `dist/`. +It is wired into `check:raw` — ahead of the test step, so a cycle fails in seconds +rather than after the full suite — and into the CI workflow. + +### Copying it into your own project + +**Worth doing if you build or run with SWC** (`nest start -b swc`, `start:dev:swc`): +it costs a few seconds, needs no database, and is the only thing that catches this +bug class. + +Note that `scripts/` is **not** part of the published npm package, so you cannot +copy it out of `node_modules`. Take the three pieces from the repository: + +- [`scripts/check-swc-tdz.mjs`](https://github.com/lenneTech/nest-server/blob/develop/scripts/check-swc-tdz.mjs) +- [`tsconfig.swc-tdz.json`](https://github.com/lenneTech/nest-server/blob/develop/tsconfig.swc-tdz.json) +- the `check:swc-tdz` script entry in `package.json`, plus `dist-swc-tdz` in `.gitignore` + +Then add `pnpm run check:swc-tdz` to your own `check` chain (before the tests, so it +fails fast) and to CI. + +> **Vendor-mode projects should treat this as required, not optional.** You copy +> `src/core/` into your own tree, where a local edit can re-introduce exactly this +> kind of cycle — and your `tsc` build and your test suite will both stay green while +> it does. --- From 313c6e8895c7280a77a205167862d8dc61d86275 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 21:19:47 +0200 Subject: [PATCH 12/21] fix(core): move every remaining DI token into an import-free leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leaf rule this branch introduced shipped with ten known violations of itself: `TUS_CONFIG` in `tus.module.ts`, and 22 `AI_*` tokens spread across eleven `ai/services/*.service.ts` files. None crashed — their graphs happened to be acyclic — but "happens to be acyclic" is not a safety property, it is luck that nothing is watching: - `@Inject(TOKEN)` is a constructor-parameter decorator, evaluated at class-definition time. A token declared in the file that also gets imported back is one back-import away from being read inside its own temporal dead zone. - `tsc`, `pnpm test` and `oxlint` are ALL blind to that. Only `pnpm run check:swc-tdz` sees it, and only after the fact. TUS_CONFIG now lives in `tus.constants.ts`, the AI tokens in `core-ai.constants.ts`. Both files import nothing, so they can never be mid-evaluation when someone imports them, under any compiler. Also removes the last real cycle in the AI module. `core-ai.service` imports `core-ai-interaction.service` (it needs the class), and the interaction service needed the `AiInteractionRecord` type back — a cycle held apart by ONE keyword: the `import type`, which both compilers erase. `core-ai.service` has a constructor `@Inject`, so `decoratorMetadata` emits `design:paramtypes` at module-evaluation time: an IDE "organize imports" widening that import would have armed the crash silently. The type moved to its own interface leaf, so the edge is gone rather than merely erased. Every old location re-exports what it lost. Public API is byte-identical: 472 exports on develop, 472 here, zero added, zero removed — verified by building both refs and diffing. Repo-wide cycles: 6 -> 5. The five that remain are type-only imports madge reports but both compilers erase (empty emits) — not runtime cycles at all. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/modules/ai/core-ai.constants.ts | 92 +++++++++++++++++++ src/core/modules/ai/index.ts | 1 + .../ai-interaction-record.interface.ts | 34 +++++++ .../ai/services/core-ai-budget.service.ts | 12 ++- .../core-ai-connection-preference.service.ts | 12 ++- .../ai/services/core-ai-connection.service.ts | 11 +-- .../services/core-ai-conversation.service.ts | 11 +-- .../services/core-ai-interaction.service.ts | 13 ++- .../ai/services/core-ai-mode.service.ts | 10 +- .../services/core-ai-prompt-hint.service.ts | 11 ++- .../ai/services/core-ai-prompt.service.ts | 10 +- .../ai/services/core-ai-slot.service.ts | 11 ++- .../ai/services/core-ai-tool-grant.service.ts | 12 ++- .../services/core-ai-tool-policy.service.ts | 12 ++- .../modules/ai/services/core-ai.service.ts | 19 ++-- src/core/modules/tus/tus.constants.ts | 25 +++++ src/core/modules/tus/tus.module.ts | 9 +- 17 files changed, 245 insertions(+), 60 deletions(-) create mode 100644 src/core/modules/ai/core-ai.constants.ts create mode 100644 src/core/modules/ai/interfaces/ai-interaction-record.interface.ts create mode 100644 src/core/modules/tus/tus.constants.ts diff --git a/src/core/modules/ai/core-ai.constants.ts b/src/core/modules/ai/core-ai.constants.ts new file mode 100644 index 00000000..3c84fd03 --- /dev/null +++ b/src/core/modules/ai/core-ai.constants.ts @@ -0,0 +1,92 @@ +/** + * Dependency-injection tokens of the AI module. + * + * They live in a dedicated, import-free leaf file — never in a service or in `core-ai.module.ts` — + * so that no file needing a token has to import the file that declares it, and no import cycle can + * form around one. + * + * On a cycle, a token read at MODULE-EVALUATION time — inside an `@Inject()` decorator argument, in + * the `design:paramtypes` metadata `emitDecoratorMetadata` emits, or in a static field initializer — + * reads a `const` still in its temporal dead zone, and SWC-compiled builds (`nest start -b swc`) die + * at startup with: + * + * ReferenceError: Cannot access 'AI_CONNECTION_MODEL' before initialization + * + * These tokens were previously declared across eleven `*.service.ts` files, every one of which is + * imported by `core-ai.module.ts` and injects tokens through constructor decorators. Nothing had + * crashed — the graph happened to be acyclic — but a single back-import from any of those services + * would have armed it, and `tsc`, `pnpm test` and `oxlint` are ALL blind to that. Only + * `pnpm run check:swc-tdz` sees it. + * + * A file that imports nothing can never be mid-evaluation when someone imports it, in any module + * system, under any compiler. Keep this file import-free. + * + * The old locations re-export their tokens, so every existing import path still resolves. + * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)". + */ + +// ---- budget ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_BUDGET_LIMIT_CLASS = 'AI_BUDGET_LIMIT_CLASS'; +/** Mongoose injection token. */ +export const AI_BUDGET_LIMIT_MODEL = 'AiBudgetLimit'; + +// ---- connection-preference ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_CONNECTION_PREFERENCE_CLASS = 'AI_CONNECTION_PREFERENCE_CLASS'; +/** Mongoose injection token. */ +export const AI_CONNECTION_PREFERENCE_MODEL = 'AiConnectionPreference'; + +// ---- connection ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_CONNECTION_CLASS = 'AI_CONNECTION_CLASS'; +/** Mongoose injection token. */ +export const AI_CONNECTION_MODEL = 'AiConnection'; + +// ---- conversation ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_CONVERSATION_CLASS = 'AI_CONVERSATION_CLASS'; +/** Mongoose injection token. */ +export const AI_CONVERSATION_MODEL = 'AiConversation'; + +// ---- interaction ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_INTERACTION_CLASS = 'AI_INTERACTION_CLASS'; +/** Mongoose injection token. */ +export const AI_INTERACTION_MODEL = 'AiInteraction'; + +// ---- mode ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_MODE_CLASS = 'AI_MODE_CLASS'; +/** Mongoose injection token. */ +export const AI_MODE_MODEL = 'AiMode'; + +// ---- prompt-hint ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_PROMPT_HINT_CLASS = 'AI_PROMPT_HINT_CLASS'; +/** Mongoose injection token. */ +export const AI_PROMPT_HINT_MODEL = 'AiPromptHint'; + +// ---- prompt ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_PROMPT_CLASS = 'AI_PROMPT_CLASS'; +/** Mongoose injection token. */ +export const AI_PROMPT_MODEL = 'AiPrompt'; + +// ---- slot ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_SLOT_CLASS = 'AI_SLOT_CLASS'; +/** Mongoose injection token. */ +export const AI_SLOT_MODEL = 'AiSlot'; + +// ---- tool-grant ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_TOOL_GRANT_CLASS = 'AI_TOOL_GRANT_CLASS'; +/** Mongoose injection token. */ +export const AI_TOOL_GRANT_MODEL = 'AiToolGrant'; + +// ---- tool-policy ---- +/** DI token for the model constructor (used by CrudService mapping). */ +export const AI_TOOL_POLICY_CLASS = 'AI_TOOL_POLICY_CLASS'; +/** Mongoose injection token. */ +export const AI_TOOL_POLICY_MODEL = 'AiToolPolicy'; diff --git a/src/core/modules/ai/index.ts b/src/core/modules/ai/index.ts index 2637b257..c155ce10 100644 --- a/src/core/modules/ai/index.ts +++ b/src/core/modules/ai/index.ts @@ -20,6 +20,7 @@ export * from './inputs/core-ai-slot-update.input'; export * from './inputs/core-ai-prompt.input'; export * from './hooks/ai-hook.base'; export * from './hooks/ai-hook.registry'; +export * from './interfaces/ai-interaction-record.interface'; export * from './interfaces/ai-hook.interface'; export * from './interfaces/ai-placeholder.interface'; export * from './interfaces/ai-tool.interface'; diff --git a/src/core/modules/ai/interfaces/ai-interaction-record.interface.ts b/src/core/modules/ai/interfaces/ai-interaction-record.interface.ts new file mode 100644 index 00000000..3883db39 --- /dev/null +++ b/src/core/modules/ai/interfaces/ai-interaction-record.interface.ts @@ -0,0 +1,34 @@ +/** + * The record passed to `CoreAiService.audit()` for each prompt run. + * + * It lives here, in an import-free leaf, rather than in `core-ai.service.ts` — and that is + * load-bearing, not tidiness. + * + * `core-ai.service` imports `core-ai-interaction.service` (it needs the class), and + * `core-ai-interaction.service` needs this type back. Declared in the service, that pair forms an + * import cycle. It never crashed for exactly one reason: the type was pulled in with `import type`, + * which both tsc and SWC erase, so no `require()` was emitted and the cycle was never real at + * runtime. + * + * That is a single keyword of protection, on a file where `core-ai.service` has a constructor + * `@Inject`, so `emitDecoratorMetadata` emits `design:paramtypes` at module-evaluation time — the + * exact eval-time dereference that turns a cycle fatal. An IDE "organize imports" or a lint autofix + * widening `import type` to a plain `import` would have armed it silently, and `tsc`, `pnpm test` + * and `oxlint` are ALL blind to that (vitest runs SWC through Vite's cycle-tolerant module runner). + * Only `pnpm run check:swc-tdz` would have caught it — after the fact. + * + * Moving the type out removes the edge entirely, so the cycle no longer exists rather than merely + * being disarmed. `core-ai.service` re-exports it, so every existing import path still resolves. + * + * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)". + */ +export interface AiInteractionRecord { + actions: { name: string; success: boolean }[]; + connectionId: string; + iterations: number; + prompt: string; + responseText: string; + tenantId?: string; + usage?: { completionTokens?: number; promptTokens?: number; totalTokens?: number }; + userId?: string; +} diff --git a/src/core/modules/ai/services/core-ai-budget.service.ts b/src/core/modules/ai/services/core-ai-budget.service.ts index bf081e33..c708058a 100644 --- a/src/core/modules/ai/services/core-ai-budget.service.ts +++ b/src/core/modules/ai/services/core-ai-budget.service.ts @@ -11,10 +11,14 @@ import { CoreAiBudgetLimitInput } from '../inputs/core-ai-budget-limit.input'; import { AiBudgetLimitDocument, CoreAiBudgetLimit } from '../models/core-ai-budget-limit.model'; import { CoreAiBudgetSummary, CoreAiUsageInfo, CoreAiUsageScope } from '../models/core-ai-usage-info.model'; -/** Mongoose injection token for the budget-limit model. */ -export const AI_BUDGET_LIMIT_MODEL = 'AiBudgetLimit'; -/** DI token for the budget-limit model constructor. */ -export const AI_BUDGET_LIMIT_CLASS = 'AI_BUDGET_LIMIT_CLASS'; +import { AI_BUDGET_LIMIT_CLASS, AI_BUDGET_LIMIT_MODEL } from '../core-ai.constants'; + +/** + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). + */ +export { AI_BUDGET_LIMIT_CLASS, AI_BUDGET_LIMIT_MODEL } from '../core-ai.constants'; /** Resolved effective limit for a scope. */ export interface ResolvedAiBudgetLimit { diff --git a/src/core/modules/ai/services/core-ai-connection-preference.service.ts b/src/core/modules/ai/services/core-ai-connection-preference.service.ts index b5b9117a..5783bfec 100644 --- a/src/core/modules/ai/services/core-ai-connection-preference.service.ts +++ b/src/core/modules/ai/services/core-ai-connection-preference.service.ts @@ -10,10 +10,14 @@ import { CoreAiConnectionPreference, } from '../models/core-ai-connection-preference.model'; -/** Mongoose injection token for the connection-preference model. */ -export const AI_CONNECTION_PREFERENCE_MODEL = 'AiConnectionPreference'; -/** DI token for the connection-preference model constructor. */ -export const AI_CONNECTION_PREFERENCE_CLASS = 'AI_CONNECTION_PREFERENCE_CLASS'; +import { AI_CONNECTION_PREFERENCE_CLASS, AI_CONNECTION_PREFERENCE_MODEL } from '../core-ai.constants'; + +/** + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). + */ +export { AI_CONNECTION_PREFERENCE_CLASS, AI_CONNECTION_PREFERENCE_MODEL } from '../core-ai.constants'; /** * CRUD + lookup for {@link CoreAiConnectionPreference} (tenant/user connection diff --git a/src/core/modules/ai/services/core-ai-connection.service.ts b/src/core/modules/ai/services/core-ai-connection.service.ts index bb35cba4..a4fcdebb 100644 --- a/src/core/modules/ai/services/core-ai-connection.service.ts +++ b/src/core/modules/ai/services/core-ai-connection.service.ts @@ -23,15 +23,14 @@ import { LlmProviderFactory } from '../providers/llm-provider.factory'; import { AiCryptoService } from './ai-crypto.service'; import { CoreAiConnectionPreferenceService } from './core-ai-connection-preference.service'; -/** - * Mongoose injection token for the AI connection model. - */ -export const AI_CONNECTION_MODEL = 'AiConnection'; +import { AI_CONNECTION_CLASS, AI_CONNECTION_MODEL } from '../core-ai.constants'; /** - * DI token for the AI connection model constructor (used by CrudService mapping). + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). */ -export const AI_CONNECTION_CLASS = 'AI_CONNECTION_CLASS'; +export { AI_CONNECTION_CLASS, AI_CONNECTION_MODEL } from '../core-ai.constants'; /** * CRUD service for {@link CoreAiConnection} — the database-backed LLM diff --git a/src/core/modules/ai/services/core-ai-conversation.service.ts b/src/core/modules/ai/services/core-ai-conversation.service.ts index c28a4779..d746234f 100644 --- a/src/core/modules/ai/services/core-ai-conversation.service.ts +++ b/src/core/modules/ai/services/core-ai-conversation.service.ts @@ -8,15 +8,14 @@ import { CoreAiConversationCreateInput } from '../inputs/core-ai-conversation-cr import { CoreAiConversationInput } from '../inputs/core-ai-conversation.input'; import { AiConversationDocument, CoreAiConversation } from '../models/core-ai-conversation.model'; -/** - * Mongoose injection token for the AI conversation model. - */ -export const AI_CONVERSATION_MODEL = 'AiConversation'; +import { AI_CONVERSATION_CLASS, AI_CONVERSATION_MODEL } from '../core-ai.constants'; /** - * DI token for the AI conversation model constructor. + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). */ -export const AI_CONVERSATION_CLASS = 'AI_CONVERSATION_CLASS'; +export { AI_CONVERSATION_CLASS, AI_CONVERSATION_MODEL } from '../core-ai.constants'; /** * CRUD service for multi-turn {@link CoreAiConversation}s. diff --git a/src/core/modules/ai/services/core-ai-interaction.service.ts b/src/core/modules/ai/services/core-ai-interaction.service.ts index d75b949a..a3df63b4 100644 --- a/src/core/modules/ai/services/core-ai-interaction.service.ts +++ b/src/core/modules/ai/services/core-ai-interaction.service.ts @@ -5,17 +5,16 @@ import { Model } from 'mongoose'; import { CrudService } from '../../../common/services/crud.service'; import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type'; import { AiInteractionDocument, CoreAiInteraction } from '../models/core-ai-interaction.model'; -import type { AiInteractionRecord } from './core-ai.service'; +import type { AiInteractionRecord } from '../interfaces/ai-interaction-record.interface'; -/** - * Mongoose injection token for the AI interaction model. - */ -export const AI_INTERACTION_MODEL = 'AiInteraction'; +import { AI_INTERACTION_CLASS, AI_INTERACTION_MODEL } from '../core-ai.constants'; /** - * DI token for the AI interaction model constructor. + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). */ -export const AI_INTERACTION_CLASS = 'AI_INTERACTION_CLASS'; +export { AI_INTERACTION_CLASS, AI_INTERACTION_MODEL } from '../core-ai.constants'; /** * CRUD + write service for {@link CoreAiInteraction} audit records. diff --git a/src/core/modules/ai/services/core-ai-mode.service.ts b/src/core/modules/ai/services/core-ai-mode.service.ts index 773b32b6..c1f619ab 100644 --- a/src/core/modules/ai/services/core-ai-mode.service.ts +++ b/src/core/modules/ai/services/core-ai-mode.service.ts @@ -6,8 +6,14 @@ import { CrudService } from '../../../common/services/crud.service'; import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type'; import { AiModeDocument, CoreAiMode } from '../models/core-ai-mode.model'; -export const AI_MODE_MODEL = 'AiMode'; -export const AI_MODE_CLASS = 'AI_MODE_CLASS'; +import { AI_MODE_CLASS, AI_MODE_MODEL } from '../core-ai.constants'; + +/** + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). + */ +export { AI_MODE_CLASS, AI_MODE_MODEL } from '../core-ai.constants'; /** * Named-mode store. See {@link CoreAiMode}. Override via diff --git a/src/core/modules/ai/services/core-ai-prompt-hint.service.ts b/src/core/modules/ai/services/core-ai-prompt-hint.service.ts index c318125c..9ea5e64a 100644 --- a/src/core/modules/ai/services/core-ai-prompt-hint.service.ts +++ b/src/core/modules/ai/services/core-ai-prompt-hint.service.ts @@ -9,11 +9,14 @@ import { CoreAiPromptHintCreateInput } from '../inputs/core-ai-prompt-hint-creat import { CoreAiPromptHintInput } from '../inputs/core-ai-prompt-hint.input'; import { AiPromptHintDocument, CoreAiPromptHint } from '../models/core-ai-prompt-hint.model'; -/** Mongoose injection token for the prompt-hint model. */ -export const AI_PROMPT_HINT_MODEL = 'AiPromptHint'; +import { AI_PROMPT_HINT_CLASS, AI_PROMPT_HINT_MODEL } from '../core-ai.constants'; -/** DI token for the prompt-hint model constructor. */ -export const AI_PROMPT_HINT_CLASS = 'AI_PROMPT_HINT_CLASS'; +/** + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). + */ +export { AI_PROMPT_HINT_CLASS, AI_PROMPT_HINT_MODEL } from '../core-ai.constants'; /** A failure signal recorded by the orchestrator for the learning loop. */ export interface AiPromptFeedbackSignal { diff --git a/src/core/modules/ai/services/core-ai-prompt.service.ts b/src/core/modules/ai/services/core-ai-prompt.service.ts index a91cb549..0b5637b6 100644 --- a/src/core/modules/ai/services/core-ai-prompt.service.ts +++ b/src/core/modules/ai/services/core-ai-prompt.service.ts @@ -10,8 +10,14 @@ import { CoreAiPromptCreateInput } from '../inputs/core-ai-prompt-create.input'; import { CoreAiPromptUpdateInput } from '../inputs/core-ai-prompt-update.input'; import { AiPromptDocument, CoreAiPrompt } from '../models/core-ai-prompt.model'; -export const AI_PROMPT_MODEL = 'AiPrompt'; -export const AI_PROMPT_CLASS = 'AI_PROMPT_CLASS'; +import { AI_PROMPT_CLASS, AI_PROMPT_MODEL } from '../core-ai.constants'; + +/** + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). + */ +export { AI_PROMPT_CLASS, AI_PROMPT_MODEL } from '../core-ai.constants'; const VALID_SCOPES = new Set(['tenant', 'user']); diff --git a/src/core/modules/ai/services/core-ai-slot.service.ts b/src/core/modules/ai/services/core-ai-slot.service.ts index 49ed14ef..8b6bf845 100644 --- a/src/core/modules/ai/services/core-ai-slot.service.ts +++ b/src/core/modules/ai/services/core-ai-slot.service.ts @@ -11,11 +11,14 @@ import { CoreAiSlotCreateInput } from '../inputs/core-ai-slot-create.input'; import { CoreAiSlotUpdateInput } from '../inputs/core-ai-slot-update.input'; import { AiSlotDocument, CoreAiSlot } from '../models/core-ai-slot.model'; -/** Mongoose injection token for the slot model. */ -export const AI_SLOT_MODEL = 'AiSlot'; +import { AI_SLOT_CLASS, AI_SLOT_MODEL } from '../core-ai.constants'; -/** DI token for the slot model constructor. */ -export const AI_SLOT_CLASS = 'AI_SLOT_CLASS'; +/** + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). + */ +export { AI_SLOT_CLASS, AI_SLOT_MODEL } from '../core-ai.constants'; /** A resolved prompt fragment ready for placeholder rendering + assembly. */ export interface ResolvedPromptFragment { diff --git a/src/core/modules/ai/services/core-ai-tool-grant.service.ts b/src/core/modules/ai/services/core-ai-tool-grant.service.ts index b4e4d384..8197a15c 100644 --- a/src/core/modules/ai/services/core-ai-tool-grant.service.ts +++ b/src/core/modules/ai/services/core-ai-tool-grant.service.ts @@ -6,10 +6,14 @@ import { CrudService } from '../../../common/services/crud.service'; import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type'; import { AiToolGrantDocument, CoreAiToolGrant } from '../models/core-ai-tool-grant.model'; -/** Mongoose injection token. */ -export const AI_TOOL_GRANT_MODEL = 'AiToolGrant'; -/** DI token for the model constructor. */ -export const AI_TOOL_GRANT_CLASS = 'AI_TOOL_GRANT_CLASS'; +import { AI_TOOL_GRANT_CLASS, AI_TOOL_GRANT_MODEL } from '../core-ai.constants'; + +/** + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). + */ +export { AI_TOOL_GRANT_CLASS, AI_TOOL_GRANT_MODEL } from '../core-ai.constants'; /** Scope of a persisted permission decision. */ export type AiToolGrantScope = 'conversation' | 'tenant' | 'user'; diff --git a/src/core/modules/ai/services/core-ai-tool-policy.service.ts b/src/core/modules/ai/services/core-ai-tool-policy.service.ts index ed052fa5..bf922114 100644 --- a/src/core/modules/ai/services/core-ai-tool-policy.service.ts +++ b/src/core/modules/ai/services/core-ai-tool-policy.service.ts @@ -6,10 +6,14 @@ import { CrudService } from '../../../common/services/crud.service'; import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type'; import { AiToolPolicyDocument, CoreAiToolPolicy } from '../models/core-ai-tool-policy.model'; -/** Mongoose injection token. */ -export const AI_TOOL_POLICY_MODEL = 'AiToolPolicy'; -/** DI token for the model constructor. */ -export const AI_TOOL_POLICY_CLASS = 'AI_TOOL_POLICY_CLASS'; +import { AI_TOOL_POLICY_CLASS, AI_TOOL_POLICY_MODEL } from '../core-ai.constants'; + +/** + * @deprecated Import from `../core-ai.constants` instead. Re-exported only so existing deep imports + * keep working; the tokens are declared in an import-free leaf so no cycle can form around them + * (SWC-safe — see core-ai.constants.ts). + */ +export { AI_TOOL_POLICY_CLASS, AI_TOOL_POLICY_MODEL } from '../core-ai.constants'; export interface AiToolPolicyDecision { /** Final decision: 'allow', 'deny' or 'ask'. */ diff --git a/src/core/modules/ai/services/core-ai.service.ts b/src/core/modules/ai/services/core-ai.service.ts index 41e00059..a1eb95fb 100644 --- a/src/core/modules/ai/services/core-ai.service.ts +++ b/src/core/modules/ai/services/core-ai.service.ts @@ -27,20 +27,17 @@ import { AiPromptFeedbackSignal, CoreAiPromptHintService } from './core-ai-promp import { AiToolGrantScope, CoreAiToolGrantService } from './core-ai-tool-grant.service'; import { CoreAiToolPolicyService } from './core-ai-tool-policy.service'; import { CoreAiModeService } from './core-ai-mode.service'; +import type { AiInteractionRecord } from '../interfaces/ai-interaction-record.interface'; /** - * Record passed to {@link CoreAiService.audit} for each prompt run. + * @deprecated Import from `../interfaces/ai-interaction-record.interface` instead. Re-exported only + * so existing imports keep working. + * + * The type was moved out of this file because `core-ai-interaction.service` needs it while this file + * imports THAT service — an import cycle held apart only by an `import type`. See the interface's + * docblock. */ -export interface AiInteractionRecord { - actions: { name: string; success: boolean }[]; - connectionId: string; - iterations: number; - prompt: string; - responseText: string; - tenantId?: string; - usage?: { completionTokens?: number; promptTokens?: number; totalTokens?: number }; - userId?: string; -} +export type { AiInteractionRecord } from '../interfaces/ai-interaction-record.interface'; /** * Event emitted by {@link CoreAiService.promptStream} over SSE. diff --git a/src/core/modules/tus/tus.constants.ts b/src/core/modules/tus/tus.constants.ts new file mode 100644 index 00000000..5aec6f70 --- /dev/null +++ b/src/core/modules/tus/tus.constants.ts @@ -0,0 +1,25 @@ +/** + * Dependency-injection tokens of the TUS module. + * + * They live in a dedicated, import-free leaf file — never in `tus.module.ts` or a service — so that + * no file needing a token has to import the module (or vice versa) and close an import cycle. + * + * On a cycle, a token read at MODULE-EVALUATION time — inside an `@Inject()` decorator argument, in + * `design:paramtypes` metadata, or in a static field initializer — reads a `const` still in its + * temporal dead zone, and SWC-compiled builds (`nest start -b swc`) die at startup with: + * + * ReferenceError: Cannot access 'TUS_CONFIG' before initialization + * + * A file that imports nothing can never be mid-evaluation when someone imports it, in any module + * system, under any compiler. That is the whole point — keep it import-free. + * + * `tsc`, `pnpm test` and `oxlint` are all blind to a regression here; only `pnpm run check:swc-tdz` + * sees it. See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)". + */ + +/** + * Token for injecting the resolved TUS configuration. + * + * Injected type: `Required`. + */ +export const TUS_CONFIG = 'TUS_CONFIG'; diff --git a/src/core/modules/tus/tus.module.ts b/src/core/modules/tus/tus.module.ts index 29fd4493..25e1c080 100644 --- a/src/core/modules/tus/tus.module.ts +++ b/src/core/modules/tus/tus.module.ts @@ -5,12 +5,17 @@ import { Connection } from 'mongoose'; import { ITusConfig } from '../../common/interfaces/server-options.interface'; import { CoreTusController } from './core-tus.controller'; import { CoreTusService } from './core-tus.service'; +import { TUS_CONFIG } from './tus.constants'; import { normalizeTusConfig } from './interfaces/tus-config.interface'; /** - * Token for injecting the TUS configuration + * @deprecated Import from `./tus.constants` instead. Re-exported only so existing deep imports keep + * working; the token is declared in an import-free leaf so module and services stay acyclic + * (SWC-safe — see tus.constants.ts). + * + * Do NOT import it from here inside the TUS module itself — that is what re-creates the cycle. */ -export const TUS_CONFIG = 'TUS_CONFIG'; +export { TUS_CONFIG } from './tus.constants'; /** * Options for TusModule.forRoot() From 740ed4d85f691dbfbc5beadbe761e86cb8be88d2 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 21:20:07 +0200 Subject: [PATCH 13/21] test(e2e): auto-enable low-resource mode when the machine is already loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e suites intermittently failed with `socket hang up`, 401s on auth queries, and tests hanging past the 30s timeout — all of which read like genuine product bugs while being pure resource starvation. The trigger is two full e2e runs overlapping: several `lt dev` environments, a second project's check, or simply a forgotten background run. The escape hatch already existed — `CHECK_LOW_RESOURCE=1` caps parallel forks and raises timeouts — but it was OPT-IN, which is the wrong default. It only helps the developer who already knows the env var exists, and that is precisely the developer who does not need it. Whoever gets bitten is the one who has never heard of it, staring at a "flaky" auth test and re-running it until it passes. It now auto-enables from the 1-minute load average normalised per core. Explicit settings still win in both directions: CHECK_LOW_RESOURCE=1 -> force on (CI, or a machine you know is busy) CHECK_LOW_RESOURCE=0 -> force off (benchmarking; never auto-throttle) unset -> auto (on iff normalised load >= 0.7) Verified all three: under 12 CPU burners the unset case throttles (maxForks=4, timeouts raised) and prints why; `=0` overrides it back off. Load average is unavailable on Windows, where auto-detection stays off and the flag remains. Throttling costs some wall-clock. A green suite that has to be re-run costs more, and a red one that gets dismissed as "flaky" costs the most. Co-Authored-By: Claude Opus 4.8 (1M context) --- vitest-e2e.config.ts | 46 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/vitest-e2e.config.ts b/vitest-e2e.config.ts index 2e2fbe28..c7e2b3b5 100644 --- a/vitest-e2e.config.ts +++ b/vitest-e2e.config.ts @@ -5,23 +5,49 @@ import { defineConfig } from 'vitest/config'; import { E2E_TEST_INCLUDE } from './vitest.include-globs'; -// Opt-in low-resource mode — for running MANY e2e suites at once on one machine -// (e.g. several parallel `lt dev` / `lt ticket` environments). Set -// CHECK_LOW_RESOURCE=1 to cap parallel forks and raise timeouts so the suites -// share CPU/mongod without starving each other into request timeouts (the -// failure mode observed when 2+ full e2e runs overlap — tests hang past the -// 30s testTimeout and auth queries fail as 401s). Unset (default) = full speed, -// no cap. Optionally pin the fork cap with CHECK_LOW_RESOURCE_FORKS=. +// Low-resource mode — caps parallel forks and raises timeouts so many e2e suites can share one +// machine's CPU and mongod without starving each other. The failure mode it prevents is real and +// nasty: when two full e2e runs overlap (several `lt dev` / `lt ticket` environments, or simply a +// forgotten background run), requests queue past the 30s testTimeout, auth queries come back as +// 401s, and supertest connections die with `socket hang up` — all of which read like genuine +// product bugs while being pure resource starvation. +// +// It used to be OPT-IN via CHECK_LOW_RESOURCE=1, and that is the wrong default: the mode only helps +// the developer who already knows the env var exists, which is exactly the developer who does not +// need it. Whoever gets bitten is the one who has never heard of it, staring at a "flaky" auth test. +// +// So it now AUTO-ENABLES when the machine is already loaded, using the 1-minute load average +// normalised per core. Explicit settings still win, in both directions: +// +// CHECK_LOW_RESOURCE=1 / true -> force on (CI, or a machine you know is busy) +// CHECK_LOW_RESOURCE=0 / false -> force off (benchmarking; never auto-throttle) +// unset -> auto (on iff normalised load >= LOAD_THRESHOLD) +// +// Load average is unavailable on Windows (os.loadavg() returns zeros), where auto-detection simply +// stays off — the explicit flag remains available. +const LOAD_THRESHOLD = 0.7; + +const CORES = os.availableParallelism?.() ?? os.cpus()?.length ?? 4; +const NORMALISED_LOAD = (os.loadavg()?.[0] ?? 0) / CORES; + const LOW_RESOURCE_RAW = process.env.CHECK_LOW_RESOURCE; -const LOW_RESOURCE = !!LOW_RESOURCE_RAW && LOW_RESOURCE_RAW !== '0' && LOW_RESOURCE_RAW !== 'false'; +const LOW_RESOURCE_FORCED_OFF = LOW_RESOURCE_RAW === '0' || LOW_RESOURCE_RAW === 'false'; +const LOW_RESOURCE_FORCED_ON = !!LOW_RESOURCE_RAW && !LOW_RESOURCE_FORCED_OFF; +const LOW_RESOURCE_AUTO = LOW_RESOURCE_RAW === undefined && NORMALISED_LOAD >= LOAD_THRESHOLD; +const LOW_RESOURCE = LOW_RESOURCE_FORCED_ON || LOW_RESOURCE_AUTO; + const LOW_RESOURCE_FORKS = (() => { if (!LOW_RESOURCE) return undefined; const explicit = Number(process.env.CHECK_LOW_RESOURCE_FORKS); if (Number.isInteger(explicit) && explicit > 0) return explicit; - return Math.max(2, Math.floor((os.cpus()?.length || 4) / 3)); + return Math.max(2, Math.floor(CORES / 3)); })(); + if (LOW_RESOURCE) { - process.stderr.write(`[e2e] low-resource mode active: maxForks=${LOW_RESOURCE_FORKS}, timeouts raised\n`); + const why = LOW_RESOURCE_AUTO + ? `machine is busy (load ${NORMALISED_LOAD.toFixed(2)}/core >= ${LOAD_THRESHOLD})` + : 'CHECK_LOW_RESOURCE set'; + process.stderr.write(`[e2e] low-resource mode: ${why} -> maxForks=${LOW_RESOURCE_FORKS}, timeouts raised\n`); } export default defineConfig({ From 6500e999265edac5069110330c4b11e30ac9b985 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 21:20:08 +0200 Subject: [PATCH 14/21] test: enforce the leaf rule instead of documenting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/rules/architecture.md introduced "DI tokens belong in an import-free leaf" and then listed ten live violations of itself. A rule with known violations is a suggestion, and a suggestion does not survive a refactor. `import-cycle-invariants.spec.ts` now fails if: - any DI token is declared in a *.module.ts or a *.service.ts (re-exports are fine — those are the backward-compat shims; only declarations re-create the edge) - any constants leaf grows an import, an export-from, or a require - `core-ai-interaction.service` imports from `core-ai.service` at all This matters because `check:swc-tdz` catches the CRASH, not the DISARMING of a safety property — and only the second one is silent. A "modernize to arrow functions", an "organize imports", a "split this file up" can each re-arm the bug with everything staying green. Every assertion was mutation-tested: the violation reintroduced, the test watched go red. That includes a false positive in the first version of the spec, which flagged the leaves' own docblocks — they legitimately contain the words `import` and `require()` while explaining the bug they prevent. Only the code is evidence. Rules updated to describe what is actually true: zero known violations, cycles 10 -> 5, and the lesson that cost the most time — removing one edge is not removing the cycle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lt-dev-docs-reviewer/MEMORY.md | 2 + .../review-committed-vs-working-tree.md | 12 +++ .../vendor-mode-atomic-file-set-check.md | 16 +++ .claude/rules/architecture.md | 40 +++++--- tests/unit/import-cycle-invariants.spec.ts | 98 ++++++++++++++++--- 5 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 .claude/agent-memory/lt-dev-docs-reviewer/review-committed-vs-working-tree.md create mode 100644 .claude/agent-memory/lt-dev-docs-reviewer/vendor-mode-atomic-file-set-check.md diff --git a/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md b/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md index 60abc30f..7500978c 100644 --- a/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md @@ -3,3 +3,5 @@ - [Doc places to check for config features](doc-surfaces-for-config-features.md) — the full set of doc surfaces a new configurable feature must update in this repo - [AI module doc coverage gaps](ai-module-doc-coverage-gaps.md) — AI features exported in index.ts (hooks, tool-grants, modes, attachments, claudeCli, compaction) but missing from user-facing docs - [Migration-guide behavior-change count trap](migration-guide-behavior-change-count-trap.md) — derive behavior changes from better-auth.config.ts/cookies.helper.ts diffs; guide Overview counts have under-reported before +- [Review committed state vs working tree](review-committed-vs-working-tree.md) — `git status` FIRST; the author's uncommitted delta can be a better revision than the commits, and untracked keystone files ship breaks +- [Vendor-mode atomic file-set check](vendor-mode-atomic-file-set-check.md) — every new file under src/core/ that an existing core file imports must be an enumerated atomic set in the guide diff --git a/.claude/agent-memory/lt-dev-docs-reviewer/review-committed-vs-working-tree.md b/.claude/agent-memory/lt-dev-docs-reviewer/review-committed-vs-working-tree.md new file mode 100644 index 00000000..a7624a57 --- /dev/null +++ b/.claude/agent-memory/lt-dev-docs-reviewer/review-committed-vs-working-tree.md @@ -0,0 +1,12 @@ +--- +name: review-committed-vs-working-tree +description: Always run `git status` when auditing a PR here — the author's working tree can hold a newer, better revision than the commits, and a doc↔code contradiction can live entirely in that uncommitted delta +metadata: + type: feedback +--- + +When auditing a branch/PR in nest-server, do NOT audit `git diff ...HEAD` alone. **Run `git status --porcelain` first** and audit the committed state and the working tree as two separate things. + +**Why:** In the 11.27.7 / `fix/better-auth-di-token-circular-import` audit, the initial `gitStatus` context said "clean" (a stale snapshot), but the tree actually had 6 modified files plus an **untracked** `tsconfig.swc-tdz.json`. The uncommitted delta was a *later, better* revision of the same work: committed HEAD had `check:swc-tdz` = `nest build -b swc` (no `-p`), which builds SWC output into the REAL `dist/` (nest-cli has `deleteOutDir: true`) — the exact footgun the working-tree version's own docblock says it fixed by adding `tsconfig.swc-tdz.json` + a throwaway `dist-swc-tdz/`. Reading only `git diff develop...HEAD` would have reported the broken design; reading only the working-tree files would have missed that the fix was never committed. The keystone file being **untracked** meant one `git commit -a` away from shipping a hard-failing `check` script for everyone. + +**How to apply:** At the start of every review: (1) `git status --porcelain`; (2) if dirty, diff working tree vs HEAD per file (`git diff -- `) and read the committed version with `git show HEAD:`; (3) call out untracked files that other committed/uncommitted files reference — a `package.json` script pointing at an untracked config is a guaranteed break. Quote file:line from the version you are actually grading, and say explicitly which one that is. See [[migration-guide-behavior-change-count-trap]]. diff --git a/.claude/agent-memory/lt-dev-docs-reviewer/vendor-mode-atomic-file-set-check.md b/.claude/agent-memory/lt-dev-docs-reviewer/vendor-mode-atomic-file-set-check.md new file mode 100644 index 00000000..aecd2025 --- /dev/null +++ b/.claude/agent-memory/lt-dev-docs-reviewer/vendor-mode-atomic-file-set-check.md @@ -0,0 +1,16 @@ +--- +name: vendor-mode-atomic-file-set-check +description: Any NEW file under src/core/ that an existing core file imports is a partial-vendor-sync hazard — the migration guide must enumerate EVERY such atomic file set, not just the headline one +metadata: + type: project +--- + +Vendor-mode consumers copy `src/core/` into their tree and sync via the `lt-dev:nest-server-core-updater` agent, file by file — not via `pnpm update`. So **every new file under `src/core/` that an existing core file imports creates an atomic file set**: if the sync takes the edited importer but not the new leaf, the consumer gets an unresolvable import and a broken build. + +**How to apply:** For each branch, list new files under `src/core/` (`git diff ...HEAD --diff-filter=A --name-only -- src/core/`) and, for each, grep which existing core files now import it. Every such cluster needs a row in the guide's vendor-mode section. Then re-count the guide's own claim — it has been wrong. + +**Why (concrete):** The 11.27.7 guide documented exactly one atomic set (BetterAuth), titled it *"the change is an atomic 4-file set"* while its own table listed **6** files, and omitted two further sets entirely: +- **core helpers:** `id.helper.ts` (new) + `clone.helper.ts` (new) + `db.helper.ts` + `input.helper.ts` + `restricted.decorator.ts` + `config.service.ts` — `db.helper.ts` now does `export { equalIds, … } from './id.helper'`, so taking db.helper without id.helper breaks the build. Identical hazard to the one the guide *did* warn about. +- **filter inputs:** `filter.input.ts` + `combined-filter.input.ts` (the latter is now a re-export shim). + +Two independent count/coverage errors in one guide's vendor section — treat this section as high-suspicion by default. Related: [[migration-guide-behavior-change-count-trap]], [[patch-release-migration-guide-convention]]. diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index b0cb63ce..31e309b8 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -94,21 +94,31 @@ Whether such a cycle throws depends on **which module the graph is entered throu ### Status per module -Repo-wide cycles went from **10 → 6** while fixing this. The six that remain are, per an SWC-emit audit, almost all **not runtime cycles at all** (type-only imports that madge reports but both compilers erase — their emits are empty). - -| Module | Status | -|--------|--------| -| `better-auth` | ✅ `core-better-auth.constants.ts` (tokens) + `core-better-auth.registry.ts` (static service refs, `import type` only) | -| `tenant` | ✅ `core-tenant.enums.ts` | -| `auth` | ✅ `interfaces/auth-provider.interface.ts` | -| `common/inputs` | ✅ `FilterInput` + `CombinedFilterInput` merged into `filter.input.ts` (declaration order is load-bearing). Split apart, a direct `require()` of `combined-filter.input` **crashed** — it was a live bug, masked by the barrel. | -| `common/helpers` | ✅ `id.helper.ts` (ID cluster, extracted from `db.helper`) + `clone.helper.ts` (`clone`/`deepFreeze`, extracted from `input.helper`). Both are true leaves. | -| `common/decorators` | ✅ `restricted.decorator` is now on **zero** cycles. It took BOTH leaves: `id.helper` killed `→ db.helper → input.helper →`, `clone.helper` killed `→ core-tenant.helpers → config.service → input.helper →`. Its three exports are additionally hoisted `function` declarations (TDZ-immune) as defense in depth. | -| `ai` | ⚠️ `core-ai-interaction.service` ↔ `core-ai.service` is kept off a real cycle **only** by an `import type` on `AiInteractionRecord`. An IDE "organize imports" could silently widen it to a value import and arm a `design:paramtypes` deref. Pinned by `tests/unit/import-cycle-invariants.spec.ts`; moving the type to a leaf would settle it. | -| `tus` | ⚠️ `TUS_CONFIG` declared in `tus.module.ts` — currently acyclic, but unguarded | -| `ai` (tokens) | ⚠️ `AI_*_CLASS` / `AI_*_MODEL` declared in 9 `ai/services/*.service.ts` — currently acyclic, but unguarded | - -The ⚠️ entries are each one refactor away from the identical crash. `check:swc-tdz` will catch it if they are ever armed — but move the binding to a leaf when you touch them. +Repo-wide cycles went from **10 → 5**, and **every DI token in `src/core/` now lives in an import-free leaf**. The five that remain are, per an SWC-emit audit, **not runtime cycles at all** — type-only imports that madge reports but both compilers erase (their emits are empty). + +Both invariants are enforced by `tests/unit/import-cycle-invariants.spec.ts`, which fails if a token reappears in a `*.module.ts` / `*.service.ts` or if a leaf grows an import. That matters, because the guard below catches the *crash*, not the *disarming* of a safety property — those are different things, and only the second one is silent. + +| Module | Token / type leaf | +|--------|-------------------| +| `better-auth` | `core-better-auth.constants.ts` (tokens) + `core-better-auth.registry.ts` (static service refs, `import type` only, deliberately **not** barrel-exported) | +| `ai` | `core-ai.constants.ts` (all 22 `AI_*` tokens) + `interfaces/ai-interaction-record.interface.ts` | +| `tus` | `tus.constants.ts` (`TUS_CONFIG`) | +| `tenant` | `core-tenant.enums.ts` | +| `auth` | `interfaces/auth-provider.interface.ts` | +| `common/helpers` | `id.helper.ts` (ID cluster, out of `db.helper`) + `clone.helper.ts` (`clone`/`deepFreeze`, out of `input.helper`) | +| `common/inputs` | `FilterInput` + `CombinedFilterInput` merged into `filter.input.ts` — declaration order is load-bearing | +| `common/decorators` | `restricted.decorator` is on **zero** cycles; its exports are hoisted `function` declarations (TDZ-immune) as defense in depth | + +Every old location re-exports what it lost, so no import path broke — the public API is byte-identical (472 exports, verified by diffing both versions). + +### What each of these actually was + +Worth knowing, because the pattern repeats: + +- **`CombinedFilterInput` was already crashing.** `require('.../combined-filter.input.js')` threw. It stayed hidden because the barrel happens to pull `filter.input` in first — a deep import or a reordering of `src/index.ts` would have surfaced it. A lazy thunk does **not** fix that shape: `emitDecoratorMetadata` still emits an eager `design:type`. +- **`restricted.decorator` sat on two cycles at once**, in the file that drives field-level access control. Removing the `db.helper` edge felt like the fix and left the `config.service` one fully intact. **Removing one edge is not removing the cycle** — re-run madge and confirm the file appears in *zero* cycles. +- **The AI interaction record was held apart by one keyword.** `import type` erases the edge; an IDE "organize imports" widening it to a value import would have armed a `design:paramtypes` deref, silently. +- **`TUS_CONFIG` and the 22 `AI_*` tokens** never crashed — their graphs happened to be acyclic. Each was one back-import away, with nothing watching. ### The lesson that cost the most time diff --git a/tests/unit/import-cycle-invariants.spec.ts b/tests/unit/import-cycle-invariants.spec.ts index 8c16d1e9..2e947871 100644 --- a/tests/unit/import-cycle-invariants.spec.ts +++ b/tests/unit/import-cycle-invariants.spec.ts @@ -28,8 +28,8 @@ * * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)". */ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; const SRC = join(__dirname, '..', '..', 'src'); @@ -38,6 +38,18 @@ function read(...segments: string[]): string { return readFileSync(join(SRC, ...segments), 'utf8'); } +/** + * Strip comments before looking for imports. + * + * These leaf files document the bug they exist to prevent, so their docblocks legitimately contain + * the words `import`, `require()` and even whole `@example` import statements. Matching the raw + * source flags those as violations — the guard would fail on its own explanation. Only the code is + * evidence. + */ +function code(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + describe('SWC/TDZ import-cycle invariants', () => { describe('restricted.decorator — the leaf imports that took it off every cycle', () => { /** @@ -212,20 +224,82 @@ describe('SWC/TDZ import-cycle invariants', () => { }); }); - describe('AI services — the type-only import is load-bearing', () => { + describe('every DI token lives in an import-free leaf', () => { + /** + * The rule, and the reason it is a test rather than a note: a token declared in a module or a + * service puts an import edge between the file that DECLARES it and every file that INJECTS it. + * `@Inject(TOKEN)` is a constructor-parameter decorator, evaluated at class-definition time — so + * the moment such an edge closes a cycle, the token is read while still in its temporal dead + * zone and SWC-compiled builds die at startup. + * + * Both of these were live violations: `TUS_CONFIG` sat in `tus.module.ts`, and 22 `AI_*` tokens + * were spread across eleven `ai/services/*.service.ts` files. Neither had crashed — the graphs + * happened to be acyclic — but each was one back-import from arming, and `tsc`, `pnpm test` and + * `oxlint` are all blind to that. + */ + const LEAVES = [ + ['modules/tus/tus.constants.ts', 'TUS_CONFIG'], + ['modules/ai/core-ai.constants.ts', 'AI_CONNECTION_MODEL'], + ['modules/better-auth/core-better-auth.constants.ts', 'BETTER_AUTH_INSTANCE'], + ] as const; + + it.each(LEAVES)('%s is a true leaf and declares %s', (file, token) => { + const source = readFileSync(join(SRC, 'core', ...file.split('/')), 'utf8'); + const body = code(source); + + expect(source).toMatch(new RegExp(`^export const ${token} = '`, 'm')); + // Import-free: no import, no export-from, no require. Anything else and it is not a leaf. + expect(body).not.toMatch(/^import\s/m); + expect(body).not.toMatch(/^export .* from /m); + expect(body).not.toMatch(/\brequire\s*\(/); + }); + + it('no DI token is declared in a *.module.ts or a *.service.ts', () => { + // A token here re-creates the very edge the leaves exist to remove. Re-exports are fine (they + // are the backward-compat shims) — only DECLARATIONS are the problem. + const offenders: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (/\.(module|service)\.ts$/.test(entry.name)) { + const source = readFileSync(full, 'utf8'); + // `export const SOME_TOKEN = 'string'` — the DI-token shape. + for (const match of source.matchAll(/^export const ([A-Z][A-Z0-9_]*) = '[^']*';/gm)) { + offenders.push(`${relative(SRC, full)} declares ${match[1]}`); + } + } + } + }; + walk(join(SRC, 'core')); + + expect(offenders, 'move these into an import-free *.constants.ts leaf').toEqual([]); + }); + }); + + describe('AI services — the interaction record no longer closes a cycle', () => { /** - * `core-ai-interaction.service` ↔ `core-ai.service` is kept off a real runtime cycle by ONE - * thing: `AiInteractionRecord` is pulled in with `import type`, which both tsc and SWC erase. - * `core-ai.service` has a constructor `@Inject`, so `decoratorMetadata` emits `design:paramtypes` - * at top level — an evaluation-time deref. Widen that `import type` to a value import (an IDE - * "organize imports" or a lint autofix will do it without asking) and the cycle becomes real and - * armed at the same instant. + * `core-ai.service` imports `core-ai-interaction.service` (it needs the class), and the + * interaction service needs the `AiInteractionRecord` type back. While that type lived in + * `core-ai.service`, the pair was a cycle held apart by ONE keyword: the `import type`, which + * both compilers erase. `core-ai.service` has a constructor `@Inject`, so `decoratorMetadata` + * emits `design:paramtypes` at top level — the exact eval-time deref that turns a cycle fatal. + * An IDE "organize imports" widening that import would have armed it silently. + * + * The type now lives in its own leaf, so the edge is gone rather than merely erased. */ - it('core-ai-interaction.service.ts imports from core-ai.service with `import type`', () => { + it('core-ai-interaction.service.ts does not import from core-ai.service at all', () => { const source = read('core', 'modules', 'ai', 'services', 'core-ai-interaction.service.ts'); - const valueImport = /^import\s+(?!type\b)[^;]*from\s+'\.\/core-ai\.service'/m; - expect(source).not.toMatch(valueImport); + expect(source).not.toMatch(/from '\.\/core-ai\.service'/); + }); + + it('AiInteractionRecord is declared in the interfaces leaf', () => { + const leaf = read('core', 'modules', 'ai', 'interfaces', 'ai-interaction-record.interface.ts'); + + expect(leaf).toMatch(/^export interface AiInteractionRecord\b/m); + expect(code(leaf)).not.toMatch(/^import\s/m); }); }); }); From 5d8651a5959e7632481d85f65a52954cb93bbb2b Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 21:20:24 +0200 Subject: [PATCH 15/21] chore: sync spectaql version stamp to 11.27.7 Auto-updated by extras/update-spectaql-version.mjs to follow the package.json bump. Co-Authored-By: Claude Opus 4.8 (1M context) --- spectaql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spectaql.yml b/spectaql.yml index 3d504177..b3c1b6cf 100644 --- a/spectaql.yml +++ b/spectaql.yml @@ -19,7 +19,7 @@ servers: info: title: lT Nest Server description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases). - version: 11.27.6 + version: 11.27.7 contact: name: lenne.Tech GmbH url: https://lenne.tech From 453a39aebb7ecb06d9b055a43c4c70f12c920f20 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 22:41:00 +0200 Subject: [PATCH 16/21] docs: complete the vendor-mode file sets (AI + TUS leaves) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide listed three atomic file sets; the AI and TUS token extractions landed afterwards and were missing. There are five, and six new files — a partial core sync that omits any one of them leaves an unresolvable import. Also adds what the guide did not say and should have: vendor-mode projects with local edits in db.helper, input.helper, filter.input, restricted.decorator or any ai/services/*.service.ts WILL get merge conflicts, because this release moves code out of exactly those files. The resolution is always the same — keep the local logic, keep the new import/export-from lines pointing at the leaf, do not restore the declarations that moved out. And the warning that matters most: the `export { … } from './…'` lines now scattered across seventeen files look exactly like dead code and are the ENTIRE backward- compatibility layer. This package has no `exports` map, so consumers deep-import those files directly; deleting one line breaks all of them with nothing turning red, because the symbol is still exported from the package root. Co-Authored-By: Claude Opus 4.8 (1M context) --- migration-guides/11.27.6-to-11.27.7.md | 70 ++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/migration-guides/11.27.6-to-11.27.7.md b/migration-guides/11.27.6-to-11.27.7.md index 90de52b3..7dc44cc4 100644 --- a/migration-guides/11.27.6-to-11.27.7.md +++ b/migration-guides/11.27.6-to-11.27.7.md @@ -108,16 +108,35 @@ The deprecated re-exports will be removed in a future MINOR. --- -## Vendor Mode: three atomic file sets +## Vendor Mode: five atomic file sets Projects that vendor the framework core (`projects/api/src/core/`) sync via the `/lt-dev:backend:update-nest-server-core` agent rather than `pnpm update`. -Every fix in this release works the same way: a symbol moved into a new **leaf** -file, and its old home now re-exports it. That makes each fix an **atomic set** — +Every change in this release works the same way: a symbol moved into a new **leaf** +file, and its old home now re-exports it. That makes each one an **atomic set** — adopt the edits without the new file and you get an unresolvable import and a broken -build. **Four files are new.** If a sync is interrupted, verify all four exist before -building. +build. + +**Six files are new.** If a sync is interrupted, verify all six exist before +building: + +``` +core/modules/better-auth/core-better-auth.constants.ts +core/modules/better-auth/core-better-auth.registry.ts +core/modules/ai/core-ai.constants.ts +core/modules/ai/interfaces/ai-interaction-record.interface.ts +core/modules/tus/tus.constants.ts +core/common/helpers/id.helper.ts +core/common/helpers/clone.helper.ts +``` + +> **Expect merge conflicts on locally-patched files.** If your project has local edits +> in `db.helper.ts`, `input.helper.ts`, `filter.input.ts`, `restricted.decorator.ts` +> or any `ai/services/*.service.ts`, this release moves code *out* of them — the sync +> will conflict there. The resolution is always the same: keep your local logic, keep +> the new `import` + `export … from` lines pointing at the leaf, and do not restore the +> declarations that moved out. **Set 1 — BetterAuth DI tokens** @@ -148,10 +167,43 @@ building. | `core/common/inputs/filter.input.ts` | now declares **both** `CombinedFilterInput` (first) and `FilterInput` — the order is load-bearing | | `core/common/inputs/combined-filter.input.ts` | reduced to a re-export shim | -> The re-export lines in `db.helper.ts`, `input.helper.ts`, `combined-filter.input.ts`, -> `core-better-auth.module.ts` and `core-better-auth.service.ts` look like dead code -> and are not: they are what keeps every existing deep import working. **Do not -> "clean them up".** The framework pins them with a test for exactly this reason. +**Set 4 — AI module** (22 DI tokens + the type that closed the last AI cycle) + +| File | Change | +|------|--------| +| `core/modules/ai/core-ai.constants.ts` | 🆕 all 22 `AI_*_MODEL` / `AI_*_CLASS` tokens | +| `core/modules/ai/interfaces/ai-interaction-record.interface.ts` | 🆕 `AiInteractionRecord` | +| `core/modules/ai/services/*.service.ts` (11 files) | tokens removed; each **re-exports its own** from the constants leaf | +| `core/modules/ai/services/core-ai.service.ts` | `AiInteractionRecord` removed; **re-exports it** from the interface leaf | +| `core/modules/ai/services/core-ai-interaction.service.ts` | takes the type from the interface leaf — no longer imports `core-ai.service` at all | +| `core/modules/ai/index.ts` | exports the interface leaf | + +> If your project registered custom AI models via these tokens, nothing changes — +> the values are unchanged and every old import path still resolves. + +**Set 5 — TUS module** + +| File | Change | +|------|--------| +| `core/modules/tus/tus.constants.ts` | 🆕 `TUS_CONFIG` | +| `core/modules/tus/tus.module.ts` | token removed; **re-exports it** from the leaf | + +--- + +> ### Do not "clean up" the re-export lines +> +> `db.helper.ts`, `input.helper.ts`, `combined-filter.input.ts`, `core-better-auth.module.ts`, +> `core-better-auth.service.ts`, `tus.module.ts`, `core-ai.service.ts` and all eleven +> `ai/services/*.service.ts` files now carry `export { … } from './…'` lines that look +> exactly like dead code. +> +> They are not. They are the *entire* backward-compatibility layer: this package has no +> `exports` map, so consumers deep-import these files directly, and deleting one line +> breaks every one of them — **with nothing turning red**, because the symbol is still +> exported from the package root. +> +> The framework pins them with a test (`tests/unit/import-cycle-invariants.spec.ts`) for +> exactly this reason. Keep the equivalent lines in your vendored copy. --- From 14a7e5fe03312d87aa15efabc4d80746c4b81127 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Tue, 14 Jul 2026 22:47:55 +0200 Subject: [PATCH 17/21] docs: correct the new-file count in the migration guide (six -> seven) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prose said six; the list under it had seven, and the diff has seven. A vendor-mode consumer verifying "all six exist" after an interrupted sync would have stopped one file short — of a file whose absence is an unresolvable import. Co-Authored-By: Claude Opus 4.8 (1M context) --- migration-guides/11.27.6-to-11.27.7.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migration-guides/11.27.6-to-11.27.7.md b/migration-guides/11.27.6-to-11.27.7.md index 7dc44cc4..3613e99c 100644 --- a/migration-guides/11.27.6-to-11.27.7.md +++ b/migration-guides/11.27.6-to-11.27.7.md @@ -118,7 +118,7 @@ file, and its old home now re-exports it. That makes each one an **atomic set** adopt the edits without the new file and you get an unresolvable import and a broken build. -**Six files are new.** If a sync is interrupted, verify all six exist before +**Seven files are new.** If a sync is interrupted, verify all seven exist before building: ``` From 97f95f73ebfb80b101df428898745c442f45acb7 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Wed, 15 Jul 2026 08:22:42 +0200 Subject: [PATCH 18/21] fix(check): don't hard-fail when npm retires the audit endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm retired the legacy `/-/npm/v1/security/audits/quick` endpoint (HTTP 410), which every pnpm 10.x calls — so `pnpm audit`, and therefore `pnpm run check`, failed for a reason unrelated to the code and unfixable by any pnpm 10 version. Blocking check on that is wrong twice: it is not a finding, and a permanently-red audit trains everyone to ignore a red audit (the real hazard). check.mjs now degrades ONLY the retired-endpoint signature (ERR_PNPM_AUDIT_BAD_RESPONSE / "audit ... retired") to a loud non-blocking warning — never a green ✓, since that would claim "no vulnerabilities", which was not verified. A real vulnerability (parseable counts) and any other non-zero exit stay fatal. On pnpm 11 (next commit) audit works again via the bulk endpoint and this degrade never triggers; it stays as a safety net. npm audit is NOT used as a fallback: it resolves the tree from package.json alone, ignoring pnpm-lock.yaml and the security overrides, so it reports phantom findings for CVEs the overrides already fix. A misleading red is worse than an honest "could not run". Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check.mjs | 50 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/scripts/check.mjs b/scripts/check.mjs index d26a9497..2ac9c689 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -161,6 +161,29 @@ function parseLint(out) { // ── audit (faithful: runs the project's OWN audit command) ────────────────── const SEVERITIES = ["critical", "high", "moderate", "low", "info"]; +// The audit could not RUN (as opposed to running and finding vulnerabilities). +// +// npm has retired the `/-/npm/v1/security/audits/quick` + `/audits` endpoints (both now 410), and +// every pnpm version — up to and including 11.13.0 — still calls them rather than the working +// `/security/advisories/bulk` endpoint. So `pnpm audit` currently exits non-zero for EVERY project, +// everywhere, with no vulnerability actually reported. Blocking `check` on that is wrong twice over: +// it is not a finding, and no version bump fixes it — it would just paint every `check` red until +// pnpm ships the migration, which trains people to ignore a red audit (the real hazard). +// +// This matches ONLY that infrastructure signature. A genuine vulnerability produces parseable JSON +// counts (handled below, still fatal), and any other non-zero exit stays fatal too — we degrade the +// retired-endpoint case specifically, never "audit failed for some reason". +// +// Why not fall back to `npm audit`? It reaches the working bulk endpoint, but it audits the WRONG +// tree: npm resolves dependencies from package.json alone and ignores both pnpm-lock.yaml and all +// of `pnpm.overrides` — which is exactly where this repo pins vulnerable transitive deps onto +// patched versions. A forced `npm audit` here reports ~18 phantom findings for CVEs the overrides +// already fix in the real install. A misleading red is worse than an honest "could not run"; the +// real fix is upstream pnpm adopting the bulk endpoint, or a pnpm-lock-aware scanner (osv-scanner). +function isAuditEndpointUnavailable(out) { + return /ERR_PNPM_AUDIT_BAD_RESPONSE/.test(out) || (/\baudit\b/i.test(out) && /\bretired\b/i.test(out)); +} + // Run the audit command exactly as the check chain defines it (same scope / // --prod / --audit-level), only appending --json for the counts. The gate is // the command's own exit code, so `check` blocks precisely when a bare @@ -176,7 +199,10 @@ async function runAudit(auditCmd) { /* fall through to raw reason */ } const total = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0; - return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total }; + // Non-zero exit with no parseable counts AND the retired-endpoint signature = infrastructure + // failure, not a finding. Surface it loudly (degraded) but do not block. + const degraded = code !== 0 && !counts && isAuditEndpointUnavailable(out); + return { auditCmd, blocking: code !== 0 && !degraded, counts, degraded, reason: counts ? null : out, total }; } // ── command runner ───────────────────────────────────────────────────────── @@ -522,9 +548,17 @@ async function main() { started, ); } - console.log( - `${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}`, - ); + if (audit.degraded) { + // Not green: audit could not run. Yellow ⚠, never a green ✓ — a ✓ here would read as + // "no vulnerabilities", which is a claim we did not verify. + console.log( + `${C.yellow("⚠")} audit ${C.yellow("could not run — npm retired the audit endpoint pnpm uses; not blocking")} ${C.dim(`(${fmtDuration(dur)})`)}`, + ); + } else { + console.log( + `${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}`, + ); + } results.push({ audit, kind: "audit" }); } @@ -614,7 +648,13 @@ function report(started, results) { `\n${C.bold("Vulnerabilities")} ${C.dim(audit ? `(${audit.auditCmd})` : "(no audit step)")}`, ); console.log( - ` ${audit?.counts ? renderVulnLine(audit.counts) : C.dim(audit ? "counts unavailable" : "—")}`, + ` ${ + audit?.counts + ? renderVulnLine(audit.counts) + : audit?.degraded + ? C.yellow("audit could not run (npm retired the endpoint pnpm uses) — vulnerabilities NOT checked") + : C.dim(audit ? "counts unavailable" : "—") + }`, ); console.log(`\n${C.bold("Tests")}`); From a11b1c2e4eb7a1785be6eb9cc53c881d79e6a2fd Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Wed, 15 Jul 2026 08:23:05 +0200 Subject: [PATCH 19/21] chore(pnpm): upgrade to pnpm 11 and migrate config to pnpm-workspace.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: `pnpm audit` is broken on every pnpm 10.x — npm retired the legacy audit endpoint (HTTP 410) and only pnpm 11 uses the working bulk-advisory endpoint. Verified empirically across 10.33.2, 10.34.5 and 11.13.0 (the repo's packageManager pin forces pnpm to re-exec the pinned version, so the fix only lands with the packageManager bump). pnpm 11 is a breaking config change, migrated canonically per https://pnpm.io/blog/releases/11.0: - The `pnpm` field in package.json is no longer read → moved to pnpm-workspace.yaml: overrides, allowBuilds, nodeLinker, autoInstallPeers, strictPeerDependencies, peerDependencyRules. .npmrc is now auth/registry only. - `allowBuilds` replaces `onlyBuiltDependencies` — a map that must classify EVERY install-script package as true/false. bcrypt/@swc/core/esbuild/… = true; @scarf/scarf (telemetry) = false. An unclassified one makes `pnpm install` exit non-zero and appends a broken stub (that stub was the pre-existing mess in the repo). Security overrides pruned 36 → 9. Each survivor is load-bearing: removing it lets the package resolve back INTO its vulnerable range (verified by a with/without lockfile diff, and confirmed by `pnpm audit` — clean before and after the prune). The 27 removed were no-ops (parents have since shipped fixed versions). Kept: ajv, @babel/core, picomatch, ws, uuid, vite, nodemailer, multer, js-yaml. Verified working on pnpm 11: - `pnpm install` (frozen + non-frozen), native builds (bcrypt), 0 ignored-builds errors - `pnpm audit` runs for real via the bulk endpoint → 0 vulnerabilities - every pnpm invocation in scripts/Dockerfile/CI: pnpm pack, store prune, install --prod --ignore-scripts, rebuild bcrypt — all exit 0; none use commands removed in v11 (install -g, link, server) - `pnpm run check` fully green (2107 tests) - CI (pnpm/action-setup@v6) and Docker (corepack) follow the packageManager field, so they use pnpm 11 with no workflow/Dockerfile edits Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/rules/package-management.md | 37 +- .npmrc | 4 +- FRAMEWORK-API.md | 2 +- migration-guides/11.27.6-to-11.27.7.md | 32 ++ package.json | 101 +--- pnpm-lock.yaml | 626 ++++++++++++------------- pnpm-workspace.yaml | 53 +++ 7 files changed, 411 insertions(+), 444 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/.claude/rules/package-management.md b/.claude/rules/package-management.md index a905969c..23c36728 100644 --- a/.claude/rules/package-management.md +++ b/.claude/rules/package-management.md @@ -97,9 +97,23 @@ peerDependencies may use ranges when necessary for compatibility with consuming The `pnpm-lock.yaml` file must always be committed. It provides additional reproducibility even if someone accidentally introduces a version range. +## Package Manager: pnpm 11 + +This repo is pinned to **pnpm 11** via the `packageManager` field (corepack/`pnpm/action-setup` follow it, so CI and Docker use it automatically — no version is hardcoded anywhere else). + +pnpm 11 **no longer reads the `pnpm` field in `package.json`**, and `.npmrc` is auth/registry only. All pnpm-specific settings live in **`pnpm-workspace.yaml`**: + +- `overrides:` — the security overrides (see below) +- `allowBuilds:` — a **map** of `pkg: true|false` classifying every package that has an install script (canonical v11 form; replaces `onlyBuiltDependencies`). Native builds we need are `true` (`bcrypt`, `@swc/core`, …); telemetry like `@scarf/scarf` is `false`. **Every build-script package must be classified**, or `pnpm install` exits non-zero with `ERR_PNPM_IGNORED_BUILDS` and appends a broken stub. +- `nodeLinker`, `autoInstallPeers`, `strictPeerDependencies`, `peerDependencyRules` — moved here from `.npmrc` (camelCase). + +`pnpm audit`: pnpm 10.x is broken (npm retired the legacy audit endpoint → HTTP 410); pnpm 11 uses the working bulk-advisory endpoint. `scripts/check.mjs` degrades the retired-endpoint failure to a non-blocking warning as a safety net, so `check` stays green + honest even if a future endpoint change lands. + ## Overrides -Package overrides are configured in the `pnpm.overrides` section of `package.json` and are typically used to force transitive dependencies to a security-patched version. +Package overrides live in the `overrides:` section of **`pnpm-workspace.yaml`** (they moved out of `package.json`'s `pnpm.overrides` in the pnpm 11 upgrade). They force transitive dependencies to a security-patched version. + +**Keep the set minimal.** On the pnpm 11 upgrade the list was pruned from 36 to the 9 still load-bearing — an override is only necessary if removing it lets the package resolve back INTO its vulnerable range (verify with a with/without lockfile diff; `pnpm audit` is the arbiter). Each surviving entry carries its CVE rationale as a comment. Remove an entry once its parent dependency ships a fixed version. ### Rule: Override Targets MUST Be Fixed Versions @@ -115,19 +129,14 @@ The **target** of an override (the value on the right-hand side) MUST be a fixed The **key** (left-hand side) of an override entry selects which installed versions the override applies to. Both forms are valid: -```json -{ - "pnpm": { - "overrides": { - // Form 1: Replace ALL versions of a package with a fixed one - "lodash": "4.17.23", - - // Form 2: Replace only vulnerable versions with a fixed patched one - "minimatch@<3.1.4": "3.1.4", - "path-to-regexp@>=8.0.0 <8.4.0": "8.4.2" - } - } -} +```yaml +# pnpm-workspace.yaml +overrides: + # Form 1: Replace ALL versions of a package with a fixed one + 'lodash': '4.17.23' + # Form 2: Replace only vulnerable versions with a fixed patched one + 'minimatch@<3.1.4': '3.1.4' + 'path-to-regexp@>=8.0.0 <8.4.0': '8.4.2' ``` Form 2 is preferred for security-driven overrides because it leaves non-vulnerable versions untouched, which reduces the blast radius of the override. diff --git a/.npmrc b/.npmrc index faf871f5..e793b24a 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1 @@ -node-linker=hoisted -strict-peer-dependencies=false -auto-install-peers=false +# Auth/registry settings only (pnpm 11+). pnpm-specific settings live in pnpm-workspace.yaml. diff --git a/FRAMEWORK-API.md b/FRAMEWORK-API.md index 8ae0287a..9898af7f 100644 --- a/FRAMEWORK-API.md +++ b/FRAMEWORK-API.md @@ -1,6 +1,6 @@ # @lenne.tech/nest-server — Framework API Reference -> Auto-generated from source code on 2026-07-14 (v11.27.7) +> Auto-generated from source code on 2026-07-15 (v11.27.7) > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code ## CoreModule.forRoot() diff --git a/migration-guides/11.27.6-to-11.27.7.md b/migration-guides/11.27.6-to-11.27.7.md index 3613e99c..318efd50 100644 --- a/migration-guides/11.27.6-to-11.27.7.md +++ b/migration-guides/11.27.6-to-11.27.7.md @@ -10,6 +10,7 @@ | **Bugfixes** | **(1)** Applications compiled with **SWC** (`nest start -b swc`, `nest build -b swc`) no longer crash at startup with `ReferenceError: Cannot access 'BETTER_AUTH_INSTANCE' before initialization` when BetterAuth is enabled. **(2)** A direct import of `core/common/inputs/combined-filter.input` no longer crashes under SWC with `ReferenceError: Cannot access 'CombinedFilterInput' before initialization`. **(3)** A consumer setting `betterAuth.options.advanced.defaultCookieAttributes` no longer silently strips the `Secure` flag from session cookies on an `https://` deployment. | | **Security** | See bugfix (3). If your project sets `advanced.defaultCookieAttributes` for any reason (cookie partitioning, a custom domain) **and** runs on an `https://` baseURL, your session cookies were being sent without `Secure`. Updating fixes this with no action required. | | **Deprecations** | The BetterAuth DI tokens re-exported from `core-better-auth.module` / `core-better-auth.service` are now marked deprecated in their docblocks. Import them from `core-better-auth.constants` (or the package root) instead. Both paths keep working. | +| **Toolchain** | The framework repo moved to **pnpm 11** (see the note below). **npm consumers are unaffected** — you do not inherit the framework's `packageManager` or `pnpm-workspace.yaml`. Relevant only if you contribute to the framework or mirror its toolchain. | | **Migration Effort** | **npm mode:** 0 minutes (automatic) — `pnpm update` is enough. **Vendor mode:** see the note below — the BetterAuth change is an atomic file set. | This is a **startup-crash + cookie-security bugfix release**. No source-code or @@ -17,6 +18,37 @@ config changes are required in consuming projects. --- +## Toolchain: pnpm 11 (framework contributors only) + +**npm consumers of `@lenne.tech/nest-server` can ignore this section** — installing the +package does not import our package manager or its config. + +The framework repo itself upgraded from pnpm 10 to **pnpm 11**, for one concrete reason: +**`pnpm audit` is broken on every pnpm 10.x** — npm retired the legacy audit endpoint +(HTTP 410) and only pnpm 11 uses the working bulk-advisory endpoint. The upgrade also +required migrating pnpm's config, because pnpm 11 has breaking changes: + +- **The `pnpm` field in `package.json` is gone.** All pnpm settings moved to + `pnpm-workspace.yaml`: `overrides`, `allowBuilds`, `nodeLinker`, `autoInstallPeers`, + `strictPeerDependencies`, `peerDependencyRules`. `.npmrc` is now auth/registry only. +- **`allowBuilds` replaced `onlyBuiltDependencies`** — a map that must classify *every* + package with an install script as `true` (build it, e.g. `bcrypt`) or `false` (don't, + e.g. the `@scarf/scarf` telemetry). An unclassified build-script package makes + `pnpm install` exit non-zero. +- **Security overrides pruned 36 → 9.** Only entries still load-bearing (removing them + lets the package resolve back into its vulnerable range) were kept; `pnpm audit` on + pnpm 11 confirms the pruned set is still clean. + +CI (`pnpm/action-setup`) and Docker (`corepack`) follow the `packageManager` field, so no +workflow or Dockerfile edits were needed — they use pnpm 11 automatically. + +**If you develop against or vendor the framework's toolchain:** switch to pnpm 11 +(`corepack use pnpm@11` or let corepack follow `packageManager`), and move any +`package.json#pnpm` settings you have into `pnpm-workspace.yaml`. See +`.claude/rules/package-management.md` for the full pattern. + +--- + ## Quick Migration No code changes required. diff --git a/package.json b/package.json index 65118875..9554dd06 100644 --- a/package.json +++ b/package.json @@ -184,104 +184,5 @@ "watch": { "build:dev": "src" }, - "packageManager": "pnpm@10.33.2", - "pnpm": { - "//overrides": { - "axios@<1.16.0": "Security: Multiple CVEs in axios <1.15.2 (SSRF via NO_PROXY, prototype pollution, header injection, CRLF injection in form-data, XSRF leakage, auth bypass, response tampering, no_proxy IP alias bypass, streamed upload/response bypass, toFormData DoS, null byte injection) - transitive via @getbrevo/brevo and node-mailjet. Remove when @getbrevo/brevo and node-mailjet ship axios>=1.16.0", - "fast-uri@<3.1.2": "Security: host confusion (GHSA-3vp4-mxqf-vmh4) and path traversal (GHSA-q4mq-mw7v-xq57) in fast-uri <3.1.2 - transitive via @compodoc/compodoc>@angular-devkit/schematics>@angular-devkit/core>ajv", - "@babel/plugin-transform-modules-systemjs@>=7.12.0 <7.29.4": "Security: prototype pollution-based variable shadowing (GHSA-7p7r-hpq8-6w8q) generates unsafe code - transitive via @compodoc/compodoc>@babel/preset-env", - "minimatch@<3.1.5": "Security: RegExp DoS (GHSA-p8p7-x288-28g6) - transitive via @getbrevo/brevo>rewire>eslint", - "minimatch@>=9.0.0 <9.0.9": "Security: RegExp DoS - transitive via @nestjs/cli>@swc/cli", - "minimatch@>=10.0.0 <10.2.5": "Security: RegExp DoS - transitive via @nestjs/apollo>ts-morph>@ts-morph/common and nodemon", - "ajv@<6.14.0": "Security: prototype pollution - transitive via @getbrevo/brevo>rewire>eslint", - "ajv@>=7.0.0-alpha.0 <8.18.0": "Security: prototype pollution - transitive via @nestjs/cli>@angular-devkit", - "undici@>=7.0.0 <7.28.0": "Security: various CVEs <7.28.0 (incl. high) - transitive via @compodoc/compodoc>cheerio + better-auth", - "piscina@<4.9.3": "Security: high CVE in piscina <4.9.3 - transitive via the build/test toolchain", - "@babel/core@<7.29.6": "Security: @babel/core <7.29.6 advisory - transitive via @compodoc/compodoc>@babel/preset-env", - "handlebars@>=4.0.0 <4.7.9": "Security: prototype pollution (GHSA-q42p-pg8m-cqh6) - transitive via @compodoc/compodoc", - "brace-expansion@<1.1.13": "Security: RegExp DoS - transitive via eslint>minimatch", - "brace-expansion@>=4.0.0 <5.0.6": "Security: RegExp DoS - Large numeric range defeats brace expansion (GHSA-jxxr-4gwj-5jf2) - transitive via nodemon>minimatch and @ts-morph/common>minimatch and @compodoc/compodoc>glob>minimatch", - "picomatch@<2.3.2": "Security: ReDoS - transitive via @nestjs/graphql>fast-glob>micromatch and @compodoc/compodoc>chokidar", - "picomatch@>=4.0.0 <4.0.4": "Security: ReDoS - transitive via vitest and vite", - "path-to-regexp@>=8.0.0 <8.4.2": "Security: ReDoS (GHSA-rhx6-c78j-4q9w) - transitive via express>router", - "kysely@>=0.26.0 <0.28.17": "Security: JSON-path traversal injection via unsanitized input (SQL injection) - transitive via better-auth>@better-auth/passkey>@better-auth/core", - "@protobufjs/utf8@<=1.1.0": "Security: overlong UTF-8 decoding - transitive via @apollo/server>@apollo/usage-reporting-protobuf", - "ws@>=8.0.0 <8.21.0": "Security: Memory exhaustion DoS + uninitialized memory disclosure (GHSA-96hv-2xvq-fx4p) - transitive via @nestjs/graphql", - "ws@>=7.0.0 <7.5.11": "Security: Memory exhaustion DoS in ws 7.x (GHSA-96hv-2xvq-fx4p) - transitive via @nestjs/graphql>subscriptions-transport-ws", - "qs@>=6.11.1 <=6.15.1": "Security: remotely triggerable DoS in qs.stringify with comma-format arrays (GHSA-q8mj-m7cp-5q26) - transitive via @compodoc/compodoc>body-parser", - "lodash@>=4.0.0 <4.18.0": "Security: CVE in lodash@4.17.x - transitive via @nestjs/graphql. 4.18.1 is the latest patched version", - "defu@<=6.1.6": "Security: prototype pollution via __proto__ key - transitive via better-auth", - "follow-redirects@<=1.15.11": "Security: Custom Authentication Headers leak on cross-domain redirect (GHSA-r4q5-vmmm-2653) - transitive via axios>@getbrevo/brevo and axios>node-mailjet", - "uuid@<14.0.0": "Security: Missing buffer bounds check in v3/v5/v6 (GHSA-w5hq-g745-h8pq) - transitive via @compodoc/compodoc and @compodoc/compodoc>@compodoc/live-server>http-auth", - "postcss@<8.5.10": "Security: XSS via Unescaped in CSS Stringify Output (GHSA-qx2v-qp2m-jg93) - transitive via vite. Remove when vite ships with postcss>=8.5.10", - "esbuild@>=0.17.0 <0.28.1": "Security: esbuild dev-server responds to any request origin, letting any website the developer visits read files it serves (GHSA-67mh-4wv8-2f99, moderate); pinned to the latest patched release to keep esbuild aligned across the toolchain - dev-only, transitive via vite>vitest and @nestjs build tooling. Remove when all consumers resolve esbuild>=0.28.1", - "form-data@<4.0.6": "Security: CRLF injection via unescaped multipart field names/filenames (GHSA-hmw2-7cc7-3qxx) - transitive via @getbrevo/brevo>axios and node-mailjet>axios", - "vite@>=8.0.0 <8.0.16": "Security: fs.deny bypass on Windows alternate paths + file read CVEs - transitive via better-auth>vitest", - "hono@<4.12.25": "Security: multiple CVEs <4.12.25 (prototype pollution, bodyLimit/Vary bypass, JWT NumericDate) - transitive via @nestjs/terminus>prisma>@prisma/dev", - "nodemailer@<9.0.1": "Security: email/header injection CVEs <9.0.1 - direct dependency", - "multer@<2.2.0": "Security: unhandled multipart errors / DoS <2.2.0 - transitive via @nestjs/platform-express", - "js-yaml@<4.2.0": "Security: special-character handling / prototype pollution (patched in 4.2.0; 4.1.2 was never published) - transitive via @nestjs/swagger", - "@xhmikosr/decompress@<11.1.3": "Security: archive extraction can create files/links outside the target directory (GHSA-mp2f-45pm-3cg9, critical) - transitive via @swc/cli>@xhmikosr/bin-wrapper>@xhmikosr/downloader; @swc/cli 0.8.1 is already the latest release and still resolves the vulnerable range, so an override is the only fix", - "morgan@<1.11.0": "Security: Log Forging via unneutralized control characters in :remote-user (GHSA-4vj7-5mj6-jm8m, moderate, patched in 1.11.0) - dev-only, transitive via @compodoc/compodoc>@compodoc/live-server>morgan" - }, - "overrides": { - "axios@<1.16.0": "1.16.0", - "fast-uri@<3.1.2": "3.1.2", - "@babel/plugin-transform-modules-systemjs@>=7.12.0 <7.29.4": "7.29.4", - "minimatch@<3.1.5": "3.1.5", - "minimatch@>=9.0.0 <9.0.9": "9.0.9", - "minimatch@>=10.0.0 <10.2.5": "10.2.5", - "ajv@<6.14.0": "6.14.0", - "ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0", - "undici@>=7.0.0 <7.28.0": "7.28.0", - "piscina@<4.9.3": "4.9.3", - "@babel/core@<7.29.6": "7.29.6", - "handlebars@>=4.0.0 <4.7.9": "4.7.9", - "brace-expansion@<1.1.13": "1.1.13", - "brace-expansion@>=4.0.0 <5.0.6": "5.0.6", - "picomatch@<2.3.2": "2.3.2", - "picomatch@>=4.0.0 <4.0.4": "4.0.4", - "path-to-regexp@>=8.0.0 <8.4.2": "8.4.2", - "kysely@>=0.26.0 <0.28.17": "0.28.17", - "@protobufjs/utf8@<=1.1.0": "1.1.1", - "ws@>=8.0.0 <8.21.0": "8.21.0", - "ws@>=7.0.0 <7.5.11": "7.5.11", - "qs@>=6.11.1 <=6.15.1": "6.15.2", - "lodash@>=4.0.0 <4.18.0": "4.18.1", - "defu@<=6.1.6": "6.1.7", - "follow-redirects@<=1.15.11": "1.16.0", - "uuid@<14.0.0": "14.0.0", - "postcss@<8.5.10": "8.5.12", - "esbuild@>=0.17.0 <0.28.1": "0.28.1", - "form-data@<4.0.6": "4.0.6", - "vite@>=8.0.0 <8.0.16": "8.0.16", - "hono@<4.12.25": "4.12.25", - "nodemailer@<9.0.1": "9.0.1", - "multer@<2.2.0": "2.2.0", - "js-yaml@<4.2.0": "4.2.0", - "@xhmikosr/decompress@<11.1.3": "11.1.3", - "morgan@<1.11.0": "1.11.0" - }, - "//peerDependencyRules": "allowedVersions: deps lag behind our newer majors (graphql-upload wants @types/express@^4, the deprecated apollo playground plugin wants @apollo/server@^4) — both work with our v5. ignoreMissing: browser-only vis-network peers pulled in transitively via yuml-diagram (server-side UML generation never renders, so these are not needed).", - "peerDependencyRules": { - "allowedVersions": { - "@apollo/server": "5", - "@types/express": "5" - }, - "ignoreMissing": [ - "@egjs/hammerjs", - "keycharm", - "vis-data", - "vis-util" - ] - }, - "onlyBuiltDependencies": [ - "bcrypt", - "@swc/core", - "esbuild", - "@nestjs/core", - "@compodoc/compodoc", - "@apollo/protobufjs" - ] - } + "packageManager": "pnpm@11.13.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3cc7afd..879954ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,42 +5,15 @@ settings: excludeLinksFromLockfile: false overrides: - axios@<1.16.0: 1.16.0 - fast-uri@<3.1.2: 3.1.2 - '@babel/plugin-transform-modules-systemjs@>=7.12.0 <7.29.4': 7.29.4 - minimatch@<3.1.5: 3.1.5 - minimatch@>=9.0.0 <9.0.9: 9.0.9 - minimatch@>=10.0.0 <10.2.5: 10.2.5 - ajv@<6.14.0: 6.14.0 ajv@>=7.0.0-alpha.0 <8.18.0: 8.18.0 - undici@>=7.0.0 <7.28.0: 7.28.0 - piscina@<4.9.3: 4.9.3 '@babel/core@<7.29.6': 7.29.6 - handlebars@>=4.0.0 <4.7.9: 4.7.9 - brace-expansion@<1.1.13: 1.1.13 - brace-expansion@>=4.0.0 <5.0.6: 5.0.6 - picomatch@<2.3.2: 2.3.2 picomatch@>=4.0.0 <4.0.4: 4.0.4 - path-to-regexp@>=8.0.0 <8.4.2: 8.4.2 - kysely@>=0.26.0 <0.28.17: 0.28.17 - '@protobufjs/utf8@<=1.1.0': 1.1.1 ws@>=8.0.0 <8.21.0: 8.21.0 - ws@>=7.0.0 <7.5.11: 7.5.11 - qs@>=6.11.1 <=6.15.1: 6.15.2 - lodash@>=4.0.0 <4.18.0: 4.18.1 - defu@<=6.1.6: 6.1.7 - follow-redirects@<=1.15.11: 1.16.0 uuid@<14.0.0: 14.0.0 - postcss@<8.5.10: 8.5.12 - esbuild@>=0.17.0 <0.28.1: 0.28.1 - form-data@<4.0.6: 4.0.6 vite@>=8.0.0 <8.0.16: 8.0.16 - hono@<4.12.25: 4.12.25 nodemailer@<9.0.1: 9.0.1 multer@<2.2.0: 2.2.0 js-yaml@<4.2.0: 4.2.0 - '@xhmikosr/decompress@<11.1.3': 11.1.3 - morgan@<1.11.0: 1.11.0 importers: @@ -48,22 +21,22 @@ importers: dependencies: '@apollo/server': specifier: 5.5.1 - version: 5.5.1(graphql@16.14.0) + version: 5.5.1(graphql@16.14.0)(supports-color@5.5.0) '@as-integrations/express5': specifier: 1.1.2 - version: 1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1) + version: 1.1.2(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0))(express@5.2.1) '@better-auth/passkey': specifier: 1.6.23 version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.3.6))(jose@6.2.1)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(mongodb@7.2.0)(vitest@4.1.7))(better-call@1.3.7(zod@4.3.6))(nanostores@1.1.1) '@getbrevo/brevo': specifier: 3.0.1 - version: 3.0.1 + version: 3.0.1(supports-color@5.5.0) '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0 '@nestjs/apollo': specifier: 13.4.2 - version: 13.4.2(@apollo/server@5.5.1(graphql@16.14.0))(@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1))(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(@nestjs/graphql@13.4.2(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2)(ts-morph@27.0.2))(graphql@16.14.0) + version: 13.4.2(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0))(@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0))(express@5.2.1))(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(@nestjs/graphql@13.4.2(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2)(ts-morph@27.0.2))(graphql@16.14.0) '@nestjs/common': specifier: 11.1.23 version: 11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -99,10 +72,10 @@ importers: version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@tus/file-store': specifier: 2.1.0 - version: 2.1.0 + version: 2.1.0(supports-color@5.5.0) '@tus/server': specifier: 2.4.1 - version: 2.4.1 + version: 2.4.1(supports-color@5.5.0) '@types/supertest': specifier: 7.2.0 version: 7.2.0 @@ -186,7 +159,7 @@ importers: version: 7.8.2 supertest: specifier: 7.2.2 - version: 7.2.2 + version: 7.2.2(supports-color@5.5.0) ts-morph: specifier: 27.0.2 version: 27.0.2 @@ -196,7 +169,7 @@ importers: devDependencies: '@compodoc/compodoc': specifier: 1.2.1 - version: 1.2.1(component-emitter@1.3.1)(typescript@5.9.3) + version: 1.2.1(component-emitter@1.3.1)(supports-color@5.5.0)(typescript@5.9.3) '@nestjs/cli': specifier: 11.0.21 version: 11.0.21(@swc/cli@0.8.1(@swc/core@1.15.40))(@swc/core@1.15.40)(@types/node@25.9.1)(esbuild@0.28.1) @@ -295,7 +268,7 @@ importers: version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(terser@5.46.0)(tsx@4.22.3) vite-plugin-node: specifier: 8.0.0 - version: 8.0.0(@swc/core@1.15.40)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(terser@5.46.0)(tsx@4.22.3)) + version: 8.0.0(@swc/core@1.15.40)(supports-color@5.5.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(terser@5.46.0)(tsx@4.22.3)) vitest: specifier: 4.1.7 version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(terser@5.46.0)(tsx@4.22.3)) @@ -973,7 +946,7 @@ packages: '@opentelemetry/api': ^1.9.0 better-call: 1.3.7 jose: ^6.1.0 - kysely: 0.28.17 + kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 peerDependenciesMeta: '@cloudflare/workers-types': @@ -1316,7 +1289,7 @@ packages: resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.25 + hono: ^4 '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} @@ -2643,6 +2616,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitest/coverage-v8@4.1.7': resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==} @@ -2819,7 +2793,7 @@ packages: ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: - ajv: 6.14.0 + ajv: ^6.9.1 ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} @@ -5881,7 +5855,7 @@ packages: peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.1.18 - esbuild: 0.28.1 + esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 @@ -6211,12 +6185,12 @@ snapshots: '@apollo/utils.logger': 3.0.0 graphql: 16.14.0 - '@apollo/server-plugin-landing-page-graphql-playground@4.0.1(@apollo/server@5.5.1(graphql@16.14.0))': + '@apollo/server-plugin-landing-page-graphql-playground@4.0.1(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0))': dependencies: - '@apollo/server': 5.5.1(graphql@16.14.0) + '@apollo/server': 5.5.1(graphql@16.14.0)(supports-color@5.5.0) '@apollographql/graphql-playground-html': 1.6.29 - '@apollo/server@5.5.1(graphql@16.14.0)': + '@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0)': dependencies: '@apollo/cache-control-types': 1.0.3(graphql@16.14.0) '@apollo/server-gateway-interface': 2.0.0(graphql@16.14.0) @@ -6230,10 +6204,10 @@ snapshots: '@apollo/utils.withrequired': 3.0.0 '@graphql-tools/schema': 10.0.32(graphql@16.14.0) async-retry: 1.3.3 - body-parser: 2.2.2 + body-parser: 2.2.2(supports-color@5.5.0) content-type: 1.0.5 cors: 2.8.6 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@5.5.0) graphql: 16.14.0 loglevel: 1.9.2 lru-cache: 11.2.6 @@ -6301,9 +6275,9 @@ snapshots: '@arr/every@1.0.1': {} - '@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1)': + '@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0))(express@5.2.1)': dependencies: - '@apollo/server': 5.5.1(graphql@16.14.0) + '@apollo/server': 5.5.1(graphql@16.14.0)(supports-color@5.5.0) express: 5.2.1 '@babel/code-frame@7.29.0': @@ -6320,7 +6294,7 @@ snapshots: '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.6': + '@babel/core@7.29.6(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.7 @@ -6329,7 +6303,7 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -6360,29 +6334,29 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.6)': + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.6)': + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-annotate-as-pure': 7.27.3 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.7(@babel/core@7.29.6)': + '@babel/helper-define-polyfill-provider@0.6.7(@babel/core@7.29.6(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3(supports-color@5.5.0) @@ -6395,24 +6369,24 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -6422,27 +6396,27 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.6)': + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.6)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -6460,7 +6434,7 @@ snapshots: '@babel/helper-wrap-function@7.28.6': dependencies: '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -6478,473 +6452,473 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.6)': + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.6)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) - '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.6)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.6)': + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.6) - '@babel/traverse': 7.29.0 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.6) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6) - '@babel/traverse': 7.29.0 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.6)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.6)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.6)': + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.6)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.6) - '@babel/traverse': 7.29.0 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.6)': + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.6)': + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.6)': + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.6)': + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/preset-env@7.28.6(@babel/core@7.29.6)': + '@babel/preset-env@7.28.6(@babel/core@7.29.6(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.6) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.6) - '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.6) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.6) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6) - '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.6) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.6) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.6) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.6) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.6) - '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.6) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.6) - '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.6) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.6) - babel-plugin-polyfill-corejs2: 0.4.16(@babel/core@7.29.6) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.6) - babel-plugin-polyfill-regenerator: 0.6.7(@babel/core@7.29.6) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.6(supports-color@5.5.0)) + babel-plugin-polyfill-corejs2: 0.4.16(@babel/core@7.29.6(supports-color@5.5.0))(supports-color@5.5.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.6(supports-color@5.5.0)) + babel-plugin-polyfill-regenerator: 0.6.7(@babel/core@7.29.6(supports-color@5.5.0)) core-js-compat: 3.48.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.6)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.6(supports-color@5.5.0))': dependencies: - '@babel/core': 7.29.6 + '@babel/core': 7.29.6(supports-color@5.5.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/types': 7.29.0 esutils: 2.0.3 @@ -6963,7 +6937,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.7 @@ -7057,16 +7031,16 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@compodoc/compodoc@1.2.1(component-emitter@1.3.1)(typescript@5.9.3)': + '@compodoc/compodoc@1.2.1(component-emitter@1.3.1)(supports-color@5.5.0)(typescript@5.9.3)': dependencies: '@angular-devkit/schematics': 21.1.0(chokidar@5.0.0) - '@babel/core': 7.29.6 - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.6) - '@babel/preset-env': 7.28.6(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0)) + '@babel/preset-env': 7.28.6(@babel/core@7.29.6(supports-color@5.5.0))(supports-color@5.5.0) '@compodoc/live-server': 1.2.3 '@compodoc/ngd-transformer': 2.1.3 '@polka/send-type': 0.5.2 - body-parser: 2.2.2 + body-parser: 2.2.2(supports-color@5.5.0) bootstrap.native: 5.1.6 cheerio: 1.1.2 chokidar: 5.0.0 @@ -7243,12 +7217,12 @@ snapshots: '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: - eslint: 8.57.1 + eslint: 8.57.1(supports-color@5.5.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/eslintrc@2.1.4': + '@eslint/eslintrc@2.1.4(supports-color@5.5.0)': dependencies: ajv: 6.14.0 debug: 4.4.3(supports-color@5.5.0) @@ -7264,11 +7238,11 @@ snapshots: '@eslint/js@8.57.1': {} - '@getbrevo/brevo@3.0.1': + '@getbrevo/brevo@3.0.1(supports-color@5.5.0)': dependencies: axios: 1.16.0 bluebird: 3.7.2 - rewire: 7.0.0 + rewire: 7.0.0(supports-color@5.5.0) transitivePeerDependencies: - debug - supports-color @@ -7325,7 +7299,7 @@ snapshots: dependencies: hono: 4.12.25 - '@humanwhocodes/config-array@0.13.0': + '@humanwhocodes/config-array@0.13.0(supports-color@5.5.0)': dependencies: '@humanwhocodes/object-schema': 2.0.3 debug: 4.4.3(supports-color@5.5.0) @@ -7622,10 +7596,10 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@nestjs/apollo@13.4.2(@apollo/server@5.5.1(graphql@16.14.0))(@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1))(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(@nestjs/graphql@13.4.2(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2)(ts-morph@27.0.2))(graphql@16.14.0)': + '@nestjs/apollo@13.4.2(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0))(@as-integrations/express5@1.1.2(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0))(express@5.2.1))(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(@nestjs/graphql@13.4.2(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2)(ts-morph@27.0.2))(graphql@16.14.0)': dependencies: - '@apollo/server': 5.5.1(graphql@16.14.0) - '@apollo/server-plugin-landing-page-graphql-playground': 4.0.1(@apollo/server@5.5.1(graphql@16.14.0)) + '@apollo/server': 5.5.1(graphql@16.14.0)(supports-color@5.5.0) + '@apollo/server-plugin-landing-page-graphql-playground': 4.0.1(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0)) '@nestjs/common': 11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.23)(@nestjs/websockets@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/graphql': 13.4.2(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(class-transformer@0.5.1)(class-validator@0.15.1)(graphql@16.14.0)(reflect-metadata@0.2.2)(ts-morph@27.0.2) @@ -7634,7 +7608,7 @@ snapshots: lodash.omit: 4.18.0 tslib: 2.8.1 optionalDependencies: - '@as-integrations/express5': 1.1.2(@apollo/server@5.5.1(graphql@16.14.0))(express@5.2.1) + '@as-integrations/express5': 1.1.2(@apollo/server@5.5.1(graphql@16.14.0)(supports-color@5.5.0))(express@5.2.1) '@nestjs/cli@11.0.21(@swc/cli@0.8.1(@swc/core@1.15.40))(@swc/core@1.15.40)(@types/node@25.9.1)(esbuild@0.28.1)': dependencies: @@ -8287,7 +8261,7 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tus/file-store@2.1.0': + '@tus/file-store@2.1.0(supports-color@5.5.0)': dependencies: '@tus/utils': 0.7.0 debug: 4.4.3(supports-color@5.5.0) @@ -8298,7 +8272,7 @@ snapshots: - '@opentelemetry/api' - supports-color - '@tus/server@2.4.1': + '@tus/server@2.4.1(supports-color@5.5.0)': dependencies: '@tus/utils': 0.7.0 debug: 4.4.3(supports-color@5.5.0) @@ -8307,7 +8281,7 @@ snapshots: srvx: 0.11.15 optionalDependencies: '@redis/client': 5.12.1 - ioredis: 5.10.0 + ioredis: 5.10.0(supports-color@5.5.0) transitivePeerDependencies: - '@node-rs/xxhash' - '@opentelemetry/api' @@ -8825,27 +8799,27 @@ snapshots: b4a@1.8.0: {} - babel-plugin-polyfill-corejs2@0.4.16(@babel/core@7.29.6): + babel-plugin-polyfill-corejs2@0.4.16(@babel/core@7.29.6(supports-color@5.5.0))(supports-color@5.5.0): dependencies: '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.6 - '@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.29.6(supports-color@5.5.0))(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.6): + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.6(supports-color@5.5.0)): dependencies: - '@babel/core': 7.29.6 - '@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.29.6(supports-color@5.5.0))(supports-color@5.5.0) core-js-compat: 3.48.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.7(@babel/core@7.29.6): + babel-plugin-polyfill-regenerator@0.6.7(@babel/core@7.29.6(supports-color@5.5.0)): dependencies: - '@babel/core': 7.29.6 - '@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.29.6) + '@babel/core': 7.29.6(supports-color@5.5.0) + '@babel/helper-define-polyfill-provider': 0.6.7(@babel/core@7.29.6(supports-color@5.5.0))(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -8932,7 +8906,7 @@ snapshots: bluebird@3.7.2: {} - body-parser@2.2.2: + body-parser@2.2.2(supports-color@5.5.0): dependencies: bytes: 3.1.2 content-type: 1.0.5 @@ -9492,13 +9466,13 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint@8.57.1: + eslint@8.57.1(supports-color@5.5.0): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) '@eslint-community/regexpp': 4.12.2 - '@eslint/eslintrc': 2.1.4 + '@eslint/eslintrc': 2.1.4(supports-color@5.5.0) '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/config-array': 0.13.0(supports-color@5.5.0) '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.3.0 @@ -9644,7 +9618,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.2.2(supports-color@5.5.0) content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 @@ -9654,7 +9628,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@5.5.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -9760,7 +9734,7 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@5.5.0): dependencies: debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 @@ -10125,7 +10099,7 @@ snapshots: dependencies: kind-of: 6.0.3 - ioredis@5.10.0: + ioredis@5.10.0(supports-color@5.5.0): dependencies: '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 @@ -11090,9 +11064,9 @@ snapshots: reusify@1.1.0: {} - rewire@7.0.0: + rewire@7.0.0(supports-color@5.5.0): dependencies: - eslint: 8.57.1 + eslint: 8.57.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -11417,7 +11391,7 @@ snapshots: make-asynchronous: 1.1.0 time-span: 5.1.0 - superagent@10.3.0: + superagent@10.3.0(supports-color@5.5.0): dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 @@ -11431,11 +11405,11 @@ snapshots: transitivePeerDependencies: - supports-color - supertest@7.2.2: + supertest@7.2.2(supports-color@5.5.0): dependencies: cookie-signature: 1.2.2 methods: 1.1.2 - superagent: 10.3.0 + superagent: 10.3.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -11745,7 +11719,7 @@ snapshots: component-emitter: 1.3.1 uuid: 14.0.0 - vite-plugin-node@8.0.0(@swc/core@1.15.40)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(terser@5.46.0)(tsx@4.22.3)): + vite-plugin-node@8.0.0(@swc/core@1.15.40)(supports-color@5.5.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(terser@5.46.0)(tsx@4.22.3)): dependencies: chalk: 4.1.2 debounce: 2.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..e1db5107 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,53 @@ +# pnpm settings (single source of truth). +# pnpm 11 no longer reads the "pnpm" field in package.json, and .npmrc is auth/registry only +# (https://pnpm.io/blog/releases/11.0). Everything pnpm-specific lives here. + +# Migrated from .npmrc (pnpm 11 no longer reads these from .npmrc): +nodeLinker: hoisted +autoInstallPeers: false +strictPeerDependencies: false + +# Packages allowed to run install scripts (native builds). pnpm 11 merges the old +# onlyBuiltDependencies/neverBuiltDependencies into this single allowBuilds map. +allowBuilds: + 'bcrypt': true + '@swc/core': true + 'esbuild': true + '@nestjs/core': true + '@compodoc/compodoc': true + '@apollo/protobufjs': true + '@scarf/scarf': false + +peerDependencyRules: + allowedVersions: + '@apollo/server': '5' + '@types/express': '5' + ignoreMissing: + - '@egjs/hammerjs' + - 'keycharm' + - 'vis-data' + - 'vis-util' + +# Security overrides: force vulnerable transitive deps onto patched versions. +# Pruned on the pnpm 11 upgrade to the 9 still load-bearing (each verified: removing it lets the +# package resolve back INTO the vulnerable range). 27 obsolete no-ops removed (parents upgraded); +# pnpm 11 `pnpm audit` (working again via the bulk endpoint) is the safety net for regressions. +overrides: + # Security: prototype pollution - transitive via @nestjs/cli>@angular-devkit + 'ajv@>=7.0.0-alpha.0 <8.18.0': '8.18.0' + # Security: @babel/core <7.29.6 advisory - transitive via @compodoc/compodoc>@babel/preset-env + '@babel/core@<7.29.6': '7.29.6' + # Security: ReDoS - transitive via vitest and vite + 'picomatch@>=4.0.0 <4.0.4': '4.0.4' + # Security: Memory exhaustion DoS + uninitialized memory disclosure (GHSA-96hv-2xvq-fx4p) - transitive via @nestjs/graphql + 'ws@>=8.0.0 <8.21.0': '8.21.0' + # Security: Missing buffer bounds check in v3/v5/v6 (GHSA-w5hq-g745-h8pq) - transitive via @compodoc/compodoc and @compodoc/compodoc>@compodoc/live-server>http-auth + 'uuid@<14.0.0': '14.0.0' + # Security: fs.deny bypass on Windows alternate paths + file read CVEs - transitive via better-auth>vitest + 'vite@>=8.0.0 <8.0.16': '8.0.16' + # Security: email/header injection CVEs <9.0.1 - direct dependency + 'nodemailer@<9.0.1': '9.0.1' + # Security: unhandled multipart errors / DoS <2.2.0 - transitive via @nestjs/platform-express + 'multer@<2.2.0': '2.2.0' + # Security: special-character handling / prototype pollution (patched in 4.2.0; 4.1.2 was never published) - transitive via @nestjs/swagger + 'js-yaml@<4.2.0': '4.2.0' From e2322913a7324a886460d9b6bbb666a2f8780a10 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Wed, 15 Jul 2026 08:52:44 +0200 Subject: [PATCH 20/21] test(e2e): isolate the run database per worker to kill the 401 flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e config runs spec files in parallel forks (fileParallelism: true), and global-setup gives the whole RUN one database. So every parallel file shared one DB — and a file that mutates a GLOBAL collection corrupted the others mid-run. Concrete failure: better-auth-integration.story.test.ts clears the `jwks` collection (BetterAuth's JWT signing keys, used implicitly by every BetterAuth instance) in a beforeAll. Run in parallel with better-auth-autoregister-false, it wiped the keys that file's token was signed with → its authenticated request got a spurious 401 (expected 200). Isolated, both pass; together under parallel load, the 401 appeared intermittently. Root cause, not flakiness. tests/setup.ts runs in every fork BEFORE the test file (and config.env.ts) is imported, so it appends the vitest pool id to MONGODB_URI there: each concurrent fork gets its own database (…-run--p-w). Files sharing a fork run sequentially (safe); files in different forks can no longer collide. This is the right layer — better-auth-integration uses ServerModule, which reads the DB from the already-imported config, so per-file overrides in the spec don't work. No reporter change needed: db-lifecycle.reporter drops every DB whose name starts with the run DB name, which already matches the -w forks — verified no orphaned databases remain after a run. Verified: the two colliding files pass together 3x; full `pnpm run check` green 3x in a row (2107 tests); zero orphaned -w databases afterwards. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/setup.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/setup.ts b/tests/setup.ts index 7c68033d..76160d19 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -19,6 +19,31 @@ * that assert a log call use `vi.spyOn(Logger, ...)`, which replaces the method outright and * keeps working regardless of the configured level. */ +/* + * Per-worker database isolation (e2e). + * + * `global-setup.ts` gives each RUN one database (`…-run--p`) via MONGODB_URI, and the e2e + * config runs spec files in PARALLEL forks (`fileParallelism: true`). All those forks share that one + * database — so a file that mutates a GLOBAL collection breaks every other file running at the same + * time. The concrete flake: `better-auth-integration` clears the `jwks` collection (BetterAuth's JWT + * signing keys, used implicitly by every BetterAuth instance) mid-run, which invalidates the tokens + * of a parallel `better-auth-*` spec → its authenticated request gets a spurious 401. + * + * This runs in every fork BEFORE the test file (and therefore before `config.env.ts`) is imported, + * so appending the vitest pool id to MONGODB_URI here gives each CONCURRENT fork its own database. + * Files that share a fork run sequentially (safe); files in different forks can no longer collide. + * + * Cleanup is already handled: `db-lifecycle.reporter.ts` drops every DB whose name starts with the + * run DB name (these are exactly `…-run--p-w`) on a passing run, and collects them as + * stale on the next run otherwise. An externally pinned MONGODB_URI (CI service container) still gets + * per-fork isolation; its mongod is ephemeral so the extra DBs are discarded with the container. + */ +if (process.env.MONGODB_URI && !/-w\d+(\?|$)/.test(process.env.MONGODB_URI)) { + const poolId = process.env.VITEST_POOL_ID || process.env.VITEST_WORKER_ID || '0'; + // Insert the suffix before the query string (…/dbname?opts → …/dbname-wN?opts). + process.env.MONGODB_URI = process.env.MONGODB_URI.replace(/(\/[^/?]+)(\?|$)/, `$1-w${poolId}$2`); +} + import { Logger } from '@nestjs/common'; const originalConsoleWarn = console.warn.bind(console); From 2c1eb571349f5e78de8165dd6ebf49277dd94b69 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Wed, 15 Jul 2026 08:55:21 +0200 Subject: [PATCH 21/21] test(e2e): point the failure-debug message at the per-worker fork DBs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-worker DB isolation put the actual test data in `${runDb}-w` fork databases, but the "kept for debugging" message still named the base `${runDb}` — which the main process holds but no fork writes to. A developer debugging a failed run would connect to an empty database. The failure branch now lists the real `${runDb}-w*` fork databases (best-effort; falls back to the base name if the listing fails). Verified end to end: a failing run keeps the fork DB with its data and names it correctly; the next passing run drops it (no orphaned databases). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/db-lifecycle.reporter.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/db-lifecycle.reporter.ts b/tests/db-lifecycle.reporter.ts index c8661a64..a3d059d3 100644 --- a/tests/db-lifecycle.reporter.ts +++ b/tests/db-lifecycle.reporter.ts @@ -104,10 +104,29 @@ export default class DbLifecycleReporter { } if (reason !== 'passed' || unhandledErrors.length > 0) { + // The actual test data lives in the per-worker fork databases (`${dbName}-w`, created in + // tests/setup.ts), NOT in `${dbName}` itself — the main process's URI names the base, but every + // fork suffixes it with its pool id. List the fork DBs so a developer debugging a failure + // connects to the right ones. Best-effort: fall back to the base name if the listing fails. + let debugTargets = [dbName]; + try { + const connection = await MongoClient.connect(uri); + try { + const { databases } = await connection.db().admin().listDatabases({ nameOnly: true }); + const forks = databases.map((d) => d.name).filter((n) => n.startsWith(`${dbName}-w`)); + if (forks.length > 0) { + debugTargets = forks; + } + } finally { + await connection.close(); + } + } catch { + /* keep the base-name fallback */ + } console.info( - `\n⚠ Test database kept for debugging: ${dbName}` - + `\n URI: ${serverUri}/${dbName}` - + '\n It will be removed automatically by the next successful test run.', + `\n⚠ Test database(s) kept for debugging:` + + debugTargets.map((name) => `\n ${serverUri}/${name}`).join('') + + '\n Removed automatically by the next successful test run.', ); return; }