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..7500978c 100644 --- a/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-docs-reviewer/MEMORY.md @@ -1,4 +1,7 @@ -- [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 +- [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/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-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/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..31e309b8 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -60,6 +60,72 @@ 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 → 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 + +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..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()`: @@ -250,6 +257,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 +345,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/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/.github/workflows/build.yml b/.github/workflows/build.yml index 25c7344d..0f016f59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,16 @@ jobs: - name: Optimize and check run: pnpm run prepublishOnly + # 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 + - name: Build run: pnpm run build 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/.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/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/FRAMEWORK-API.md b/FRAMEWORK-API.md index 7f966530..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-11 (v11.27.6) +> 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 new file mode 100644 index 00000000..318efd50 --- /dev/null +++ b/migration-guides/11.27.6-to-11.27.7.md @@ -0,0 +1,374 @@ +# Migration Guide: 11.27.6 → 11.27.7 + +## Overview + +| Category | Details | +|----------|---------| +| **Breaking Changes** | None | +| **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. | +| **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 +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. + +```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: 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 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. + +**Seven files are new.** If a sync is interrupted, verify all seven 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** + +| File | Change | +|------|--------| +| `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` | 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 | + +**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. + +--- + +## 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 -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 +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 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. + +--- + +## 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) diff --git a/package.json b/package.json index b9542eeb..9554dd06 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,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 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 ", @@ -183,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' diff --git a/scripts/check-swc-tdz.mjs b/scripts/check-swc-tdz.mjs new file mode 100644 index 00000000..f5e62952 --- /dev/null +++ b/scripts/check-swc-tdz.mjs @@ -0,0 +1,287 @@ +#!/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, rmSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { availableParallelism } from 'node:os'; +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), '..'); + +/** + * 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 + * 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/']; + +// 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_BASENAMES.includes(basename(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) }); + } + } + + // 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] ${relative(ROOT, DIST)}/ not found — run \`nest build -b swc -p tsconfig.swc-tdz.json\` 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`); +} + +// 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. + * + * 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'], + }); + + // 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', (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, a `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' + + ` 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/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")}`); 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 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/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. */ 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'; /** 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/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/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/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..f2099bbe --- /dev/null +++ b/src/core/modules/better-auth/core-better-auth.constants.ts @@ -0,0 +1,73 @@ +/** + * 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 + * + * 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. + * + * 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 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 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 38e48c75..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, CoreBetterAuthService } from './core-better-auth.service'; +import { CoreBetterAuthService } from './core-better-auth.service'; /** - * Token for injecting the better-auth instance + * @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 const BETTER_AUTH_INSTANCE = 'BETTER_AUTH_INSTANCE'; +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 38bf4c51..d96bf9ac 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,24 @@ export interface SessionResult { user: BetterAuthSessionUser | null; } +/** + * @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. * @@ -51,17 +69,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..52e97b27 100644 --- a/src/core/modules/better-auth/index.ts +++ b/src/core/modules/better-auth/index.ts @@ -39,8 +39,17 @@ 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'; +// 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/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() diff --git a/tests/db-lifecycle.reporter.ts b/tests/db-lifecycle.reporter.ts index fee7d36a..a3d059d3 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 @@ -94,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; } @@ -137,7 +166,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(); 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); 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/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); + }); +}); diff --git a/tests/unit/import-cycle-invariants.spec.ts b/tests/unit/import-cycle-invariants.spec.ts new file mode 100644 index 00000000..2e947871 --- /dev/null +++ b/tests/unit/import-cycle-invariants.spec.ts @@ -0,0 +1,305 @@ +/** + * 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 { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } 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'); +} + +/** + * 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', () => { + /** + * `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('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('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.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 does not import from core-ai.service at all', () => { + const source = read('core', 'modules', 'ai', 'services', 'core-ai-interaction.service.ts'); + + 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); + }); + }); +}); 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" + } +} 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({