diff --git a/.claude/agent-memory/lt-dev-security-reviewer/MEMORY.md b/.claude/agent-memory/lt-dev-security-reviewer/MEMORY.md index 06d640ba..17b4c9ca 100644 --- a/.claude/agent-memory/lt-dev-security-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-security-reviewer/MEMORY.md @@ -8,6 +8,7 @@ - [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'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) +- [project-betterauth-input-false-enforcement.md](project-betterauth-input-false-enforcement.md) — BA `input:false` privesc fix: single chokepoint parseInputData covers create/update/social; keyed on object-KEY not fieldName (shadow-key gap); nest-server native $set path disjoint ## Review Methodology diff --git a/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-input-false-enforcement.md b/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-input-false-enforcement.md new file mode 100644 index 00000000..92d2ea3b --- /dev/null +++ b/.claude/agent-memory/lt-dev-security-reviewer/project-betterauth-input-false-enforcement.md @@ -0,0 +1,25 @@ +--- +name: project-betterauth-input-false-enforcement +description: How Better-Auth `input:false` on user additionalFields is enforced (single chokepoint parseInputData, keyed on object-key not fieldName) + the roles/verified privesc fix completeness +metadata: + type: project +--- + +Better-Auth `input: false` on `user.additionalFields` (roles/verified/verifiedAt/twoFactorEnabled/iamId privilege-escalation fix in `better-auth.config.ts` `buildUserFields` + `PROTECTED_INPUT_FALSE_KEYS` re-assertion loop). + +**Why:** confirmed vertical privesc — any authed user could `POST /iam/update-user {"roles":["admin"]}` (raw-forwarded to BA native handler under sessionMiddleware, bypassing controller `@Roles(ADMIN)` + `checkRoles`). Fix marks server-managed fields `input:false`. + +**How to apply — the enforcement is a SINGLE chokepoint** (`node_modules/better-auth/dist/db/schema.mjs` `parseInputData`, BA 1.6.23), so covering it covers ALL native user-write routes at once: +- `parseUserInput(create)` = sign-up: `input:false` + defaultValue → SILENTLY substitutes default (no 400). So sign-up privesc is blocked but returns 201, not FIELD_NOT_ALLOWED. +- `parseUserInput(update)` = update-user: `input:false` → throws FIELD_NOT_ALLOWED (400). +- `parseAdditionalUserInputFromProviderProfile` = social/OAuth link: explicitly `if (schema[key]?.input===false) continue` BEFORE parse — stronger, filtered out. +- `getFields(user,'input')` returns ONLY additionalFields (coreSchema is `{}` for input mode). So BA core fields (email/emailVerified/password) are NOT settable via update-user regardless: `email` throws EMAIL_CAN_NOT_BE_UPDATED, the rest are silently dropped (not keys in the input schema). additionalFields were the ONLY privesc vector. +- Controller-handled `/sign-up/email` (in CONTROLLER_HANDLED_PATHS) is triple-safe: DTO `CoreBetterAuthSignUpInput` has no roles, handler passes only `{email,name,password}` to `api.signUpEmail`, and BA would substitute defaults anyway. + +**Residual completeness gaps (both project-config-only, LOW/INFO — external attacker cannot trigger):** +1. **Shadow-key gap:** both `parseInputData` and the re-assertion loop key on the object-KEY, not `fieldName`/column. A project declaring `additionalUserFields: { anyKey: { fieldName: 'roles', input: true } }` escapes the hard-lock. Hardening: also reject any non-protected field whose `fieldName` collides with a protected column. +2. **admin plugin:** default plugins are jwt/twoFactor/passkey only. If a project adds BA's admin plugin, it introduces singular `role`/`banned`/`banReason`/`banExpires` NOT in `PROTECTED_INPUT_FALSE_KEYS`. nest-server authz uses `roles` (array), so singular `role` is orthogonal; admin endpoints are admin-gated by the plugin. + +**nest-server's own role path is disjoint and unaffected:** `UserService.setRoles` / `CrudService.update` / user-mapper `linkOrCreateUser`/`mapSessionUser` write via native Mongoose `$set`, never `api.updateUser`. The one regression class to check on any future change: a sync path that writes a protected field THROUGH `api.updateUser` would now 400. + +Rejection is HTTP 400 (BAD_REQUEST) — does NOT touch the 401/403 auto-logout policy or `securityCheck` (input-parse stage, pre-persistence). diff --git a/.claude/agent-memory/lt-dev-test-reviewer/MEMORY.md b/.claude/agent-memory/lt-dev-test-reviewer/MEMORY.md index c9106bca..17388200 100644 --- a/.claude/agent-memory/lt-dev-test-reviewer/MEMORY.md +++ b/.claude/agent-memory/lt-dev-test-reviewer/MEMORY.md @@ -2,3 +2,4 @@ - [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. +- [Single E2E File Run Gotcha](single-e2e-file-run-gotcha.md) — `pnpm run test:e2e -- ` does NOT filter; the nested `pnpm run vitest` alias swallows the `--` positional and the WHOLE suite runs. Call vitest directly to isolate one file. diff --git a/.claude/agent-memory/lt-dev-test-reviewer/single-e2e-file-run-gotcha.md b/.claude/agent-memory/lt-dev-test-reviewer/single-e2e-file-run-gotcha.md new file mode 100644 index 00000000..826ca83a --- /dev/null +++ b/.claude/agent-memory/lt-dev-test-reviewer/single-e2e-file-run-gotcha.md @@ -0,0 +1,17 @@ +--- +name: single-e2e-file-run-gotcha +description: `pnpm run test:e2e -- ` does NOT filter to one file — it runs the whole e2e suite, because test:e2e is a nested `pnpm run vitest` alias that swallows the `--` positional +metadata: + type: project +--- + +**`pnpm run test:e2e -- tests/stories/foo.e2e-spec.ts` runs the ENTIRE e2e suite, not that one file.** Confirmed empirically 2026-07-15: the command produced `Test Files 52 passed (52) / Tests 1387 passed (1387)`, i.e. every file ran. + +**Why:** `package.json` chains aliases — `test:e2e` = `"pnpm run vitest"`, and `vitest` = `"NODE_ENV=e2e vitest run --config vitest-e2e.config.ts"`. The `-- ` positional is consumed forwarding into the FIRST `pnpm run` and is lost at the nested `pnpm run vitest` boundary, so the real `vitest run` executes with no file filter. + +**How to apply:** +- To run a single e2e file, bypass the double-alias — invoke vitest directly: + `NODE_ENV=e2e npx vitest run --config vitest-e2e.config.ts tests/stories/foo.e2e-spec.ts` + (verify the summary shows `Test Files 1 passed (1)` before trusting it as isolated). +- This matters for the test-reviewer/orchestrator flow: when a background `check` run is already exercising the full suite, a naive `test:e2e -- ` "quick check" silently launches a SECOND full suite in parallel. That is not catastrophic — per [[e2e-isolation-model]] each RUN gets its own DB (`nest-server-e2e-run--p`), and concurrent full suites are verified safe — but it wastes ~2min and defeats the intent of "run just the one file". +- Observed cleanly here: my run (`...-p31704`) and the concurrent background check (`...-p16805`) used separate run-DBs and the reporter dropped each independently; no interference. diff --git a/.claude/rules/better-auth.md b/.claude/rules/better-auth.md index 5a4a16bf..a79cb660 100644 --- a/.claude/rules/better-auth.md +++ b/.claude/rules/better-auth.md @@ -50,6 +50,42 @@ All Better-Auth code must be implemented with maximum security: 3. **One-time use** - Tokens/challenges must be deleted after use 4. **Secrets protection** - Never expose internal tokens to clients 5. **Cookie signing** - Use proper HMAC signatures for cookies +6. **Server-managed user fields are `input: false`** - Any user field that must never be settable + from client input (privilege/verification/2FA/identity boundaries) MUST be registered with + `input: false` on the Better-Auth additional-fields schema, and re-asserted AFTER the + `additionalUserFields` merge so a project override cannot re-open it (see below) + +### Rule: Server-managed user fields must be `input: false` (and re-asserted after the merge) + +`buildUserFields()` (`better-auth.config.ts`) builds the Better-Auth `user.additionalFields`. Better-Auth +defaults `input: true`, so **any additional field is client-settable via `POST /iam/sign-up/email` and +`POST /iam/update-user`** — and `/iam/update-user` runs under Better-Auth's native `sessionMiddleware` +(any authenticated user), bypassing the controller's class-level `@Roles(ADMIN)` and nest-server's +`checkRoles`. A server-managed field left at the default is therefore a direct vulnerability: + +| Field | If client-settable | +|-------|--------------------| +| `roles` | vertical privilege escalation (self-granting `admin`) | +| `verified` / `verifiedAt` | email-verification bypass | +| `twoFactorEnabled` | self-toggling the 2FA state | +| `iamId` | identity / account-linking hijack | + +**Invariants (enforced in `buildUserFields()`):** + +1. Every server-managed core field is declared with `input: false`. +2. The security-critical subset lives in a single `PROTECTED_INPUT_FALSE_KEYS` constant and is + **re-asserted after** the project `additionalUserFields` merge — an override (even one that + replaces the field wholesale, dropping `input`) cannot silently re-open a protected key. +3. The re-assertion also locks any **shadow field** whose `fieldName` maps to a protected column + (e.g. `{ customRoles: { fieldName: 'roles', input: true } }`), so the lock is column-scoped, not + just key-scoped. +4. `input: false` only gates Better-Auth's native input parsing. nest-server's own role path + (`UserService.setRoles`, `CrudService.update` via `checkRoles`, the user mapper's `$set`) does + **not** use it and stays fully functional — verify with a positive no-regression test. + +When adding a new server-managed field, add `input: false`; if it is a privilege/verification/identity +boundary, also add its key to `PROTECTED_INPUT_FALSE_KEYS`. Regression coverage lives in +`tests/stories/better-auth-privilege-escalation.e2e-spec.ts`. ### Security Review Checklist diff --git a/.claude/rules/configurable-features.md b/.claude/rules/configurable-features.md index 8997a0ed..dbaf5113 100644 --- a/.claude/rules/configurable-features.md +++ b/.claude/rules/configurable-features.md @@ -204,6 +204,7 @@ This pattern is currently applied to: | BetterAuth Passkey Plugin | `betterAuth.passkey` | Boolean Shorthand | `rpName: 'Nest Server'` | | BetterAuth Cross-Subdomain Cookies | `betterAuth.crossSubDomainCookies` | Boolean Shorthand | `domain: auto (appUrl → baseUrl without api. prefix)` | | BetterAuth Disable Sign-Up | `betterAuth.emailAndPassword.disableSignUp` | Explicit Boolean | `false` (sign-up enabled) | +| BetterAuth Server-Managed User Fields | `betterAuth.additionalUserFields[].input` | Explicit Boolean | `true` (Better-Auth default). `input: false` makes Better-Auth reject client-supplied values (`FIELD_NOT_ALLOWED` HTTP 400 on `/iam/update-user`; server default substituted on sign-up). The core fields `roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, `iamId` are hard-locked to `input: false` via `PROTECTED_INPUT_FALSE_KEYS`, **re-asserted after** the `additionalUserFields` merge (and for any shadow field whose `fieldName` maps to a protected column) — a project override cannot re-open them. Closes vertical privilege-escalation (self-granting `roles`), email-verification bypass and 2FA self-toggle via the Better-Auth native input path. nest-server's own role path (`UserService.setRoles` / `CrudService.update` `checkRoles`) is unaffected. `termsAndPrivacyAcceptedAt` is `input: false` by default but intentionally NOT hard-locked (consent timestamp, not a privilege boundary) | | System Setup | `systemSetup` | Enabled by Default (when BetterAuth active) | `initialAdmin: undefined` | | GraphQL | `graphQl` | Explicit Disable (`false`) | Enabled (full GraphQL stack) | | Mongoose Password Plugin | `security.mongoosePasswordPlugin` | Boolean Shorthand | `true` (enabled), `skipPatterns: []` | diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 0e55d876..f2e17076 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -45,8 +45,10 @@ pnpm run test:cleanup `NODE_ENV`; vitest defaults it to `test`, and `getEnvironmentConfig()` falls back to `config.local`. - Both runners load `tests/setup.ts` via `setupFiles` (Nest Logger restricted to `error`/`fatal`, `@UnifiedField` deprecation warnings filtered). -- Database (E2E only): **one unique database per run** (`nest-server-e2e-run--p`), created by `tests/global-setup.ts` so concurrent runs cannot interfere with each other -- DB lifecycle (`tests/db-lifecycle.reporter.ts`): run passes → DB dropped immediately + stale run DBs from crashed/failed runs collected; run fails → DB kept for debugging, removed by the next successful run. An externally set `MONGODB_URI` (CI) opts out of the scheme. +- Database (E2E only): **one unique database per run** (`nest-server-e2e-run--p`), created by `tests/global-setup.ts` so concurrent runs cannot interfere with each other. Specs needing an extra DB derive it via `deriveTestDbUri('')` — never a hardcoded/`Date.now()` name (escapes the cleanup scheme). +- DB lifecycle (`tests/db-lifecycle.reporter.ts`): run passes → DB dropped immediately + stale run DBs from crashed/failed runs collected; run fails → DB kept for debugging. Additionally `tests/global-setup.ts` runs a **startup sweep** (shared `isStaleTestDb()` predicate, dead-PID/age guarded) — leftovers are removed when the NEXT run starts, which survives SIGKILL (check watchdog) and `--reporter` CLI overrides. An externally set `MONGODB_URI` (CI) opts out of the scheme. +- Run governor (`tests/e2e-run-slots.ts`): machine-wide slot dir (`/lt-e2e-run-slots`) caps concurrent e2e runs across ALL lt projects/sessions (default 2 on ≥8 cores). Further runs wait, logging `[e2e-governor] waiting…` every 15s (keeps the check watchdog fed — a queued run is NOT hung). The e2e config counts foreign slots at load time and drops to low-resource mode (reduced forks, raised timeouts) when another run is active — deterministic, unlike the lagging 1-min load average (kept as second signal). Knobs: `LT_E2E_MAX_RUNS` (0 disables), `LT_E2E_SLOT_DIR`, `LT_E2E_SLOT_TIMEOUT` (fail-open). +- `retry: 2` (e2e) is deliberate — with `retry: 5`, one spec file with broken app/socket state ground through 6 attempts × 30s timeout × 22 tests ≈ an hour at 0% CPU (looked like a deadlock; the check watchdog killed it). Never raise retry to paper over contention. - Test helper: `src/test/test.helper.ts` - Coverage: Collected from `src/**/*.{ts,js}`. The two runners are separate vitest processes, so they write separate reports (`coverage/unit`, `coverage/e2e`) rather than overwriting each other. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f016f59..d8df5db5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,8 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v6 + with: + version: 11 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ab7b96ff..6f85269e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,6 +19,8 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v6 + with: + version: 11 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 diff --git a/FRAMEWORK-API.md b/FRAMEWORK-API.md index 5693bb30..047003c6 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-15 (v11.28.1) +> Auto-generated from source code on 2026-07-16 (v11.29.0) > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code ## CoreModule.forRoot() @@ -196,6 +196,14 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen - `enabled?`: `boolean` (default: `true (enabled by default when BetterAuth is active)`) — Whether sign-up checks are enabled. - `requiredFields?`: `string[]` (default: `['termsAndPrivacyAccepted']`) — Fields that must be provided and truthy during sign-up. +### IBetterAuthUserField + + - `defaultValue?`: `unknown` — Default value for the field + - `fieldName?`: `string` — Database field name (if different from key) + - `input?`: `boolean` (default: `true (Better-Auth default when omitted)`) — Whether a client may supply this field's value via Better-Auth's native input parsing + - `required?`: `boolean` — Whether this field is required + - `type`: `BetterAuthFieldType` — Field type + ### ServiceOptions - `checkRights?`: `boolean` diff --git a/docs/REQUEST-LIFECYCLE.md b/docs/REQUEST-LIFECYCLE.md index 628c564c..2cc1e2d6 100644 --- a/docs/REQUEST-LIFECYCLE.md +++ b/docs/REQUEST-LIFECYCLE.md @@ -528,6 +528,18 @@ Authenticates the request using three strategies in priority order: If authentication succeeds, `req.user` is set with the authenticated user (including `hasRole()` method). +> **Native `/iam/*` routes bypass the `@Roles()`/`checkRoles` layer.** Better-Auth's own endpoints +> (e.g. `POST /iam/update-user`, `POST /iam/sign-up/email`) that are **not** in +> `CONTROLLER_HANDLED_PATHS` are forwarded raw by `CoreBetterAuthApiMiddleware` to Better-Auth's +> native handler under its `sessionMiddleware` — i.e. reachable by **any authenticated user**, +> independent of the controller's class-level `@Roles(ADMIN)` and nest-server's `checkRoles`. This is +> why server-managed user fields (`roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, `iamId`) are +> locked at the Better-Auth schema layer with `input: false` (see `betterAuth.additionalUserFields[].input` +> in `.claude/rules/configurable-features.md` and `.claude/rules/better-auth.md` §2): field-level +> input rejection is the correct control here because the guard layer does not run on these +> raw-forwarded routes. A forged `POST /iam/update-user {"roles":["admin"]}` is rejected with +> `FIELD_NOT_ALLOWED` (HTTP 400) at the input-parse stage, before any persistence. + #### 3. graphqlUploadExpress Only for GraphQL routes. Handles multipart file upload requests according to the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). diff --git a/migration-guides/11.28.1-to-11.29.0.md b/migration-guides/11.28.1-to-11.29.0.md new file mode 100644 index 00000000..7d71a65d --- /dev/null +++ b/migration-guides/11.28.1-to-11.29.0.md @@ -0,0 +1,231 @@ +# Migration Guide: 11.28.1 → 11.29.0 + +## Overview + +| Category | Details | +|----------|---------| +| **Breaking Changes** | Server-managed Better-Auth user fields (`roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, `iamId`, and by default `termsAndPrivacyAcceptedAt`) are now registered with `input: false`, so Better-Auth's **native** input parsing rejects client-supplied values. Behavioral change on `POST /iam/update-user` and `POST /iam/sign-up/email`. | +| **Security Fix** | Closes a confirmed **vertical privilege-escalation** vulnerability (OWASP A01): any authenticated user could self-grant `admin` via `POST /iam/update-user {"roles":["admin"]}`. | +| **New Features** | New per-field `input?: boolean` option on `IBetterAuthUserField` (`betterAuth.additionalUserFields`) so projects can mark their own fields as server-managed. New parallel-safe e2e test infrastructure (machine-wide run governor, startup sweep, `retry: 2`) — optional adoption, see below. | +| **Bugfix** | `CoreUserService.create()` now checks email uniqueness at application level (the unique index alone misses duplicates on a fresh database while autoIndex is still building — same window on freshly deployed production DBs). **Behavioral note:** a duplicate email via `userService.create()` now always yields **400 "Email address already in use"** — previously, callers other than `signUp` could see a raw **422 Unprocessable Entity** when the error surfaced as a Mongo duplicate-key error. Adjust tests that asserted the 422. | +| **Migration Effort** | **~0 minutes for most projects** (`pnpm update`). **Audit required** only if your project relied on client-setting one of the locked fields via the Better-Auth input path — see below. | + +MINOR was incremented because this repo's scheme treats behavioral/breaking changes as MINOR (MAJOR mirrors the NestJS version, still 11). + +--- + +## Why this change + +Better-Auth registers `betterAuth.additionalUserFields` as native `additionalFields`, and Better-Auth +defaults each field to `input: true` — i.e. **client-settable** via `POST /iam/sign-up/email` and +`POST /iam/update-user`. Critically, `/iam/update-user` is **not** in `CONTROLLER_HANDLED_PATHS`, so +`CoreBetterAuthApiMiddleware` forwards it raw to Better-Auth's native handler under its +`sessionMiddleware` — reachable by **any authenticated user**, bypassing the controller's class-level +`@Roles(ADMIN)` and nest-server's `checkRoles`. + +Before 11.29.0 the server-managed `roles` field was registered **without** `input: false`. The result: + +```http +POST /iam/update-user +Authorization: Bearer + +{ "roles": ["admin"] } +``` + +…wrote `roles: ["admin"]` onto the caller's own user row, which then became authoritative for +`@Restricted(RoleEnum.ADMIN)`. A freshly self-registered user could make themselves admin. + +## What changed + +Server-managed core fields are now registered with `input: false` and **re-asserted after** any +project `additionalUserFields` are merged, so a project override cannot silently re-open them. The +security-critical, hard-locked set (`PROTECTED_INPUT_FALSE_KEYS`) is: + +| Field | Prevented attack | +|-------|------------------| +| `roles` | vertical privilege escalation (self-granting `admin`) | +| `verified` / `verifiedAt` | email-verification bypass | +| `twoFactorEnabled` | self-toggling the 2FA state | +| `iamId` | identity / account-linking hijack | + +The lock is **column-scoped**: a shadow field whose `fieldName` maps to one of these columns +(e.g. `{ customRoles: { fieldName: 'roles', input: true } }`) is also forced to `input: false`. + +Runtime behavior after the change: + +- `POST /iam/update-user {"roles":[...]}` (or `verified`, etc.) → **`FIELD_NOT_ALLOWED` (HTTP 400)**. +- `POST /iam/sign-up/email {"roles":[...]}` → account created with the **server default** (`roles: []`); + the forged value is silently dropped, not persisted. +- `termsAndPrivacyAcceptedAt` is `input: false` by **default** but is intentionally **not** in the + hard-lock set (it is a consent timestamp, not a privilege boundary) — a project may re-open it with + `additionalUserFields: { termsAndPrivacyAcceptedAt: { type: 'date', input: true } }`. + +**Unaffected:** nest-server's own role-assignment path — `UserService.setRoles`, +`CrudService.update` (via `checkRoles`), and the Better-Auth user mapper's native `$set` writes — does +**not** use Better-Auth input parsing and keeps working exactly as before. `input: false` only gates +the Better-Auth native sign-up/update-user routes. + +--- + +## Quick Migration (npm mode) + +For the overwhelming majority of projects, **no code change is required**: + +```bash +pnpm add @lenne.tech/nest-server@11.29.0 +pnpm run build +pnpm test +``` + +Your existing role assignment (admin panels calling `updateUser`/`setRoles`, sign-up flows, +verification flows) continues to work — those go through nest-server's service layer, not the +Better-Auth native input path. + +--- + +## New: Parallel-Safe e2e Test Infrastructure (Optional Adoption) + +This release also overhauls the e2e test infrastructure so that MANY parallel sessions +(`lt ticket` worktrees, several projects, agent sessions) can run test suites on one machine +without starving each other or leaving database garbage behind. **These are repository files, not +npm-package code** — `pnpm update` does NOT bring them into your project. Adopt them by syncing the +test files from [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (or via the +lt-dev fullstack update agent): + +| File | What it adds | +|------|--------------| +| `tests/e2e-run-slots.ts` (new) | **Machine-wide run governor**: at most N concurrent e2e runs across ALL lt projects (slot files in the OS temp dir, PID-liveness crash recovery, fail-open). Knobs: `LT_E2E_MAX_RUNS` (0 disables), `LT_E2E_SLOT_DIR`, `LT_E2E_SLOT_TIMEOUT`. | +| `tests/global-setup.ts` | **Startup sweep** — stale test DBs of this project AND leftover upload-test artifacts (`tests/*.txt` / `*.bin` from aborted file-upload specs) are removed when the next run STARTS, so cleanup survives SIGKILL (watchdog) and `--reporter` CLI overrides; then acquires a governor slot. | +| `tests/db-lifecycle.reporter.ts` | Exports the shared `isStaleTestDb()` predicate used by the sweep; failure message now points to the startup-sweep semantics. | +| `vitest-e2e.config.ts` | Auto low-resource mode when **another e2e run is active** (deterministic slot signal — the 1-minute load average structurally lags short runs) or load is high; **`retry: 2`** (a higher retry turns one broken spec file into an hour-long 0%-CPU grind that looks like a deadlock). | +| `tests/unit/e2e-run-slots.spec.ts` (new) | Unit tests for the governor. | + +Measured effect (12-core machine): two overlapping full-speed runs previously drove load to 30 with +spurious 401 failures; with the governor, 6 parallel project checks all pass in 109–125s. A run +printing `[e2e-governor] waiting for a free e2e slot` every 15s is **queued, not hung**. + +Rule carried over from the reporter docs: specs that need an extra database must derive it via +`deriveTestDbUri('')` — never a hardcoded or `Date.now()`-based name — and drop their DB +**after** `app.close()` (async module init can re-create a database dropped while the app is alive). + +**Related: `scripts/check.mjs` (canonical version distributed by the lt CLI).** The report-driven +check wrapper gained the same robustness set across all lt base repos: an **idle-watchdog** that +kills a test step after 300s of silence and diagnoses it as a hang (`CHECK_IDLE_TIMEOUT` / +`--idle-timeout=`, 0 disables), whole-process-tree kills (no orphaned fork workers), a +SIGTERM/SIGKILL exit hint (a killed step is not an assertion failure), summed vitest metrics +(unit + e2e runs are no longer under-reported), and the audit rendered like every other step. +Consumer projects receive it via `lt fullstack update` (self-heal; skips a `scripts/check.mjs` +with uncommitted local edits) — `lt dev doctor` warns when the local copy drifts from the +canonical version. + +--- + +## Audit Before Upgrading + +> **Mirrors the v11.28.x precedent in [`.claude/rules/role-system.md`](../.claude/rules/role-system.md) +> ("Audit every `S_SELF`/`S_CREATOR` on an input type before upgrading").** + +You only need to act if **both** are true for your project: + +1. You send `roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, `iamId` (or + `termsAndPrivacyAcceptedAt`) in the **body of `POST /iam/update-user` or `POST /iam/sign-up/email`** + (i.e. through Better-Auth's native input path), **and** +2. You expected that value to be persisted. + +If so, those requests will now receive `400 FIELD_NOT_ALLOWED` (update-user) or silently get the +server default (sign-up). **Move the assignment to the correct server-side path:** + +```typescript +// BEFORE (relied on Better-Auth native input — now rejected): +// POST /iam/update-user { "roles": ["admin"] } + +// AFTER — assign roles through nest-server's service layer (authorized + audited): +await this.userService.setRoles(userId, [RoleEnum.ADMIN]); +// or, from an admin-driven update that runs checkRoles: +await this.userService.update(userId, { roles: [RoleEnum.ADMIN] }, { currentUser: adminUser }); +``` + +If you deliberately need one of your **own** `additionalUserFields` to be server-managed, set +`input: false` on it: + +```typescript +betterAuth: { + additionalUserFields: { + internalScore: { type: 'number', defaultValue: 0, input: false }, // rejects client input + }, +} +``` + +**Note:** You cannot re-open a hard-locked key (`roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, +`iamId`) via `additionalUserFields` — the re-assertion re-locks it by design. This is intentional and +is the security guarantee of this release. + +--- + +## Compatibility Notes + +| Pattern | Status | +|---------|--------| +| Admin panel updating roles via `UserService`/`CrudService`/GraphQL `updateUser` | ✅ Unaffected — service layer, not Better-Auth input | +| Sign-up / email verification / 2FA enable-disable flows | ✅ Unaffected — server-managed | +| Reading `roles`/`verified` in responses | ✅ Unaffected — `input: false` only gates writes | +| Client sending `roles`/`verified` in `/iam/update-user` or `/iam/sign-up/email` body | ⚠️ Now rejected/dropped — this was the vulnerability | +| Custom `additionalUserFields` (non-protected keys) | ✅ Unaffected — still client-settable unless you set `input: false` | + +### Toolchain: `engines.pnpm` now advertised + +`package.json` now declares `engines.pnpm: "^11.0.0"` (and drops the repo-internal `packageManager` +pin). Impact on consumers is **low and pnpm-only**: + +- **npm / yarn consumers:** unaffected — they ignore `engines.pnpm`. +- **pnpm consumers on pnpm ≥ 11:** unaffected. +- **pnpm consumers on pnpm < 11:** you may see an `EBADENGINE` / `ERR_PNPM_UNSUPPORTED_ENGINE` + **warning** on install (a hard error only if you run with `engine-strict=true`). Upgrade to pnpm 11 + (`corepack use pnpm@11` or `npm i -g pnpm@11`) to silence it. The framework's runtime does not + depend on pnpm — this only concerns the install step for pnpm-based projects. + +No `engines.node` change (still `>= 22`). + +### Vendor-mode consumers (`src/core/` copied into your project) + +This release touches only `src/core/modules/better-auth/better-auth.config.ts`, +`src/core/common/interfaces/server-options.interface.ts` and +`src/core/modules/user/core-user.service.ts` (bugfix: application-level email-uniqueness check in +`create()` — the unique index alone misses duplicates on a fresh database while autoIndex is still +building; raw Mongo duplicate-key errors now also map to the correct "Email address already in use" +message) — no new files, no new imports, no new runtime dependencies. After syncing `src/core/` (via `/lt-dev:backend:update-nest-server-core` / +`lt-dev:nest-server-core-updater`), no additional package or config work is required. Run your +Better-Auth tests to confirm. + +--- + +## Troubleshooting + +### A client integration started getting `400 FIELD_NOT_ALLOWED` on `/iam/update-user` + +Working as intended. The client was setting a server-managed field (`roles`/`verified`/…) through the +Better-Auth native input path. Move that assignment to the server (`UserService.setRoles` / +`CrudService.update` with an authorized `currentUser`), or — for your own field — decide whether it +should really be `input: false`. + +### A user's `roles` no longer update after sign-up + +If you relied on passing `roles` in the sign-up body, that value is now dropped (server default +applied). Assign roles explicitly after account creation via `UserService.setRoles`, or through your +admin-provisioning flow. + +### I need one of my own additional fields to reject client input + +Add `input: false` to it in `betterAuth.additionalUserFields`. See the "Audit" section above. + +--- + +## References + +- [Role System — S_ roles, 401/403 policy, S_SELF/S_CREATOR ownership](../.claude/rules/role-system.md) +- [Better-Auth Module Rules — §2 server-managed fields must be `input: false`](../.claude/rules/better-auth.md) +- [Configurable Features — BetterAuth Server-Managed User Fields](../.claude/rules/configurable-features.md) +- [Request Lifecycle — native `/iam/*` routes bypass the guard layer](../docs/REQUEST-LIFECYCLE.md) +- Regression test: `tests/stories/better-auth-privilege-escalation.e2e-spec.ts` +- [Migration Guide 11.28.0 → 11.28.1](./11.28.0-to-11.28.1.md) — previous release +- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation) diff --git a/package.json b/package.json index b6efd46f..70e25daf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lenne.tech/nest-server", - "version": "11.28.1", + "version": "11.29.0", "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 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:raw": "pnpm install --frozen-lockfile && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && 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 && pnpm run check:manifest && 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 && pnpm run check:manifest && bash scripts/check-server-start.sh", + "check:manifest": "node scripts/check-package-manifest.mjs", "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", @@ -75,7 +76,8 @@ "url": "https://github.com/lenneTech/nest-server/issues" }, "engines": { - "node": ">= 22" + "node": ">= 22", + "pnpm": "^11.0.0" }, "dependencies": { "@apollo/server": "5.5.1", @@ -187,6 +189,5 @@ ], "watch": { "build:dev": "src" - }, - "packageManager": "pnpm@11.13.0" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b14c9c03..c4941e11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,7 @@ overrides: uuid@<14.0.0: 14.0.0 '@babel/core@<7.29.6': 7.29.6 js-yaml@<4.2.0: 4.2.0 + websocket-driver@<0.7.5: 0.7.5 importers: @@ -5957,8 +5958,8 @@ packages: webpack-cli: optional: true - websocket-driver@0.7.4: - resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + websocket-driver@0.7.5: + resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==} engines: {node: '>=0.8.0'} websocket-extensions@0.1.4: @@ -6284,7 +6285,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.7 '@babel/generator': 7.29.7 @@ -6293,7 +6294,7 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -6324,29 +6325,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)(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)': 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) @@ -6359,24 +6360,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.7 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.7 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.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -6386,27 +6387,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.7 transitivePeerDependencies: - supports-color @@ -6422,7 +6423,7 @@ snapshots: '@babel/helper-wrap-function@7.28.6': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -6436,473 +6437,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)(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)': 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)(supports-color@5.5.0) - 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.7 esutils: 2.0.3 @@ -6921,7 +6922,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.7 '@babel/generator': 7.29.7 @@ -7013,9 +7014,9 @@ snapshots: '@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)(supports-color@5.5.0) + '@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 @@ -8773,27 +8774,27 @@ snapshots: b4a@1.8.0: {} - babel-plugin-polyfill-corejs2@0.4.16(@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): 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)(supports-color@5.5.0) + '@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)(supports-color@5.5.0) + '@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)(supports-color@5.5.0) + '@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 @@ -9659,7 +9660,7 @@ snapshots: faye-websocket@0.11.4: dependencies: - websocket-driver: 0.7.4 + websocket-driver: 0.7.5 fdir@6.5.0(picomatch@4.0.4): optionalDependencies: @@ -11794,7 +11795,7 @@ snapshots: - esbuild - uglify-js - websocket-driver@0.7.4: + websocket-driver@0.7.5: dependencies: http-parser-js: 0.5.10 safe-buffer: 5.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a45fe87c..731c49da 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -45,3 +45,5 @@ overrides: '@babel/core@<7.29.6': '7.29.6' # Security: Quadratic-complexity DoS in merge key (GHSA-h67p-54hq-rp68, patched >=4.1.2 but 4.1.2 was never published, so 4.2.0 is the minimum available fix) - transitive via @nestjs/swagger + @compodoc/compodoc (resolves to vulnerable 4.1.1 without this) 'js-yaml@<4.2.0': '4.2.0' + # Security: Message corruption via protocol length headers (GHSA-xv26-6w52-cph6, critical) + resource-limit bypass via message compression (GHSA-mp7j-qc5w-4988, moderate), patched >=0.7.5 - transitive via @compodoc/compodoc>@compodoc/live-server>faye-websocket (dev-only doc toolchain) + 'websocket-driver@<0.7.5': '0.7.5' diff --git a/scripts/check-package-manifest.mjs b/scripts/check-package-manifest.mjs new file mode 100644 index 00000000..bbbe4de0 --- /dev/null +++ b/scripts/check-package-manifest.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Verifies that what package.json PROMISES actually ships in the tarball. + * + * Two silent failure modes this catches — both produce a green `check` and a + * broken release: + * + * 1. A `files` pattern that matches nothing. Rename `.claude/rules/` or move + * `src/`, and npm packs zero files for that entry without any warning. The + * published package is simply missing them — vendor mode (which consumes + * `src/**`) or the lt-dev agent rules then break in consuming projects, + * not here. + * 2. `main` / `types` / `bin` pointing at a path that is not packed, e.g. + * after a build-output move. `check` builds into dist/ and is happy; the + * installed package cannot be required at all. + * + * Runs on the ALREADY BUILT tree (--ignore-scripts, no prepack) — so put it + * after the build step in the `check` chain, where dist/ is fresh. + * + * Exit code: 0 when every promise holds, 1 otherwise. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); + +let raw; +try { + raw = execFileSync("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} catch (error) { + console.error("[package-manifest] `npm pack --dry-run` failed:"); + console.error(`${error.stdout ?? ""}${error.stderr ?? ""}`.trim().split("\n").slice(-10).join("\n")); + process.exit(1); +} + +const packed = JSON.parse(raw)[0].files.map((f) => f.path); +const problems = []; + +// 1. Every `files` entry must contribute at least one file. +for (const pattern of pkg.files ?? []) { + const base = pattern.replace(/\/\*\*\/\*$/, "").replace(/\/\*$/, ""); + const hits = packed.filter((f) => f === base || f.startsWith(`${base}/`)).length; + if (hits === 0) { + problems.push(`files entry "${pattern}" matches nothing — it will ship empty`); + } +} + +// 2. Every advertised entry point must actually be in the tarball. This covers +// the `exports` map too — a subpath like "./testing" pointing at an unpacked +// file fails only in the consumer, on import. +function exportTargets(node, path = "exports", out = []) { + if (typeof node === "string") { + if (node.startsWith("./")) out.push([path, node]); + } else if (node && typeof node === "object") { + for (const [key, value] of Object.entries(node)) exportTargets(value, `${path}.${key}`, out); + } + return out; +} + +const entries = [ + ["main", pkg.main], + ["types", pkg.types], + ["module", pkg.module], + ...Object.entries(pkg.bin ?? {}).map(([k, v]) => [`bin.${k}`, v]), + ...exportTargets(pkg.exports), +]; +for (const [label, target] of entries) { + if (!target) continue; + const path = String(target).replace(/^\.\//, ""); + if (!packed.includes(path)) { + problems.push(`${label} -> "${target}" is not in the tarball`); + } +} + +if (problems.length > 0) { + console.error("[package-manifest] the published package would be broken:\n"); + for (const p of problems) console.error(` ✗ ${p}`); + console.error(`\n Inspect with: npm pack --dry-run --ignore-scripts`); + process.exit(1); +} + +console.log(`[package-manifest] ok — ${packed.length} files, every files entry and entry point resolves`); diff --git a/scripts/check.mjs b/scripts/check.mjs index 2ac9c689..ea2dca5b 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -535,9 +535,9 @@ async function main() { if (!TTY) process.stdout.write(` ${C.dim("→")} audit\n`); else drawLive([`${C.cyan(FRAMES[0])} audit`]); const audit = await runAudit(auditCmd); - liveCount = 0; const dur = Date.now() - t; if (audit.blocking) { + liveCount = 0; // the failure line must survive — nothing may overwrite it const summary = audit.counts ? `${audit.total} vuln (${renderVulnLine(audit.counts)})` : "failed"; @@ -550,16 +550,21 @@ async function main() { } 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. + // "no vulnerabilities", which is a claim we did not verify. Warnings stay visible. + liveCount = 0; 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)})`)}`, + } else if (!TTY) { + process.stdout.write( + ` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`, ); } + // TTY success: NO permanent line — the live status view overwrites the audit + // row (like every other step), and the result lands in the report twice: + // the Steps list (entry below) and the Vulnerabilities section. results.push({ audit, kind: "audit" }); + results.push({ dur, kind: "step", label: "audit", project: "." }); } // Per-project steps — parallel by default, serial with --sequential. diff --git a/scripts/generate-framework-api.ts b/scripts/generate-framework-api.ts index 68c7b3c2..f7325c32 100644 --- a/scripts/generate-framework-api.ts +++ b/scripts/generate-framework-api.ts @@ -232,7 +232,7 @@ function main() { } // IBetterAuth is a type alias (union), extract the underlying interfaces - const betterAuthInterfaces = ['IBetterAuthPasskeyConfig', 'IBetterAuthTwoFactorConfig', 'IBetterAuthJwtConfig', 'IBetterAuthEmailVerificationConfig', 'IBetterAuthRateLimit', 'IBetterAuthSignUpChecksConfig']; + const betterAuthInterfaces = ['IBetterAuthPasskeyConfig', 'IBetterAuthTwoFactorConfig', 'IBetterAuthJwtConfig', 'IBetterAuthEmailVerificationConfig', 'IBetterAuthRateLimit', 'IBetterAuthSignUpChecksConfig', 'IBetterAuthUserField']; for (const name of betterAuthInterfaces) { const iface = serverOptionsSf.getInterface(name); diff --git a/spectaql.yml b/spectaql.yml index c35f52b1..8a0e7434 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.28.1 + version: 11.29.0 contact: name: lenne.Tech GmbH url: https://lenne.tech diff --git a/src/core/common/interfaces/server-options.interface.ts b/src/core/common/interfaces/server-options.interface.ts index f220cbd1..874f14ef 100644 --- a/src/core/common/interfaces/server-options.interface.ts +++ b/src/core/common/interfaces/server-options.interface.ts @@ -727,6 +727,18 @@ export interface IBetterAuthUserField { */ fieldName?: string; + /** + * Whether a client may supply this field's value via Better-Auth's native input parsing + * (sign-up create / update-user). + * + * When `false`, Better-Auth rejects client-supplied values: it throws `FIELD_NOT_ALLOWED` + * on the update-user route and silently substitutes the server-side default on sign-up. + * Use this to mark server-managed fields that must never be set from client input. + * + * @default true (Better-Auth default when omitted) + */ + input?: boolean; + /** * Whether this field is required */ diff --git a/src/core/modules/better-auth/README.md b/src/core/modules/better-auth/README.md index 602d2c4a..57fbf9fa 100644 --- a/src/core/modules/better-auth/README.md +++ b/src/core/modules/better-auth/README.md @@ -641,6 +641,8 @@ const config = { department: { type: 'string', required: true }, preferences: { type: 'string', defaultValue: '{}' }, isActive: { type: 'boolean', defaultValue: true }, + // Server-managed field: reject any client-supplied value (sign-up / update-user) + internalScore: { type: 'number', defaultValue: 0, input: false }, }, }, }; @@ -648,6 +650,26 @@ const config = { **Available field types:** `'string'`, `'number'`, `'boolean'`, `'date'`, `'json'`, `'string[]'`, `'number[]'` +**Field options:** + +| Option | Type | Default | Description | +| -------------- | ---------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | field type | — | Required. One of the field types above. | +| `defaultValue` | any | — | Value used when the client does not (or may not) supply one. | +| `required` | boolean | `false` | Whether the field is required. | +| `input` | boolean | `true` (Better-Auth default) | When `false`, Better-Auth rejects client-supplied values: it throws `FIELD_NOT_ALLOWED` (HTTP 400) on `POST /iam/update-user` and substitutes the server default on sign-up. Use it to mark **server-managed** fields that must never be set from client input. | + +> **⚠️ Security — server-managed core fields are hard-locked.** The core fields `roles`, `verified`, +> `verifiedAt`, `twoFactorEnabled` and `iamId` are registered with `input: false` and **re-asserted +> after** your `additionalUserFields` are merged, so a project override **cannot** re-open them for +> client input. This closes a vertical privilege-escalation path (a client can no longer self-grant +> `roles: ['admin']`, self-verify, or toggle 2FA via `POST /iam/update-user`). The lock also applies +> to any shadow field whose `fieldName` maps to one of those columns. `roles`/`verified` etc. are +> still assigned server-side through nest-server's own service layer (`UserService.setRoles`, +> `CrudService.update` with `checkRoles`) — that path does not use Better-Auth input parsing and is +> unaffected. (`termsAndPrivacyAcceptedAt` is `input: false` by default but is intentionally NOT +> hard-locked — it is a consent timestamp, not a privilege boundary, so a project may re-open it.) + ### Module Integration (Recommended Pattern) By default (`autoRegister: false`), projects integrate BetterAuth via an **extended module** in their project. This follows the same pattern as Legacy Auth and allows for custom resolvers, controllers, and project-specific authentication logic. diff --git a/src/core/modules/better-auth/better-auth.config.ts b/src/core/modules/better-auth/better-auth.config.ts index 52c9b171..aaac3130 100644 --- a/src/core/modules/better-auth/better-auth.config.ts +++ b/src/core/modules/better-auth/better-auth.config.ts @@ -275,6 +275,14 @@ interface SocialProviderConfig { interface UserFieldConfig { defaultValue?: unknown; fieldName?: string; + /** + * Whether a client may supply this field's value via Better-Auth's native input parsing + * (sign-up create / update-user). When `false`, Better-Auth rejects client-supplied values + * (throws FIELD_NOT_ALLOWED on update, silently substitutes the server default on create). + * Defaults to `true` in Better-Auth when omitted. Used to lock server-managed fields + * (e.g. `roles`) so authenticated users cannot self-set them. + */ + input?: boolean; required?: boolean; type: BetterAuthFieldType; } @@ -808,6 +816,28 @@ export function buildTrustedOrigins( return undefined; } +/** + * Server-managed, security-critical user fields whose `input: false` MUST be re-asserted after + * merging project-supplied `additionalUserFields`, so a project override cannot silently re-open + * a protected key. Each of these, if client-settable, is a concrete vulnerability: + * + * - `roles` → vertical privilege escalation (self-granting `admin`) + * - `verified` → email-verification bypass + * - `verifiedAt` → email-verification bypass + * - `twoFactorEnabled` → self-toggling the 2FA state + * - `iamId` → identity / account-linking hijack + * + * Note: `termsAndPrivacyAcceptedAt` is server-managed by default (input:false above) but is + * intentionally NOT hard-locked here — a project may legitimately choose to accept a client-set + * consent timestamp; it is not a privilege boundary. + * + * These protections only concern Better-Auth's native input parsing (sign-up / update-user). + * nest-server's own role assignment (UserService/CrudService `checkRoles` + Mongoose writes, + * `setRoles`, and the user mapper's native `$set` writes) does NOT use Better-Auth input parsing + * and is therefore unaffected by `input: false`. + */ +const PROTECTED_INPUT_FALSE_KEYS = ['iamId', 'roles', 'twoFactorEnabled', 'verified', 'verifiedAt'] as const; + /** * Builds the user additional fields configuration. * Merges core fields (firstName, lastName, etc.) with custom fields from config. @@ -824,6 +854,9 @@ function buildUserFields(config: IBetterAuth): Record { iamId: { defaultValue: null, fieldName: 'iamId', + // Server-managed: linked to the Better-Auth identity by the user mapper (native DB write), + // never from client input. input:false blocks identity/account-linking hijack. + input: false, type: 'string', }, lastName: { @@ -834,45 +867,83 @@ function buildUserFields(config: IBetterAuth): Record { roles: { defaultValue: [], fieldName: 'roles', + // Server-managed: assigned via UserService/CrudService (checkRoles guard) + setRoles, + // never from client input. input:false blocks vertical privilege escalation + // (e.g. a self-registered user POSTing {"roles":["admin"]} to /iam/update-user). + input: false, type: 'string[]', }, // Track when terms and privacy policy were accepted (for sign-up checks) termsAndPrivacyAcceptedAt: { defaultValue: null, fieldName: 'termsAndPrivacyAcceptedAt', + // Server-managed consent timestamp: set by the sign-up flow, not client input. + input: false, type: 'date', }, twoFactorEnabled: { defaultValue: false, fieldName: 'twoFactorEnabled', + // Server-managed: toggled by the 2FA enable/disable flow, never from client input. + // input:false blocks self-toggling the 2FA state. + input: false, type: 'boolean', }, verified: { defaultValue: false, fieldName: 'verified', + // Server-managed: synced from Better-Auth email verification, never from client input. + // input:false blocks email-verification bypass. + input: false, type: 'boolean', }, // Track when email was verified (synced from Better-Auth) verifiedAt: { defaultValue: null, fieldName: 'verifiedAt', + // Server-managed: synced from Better-Auth email verification, never from client input. + // input:false blocks email-verification bypass. + input: false, type: 'date', }, }; // Merge with custom additional fields from configuration - // Custom fields can override core fields or add new ones + // Custom fields can override core fields or add new ones. + // The `input` flag is carried through so projects can mark their own fields as server-managed. if (config.additionalUserFields) { for (const [key, field] of Object.entries(config.additionalUserFields)) { coreFields[key] = { defaultValue: field.defaultValue, fieldName: field.fieldName || key, + input: field.input, required: field.required, type: field.type, }; } } + // Security: re-assert input:false on server-managed, security-critical fields AFTER the merge, + // so a project-supplied additionalUserFields override cannot silently re-open a protected key + // (privilege escalation, email-verification bypass, 2FA bypass, identity hijack). + for (const key of PROTECTED_INPUT_FALSE_KEYS) { + if (coreFields[key]) { + coreFields[key].input = false; + } + } + + // Security (defense-in-depth): the loop above keys on the well-known object key. A project could + // still reopen a protected COLUMN by registering a shadow field under a different key that maps to + // it, e.g. `additionalUserFields: { customRoles: { fieldName: 'roles', input: true } }`. Lock any + // such field too, so no additionalUserFields entry — regardless of its key — can feed client input + // into a protected column. + const protectedColumns = new Set(PROTECTED_INPUT_FALSE_KEYS); + for (const [key, field] of Object.entries(coreFields)) { + if (!protectedColumns.has(key) && field.fieldName && protectedColumns.has(field.fieldName)) { + field.input = false; + } + } + return coreFields; } diff --git a/src/core/modules/user/core-user.service.ts b/src/core/modules/user/core-user.service.ts index 7d1921ff..a72ebb27 100644 --- a/src/core/modules/user/core-user.service.ts +++ b/src/core/modules/user/core-user.service.ts @@ -53,6 +53,19 @@ export abstract class CoreUserService< serviceOptions = prepareServiceOptionsForCreate(serviceOptions); return this.process( async (data) => { + // Application-level email-uniqueness check. The unique index alone is NOT + // sufficient: on a FRESH database Mongoose builds indexes asynchronously + // (autoIndex), so an early duplicate sign-up can slip through before the + // index exists — observed as a load-dependent e2e flake, and the same + // window exists on a freshly deployed production database. The index + // remains the backstop for truly concurrent duplicate requests. + if (data.input?.email) { + const existing = await this.mainDbModel.findOne({ email: data.input.email }).lean().exec(); + if (existing) { + throw new BadRequestException('Email address already in use'); + } + } + // Create user with verification token const currentUserId = serviceOptions?.currentUser?._id; const createdUser = new this.mainDbModel({ @@ -66,7 +79,7 @@ export abstract class CoreUserService< try { await createdUser.save(); } catch (error) { - if (error?.errors?.email?.kind === 'unique') { + if (error?.errors?.email?.kind === 'unique' || error?.code === 11000) { throw new BadRequestException('Email address already in use'); } else { throw new UnprocessableEntityException(); diff --git a/tests/db-lifecycle.reporter.ts b/tests/db-lifecycle.reporter.ts index a3d059d3..d0bc28bb 100644 --- a/tests/db-lifecycle.reporter.ts +++ b/tests/db-lifecycle.reporter.ts @@ -71,6 +71,42 @@ function isPidAlive(pid: number): boolean { } } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Is `name` a leftover test database of the given base name that no live run owns? + * + * Shared between the end-of-run cleanup (reporter below) and the START-of-run sweep in + * tests/global-setup.ts. The startup sweep is what makes cleanup survive every abnormal + * end: a SIGKILLed run (check.mjs watchdog escalation), a run started with an explicit + * `--reporter` CLI flag (which replaces the config reporters, including this one), or a + * crashed terminal — none of them can clean up after themselves, so the NEXT run cleans + * up BEFORE it starts instead. + * + * Callers must additionally apply SAFE_TEST_DB_PATTERN before dropping. + */ +export function isStaleTestDb(name: string, base: string, now: number = Date.now()): boolean { + if (name === base) { + // Legacy fixed-name test DB (pre unique-name scheme) — nothing writes it anymore. + return true; + } + if (name.startsWith(`${base}-run-`)) { + // Another run's DB (or a DB derived from it): stale when its creating + // process is dead or it exceeded the age cap. + const match = name.match(/-run-(\d+)-p(\d+)/); + return match + ? !isPidAlive(Number(match[2])) || now - Number(match[1]) > STALE_MAX_AGE_MS + : false; + } + // Legacy pre-unique-scheme leftovers carrying a trailing timestamp, + // e.g. `-setup-1783062745355` from aborted runs of older code. + // The 1h age guard protects a concurrently running old-code suite. + const legacy = name.match(new RegExp(`^${escapeRegExp(base)}-.+-(\\d{13})$`)); + return legacy ? now - Number(legacy[1]) > 60 * 60 * 1000 : false; +} + /** * Vitest reporter managing the lifecycle of per-run test databases * (created by tests/global-setup.ts): @@ -105,28 +141,26 @@ 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]; + // tests/setup.ts), NOT in `${dbName}` itself. COMPACT on purpose: this block prints inside + // the failure output, and check.mjs shows only the LAST ~40 lines of a failed step — a + // full 27-line DB listing used to flood that window and push the actual test failure out + // of sight. The pattern + count is enough to connect to the right database. + let forkCount = 0; 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; - } + forkCount = databases.filter((d) => d.name.startsWith(`${dbName}-w`)).length; } finally { await connection.close(); } } catch { - /* keep the base-name fallback */ + /* count stays 0 — the pattern line below is still correct */ } console.info( - `\n⚠ Test database(s) kept for debugging:` - + debugTargets.map((name) => `\n ${serverUri}/${name}`).join('') - + '\n Removed automatically by the next successful test run.', + `\n⚠ Test databases kept for debugging: ${serverUri}/${dbName}-w` + + (forkCount > 0 ? ` (${forkCount} databases, one per worker fork + derived)` : '') + + '\n Removed automatically when the next test run starts (startup sweep).', ); return; } @@ -139,33 +173,14 @@ export default class DbLifecycleReporter { dropped.push(dbName); const base = dbName.replace(RUN_DB_PATTERN, ''); - // Legacy pre-unique-scheme leftovers carrying a trailing timestamp, - // e.g. `-setup-skip-1783062745355` from aborted runs of older - // code. The 1h age guard protects a concurrently running old-code suite. - const legacyTimestamped = new RegExp(`^${base}-.+-(\\d{13})$`); const { databases } = await connection.db().admin().listDatabases({ nameOnly: true }); for (const { name } of databases) { if (name === dbName) { continue; } - let stale = false; - if (name.startsWith(`${dbName}-`)) { - // DB derived from THIS run's name (e.g. its setup-skip DB) — the run is over. - stale = true; - } else if (name === base) { - // Legacy fixed-name test DB (pre unique-name scheme) — nothing writes it anymore. - stale = true; - } else if (name.startsWith(`${base}-run-`)) { - // Another run's DB (or a DB derived from it): stale when its - // creating process is dead or it exceeded the age cap. - const match = name.match(/-run-(\d+)-p(\d+)/); - stale = match - ? !isPidAlive(Number(match[2])) || Date.now() - Number(match[1]) > STALE_MAX_AGE_MS - : false; - } else { - const legacy = name.match(legacyTimestamped); - stale = legacy ? Date.now() - Number(legacy[1]) > 60 * 60 * 1000 : false; - } + // DBs derived from THIS run's name (per-worker `-w` and their derived + // suffixes) — the run is over, they go regardless of PID/age. + const stale = name.startsWith(`${dbName}-`) || isStaleTestDb(name, base); // 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(); diff --git a/tests/e2e-run-slots.ts b/tests/e2e-run-slots.ts new file mode 100644 index 00000000..5a973758 --- /dev/null +++ b/tests/e2e-run-slots.ts @@ -0,0 +1,211 @@ +import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import { join } from 'node:path'; + +/** + * Machine-wide e2e run governor. + * + * Problem this solves: every `lt dev` / `lt ticket` session that runs `check` starts a full + * e2e suite, and vitest's default fork count assumes an EXCLUSIVE machine (`numCpus - 1`). + * Two overlapping full-speed runs saturate the box (measured: load 30 on 12 cores), requests + * queue past the testTimeout, auth queries come back as spurious 401s, and one of the runs + * fails — pure resource starvation that reads like product bugs. The load-average heuristic + * in vitest-e2e.config.ts cannot protect against this alone: the 1-minute average lags a + * 30-60s test run, so two runs STARTING together both see an idle machine. + * + * Mechanism: a cross-process slot directory in the OS temp dir, shared by ALL lt projects on + * the machine. Each running e2e suite holds one slot file (`.slot`); further runs wait + * until a slot frees up. Crash-safety needs no daemon: slots are reclaimed by PID-liveness + * checks, so a SIGKILLed run (check.mjs watchdog, closed terminal) never blocks anyone. + * + * While waiting, a log line is emitted every 15s — this doubles as a keep-alive for the + * check.mjs no-output watchdog (300s), so a queued run is never mistaken for a deadlock. + * + * Env overrides: + * LT_E2E_MAX_RUNS= number of concurrent e2e runs machine-wide (default: 2 on >=8 + * cores, else 1; 0 disables the governor entirely) + * LT_E2E_SLOT_DIR= slot directory (default: /lt-e2e-run-slots) + * LT_E2E_SLOT_TIMEOUT= max seconds to wait for a slot before proceeding anyway + * (fail-open; default 900) + */ + +const SLOT_SUFFIX = '.slot'; + +export function slotDir(): string { + return process.env.LT_E2E_SLOT_DIR || join(os.tmpdir(), 'lt-e2e-run-slots'); +} + +export function maxConcurrentE2eRuns(): number { + const explicit = Number(process.env.LT_E2E_MAX_RUNS); + if (Number.isInteger(explicit) && explicit >= 0) { + return explicit; + } + const cores = os.availableParallelism?.() ?? os.cpus()?.length ?? 4; + return cores >= 8 ? 2 : 1; +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +interface SlotInfo { + pid: number; + startedAt: number; +} + +/** + * All currently held slots (live PIDs only). Slot files of dead processes are + * removed as a side effect — this is the crash-recovery path. + */ +export function activeRuns(): SlotInfo[] { + let entries: string[]; + try { + entries = readdirSync(slotDir()); + } catch { + return []; + } + const active: SlotInfo[] = []; + for (const entry of entries) { + if (!entry.endsWith(SLOT_SUFFIX)) { + continue; + } + const pid = Number(entry.slice(0, -SLOT_SUFFIX.length)); + if (!Number.isInteger(pid) || pid <= 0) { + continue; + } + const path = join(slotDir(), entry); + if (!isPidAlive(pid)) { + try { + unlinkSync(path); + } catch { + /* another process removed it first */ + } + continue; + } + let startedAt = 0; + try { + startedAt = JSON.parse(readFileSync(path, 'utf8')).startedAt ?? 0; + } catch { + try { + startedAt = statSync(path).mtimeMs; + } catch { + /* keep 0 */ + } + } + active.push({ pid, startedAt }); + } + return active; +} + +/** Held slots excluding this process — the config uses this to detect parallel runs. */ +export function countOtherActiveRuns(): number { + return activeRuns().filter((s) => s.pid !== process.pid).length; +} + +function ownSlotPath(): string { + return join(slotDir(), `${process.pid}${SLOT_SUFFIX}`); +} + +function releaseOwnSlot(): void { + try { + unlinkSync(ownSlotPath()); + } catch { + /* already released or never claimed */ + } +} + +function claimOwnSlot(): SlotInfo { + const info: SlotInfo = { pid: process.pid, startedAt: Date.now() }; + mkdirSync(slotDir(), { recursive: true }); + writeFileSync(ownSlotPath(), JSON.stringify(info), { flag: 'w' }); + return info; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export interface AcquireOptions { + log?: (message: string) => void; + /** Log cadence while waiting (default 15s — keeps the check.mjs watchdog fed). */ + logEveryMs?: number; + maxRuns?: number; + pollMs?: number; + timeoutMs?: number; +} + +/** + * Wait for a free e2e slot, then claim it. Returns a release function. + * + * Fail-open by design: after the timeout the slot is claimed anyway (with a warning) — + * a broken governor must never be able to block a developer's test run outright. + * + * A process exiting normally releases via the returned function (wired to vitest's + * globalSetup teardown); a killed process leaves its file behind, which the next + * caller's PID-liveness check reclaims. + */ +export async function acquireRunSlot(options: AcquireOptions = {}): Promise<() => void> { + const log = options.log ?? ((message: string) => console.info(message)); + const maxRuns = options.maxRuns ?? maxConcurrentE2eRuns(); + const pollMs = options.pollMs ?? 1000; + const logEveryMs = options.logEveryMs ?? 15000; + const timeoutMs = options.timeoutMs + ?? (Number(process.env.LT_E2E_SLOT_TIMEOUT) > 0 ? Number(process.env.LT_E2E_SLOT_TIMEOUT) * 1000 : 900000); + + if (maxRuns === 0) { + return () => {}; + } + + const startedWaiting = Date.now(); + let lastLog = 0; + let waited = false; + for (;;) { + const others = activeRuns().filter((s) => s.pid !== process.pid); + if (others.length < maxRuns) { + const own = claimOwnSlot(); + // Two waiters can pass the check simultaneously and overshoot the limit. + // The newest claimant backs off (tie-break by pid) — bounded, since one + // of them always keeps its slot. + const overshoot = activeRuns(); + if (overshoot.length > maxRuns) { + const newest = [...overshoot].sort( + (a, b) => b.startedAt - a.startedAt || b.pid - a.pid, + )[0]; + if (newest.pid === own.pid) { + releaseOwnSlot(); + await sleep(pollMs + Math.floor(Math.random() * pollMs)); + continue; + } + } + if (waited) { + log(`[e2e-governor] slot acquired after ${Math.round((Date.now() - startedWaiting) / 1000)}s`); + } + return releaseOwnSlot; + } + + if (Date.now() - startedWaiting >= timeoutMs) { + log( + `[e2e-governor] no slot freed within ${Math.round(timeoutMs / 1000)}s — proceeding anyway (fail-open). ` + + `Active runs: ${others.map((s) => `pid ${s.pid}`).join(', ')}`, + ); + claimOwnSlot(); + return releaseOwnSlot; + } + + waited = true; + if (Date.now() - lastLog >= logEveryMs) { + lastLog = Date.now(); + log( + `[e2e-governor] waiting for a free e2e slot — ${others.length}/${maxRuns} in use ` + + `(${others.map((s) => `pid ${s.pid}`).join(', ')}). ` + + 'Concurrent full-speed e2e runs starve each other; queuing is faster overall.', + ); + } + await sleep(pollMs); + } +} diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 5512554e..e0260d8a 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -1,7 +1,11 @@ +import { readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; + import { MongoClient } from 'mongodb'; import envConfig from '../src/config.env'; -import { SAFE_TEST_DB_PATTERN, splitMongoUri } from './db-lifecycle.reporter'; +import { isStaleTestDb, SAFE_TEST_DB_PATTERN, splitMongoUri } from './db-lifecycle.reporter'; +import { acquireRunSlot } from './e2e-run-slots'; /** * Vitest global setup: give every test run its OWN database. @@ -15,18 +19,38 @@ import { SAFE_TEST_DB_PATTERN, splitMongoUri } from './db-lifecycle.reporter'; * The name is set via process.env.MONGODB_URI BEFORE the fork workers spawn, * so config.env.ts inside every worker resolves to this run's database. * + * Three additional responsibilities (in execution order): + * + * 1. STARTUP SWEEP — drop every stale leftover DB of this project's base name + * (dead creating PID, over the age cap, or legacy-named). The end-of-run + * cleanup in db-lifecycle.reporter.ts cannot run when a run is SIGKILLed + * (check.mjs watchdog escalation, closed terminal) or when vitest was + * started with an explicit `--reporter` flag (which replaces the config + * reporters). Sweeping at STARTUP makes cleanup independent of how the + * previous run died: restarting the suite always restores a clean state. + * + * 2. RUN GOVERNOR — acquire a machine-wide e2e slot (tests/e2e-run-slots.ts) + * so at most N e2e suites run concurrently across ALL lt projects/sessions + * on this machine. Measured on 12 cores: one full-speed run takes ~34s at + * load 9; two concurrent runs drive load to 30 and produce spurious 401 + * failures. Queuing is faster AND stable. The slot is released by the + * teardown below; a killed process's slot is reclaimed via PID-liveness. + * + * 3. UNIQUE RUN DB — as before. + * * Lifecycle (see tests/db-lifecycle.reporter.ts): - * - run PASSES → its database is dropped right away, and stale run databases - * from earlier crashed/failed runs (dead PID or older than 7 days) plus the - * legacy fixed-name database are collected too; + * - run PASSES → its database is dropped right away, plus stale leftovers; * - run FAILS → its database is KEPT for debugging and removed automatically - * by the next successful run. + * by the next run (startup sweep or end-of-run collection). * * 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 — but only if it names a disposable test database - * (see the guard below). + * the unique-name scheme AND the sweep/governor: that URI is used as-is and + * dropped up front, exactly like the previous behavior — but only if it names + * a disposable test database (see the guard below). */ + +let releaseRunSlot: (() => void) | undefined; + export async function setup() { if (process.env.MONGODB_URI) { const connection = await MongoClient.connect(process.env.MONGODB_URI); @@ -52,7 +76,59 @@ export async function setup() { } const { dbName, query, serverUri } = splitMongoUri(envConfig.mongoose.uri); + + // 0. Filesystem sweep — remove upload-test artifacts (`tests/*.txt` / `*.bin`) + // left behind by aborted file-upload specs. Same philosophy as the DB sweep + // below: restarting the suite restores a clean state no matter how the + // previous run died. No tracked fixtures match these patterns (git-verified); + // `pnpm run test:cleanup` remains for manual use. + try { + // vitest runs globalSetup with cwd = project root (config `root: './'`). + const testsDir = join(process.cwd(), 'tests'); + for (const entry of readdirSync(testsDir)) { + if ((entry.endsWith('.txt') || entry.endsWith('.bin')) && entry !== '.gitkeep') { + unlinkSync(join(testsDir, entry)); + } + } + } catch { + /* best-effort — never block the run on artifact cleanup */ + } + + // 1. Startup sweep — restore a clean state regardless of how earlier runs ended. + try { + const connection = await MongoClient.connect(`${serverUri}/${dbName}${query}`); + try { + const { databases } = await connection.db().admin().listDatabases({ nameOnly: true }); + const swept: string[] = []; + for (const { name } of databases) { + if (isStaleTestDb(name, dbName) && SAFE_TEST_DB_PATTERN.test(name)) { + await connection.db(name).dropDatabase(); + swept.push(name); + } + } + if (swept.length > 0) { + console.info(`Startup sweep: dropped ${swept.length} stale test database(s): ${swept.join(', ')}`); + } + } finally { + await connection.close(); + } + } catch (error) { + // Best-effort: a failed sweep must never block the test run itself. + console.warn( + `Startup sweep skipped: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + + // 2. Machine-wide run governor — wait for a free e2e slot before spawning forks. + releaseRunSlot = await acquireRunSlot(); + + // 3. Unique per-run database. const runDbName = `${dbName}-run-${Date.now()}-p${process.pid}`; process.env.MONGODB_URI = `${serverUri}/${runDbName}${query}`; console.info(`Test database for this run: ${runDbName}`); } + +export async function teardown() { + releaseRunSlot?.(); + releaseRunSlot = undefined; +} diff --git a/tests/stories/better-auth-privilege-escalation.e2e-spec.ts b/tests/stories/better-auth-privilege-escalation.e2e-spec.ts new file mode 100644 index 00000000..24bd4b63 --- /dev/null +++ b/tests/stories/better-auth-privilege-escalation.e2e-spec.ts @@ -0,0 +1,301 @@ +/** + * Story: Better-Auth Privilege-Escalation Prevention (`input: false` on server-managed fields) + * + * Regression test for a confirmed vertical privilege-escalation vulnerability (OWASP A01): + * ANY authenticated user — including a freshly self-registered one — could make themselves + * admin via `POST /iam/update-user {"roles":["admin"]}`. + * + * Root cause: the server-managed `roles` field was registered as a Better-Auth + * `additionalField` WITHOUT `input: false`. Better-Auth defaults `input: true`, so it accepted + * the field from client input. Because `/iam/update-user` is NOT in `CONTROLLER_HANDLED_PATHS`, + * the API middleware forwards it RAW to Better-Auth's native handler under `sessionMiddleware` + * (any authenticated user), bypassing the controller's class-level `@Roles(ADMIN)` and + * nest-server's `checkRoles` guard. The forged `roles:['admin']` was then written to the + * caller's own users row and became authoritative for `@Restricted(RoleEnum.ADMIN)`. + * + * Fix: server-managed core fields (`roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, + * `termsAndPrivacyAcceptedAt`, `iamId`) are registered with `input: false`, so Better-Auth's + * native input parsing rejects client-supplied values: it throws `FIELD_NOT_ALLOWED` (HTTP 400) + * on the update-user route and substitutes the server default on sign-up create. + * + * These tests assert (the DB-state assertion is the load-bearing one): + * - a non-admin's `POST /iam/update-user {"roles":["admin"]}` is rejected (or ignored) AND the + * user's `roles` stay `[]` / `hasRole('admin')` is false; + * - the same protection holds for `verified` (email-verification bypass); + * - a legitimate update via update-user (e.g. `name`) still works (proves the 400 is + * specifically the field lock, not a broken/unauthenticated request); + * - the normal nest-server role-assignment path (UserService, Mongoose writes + `checkRoles`) + * STILL works — the fix does not over-restrict server-side role assignment. + * + * Prerequisites: Better-Auth must be enabled for these tests to run. + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { PubSub } from 'graphql-subscriptions'; +import { Db, MongoClient } from 'mongodb'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { CoreBetterAuthService, HttpExceptionLogFilter, RoleEnum, TestHelper } from '../../src'; +import envConfig from '../../src/config.env'; +import { ServerModule } from '../../src/server/server.module'; +import { UserService } from '../../src/server/modules/user/user.service'; + +describe('Story: Better-Auth Privilege-Escalation Prevention', () => { + // Test environment + let app; + let testHelper: TestHelper; + let mongoClient: MongoClient; + let db: Db; + let betterAuthService: CoreBetterAuthService; + let userService: UserService; + let isBetterAuthEnabled: boolean; + + // Test data tracking for cleanup + const testEmails: string[] = []; + + // Helper to generate unique test emails + const generateTestEmail = (prefix: string): string => { + const email = `privesc-${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}@test.com`; + testEmails.push(email); + return email; + }; + + /** + * Sign up a plain non-admin user (default `roles: []`), sign in, and return the identifiers + * needed to drive an authenticated update-user request. + * + * update-user runs under Better-Auth's native sessionMiddleware. We authenticate with the JWT + * returned from sign-in: the API middleware resolves it to the DB session (via + * CoreBetterAuthMiddleware) and re-signs it as a session cookie for Better-Auth's native handler. + */ + async function createSignedInUser(prefix: string): Promise<{ email: string; token: string; userId: string }> { + const email = generateTestEmail(prefix); + const password = 'PrivEscTest123!'; + + await testHelper.rest('/iam/sign-up/email', { + method: 'POST', + payload: { email, name: 'PrivEsc User', password, termsAndPrivacyAccepted: true }, + statusCode: 201, + }); + + // ENV PRECONDITION: this sign-in assumes `betterAuth.emailVerification` is disabled, which is + // the case for the e2e/ci/development configs — this suite's designated runner is NODE_ENV=e2e + // (via `pnpm test`). We deliberately do NOT pre-verify the user in the DB to force env- + // independence: setting `emailVerified` here would be synced to the nest-server `verified` field + // by the user mapper on sign-in, which would invalidate the `verified` email-verification-bypass + // assertion further down. Running this file under the `local` config (e.g. a bare + // `npx vitest run --config vitest-e2e.config.ts ` WITHOUT NODE_ENV=e2e) will therefore + // fail sign-in with 401 EMAIL_VERIFICATION_REQUIRED — that is a wrong-runner symptom, not a bug. + // The guard `it('has Better-Auth enabled …')` plus this note make the requirement explicit. + const signIn = await testHelper.rest('/iam/sign-in/email', { + method: 'POST', + payload: { email, password }, + statusCode: 200, + }); + + const dbUser = await db.collection('users').findOne({ email }); + return { email, token: signIn?.token || '', userId: dbUser?._id?.toString() || '' }; + } + + // =================================================================================================================== + // Setup and Teardown + // =================================================================================================================== + + beforeAll(async () => { + try { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ServerModule], + providers: [UserService, { provide: 'PUB_SUB', useValue: new PubSub() }], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new HttpExceptionLogFilter()); + app.setBaseViewsDir(envConfig.templates.path); + app.setViewEngine(envConfig.templates.engine); + await app.init(); + testHelper = new TestHelper(app); + + betterAuthService = moduleFixture.get(CoreBetterAuthService); + userService = moduleFixture.get(UserService); + isBetterAuthEnabled = betterAuthService.isEnabled(); + + mongoClient = await MongoClient.connect(envConfig.mongoose.uri); + db = mongoClient.db(); + } catch (e) { + console.error('beforeAllError', e); + throw e; + } + }); + + afterAll(async () => { + if (db) { + for (const email of testEmails) { + const user = await db.collection('users').findOne({ email }); + if (user) { + const userIds: any[] = [user._id, user._id.toString()]; + if (user.iamId) { + userIds.push(user.iamId); + } + await db.collection('users').deleteOne({ _id: user._id }); + await db.collection('account').deleteMany({ userId: { $in: userIds } }); + await db.collection('session').deleteMany({ userId: { $in: userIds } }); + } + } + } + + if (mongoClient) { + await mongoClient.close(); + } + if (app) { + await app.close(); + } + }); + + // =================================================================================================================== + // Guard: Better-Auth must be enabled for this regression suite to be meaningful + // =================================================================================================================== + + it('has Better-Auth enabled (otherwise this regression suite is meaningless)', () => { + expect(isBetterAuthEnabled).toBe(true); + }); + + // =================================================================================================================== + // The core vulnerability: self-service privilege escalation via update-user + // =================================================================================================================== + + describe('POST /iam/update-user cannot self-set `roles`', () => { + it('SECURITY: rejects {"roles":["admin"]} from a non-admin AND leaves roles unchanged', async () => { + const { email, token, userId } = await createSignedInUser('roles-attack'); + expect(token).not.toBe(''); + + // Sanity: the freshly registered user starts with no roles. + const before = await db.collection('users').findOne({ email }); + expect(before?.roles ?? []).toEqual([]); + + // The attack: forge admin via the raw-forwarded Better-Auth update-user route. + // With `input: false` on `roles`, Better-Auth's native handler throws FIELD_NOT_ALLOWED (400). + const attack = await testHelper.rest('/iam/update-user', { + method: 'POST', + payload: { roles: ['admin'] }, + statusCode: 400, + token, + }); + + // Response shape (secondary): Better-Auth's native FIELD_NOT_ALLOWED error. + expect(attack?.code).toBe('FIELD_NOT_ALLOWED'); + + // LOAD-BEARING assertion: regardless of the response shape, the user must NOT have become admin. + const after = await db.collection('users').findOne({ email }); + expect(after?.roles ?? []).toEqual([]); + expect((after?.roles ?? []).includes('admin')).toBe(false); + + // And the resolver-level role check must agree. + const model = await userService.getViaEmail(email); + expect(model.hasRole(RoleEnum.ADMIN)).toBe(false); + + void userId; + }); + + it('SECURITY: rejects {"verified":true} from a non-admin AND leaves verified false (email-verification bypass)', async () => { + const { email, token } = await createSignedInUser('verified-attack'); + expect(token).not.toBe(''); + + const attack = await testHelper.rest('/iam/update-user', { + method: 'POST', + payload: { verified: true }, + statusCode: 400, + token, + }); + + expect(attack?.code).toBe('FIELD_NOT_ALLOWED'); + + // LOAD-BEARING assertion: the user must NOT have self-verified. + const after = await db.collection('users').findOne({ email }); + expect(after?.verified === true).toBe(false); + }); + + it('SECURITY: sign-up create body cannot self-set `roles` (server default substituted, not persisted)', async () => { + // The OTHER half of the fix: the create path. Unlike update-user (which throws 400), + // Better-Auth's create path with `input: false` silently substitutes the server default, + // and the controller-handled sign-up additionally whitelists `roles` out of the DTO — so a + // forged `roles:['admin']` in the sign-up body is dropped and the account is created with []. + const email = generateTestEmail('signup-roles-attack'); + const password = 'PrivEscTest123!'; + + await testHelper.rest('/iam/sign-up/email', { + method: 'POST', + payload: { email, name: 'Attacker', password, roles: ['admin'], termsAndPrivacyAccepted: true }, + statusCode: 201, + }); + + // LOAD-BEARING: the forged role must not have been persisted on the new account. + const after = await db.collection('users').findOne({ email }); + expect(after?.roles ?? []).toEqual([]); + expect((after?.roles ?? []).includes('admin')).toBe(false); + + const model = await userService.getViaEmail(email); + expect(model.hasRole(RoleEnum.ADMIN)).toBe(false); + }); + + it('CONTROL: a legitimate update-user field (name) still succeeds (proves the 400 is the field lock, not auth)', async () => { + const { email, token } = await createSignedInUser('legit-update'); + expect(token).not.toBe(''); + + const result = await testHelper.rest('/iam/update-user', { + method: 'POST', + payload: { name: 'Renamed User' }, + statusCode: 200, + token, + }); + + expect(result?.status).toBe(true); + + const after = await db.collection('users').findOne({ email }); + expect(after?.name).toBe('Renamed User'); + // The legitimate update must not have granted any roles either. + expect(after?.roles ?? []).toEqual([]); + }); + }); + + // =================================================================================================================== + // Positive: the fix must NOT break legitimate server-side role assignment + // =================================================================================================================== + + // API-first note: the SECURITY assertions above deliberately go through the real REST endpoints + // (/iam/update-user, /iam/sign-up/email). The POSITIVE cases below call UserService directly on + // purpose — their whole point is to isolate and prove the nest-server *service-layer* write path + // (setRoles / CrudService.update via `checkRoles` + Mongoose `$set`) is NOT gated by Better-Auth's + // `input: false`. Routing them through a GraphQL/REST admin endpoint would conflate the two layers + // and defeat that isolation, so the direct call is intentional here, not an API-first violation. + describe('nest-server role assignment is unaffected by input:false', () => { + it('POSITIVE: UserService.setRoles assigns roles via the Mongoose write path (no Better-Auth input parsing)', async () => { + const { email, userId } = await createSignedInUser('setroles'); + expect(userId).not.toBe(''); + + await userService.setRoles(userId, [RoleEnum.ADMIN]); + + const after = await db.collection('users').findOne({ email }); + expect(after?.roles).toEqual([RoleEnum.ADMIN]); + + const model = await userService.getViaEmail(email); + expect(model.hasRole(RoleEnum.ADMIN)).toBe(true); + }); + + it('POSITIVE: admin-driven UserService.update sets roles through CrudService checkRoles + Mongoose', async () => { + // An admin actor whose model resolves hasRole(ADMIN) === true. + const admin = await createSignedInUser('admin-actor'); + await userService.setRoles(admin.userId, [RoleEnum.ADMIN]); + const adminModel = await userService.getViaEmail(admin.email); + expect(adminModel.hasRole(RoleEnum.ADMIN)).toBe(true); + + // The target the admin promotes. + const target = await createSignedInUser('promote-target'); + expect((await db.collection('users').findOne({ email: target.email }))?.roles ?? []).toEqual([]); + + await userService.update(target.userId, { roles: [RoleEnum.ADMIN] } as any, { currentUser: adminModel as any }); + + const after = await db.collection('users').findOne({ email: target.email }); + expect(after?.roles).toEqual([RoleEnum.ADMIN]); + }); + }); +}); diff --git a/tests/stories/system-setup.e2e-spec.ts b/tests/stories/system-setup.e2e-spec.ts index de454b1d..1d3a3fef 100644 --- a/tests/stories/system-setup.e2e-spec.ts +++ b/tests/stories/system-setup.e2e-spec.ts @@ -39,14 +39,17 @@ import { AuthModule } from '../../src/server/modules/auth/auth.module'; import { BetterAuthModule } from '../../src/server/modules/better-auth/better-auth.module'; import { FileModule } from '../../src/server/modules/file/file.module'; import { ServerController } from '../../src/server/server.controller'; +import { deriveTestDbUri } from '../db-lifecycle.reporter'; -// Isolated database to avoid interfering with parallel tests -const SYSTEM_SETUP_DB = `nest-server-e2e-setup-${Date.now()}`; +// Isolated database to avoid interfering with parallel tests. Derived from the +// current (per-run, per-worker) test DB name so the db-lifecycle cleanup owns it — +// a hardcoded `...-setup-${Date.now()}` name escaped the per-run scheme and leaked +// two databases per run (collected only by the 1h legacy rule). const testConfig = { ...envConfig, mongoose: { ...envConfig.mongoose, - uri: `mongodb://127.0.0.1/${SYSTEM_SETUP_DB}`, + uri: deriveTestDbUri('setup'), }, }; @@ -107,6 +110,13 @@ describe('Story: System Setup', () => { }); afterAll(async () => { + // Close the app BEFORE dropping: modules still finishing async initialisation + // (e.g. AI module collection/index creation) re-create the database when it is + // dropped while the app is alive — that race is exactly how dropped `-setup-` + // databases used to reappear as empty leftovers. + if (app) { + await app.close(); + } // Drop the entire temporary database (no shared data to worry about) if (db) { try { @@ -115,13 +125,9 @@ describe('Story: System Setup', () => { // Ignore cleanup errors } } - if (mongoClient) { await mongoClient.close(); } - if (app) { - await app.close(); - } CoreBetterAuthModule.reset(); }); @@ -255,7 +261,6 @@ describe('Story: System Setup', () => { // ============================================================================= describe('Story: System Setup - Auto-Creation via Config', () => { - const AUTO_DB = `nest-server-e2e-setup-auto-${Date.now()}`; const AUTO_ADMIN_EMAIL = `auto-admin-${Date.now()}@test.com`; const AUTO_ADMIN_PASSWORD = 'AutoPassword123!'; const AUTO_ADMIN_NAME = 'Auto Admin'; @@ -269,7 +274,7 @@ describe('Story: System Setup - Auto-Creation via Config', () => { ...envConfig, mongoose: { ...envConfig.mongoose, - uri: `mongodb://127.0.0.1/${AUTO_DB}`, + uri: deriveTestDbUri('setup-auto'), }, systemSetup: { initialAdmin: { @@ -320,6 +325,11 @@ describe('Story: System Setup - Auto-Creation via Config', () => { }); afterAll(async () => { + // App first — see the first afterAll above: dropping while the app is alive + // races against async module initialisation re-creating the database. + if (app) { + await app.close(); + } if (db) { try { await db.dropDatabase(); @@ -330,9 +340,6 @@ describe('Story: System Setup - Auto-Creation via Config', () => { if (mongoClient) { await mongoClient.close(); } - if (app) { - await app.close(); - } CoreBetterAuthModule.reset(); }); @@ -469,6 +476,11 @@ describe('Story: System Setup - Auto-Creation skipped with existing users', () = }); afterAll(async () => { + // App first — see the first afterAll above: dropping while the app is alive + // races against async module initialisation re-creating the database. + if (app) { + await app.close(); + } if (db) { try { await db.dropDatabase(); @@ -479,9 +491,6 @@ describe('Story: System Setup - Auto-Creation skipped with existing users', () = if (mongoClient) { await mongoClient.close(); } - if (app) { - await app.close(); - } CoreBetterAuthModule.reset(); }); diff --git a/tests/unit/e2e-run-slots.spec.ts b/tests/unit/e2e-run-slots.spec.ts new file mode 100644 index 00000000..19acabef --- /dev/null +++ b/tests/unit/e2e-run-slots.spec.ts @@ -0,0 +1,106 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { acquireRunSlot, activeRuns, countOtherActiveRuns, maxConcurrentE2eRuns, slotDir } from '../e2e-run-slots'; + +/** + * Unit tests for the machine-wide e2e run governor (tests/e2e-run-slots.ts). + * + * The governor is what keeps parallel `lt dev` / `lt ticket` sessions from starving + * each other's e2e runs, and its crash-recovery (PID-liveness reclaim) is what keeps + * a SIGKILLed run from blocking every later one — both are worth pinning down. + */ +describe('e2e-run-slots', () => { + let dir: string; + const savedEnv: Record = {}; + + beforeEach(() => { + for (const key of ['LT_E2E_SLOT_DIR', 'LT_E2E_MAX_RUNS', 'LT_E2E_SLOT_TIMEOUT']) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + dir = mkdtempSync(join(os.tmpdir(), 'e2e-slots-test-')); + process.env.LT_E2E_SLOT_DIR = dir; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + rmSync(dir, { force: true, recursive: true }); + }); + + it('resolves the slot dir from the env override', () => { + expect(slotDir()).toBe(dir); + }); + + it('respects LT_E2E_MAX_RUNS including 0 (governor disabled)', () => { + process.env.LT_E2E_MAX_RUNS = '3'; + expect(maxConcurrentE2eRuns()).toBe(3); + process.env.LT_E2E_MAX_RUNS = '0'; + expect(maxConcurrentE2eRuns()).toBe(0); + delete process.env.LT_E2E_MAX_RUNS; + expect(maxConcurrentE2eRuns()).toBeGreaterThanOrEqual(1); + }); + + it('acquires and releases a slot file for the own process', async () => { + const release = await acquireRunSlot({ maxRuns: 2, pollMs: 5 }); + expect(existsSync(join(dir, `${process.pid}.slot`))).toBe(true); + expect(activeRuns().map((s) => s.pid)).toContain(process.pid); + expect(countOtherActiveRuns()).toBe(0); + release(); + expect(existsSync(join(dir, `${process.pid}.slot`))).toBe(false); + }); + + it('reclaims slot files of dead processes', () => { + // A PID far above any real one on this machine — kill(pid, 0) throws ESRCH. + writeFileSync(join(dir, '99999999.slot'), JSON.stringify({ pid: 99999999, startedAt: Date.now() })); + expect(activeRuns()).toHaveLength(0); + expect(readdirSync(dir)).toHaveLength(0); + }); + + it('counts a live foreign slot and waits for it, then proceeds fail-open after the timeout', async () => { + // The parent process (vitest main / shell) is alive for the duration of the test. + const foreignPid = process.ppid; + writeFileSync(join(dir, `${foreignPid}.slot`), JSON.stringify({ pid: foreignPid, startedAt: Date.now() })); + expect(countOtherActiveRuns()).toBe(1); + + const logs: string[] = []; + const release = await acquireRunSlot({ + log: (m) => logs.push(m), + logEveryMs: 10, + maxRuns: 1, + pollMs: 10, + timeoutMs: 120, + }); + expect(logs.some((m) => m.includes('waiting for a free e2e slot'))).toBe(true); + expect(logs.some((m) => m.includes('fail-open'))).toBe(true); + // Fail-open still claims a slot so later runs see the true concurrency. + expect(existsSync(join(dir, `${process.pid}.slot`))).toBe(true); + release(); + }); + + it('acquires immediately when a slot is free even with foreign runs present', async () => { + const foreignPid = process.ppid; + writeFileSync(join(dir, `${foreignPid}.slot`), JSON.stringify({ pid: foreignPid, startedAt: Date.now() })); + + const logs: string[] = []; + const release = await acquireRunSlot({ log: (m) => logs.push(m), maxRuns: 2, pollMs: 5 }); + expect(logs).toHaveLength(0); + expect(activeRuns()).toHaveLength(2); + release(); + expect(activeRuns()).toHaveLength(1); + }); + + it('is a no-op when the governor is disabled via maxRuns 0', async () => { + const release = await acquireRunSlot({ maxRuns: 0 }); + expect(readdirSync(dir)).toHaveLength(0); + release(); + }); +}); diff --git a/vitest-e2e.config.ts b/vitest-e2e.config.ts index c7e2b3b5..5524690c 100644 --- a/vitest-e2e.config.ts +++ b/vitest-e2e.config.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import swc from 'unplugin-swc'; import { defineConfig } from 'vitest/config'; +import { countOtherActiveRuns } from './tests/e2e-run-slots'; import { E2E_TEST_INCLUDE } from './vitest.include-globs'; // Low-resource mode — caps parallel forks and raises timeouts so many e2e suites can share one @@ -16,24 +17,33 @@ import { E2E_TEST_INCLUDE } from './vitest.include-globs'; // 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: +// So it AUTO-ENABLES on either of two signals. 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) +// unset -> auto (on iff another e2e run is active OR load is high) // -// Load average is unavailable on Windows (os.loadavg() returns zeros), where auto-detection simply -// stays off — the explicit flag remains available. +// Signal 1 — another e2e run is ACTIVE right now (slot files of tests/e2e-run-slots.ts, checked +// by PID-liveness). This is the deterministic signal: measured on 12 cores, a single full-speed +// run only drives the 1-minute load average up near its END (~2.5 -> 9 over 34s), so a second +// run starting 15s in still sees a "calm" machine — the load heuristic alone structurally cannot +// catch overlapping starts. The slot count can. +// +// Signal 2 — the 1-minute load average normalised per core is already high (>= LOAD_THRESHOLD). +// This catches machine pressure from anything that is NOT an e2e run (builds, dev servers, other +// tools). Load average is unavailable on Windows (os.loadavg() returns zeros), where this signal +// simply stays off — the slot signal and the explicit flag remain available. const LOAD_THRESHOLD = 0.7; const CORES = os.availableParallelism?.() ?? os.cpus()?.length ?? 4; const NORMALISED_LOAD = (os.loadavg()?.[0] ?? 0) / CORES; +const ACTIVE_E2E_RUNS = countOtherActiveRuns(); const LOW_RESOURCE_RAW = process.env.CHECK_LOW_RESOURCE; 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_AUTO = + LOW_RESOURCE_RAW === undefined && (ACTIVE_E2E_RUNS > 0 || NORMALISED_LOAD >= LOAD_THRESHOLD); const LOW_RESOURCE = LOW_RESOURCE_FORCED_ON || LOW_RESOURCE_AUTO; const LOW_RESOURCE_FORKS = (() => { @@ -44,9 +54,11 @@ const LOW_RESOURCE_FORKS = (() => { })(); if (LOW_RESOURCE) { - const why = LOW_RESOURCE_AUTO - ? `machine is busy (load ${NORMALISED_LOAD.toFixed(2)}/core >= ${LOAD_THRESHOLD})` - : 'CHECK_LOW_RESOURCE set'; + const why = LOW_RESOURCE_FORCED_ON + ? 'CHECK_LOW_RESOURCE set' + : ACTIVE_E2E_RUNS > 0 + ? `${ACTIVE_E2E_RUNS} other e2e run(s) active on this machine` + : `machine is busy (load ${NORMALISED_LOAD.toFixed(2)}/core >= ${LOAD_THRESHOLD})`; process.stderr.write(`[e2e] low-resource mode: ${why} -> maxForks=${LOW_RESOURCE_FORKS}, timeouts raised\n`); } @@ -105,9 +117,14 @@ export default defineConfig({ // db-lifecycle: drops this run's unique DB on success (+ collects stale // run DBs), keeps it for debugging on failure — see tests/db-lifecycle.reporter.ts reporters: ['default', './tests/db-lifecycle.reporter.ts'], - // Retry flaky tests up to 3 times before failing - // This handles intermittent MongoDB race conditions - retry: 5, + // Retry flaky tests before failing (intermittent MongoDB race conditions). + // Deliberately LOW: retry multiplies worst-case runtime per test. Observed + // failure mode with retry: 5 — one spec file whose app/socket state broke + // under resource pressure ground through (1+5) attempts × 30s testTimeout × + // 22 tests ≈ an hour at 0% CPU, indistinguishable from a deadlock (this is + // what the check.mjs watchdog used to kill). The e2e-run governor removes + // the pressure trigger; retry: 2 caps the multiplier at 3× as backstop. + retry: 2, root: './', teardownTimeout: 30000, testTimeout: LOW_RESOURCE ? 60000 : 30000,