Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/agent-memory/lt-dev-security-reviewer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions .claude/agent-memory/lt-dev-test-reviewer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <file>` does NOT filter; the nested `pnpm run vitest` alias swallows the `--` positional and the WHOLE suite runs. Call vitest directly to isolate one file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
name: single-e2e-file-run-gotcha
description: `pnpm run test:e2e -- <file>` 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 `-- <file>` 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 -- <file>` "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-<ts>-p<pid>`), 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.
36 changes: 36 additions & 0 deletions .claude/rules/better-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .claude/rules/configurable-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: []` |
Expand Down
6 changes: 4 additions & 2 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ts>-p<pid>`), 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-<ts>-p<pid>`), created by `tests/global-setup.ts` so concurrent runs cannot interfere with each other. Specs needing an extra DB derive it via `deriveTestDbUri('<suffix>')` — 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 (`<tmpdir>/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.
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion FRAMEWORK-API.md
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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`
Expand Down
12 changes: 12 additions & 0 deletions docs/REQUEST-LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading