@lenne.tech/nest-server 11.38.0 → 11.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/configurable-features.md +24 -1
- package/.claude/rules/module-inheritance.md +2 -0
- package/.claude/rules/package-management.md +117 -2
- package/.claude/rules/testing.md +238 -5
- package/CLAUDE.md +13 -1
- package/FRAMEWORK-API.md +2 -2
- package/dist/config.env.js +4 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/config.helper.d.ts +2 -0
- package/dist/core/common/helpers/config.helper.js +18 -0
- package/dist/core/common/helpers/config.helper.js.map +1 -1
- package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
- package/dist/core/common/helpers/cookies.helper.js +9 -0
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -0
- package/dist/core/common/helpers/input.helper.js +4 -0
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/service.helper.js +6 -1
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
- package/dist/core/common/services/email.service.d.ts +4 -1
- package/dist/core/common/services/email.service.js +25 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/module.service.js +1 -0
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +2 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +28 -3
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +7 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +3 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +27 -0
- package/dist/core/modules/error-code/error-codes.js +24 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/hub/core-hub.service.js +1 -0
- package/dist/core/modules/hub/core-hub.service.js.map +1 -1
- package/dist/core/modules/user/core-user.model.d.ts +1 -0
- package/dist/core/modules/user/core-user.model.js +11 -1
- package/dist/core/modules/user/core-user.model.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +9 -0
- package/dist/core/modules/user/core-user.service.js +96 -4
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +3 -0
- package/dist/server/modules/user/user.model.d.ts +5 -0
- package/dist/server/modules/user/user.service.js +12 -6
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/templates/password-reset-de.ejs +12 -0
- package/dist/templates/password-reset-en.ejs +12 -0
- package/dist/templates/password-reset.ejs +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +14 -0
- package/migration-guides/11.37.x-to-11.38.x.md +18 -1
- package/migration-guides/11.38.x-to-11.39.x.md +456 -0
- package/migration-guides/11.39.0-to-11.40.0.md +186 -0
- package/package.json +5 -4
- package/src/config.env.ts +19 -3
- package/src/core/common/helpers/config.helper.ts +79 -0
- package/src/core/common/helpers/cookies.helper.ts +38 -0
- package/src/core/common/helpers/input.helper.ts +37 -0
- package/src/core/common/helpers/service.helper.ts +9 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
- package/src/core/common/interfaces/server-options.interface.ts +120 -4
- package/src/core/common/services/email.service.ts +46 -1
- package/src/core/common/services/module.service.ts +1 -0
- package/src/core/modules/ai/README.md +33 -0
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +76 -3
- package/src/core/modules/better-auth/better-auth.config.ts +25 -0
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
- package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +55 -0
- package/src/core/modules/hub/core-hub.service.ts +1 -0
- package/src/core/modules/user/core-user.model.ts +28 -1
- package/src/core/modules/user/core-user.service.ts +267 -5
- package/src/server/modules/user/user.service.ts +26 -7
- package/src/templates/password-reset-de.ejs +12 -0
- package/src/templates/password-reset-en.ejs +12 -0
- package/src/templates/password-reset.ejs +1 -0
|
@@ -235,6 +235,25 @@ Rules:
|
|
|
235
235
|
(`Number.isFinite(ttl) && ttl > 0`), or a "disabled" value silently becomes an
|
|
236
236
|
expiry of `now + NaN` — every lookup misses while the cache grows.
|
|
237
237
|
|
|
238
|
+
### Family C — `0` = NO LIMIT, but an INVALID value falls back to the DEFAULT
|
|
239
|
+
|
|
240
|
+
A third shape, and it exists because Family A's rule is wrong for one class of knob. Family A says
|
|
241
|
+
"negative and `NaN` behave like `0`", i.e. a misconfigured value degrades to *unbounded*. That is
|
|
242
|
+
right for a cap on tool-description length. It is exactly backwards for a knob that bounds the
|
|
243
|
+
lifetime of a **credential**.
|
|
244
|
+
|
|
245
|
+
| Config | `0` | invalid (negative, `NaN`, `''`, a word) |
|
|
246
|
+
|--------|-----|------------------------------------------|
|
|
247
|
+
| `auth.passwordReset.tokenExpiresInMinutes` | never expires — the explicit opt-out | **the default (60)**, never "unbounded" |
|
|
248
|
+
|
|
249
|
+
The asymmetry is the protection: switching off the expiry of an account-takeover credential is a
|
|
250
|
+
decision somebody has to state, and a typo in an environment variable must never be the thing that
|
|
251
|
+
states it. Note `Number('')` is `0`, so an empty value has to be rejected *before* the coercion or
|
|
252
|
+
it reads as a deliberate opt-out.
|
|
253
|
+
|
|
254
|
+
**When adding a knob, ask what a misconfiguration should cost.** If the answer is "a bound nobody
|
|
255
|
+
intended to remove", it belongs here and not in Family A.
|
|
256
|
+
|
|
238
257
|
### Both families
|
|
239
258
|
|
|
240
259
|
**Warn when the knob is inert.** If a value only takes effect together with another
|
|
@@ -273,10 +292,13 @@ This pattern is currently applied to:
|
|
|
273
292
|
|
|
274
293
|
| Feature | Config Path | Pattern | Default Values |
|
|
275
294
|
|---------|-------------|---------|----------------|
|
|
276
|
-
| Password-Reset Link (IAM) | `betterAuth.emailVerification.passwordResetLink` | Explicit Value with derived default | **`<appUrl>/auth/reset-password?token={token}` since 11.38.0.** Before, the mail carried the link Better-Auth generates, which points at the **API** (`https://api.example.com/iam/reset-password/<token>?callbackURL=…`) and redirects to the app from there. It works, but puts a domain the recipient does not recognise into a password mail — what people are trained to check before clicking. In this stack app and API hosts are the norm, so the app is the better default. **What it gives up:** Better-Auth's redirect route validates the token and its expiry BEFORE forwarding, so an expired link produced an error page rather than a form that fails on submit; linking straight to the app moves that error later. It is **not** a security difference — the token reaches the app URL either way, and the `callbackURL` origin check exists only because of the hop it removes. `{token}` is substituted anywhere in the value (so a PATH-parameter page configures `…/reset-password/{token}`); without the placeholder `?token=` is appended. A relative value resolves against `appUrl`. `false` keeps Better-Auth's link, including the early validation. Falls back to Better-Auth's link when no `appUrl` can be resolved — a guessed host would 404, which is worse than a working link. Separate from `email.passwordResetLink`, which serves the LEGACY flow
|
|
295
|
+
| Password-Reset Link (IAM) | `betterAuth.emailVerification.passwordResetLink` | Explicit Value with derived default | **`<appUrl>/auth/reset-password?token={token}` since 11.38.0.** Before, the mail carried the link Better-Auth generates, which points at the **API** (`https://api.example.com/iam/reset-password/<token>?callbackURL=…`) and redirects to the app from there. It works, but puts a domain the recipient does not recognise into a password mail — what people are trained to check before clicking. In this stack app and API hosts are the norm, so the app is the better default. **What it gives up:** Better-Auth's redirect route validates the token and its expiry BEFORE forwarding, so an expired link produced an error page rather than a form that fails on submit; linking straight to the app moves that error later. It is **not** a security difference — the token reaches the app URL either way, and the `callbackURL` origin check exists only because of the hop it removes. `{token}` is substituted anywhere in the value (so a PATH-parameter page configures `…/reset-password/{token}`); without the placeholder `?token=` is appended. A relative value resolves against `appUrl`. `false` keeps Better-Auth's link, including the early validation. Falls back to Better-Auth's link when no `appUrl` can be resolved — a guessed host would 404, which is worse than a working link. Separate from `email.passwordResetLink`, which serves the LEGACY flow. Since 11.38.0 both DEFAULTS produce `?token=`; the path-segment rule applies only to a CONFIGURED legacy value without `{token}` (a boot warning names that divergence). Resolution order here gained a step: explicit config → the caller's validated `redirectTo` → `<appUrl>/auth/reset-password` → Better-Auth's own link; `false` still wins over a `redirectTo`. Implementation: `core-better-auth-email-verification.service.ts` → `buildPasswordResetUrl()` |
|
|
277
296
|
| Security Headers | `security.headers` | Boolean Shorthand — **safe default** | **ON without configuration** (11.38.0+). `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Powered-By` removed. HSTS `max-age=31536000; includeSubDomains`, **`preload` off** (it commits every subdomain to a browser-vendor list that is awkward to reverse — the domain owner's decision, not a framework default). **No CSP** — a wrong one breaks the app rather than hardening it, and this package serves its own HTML from the Hub and the GraphQL playground; set `contentSecurityPolicy` explicitly if you want one. Registered as MIDDLEWARE, not an interceptor, so a request a guard rejects carries the headers too — those are the responses an attacker generates most of. **HSTS is decided by the request protocol, never by configuration**: `x-forwarded-proto` first (first hop of a chain), connection protocol as fallback. A browser REMEMBERS the header, so one sent from a dev server over `http://localhost` makes every project on that host unreachable over http for up to a year, unrecoverably — which is why no flag can switch it on where it does not belong. This also makes `trustProxy` load-bearing: behind a TLS-terminating proxy the inward connection is plain http. A reverse proxy setting the same headers wins (it writes last); this layer exists so a deployment without one, or a local instance, is not bare. `false` disables everything. Implementation: `common/middlewares/security-headers.middleware.ts` |
|
|
278
297
|
| Password-Reset Enumeration | `auth.passwordReset.preventUserEnumeration` | Explicit Boolean — **safe default** | **`true` since 11.38.0.** `POST /users/password/reset-request` answers identically for a known and an unknown address; before, it answered 404 vs 201, a working oracle for "does this person have an account", which in a multi-tenant product also answers who works at which customer. Deliberately NOT the same switch as `auth.preventUserEnumeration` (which governs SIGN-IN messages and defaults to `false`): at sign-in the distinction is genuine UX, while a reset request carries no password at all — the only thing given up is the form's ability to say "unknown address". **The status code is the smaller half.** Response TIME separates the cases far more: the known path sends mail, a network round trip orders of magnitude above everything else. `setPasswordResetTokenForEmail()` returns `null` instead of throwing and still generates a token so the CPU cost matches, but the send lives in the CALLER — a `sendPasswordResetMail()` that AWAITS it leaks the difference regardless. The reference implementation (`src/server/modules/user/user.service.ts`) does not await; copy that shape. Set `false` to restore the pre-11.38.0 behaviour |
|
|
279
298
|
| Legacy Auth Endpoints | `auth.legacyEndpoints` | **Explicit Opt-In** (a fourth shape — see below) | **`enabled: false` since 11.38.0** (was `true`). The legacy password-authentication surface — `signIn`/`signUp`/`logout`/`refreshToken` over GraphQL, `/auth/*` over REST — is reachable only when asked for. Disabled endpoints answer HTTP 410 Gone. Only relevant for the three-argument `CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(...), envConfig)`; the one-argument form never registers the module. Resolution, via the exported `isLegacyEndpointEnabled(config, transport)`: `enabled: false` wins over everything; else a per-transport boolean (`graphql` / `rest`) wins; else `enabled === true`; else **off**. The asymmetry is deliberate — an explicit `enabled: false` is a HARD off switch that a per-transport `true` cannot reopen, because it is the setting a project reached for to close legacy down and an upgrade must never widen it. Non-boolean values (config arrives as JSON through `NSC__*`) are read as "not set", so the string `'false'` cannot switch a transport ON by truthiness. `CoreLegacyAuthDeprecationInitializer` reports both directions at boot: a warning while the surface is open, and a warning when it is closed while users are still unmigrated. Env var: `LEGACY_AUTH_ENABLED`, matched strictly against `'true'`. Implementation: `auth/helpers/legacy-endpoints.helper.ts` |
|
|
299
|
+
| Password-Reset Token Lifetime | `auth.passwordReset.tokenExpiresInMinutes` | **Numeric Sentinel — Family C** (see above) | **60 minutes since 11.38.0. Before that the legacy reset token NEVER expired** — `resetPassword()` looked it up by value and compared no time, while the exception it threw read "Invalid or expired password reset token", claiming a check that did not exist. A reset link takes over an EXISTING account, so an unbounded one means a mail in an archive or a restored backup opens that account years later. The IAM half already expired after an hour (Better-Auth's `resetPasswordTokenExpiresIn`); only the legacy half had nothing. `0` opts out; an invalid value falls back to 60, never to unbounded. **A token minted before 11.38.0 carries no timestamp and is treated as EXPIRED** — reading "no timestamp" as "valid forever" would preserve the defect permanently rather than for a migration window. An expired token is answered exactly like an unknown one (404, same message) and deleted on sight. Model field `passwordResetTokenExpiresAt`, `@Restricted(S_NO_ONE)`. Implementation: `core-user.service.ts` → `passwordResetTokenExpiryMinutes()` / `isPasswordResetTokenExpired()` |
|
|
300
|
+
| Legacy Password-Reset Link | `email.passwordResetLink` | Explicit Value with derived default | **`<appUrl>/auth/reset-password?token={token}` since 11.38.0.** Two conventions live here and which applies depends on whether the option is set AT ALL: unset yields `?token=`, the same string written out yields a PATH segment. That is deliberate — a project that configures such a value has a page built for a path parameter, and moving it silently would break the flow this release repairs — and because it is not guessable, `CoreUserService` **warns once at boot** when a configured value carries no `{token}`. The warning names a precondition: `{token}` is substituted by `buildPasswordResetLink()` and nothing else, so a caller still concatenating by hand must switch FIRST or it mails the placeholder verbatim. Returns `null` rather than a string containing `undefined`; resolves `appUrl` through `resolveServerUrls` (localhost defaults, host-split `baseUrl`) and honours `cors.deriveAppUrl` |
|
|
301
|
+
| SMTP TLS Mode | `email.smtp.secure` + `SMTP_REQUIRE_TLS` | Derived from the port | **`secure` follows `SMTP_PORT` unless set to `'true'`/`'false'`.** It is not an independent setting: 465 negotiates TLS immediately, everything else upgrades via STARTTLS. The production profile shipped 587 + `secure: true` — a pair that cannot connect — so a deployment configuring only host and credentials sent NO mail, invisibly, because authentication mail is not awaited. Only the two canonical values override; anything else defers to the port, which is the only rule under which no input produces an unconnectable pair. `requireTLS` defaults to **true**: `secure: false` alone means opportunistic STARTTLS, and an on-path attacker stripping the capability line gets credentials and a live reset link in plaintext. `EmailService` warns once when the resolved pair cannot connect — reported, not overruled. Implementation: `config.helper.ts` → `resolveSmtpSecure()` / `isImpossibleSmtpTlsCombination()` |
|
|
280
302
|
| Legacy Auth Rate Limiting | `auth.rateLimit` | Presence Implies Enabled | `max: 10`, `windowSeconds: 60`. Counters live in a `RateLimitStore`: `RedisRateLimitStore` when `redis` is configured (limit enforced EXACTLY across replicas instead of `max × replicas`), else `InMemoryRateLimitStore` (previous behavior). On a Redis outage it degrades to the in-memory counter and logs once per transition — never a 500, never "allowed". `check()` / `reset()` / `clear()` are ASYNC since 11.33.0; `getStats().activeEntries` is `-1` on the Redis store |
|
|
281
303
|
| BetterAuth Rate Limiting | `betterAuth.rateLimit` | Presence Implies Enabled | `max: 10`, `windowSeconds: 60`. Same `RateLimitStore` selection, degradation and async signatures as the Legacy Auth row (namespace `better-auth`) |
|
|
282
304
|
| BetterAuth JWT Plugin | `betterAuth.jwt` | Boolean Shorthand | `expiresIn: '15m'` |
|
|
@@ -311,6 +333,7 @@ This pattern is currently applied to:
|
|
|
311
333
|
| Cron Job Deduplication | per job: `distributed` in `CronJobConfig` | Explicit Boolean | **`true` when `redis` is configured, otherwise `false`** — a single-replica project that upgrades must not silently gain a `cron-locks` collection, a lease write per tick, and a new way for a tick to be skipped. A Redis-less multi-replica fleet opts in per job with `distributed: true` (MongoDB lease). Mechanism: BullMQ job scheduler when Redis + the OPTIONAL peer `bullmq` are present AND `cronTime` is a string without `utcOffset`; otherwise local timer + lease (Redis `SET NX`, else a TTL-indexed `cron-locks` document). Tick lease TTL 3600 s. **Leases fail open** — an unreachable lease store runs the tick everywhere rather than stopping all scheduled work fleet-wide. **`runOnInit` (default `true`) deduplicates over a FIXED per-job key with a 300 s TTL**, because replicas do not share a boot instant: replicas booting within 5 min run the startup tick once between them, and a replica restarting inside that window SKIPS its startup tick — set `distributed: false` on jobs whose `runOnInit` work is per-process (warming a process-local cache). No constructor change needed: `CoreModule` fills `core-cron-jobs.registry.ts` via `CoreCronJobsInitializer` and `CoreCronJobs` reads it lazily; explicit `{ connection, redisService }` in `CoreCronJobsOptions` wins. With neither source it warns once and every replica runs every tick |
|
|
312
334
|
| Shutdown Delay | `shutdownDelayMs` | Numeric Sentinel — Family B (`0` = off) | `0` (default, no delay, no log). Waits N ms **in the SIGTERM/SIGINT handler, before `close()` is entered**, so a load balancer can finish deregistering while the instance is still fully healthy. NOT a lifecycle hook: `close()` runs `onModuleDestroy` → `beforeApplicationShutdown` → dispose → `onApplicationShutdown`, so a delay in `beforeApplicationShutdown` would wait with every module already torn down while the socket still accepts — worse than no delay. **Requires `installGracefulShutdown(app)` in main.ts, which REPLACES `server.enableShutdownHooks()`** — keeping both makes Nest close the app in parallel with the wait, so the delay silently never happens. At delay `0` the helper IS `enableShutdownHooks()`. **Keep the value well below the orchestrator grace period AND leave room for the drain that follows**: Compose `stop_grace_period` 10s, Kubernetes `terminationGracePeriodSeconds` 30s, and `installProcessDiagnostics()` force-exits after 30s — exceed any and the process is SIGKILLed mid-wait with no hook running. Warns above `10000`, capped at `60000`. Non-numeric / negative values behave like `0` |
|
|
313
335
|
| Trust Proxy | `trustProxy` | Explicit Value (pass-through, Express default) | `false` (Express's own default — the forwarded chain is not trusted). Passed verbatim to `app.set('trust proxy', …)` by `CoreTrustProxyInitializer`, a `CoreModule` provider, so a consumer inherits it by upgrading and needs no `main.ts` edit. Accepts `false` / a hop count (`1`, `2`) / `'loopback'` / a subnet list — **not** Express's predicate function: the value must survive `NEST_SERVER_CONFIG` / `NSC__*` (JSON) and the ConfigService deep clone. **This is what makes `request.ip` correct, and every IP-keyed rate limit depends on it**: unset behind Caddy/nginx/an ingress, `req.ip` is the PROXY address for every request, so all clients share ONE bucket and `auth.rateLimit.max` throttles everybody at once — exactly fleet-wide once `redis` is configured. Trusting MORE hops than exist is the opposite failure: a client prepends its own header entry and picks a fresh bucket per request. Applied at module init (inside `app.init()`/`listen()`, i.e. AFTER `main.ts`), so a configured value wins over a hand-written `app.set()`; an UNSET value is never applied, which keeps `app.set('trust proxy', fn)` in `main.ts` available as the escape hatch for the predicate form. **Unset + an IP-keyed limiter enabled (`auth.rateLimit` / `betterAuth.rateLimit`) logs a boot warning naming the shared-bucket consequence**; `trustProxy: false` is the explicit "nothing proxies me" answer that silences it. The AI limiter keys on the user id and is unaffected |
|
|
336
|
+
| AI Egress Allowlist (SSRF) | `ai.allowedBaseUrlHosts` | Explicit Value — **unset = permissive**, malformed = INTERPRETED or reported, never silently off | `undefined` → no restriction, so a local provider (Ollama on `localhost:11434`) works out of the box. Set → only those hosts are reachable, matched against `URL.host` (incl. port) and `URL.hostname`. **Accepts an array OR a comma-separated string, and the string form is not a convenience** — it is what the framework's own env mapping produces: `NSC__AI__ALLOWED_BASE_URL_HOSTS` becomes `{ ai: { allowedBaseUrlHosts: '<string>' } }` and lodash `merge` assigns that scalar straight over a configured array. Until 11.39.x a bare `!Array.isArray(...) -> return` read that as "not configured" and skipped the check entirely, so the canonical `NSC__` spelling silently disabled SSRF egress control with no log line at all. Entries are trimmed, lowercased and stripped of a fully-qualifying trailing dot, and the SAME normalisation is applied to the URL, so neither side wins by spelling one DNS name differently; a bare hostname entry matches any port, and an entry naming the scheme's default port (`example.com:443` on https) also matches the portless URL. A value that is neither array nor string carries no hostnames — the allowlist is then inactive and that is LOGGED as an error, because from the outside it looks exactly like "correctly unset". Enforced on **all three** outbound paths (`chat()`, the capability `probe()`, and `probeContextWindow()` — the last was unguarded before 11.39.x and is reachable from an ordinary user prompt via `detectAndPersistCapabilities()`, before the rate limit). Implementation: `openai-compatible.provider.ts` → `assertBaseUrlAllowed()` / `resolveAllowedBaseUrlHosts()` |
|
|
314
337
|
| AI Assistant | `ai` | Presence Implies Enabled | Core: `maxIterations: 5`, `defaultMode: 'auto'` (or `'plan'`), `rateLimit` (presence implies enabled: `max: 20`, `windowSeconds: 60`), `systemPrompt`, `documentation` (injected into the system prompt), `encryptionSecret`. **DB-backed LLM connections** (`aiConnections`, admin CRUD) with AES-256-GCM-encrypted API keys (`AiCryptoService`, secret from `ai.encryptionSecret` / `NSC__AI__ENCRYPTION_SECRET` / `SECRETS_ENCRYPTION_KEY`; `apiKeyEncrypted` is a global `secretFields` entry, never returned — only `hasApiKey`); optional `defaultConnection` one-time seed. **Provider abstraction** (`ILlmProvider`, default `OpenAiCompatibleProvider` for any OpenAI-compatible endpoint via `fetch`; per-connection `supportsNativeTools`/`supportsJsonResponse` capabilities, emulated tool calling when native tools are unavailable). **Tool registry** (`AiToolRegistry`, tools self-register, role-filtered; tools may be `mutating`/`destructive` and define `authorize()` for pre-flight data-level checks). **Plan mode** (`input.mode: 'plan'`): full plan → pre-flight authorize ALL steps → all-or-nothing execution with a translated (de/en) error when any step is not permitted. **Confirmation policy**: `confirmation.mutating: { default, enforced }` + client `input.requireConfirmation` (ignored when enforced); `destructive` always confirms. **Client metadata** (`input.metadata`: URL/nav/console logs, untrusted+capped). **Multi-turn conversations** (`aiConversations`, owner-scoped). **SSE streaming** (`POST /ai/stream`). **Audit** (`audit: false` → persist to `aiInteractions`, admin-readable). **Token budgets** (`budget: { period: 'day'|'month'|'none', user: { maxTokens?, maxPrompts? }, tenant: { maxTokens?, maxPrompts? } }`, requires audit): per-user AND per-tenant limits with config defaults; admins override per user/tenant at runtime (`aiBudgetLimits`, `CoreAiBudgetService`). Resolution: override → default → unlimited (missing/0 = unlimited). Enforced before the run (HTTP 429 + translated). Each response carries a compact `budget` summary (promptTokens, usedTokens, remainingTokens, resetAt); full breakdown via `aiUsage` query / `GET /ai/usage`. **Self-optimizing prompts**: the system prompt is assembled from keyed fragments (`CoreAiPromptBuilderService` ships built-in defaults; works with zero rows). Admin-editable overrides per slot (`aiSlots`, admin CRUD, `/ai/slots`) scoped by `key`/`locale`/`capability`/`tenantId`, with tenant override/reset semantics and placeholder tokens resolved at run time via the placeholder registry. **Governed learning loop** (`promptLearning: { enabled: true, autoApply: false }`): tool errors record `suggested` hints (`aiPromptHints`, admin CRUD, `/ai/prompt-hints`) that only reach the prompt once admin-approved (or auto-approved when `autoApply`); hints only ADD guidance, never relax permissions. **Context window** (`contextWindow`, default 8192; auto-detected per connection via `ILlmProvider.detectContextWindow()` — Ollama `/api/show` probe / known-model table / Claude alias — and persisted): per-user/session history is trimmed (oldest non-system turns dropped, last truncated) and tool-results capped to `maxToolResultChars` (default 12000) so a session never overflows the model. A connection's window can be seeded via `ai.defaultConnection.contextWindow` (validated: a non-positive/non-integer value is dropped with a warning). **Capability drift check** (`capabilityDriftCheck`, default `false`): opt-in boot self-check that probes each enabled connection with an EXPLICIT `supportsNativeTools`/`supportsJsonResponse` (built with those flags cleared so the endpoint is actually re-probed) and logs a warning on mismatch — the stored value is never changed. OFF by default because it makes outbound calls to the LLM endpoints on every boot; also skipped in the ci/e2e runners. **Deferred tool schemas** (`deferToolSchemas`, default `false`): the system-prompt tool catalog then lists only tool NAMES + descriptions instead of full JSON schemas, and the model fetches a schema on demand via the built-in `search_tools` meta-tool — with a large registry the schemas alone can dominate a small context window. `deferToolSummaryChars` (default `0` = untruncated) additionally caps each description in that DEFERRED catalog: whole sentences up to the cap (always at least the first), word-boundary cut when the first sentence already exceeds it, and a `…` marker appended ON TOP of the cap. The default of `0` keeps the saving opt-in, so enabling `deferToolSchemas` alone never changes what a description says; set roughly 200–400 alongside it to actually reclaim the context. Both apply to EMULATED providers only — a connection with `supportsNativeTools: true` receives every full description + schema via `buildToolSchemas()` regardless, so truncation and the banner are skipped there rather than asserting a cut the tool payload contradicts. The omitted tail is where preconditions and role restrictions usually live — the catalog banner tells the model to fetch the full text via `search_tools` first, but this is model GUIDANCE only: authorization is enforced server-side by the registry's role filter (`forUser()`), the execution-time re-check, and the `mutating`/`destructive` flags read by the confirmation gate — never by what the catalog shows. (`AiTool.authorize()` runs in PLAN MODE only; in auto mode and over MCP, data-level checks must live inside `execute()`.) **MCP server** (`mcp: false` → `/ai/mcp` Streamable HTTP, Bearer auth, lazy `@modelcontextprotocol/sdk`; `mcp: { oauth: true, oauthSecret }` adds OAuth 2.1 — HMAC tokens + PKCE S256 + dynamic registration via `mountAiMcpOAuth(app)` in main.ts). Overrides via `CoreModule.forRoot(env, { ai: { budgetService, connectionResolver, connectionService, controller, conversationService, interactionService, mcpClientService, modeService, placeholderRegistry, preferenceService, promptBuilder, promptHintService, promptService, resolver, service, slotService, toolGrantService, toolPolicyService } })` |
|
|
315
338
|
|
|
316
339
|
## Module Override Pattern (via `ICoreModuleOverrides`)
|
|
@@ -83,6 +83,8 @@ override async signIn(...): Promise<Auth> {
|
|
|
83
83
|
|------------|--------|---------|
|
|
84
84
|
| `CoreAuthResolver` | `checkLegacyGraphQLEnabled(name)` | Throws HTTP 410 if legacy endpoints disabled |
|
|
85
85
|
| `CoreAuthController` | `checkLegacyRESTEnabled(name)` | Throws HTTP 410 if legacy endpoints disabled |
|
|
86
|
+
| `OpenAiCompatibleProvider` | `resolveAllowedBaseUrlHosts()` | Normalises the SSRF egress allowlist (CSV string, case, trailing dot) and reports a malformed value. An override of `assertBaseUrlAllowed()` that re-reads `ConfigService` directly silently loses all of it — including the string form the framework's own `NSC__` mapping produces, which is the shape that used to switch the control off |
|
|
87
|
+
| `OpenAiCompatibleProvider` | `assertBaseUrlAllowed(url)` | Call it before ANY new outbound `fetch` you add to a provider. It already guards `chat()`, `probe()` and `probeContextWindow()`; a fourth path that forgets it is a hole the allowlist's own tests cannot see unless they assert on `fetch` |
|
|
86
88
|
|
|
87
89
|
**Rule**: When you override a method, check if the parent calls any `protected` helper methods. If so, call them in your override too.
|
|
88
90
|
|
|
@@ -151,7 +151,7 @@ Two ordering rules, both enforced by the contract test:
|
|
|
151
151
|
| Rule | Why |
|
|
152
152
|
|------|-----|
|
|
153
153
|
| `packageManager` is exact (`x.y.z+sha512.<hash>`), never a range | Same fixed-version rule as dependencies; corepack rejects ranges outright |
|
|
154
|
-
| `engines.pnpm` tracks the pin's major (`^<major>.0.0`) | A soft gate that warns pnpm 10 users. It is not a pin and cannot tell CI what to install |
|
|
154
|
+
| `engines.pnpm` tracks the pin's major (`^<major>.0.0`) | A soft gate that warns pnpm 10 users. It is not a pin and cannot tell CI what to install. Note it is deliberately looser than what the check chains rely on: `pnpm peers check` is a pnpm 11 subcommand, and `^11.0.0` does not guarantee the minor that introduced it. The real guarantee is `packageManager`, which is exact — this range is not the place to encode a floor |
|
|
155
155
|
| Never declare `devEngines.packageManager` | npm/npx abort with `EBADDEVENGINES`; corepack rejects ranges inside it |
|
|
156
156
|
| Never pass `version:` to `pnpm/action-setup` | Two sources drift; the action hard-errors on a mismatch |
|
|
157
157
|
| Never `RUN corepack …` | Absent on Node >= 25 |
|
|
@@ -168,7 +168,8 @@ Two ordering rules, both enforced by the contract test:
|
|
|
168
168
|
|
|
169
169
|
Package overrides live in the `overrides:` section of **`pnpm-workspace.yaml`** (they moved out of `package.json`'s `pnpm.overrides` in the pnpm 11 upgrade). They force transitive dependencies to a security-patched version.
|
|
170
170
|
|
|
171
|
-
**Keep the set minimal.** On the pnpm 11 upgrade the list was pruned from 36 to the 9 still
|
|
171
|
+
**Keep the set minimal.** On the pnpm 11 upgrade the list was pruned from 36 to the 9 then still
|
|
172
|
+
load-bearing (it has since grown back to 17 as new advisories landed) — an override is only necessary if removing it lets the package resolve back INTO its vulnerable range (verify with a with/without lockfile diff; `pnpm audit` is the arbiter). Each surviving entry carries its CVE rationale as a comment. Remove an entry once its parent dependency ships a fixed version.
|
|
172
173
|
|
|
173
174
|
### Rule: Override Targets MUST Be Fixed Versions
|
|
174
175
|
|
|
@@ -214,6 +215,120 @@ The fix was to change every override target to a fixed version:
|
|
|
214
215
|
"@apollo/server": "5.5.0"
|
|
215
216
|
```
|
|
216
217
|
|
|
218
|
+
### Overrides AGE, and a stale one is a lock rather than a no-op
|
|
219
|
+
|
|
220
|
+
**Rule: when a new advisory raises the patch line for a package you already override, RAISE the
|
|
221
|
+
existing entry. Never add a second one beside it.**
|
|
222
|
+
|
|
223
|
+
Both failure modes below were hit on the same day, in this repo and in `nest-server-starter`, and
|
|
224
|
+
neither is what the "unbounded target" warning above describes. An override is not a fact you record
|
|
225
|
+
once; it is a claim about a moving patch line.
|
|
226
|
+
|
|
227
|
+
**Mode 1 — the floor is overtaken.** The entry keeps doing exactly what it was written to do, and
|
|
228
|
+
that becomes the problem:
|
|
229
|
+
|
|
230
|
+
```yaml
|
|
231
|
+
# Written in August for two advisories patched >= 3.1.4
|
|
232
|
+
'fast-uri@>=3.0.0 <3.1.5': '3.1.5'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Four newer advisories then land, patched `>= 3.1.6`. The entry now **pins** every matching path at
|
|
236
|
+
3.1.5 — below the new line. Adding a second, higher entry does not help: the narrower key claims
|
|
237
|
+
its paths first, and the tree ends up carrying *both* versions while `audit` still reports the
|
|
238
|
+
vulnerability. It looks addressed in the file and is not.
|
|
239
|
+
|
|
240
|
+
**Mode 2 — the target itself becomes vulnerable.** Worse, because nothing about the entry looks
|
|
241
|
+
wrong:
|
|
242
|
+
|
|
243
|
+
```yaml
|
|
244
|
+
# Target was clean when written; a later advisory covers >=6.14.2 <=6.15.3
|
|
245
|
+
'qs@>=6.11.1 <=6.15.1': '6.15.3'
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The override is now actively pinning the tree to a vulnerable version, and the narrow key hides it
|
|
249
|
+
from a casual read.
|
|
250
|
+
|
|
251
|
+
**How to spot both — and the check that does NOT work.** "The same package appears twice under
|
|
252
|
+
`overrides:`" is the obvious heuristic and it is wrong: several packages legitimately need one entry
|
|
253
|
+
per major, because different majors coexist in the tree. This repo has three `brace-expansion`
|
|
254
|
+
entries (1.x, 2.x, 5.x) and two for `js-yaml` (4.x, 5.x), all correct. Flagging those trains the
|
|
255
|
+
reader to ignore the check.
|
|
256
|
+
|
|
257
|
+
The reliable tells are narrower:
|
|
258
|
+
|
|
259
|
+
1. **`pnpm audit` reports a package you already override.** That is never "transitive, pre-existing,
|
|
260
|
+
not our problem" — it means the entry itself is the cause, in one of the two modes above.
|
|
261
|
+
2. **Two entries for the same package within ONE major**, one of them floored below the other's
|
|
262
|
+
target. That is the stale-versus-new collision; the narrower key wins and the higher entry is
|
|
263
|
+
decoration.
|
|
264
|
+
3. **The lockfile resolves two versions of the package inside one major.** Beware `@types/x`, which
|
|
265
|
+
looks like a second resolution of `x` and is not.
|
|
266
|
+
|
|
267
|
+
**This is automated now — `pnpm run check:overrides`.** The guard (`scripts/check-overrides.mjs`)
|
|
268
|
+
cross-references every `overrides:` entry against a live `pnpm audit` report and reports four
|
|
269
|
+
classes: **TOO LOW** (the override resolves and still delivers a version the advisory covers — modes
|
|
270
|
+
1 and 2 above), **NOT MATCHING** (the selector no longer reaches the path it was written for), plus
|
|
271
|
+
**NO FIX** and **UNUSED** as warnings. It runs inside `check`, `check:fix`, `check:naf` and
|
|
272
|
+
`check:raw`, and in CI on both `build.yml` and `publish.yml`. The three tells above are still how you
|
|
273
|
+
*read* a report by hand; the guard is what makes sure somebody does. `--audit-file` and
|
|
274
|
+
`--advisory-file` make a run reproducible offline, and CI passes `--audit-file` so the
|
|
275
|
+
cannot-obtain-a-report skip (correct on a laptop, a silent pass on a runner) is unreachable there.
|
|
276
|
+
|
|
277
|
+
**The fix in both cases is one entry, floored below the current patch line, targeting a version at
|
|
278
|
+
or above it:**
|
|
279
|
+
|
|
280
|
+
```yaml
|
|
281
|
+
'fast-uri@<3.1.6': '3.1.6'
|
|
282
|
+
'qs@<6.16.0': '6.16.0'
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Two constraints on the target, both learned the hard way:**
|
|
286
|
+
|
|
287
|
+
1. **Stay within the major.** A newer major usually exists; crossing one to fix a *transitive*
|
|
288
|
+
advisory is the shape that broke a project in April 2026 (see the incident above).
|
|
289
|
+
2. **The newest patch may be refused.** This repo enforces a `minimumReleaseAge` supply-chain
|
|
290
|
+
policy, and a version published inside that window fails the install with
|
|
291
|
+
`ERR_PNPM_NO_MATURE_MATCHING_VERSION`. Take the oldest version that clears every advisory — which
|
|
292
|
+
is the right instinct regardless of the policy.
|
|
293
|
+
|
|
294
|
+
### Suppressions age the same way, and worse
|
|
295
|
+
|
|
296
|
+
`auditConfig.ignoreGhsas` in `pnpm-workspace.yaml` suppresses an advisory outright. It is the other
|
|
297
|
+
half of the problem above, and the blinder half.
|
|
298
|
+
|
|
299
|
+
An override at least stays visible: `pnpm audit` keeps reporting its package, so a stale entry can be
|
|
300
|
+
caught by reading the report against the file. **A suppressed advisory is removed from
|
|
301
|
+
`pnpm audit --json` entirely** — `advisories` drops it and `muted` comes back empty. Once a GHSA is
|
|
302
|
+
listed there, no audit run ever mentions it again. If upstream ships a fix tomorrow, nothing says so.
|
|
303
|
+
|
|
304
|
+
That is not hypothetical either. `GHSA-mh99-v99m-4gvg` was suppressed on 2026-07-28 with a
|
|
305
|
+
justification that was correct in every detail: the newest 1.x was 1.1.16 and carried no length
|
|
306
|
+
guard, and the one remaining path could not be lifted. **1.1.17 — the backport the entry said did not
|
|
307
|
+
exist — was published on 2026-07-29, one day later.** The entry sat obsolete for five weeks, and
|
|
308
|
+
nothing in the repo could have said so.
|
|
309
|
+
|
|
310
|
+
**Rules for an entry here:**
|
|
311
|
+
|
|
312
|
+
1. **Name the advisory, the affected path, and why it cannot be fixed** — not just why it is
|
|
313
|
+
tolerable.
|
|
314
|
+
2. **Record what you verified and when**, with a re-verification trigger ("re-check when @nestjs/cli
|
|
315
|
+
drops fork-ts-checker-webpack-plugin"). A justification without a date cannot be audited later.
|
|
316
|
+
3. **Assume it will stop being true without telling you.** Ask, before adding it: *what will notice
|
|
317
|
+
when this expires?* If the answer is "nothing", the entry is a liability regardless of how good
|
|
318
|
+
today's reasoning is.
|
|
319
|
+
4. **Prefer an override.** A suppression is the last resort, for an advisory with no patched version
|
|
320
|
+
anywhere. A drifted override is at least still visible.
|
|
321
|
+
|
|
322
|
+
`pnpm run check:overrides` re-checks every listed GHSA against the GitHub Advisory API and **fails**
|
|
323
|
+
on `FIX AVAILABLE` (a `first_patched_version` now exists) or on a withdrawn advisory. An unreachable
|
|
324
|
+
API is reported as `UNVERIFIED` and tolerated locally — but **fails under `CI`**, because a skip
|
|
325
|
+
nobody reads is indistinguishable from a check that ran.
|
|
326
|
+
|
|
327
|
+
One consequence worth knowing before you reach for this mechanism: the guard fires when the advisory
|
|
328
|
+
has a patched version for **any** affected range. That makes `ignoreGhsas` nearly unusable for an
|
|
329
|
+
advisory that is fixed *somewhere* but unfixable on *your* path — which is the honest outcome, since
|
|
330
|
+
that case is an override problem, not a suppression problem.
|
|
331
|
+
|
|
217
332
|
### Safe Override Workflow
|
|
218
333
|
|
|
219
334
|
When adding an override to fix a vulnerability:
|
package/.claude/rules/testing.md
CHANGED
|
@@ -266,7 +266,7 @@ pnpm run check:mutations -- --id=<id> # one mutation
|
|
|
266
266
|
pnpm run check:mutations -- --list # the registry, without running anything
|
|
267
267
|
pnpm run check:mutations -- --allow-dirty # when the fix and its evidence share a working tree
|
|
268
268
|
pnpm run check:mutations -- --jobs=4 # N mutations at a time (default: 2, or 4 on >=12 cores)
|
|
269
|
-
pnpm run check:mutations -- --no-infra # only the
|
|
269
|
+
pnpm run check:mutations -- --no-infra # only the 63 that need no MongoDB
|
|
270
270
|
pnpm run check:mutations -- --since=<ref> # only mutations touching files changed since <ref>
|
|
271
271
|
```
|
|
272
272
|
|
|
@@ -284,8 +284,11 @@ when the registry, a vitest config or a setup file changed, since those can move
|
|
|
284
284
|
**Why the gate does not cache per-mutation verdicts instead.** It runs ONCE PER RELEASE, not per
|
|
285
285
|
commit, so selective re-running would save ~10 minutes a release. The price is a cache that has to
|
|
286
286
|
model each spec's full dependency closure correctly, and getting that wrong produces a stale PASS
|
|
287
|
-
for a test that has since gone vacuous — exactly what the gate is there to prevent. Bad trade
|
|
288
|
-
mutations
|
|
287
|
+
for a test that has since gone vacuous — exactly what the gate is there to prevent. Bad trade
|
|
288
|
+
at 109 mutations — past the threshold this paragraph has carried since the gate was written,
|
|
289
|
+
now reached exactly. The full run is approaching half an hour. **The re-evaluation is due now**: the
|
|
290
|
+
next person to add a mutation should decide whether selective re-running has become worth its
|
|
291
|
+
failure mode (a stale PASS for a test that has since gone vacuous), not bump this number again.
|
|
289
292
|
|
|
290
293
|
Not part of `pnpm run check` — it edits source and re-runs whole e2e suites. It belongs in review
|
|
291
294
|
and on the publish path. It is also reachable on demand, without cutting a release:
|
|
@@ -324,9 +327,9 @@ exactly the environment where the answer matters.
|
|
|
324
327
|
|
|
325
328
|
### The cost is vitest's cold start, not the tests
|
|
326
329
|
|
|
327
|
-
Worth knowing before optimising the wrong thing: the specs behind all
|
|
330
|
+
Worth knowing before optimising the wrong thing: the specs behind all 46 e2e mutations add up to
|
|
328
331
|
**~40 seconds**. The step takes ~740s. The remaining ~700s is paying vitest's startup — process
|
|
329
|
-
spawn, transform, module graph, mongod connect, DB create and drop — once per mutation,
|
|
332
|
+
spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 109 times.
|
|
330
333
|
That work is largely single-threaded I/O and barely scales with cores: the full registry measures
|
|
331
334
|
**744s on a 12-core laptop and 777s on a 4-vCPU CI runner**.
|
|
332
335
|
|
|
@@ -370,6 +373,209 @@ When a change adds or edits a test that claims to fix or pin a bug:
|
|
|
370
373
|
4. **"Is the mutation the defect, or a proxy for it?"** A mutation that breaks everything proves
|
|
371
374
|
nothing about the specific behaviour.
|
|
372
375
|
|
|
376
|
+
## `disableConsoleIntercept` — why both runners set it
|
|
377
|
+
|
|
378
|
+
**Rule: both vitest configs in THIS repo set `disableConsoleIntercept: true`, because both were
|
|
379
|
+
measured and both showed exposure. Do not remove it to get per-file console attribution back
|
|
380
|
+
without reading this first — and do not copy it into another repo without measuring there.**
|
|
381
|
+
|
|
382
|
+
It is the fix for an `EnvironmentTeardownError` that failed `pnpm run check` roughly **1 run in
|
|
383
|
+
10**, with every test green:
|
|
384
|
+
|
|
385
|
+
```
|
|
386
|
+
EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending
|
|
387
|
+
Test Files 108 passed (108)
|
|
388
|
+
Tests 2300 passed | 1 skipped (2301)
|
|
389
|
+
Errors 1 error
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### The mechanism
|
|
393
|
+
|
|
394
|
+
vitest normally replaces `globalThis.console` in every worker (`setupConsoleLogSpy()`) and
|
|
395
|
+
forwards each write to the main thread as an `onUserConsoleLog` RPC. At worker teardown
|
|
396
|
+
`execute()` does **not** await those calls — it rejects whatever is still in flight:
|
|
397
|
+
|
|
398
|
+
```js
|
|
399
|
+
rpc.$rejectPendingCalls(({ method, reject }) =>
|
|
400
|
+
reject(new EnvironmentTeardownError(`Closing rpc while "${method}" was pending`)))
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The rejection surfaces as an unhandled error and fails the run. So a console write from the last
|
|
404
|
+
test of a file can lose a race against its own cleanup.
|
|
405
|
+
|
|
406
|
+
`disableConsoleIntercept: true` skips `setupConsoleLogSpy()` entirely, so the custom console is
|
|
407
|
+
never installed. Worker-side, `rpc.onUserConsoleLog` has exactly ONE caller — `sendLog()` inside
|
|
408
|
+
that console (`vitest/dist/chunks/console.*.js`); every other reference is main-process reporter
|
|
409
|
+
code. No spy, no caller, nothing that can be pending. **That is a structural fix, not a reduced
|
|
410
|
+
probability** — which matters, because the rate is load-dependent and no number of green runs
|
|
411
|
+
would have proved it.
|
|
412
|
+
|
|
413
|
+
### The cost, and why it is not what it looks like
|
|
414
|
+
|
|
415
|
+
The obvious reading — "you lose the `stdout | <file>` prefix" — is wrong, and both configs said so
|
|
416
|
+
until it was measured. With the intercept ON, the default reporter **swallows console output from
|
|
417
|
+
PASSING tests entirely** in a piped run (which is how CI and every piped `check` runs). Removing it
|
|
418
|
+
does not strip a prefix; it makes previously invisible output appear:
|
|
419
|
+
|
|
420
|
+
```
|
|
421
|
+
nest-server unit disableConsoleIntercept: false -> 0 visible lines
|
|
422
|
+
disableConsoleIntercept: true -> 8-9 visible lines
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
The range is not sloppiness: three of those lines are the rate-limit-store capacity warning, whose
|
|
426
|
+
count depends on how many counters a run happens to create, so the total moves between runs. Quote a
|
|
427
|
+
range when a measurement varies — a single number invites the next reader to treat a normal 8 as a
|
|
428
|
+
regression from 9 and go looking for it.
|
|
429
|
+
|
|
430
|
+
**And count the writes, not one phrasing of them.** A first attempt at re-measuring this grepped for
|
|
431
|
+
`Configured for|No local config` and reported **1**, because the other seven lines are a hub-buffer
|
|
432
|
+
warning, a ConfigService merge notice, three rate-limit warnings and two dev auth URLs — none of
|
|
433
|
+
which match those words. That reads as "no exposure" and is the same artefact as counting reporter
|
|
434
|
+
output, arrived at from a different direction. Diff a flag-off run against a flag-on run instead;
|
|
435
|
+
the difference IS the answer, and it cannot be narrowed by a pattern.
|
|
436
|
+
|
|
437
|
+
Measure by flipping the value IN THE CONFIG. A CLI override does not win against a config value —
|
|
438
|
+
a first attempt at this measurement was worthless for exactly that reason.
|
|
439
|
+
|
|
440
|
+
**And it does not point the same way in every suite.** In an e2e config, where the reporter DOES
|
|
441
|
+
print passing-test output, the intercept adds a header line per write, so removing it roughly
|
|
442
|
+
halves the volume. Same flag, opposite ledger.
|
|
443
|
+
|
|
444
|
+
That direction is the finding; do not quote a figure for it. The `57 → 37` this line used to carry
|
|
445
|
+
came from a comment in the starter's own config (`vitest-e2e.config.ts:96`, "this run went from 57
|
|
446
|
+
visible lines to 37") that nobody here has re-run, and whose scope is ambiguous — the paragraph
|
|
447
|
+
above it discusses a single-spec run, so the numbers may describe one file or the whole suite.
|
|
448
|
+
**Where a number has not been measured, state the direction and stop.** The claim that matters —
|
|
449
|
+
that the ledger flips in an e2e config, because that reporter does print passing-test output — needs
|
|
450
|
+
no figure at all, and a figure nobody can source invites the next reader to treat it as a baseline.
|
|
451
|
+
|
|
452
|
+
### Not a rule to apply blindly — measure both sides
|
|
453
|
+
|
|
454
|
+
`nest-server` sets it in both runners because both showed exposure. That is a finding here, not a
|
|
455
|
+
default. `nest-server-starter` deliberately sets it ONLY in the e2e config, `nuxt-base-starter`
|
|
456
|
+
and `offers` set it nowhere — each after measuring.
|
|
457
|
+
|
|
458
|
+
The trap is the measurement itself. Counting `grep -cE '^(stdout|stderr) \| '` reports what the
|
|
459
|
+
REPORTER PRINTS, not the RPC traffic — so a suite with 114 suppressed writes measures 0 and looks
|
|
460
|
+
immune. That artefact produced a wrong "zero exposure" verdict in the starter before
|
|
461
|
+
`nuxt-base-starter` caught it. The honest procedure is:
|
|
462
|
+
|
|
463
|
+
1. Exposure — does the suite make `console.*` calls at all? (Nest `Logger` output does not count;
|
|
464
|
+
it bypasses the intercept.)
|
|
465
|
+
2. Cost — visible lines with the flag versus without, flipped in the config.
|
|
466
|
+
3. Does it actually flake? Write COUNT does not predict the race; write TIMING relative to the end
|
|
467
|
+
of a file does. The starter's unit suite makes 114 writes and did not flake in 15 runs;
|
|
468
|
+
nest-server's makes 9 and flaked about 1 in 10.
|
|
469
|
+
|
|
470
|
+
Where a suite flakes AND the noise cost is high, the better fix is neither option: silence the
|
|
471
|
+
writes at the source. `nuxt-base-starter` measures 0 writes and 0 noise because its
|
|
472
|
+
`tests/unit/setup.ts` mocks `console.debug`/`console.info` globally.
|
|
473
|
+
|
|
474
|
+
### Three things that made this expensive to diagnose
|
|
475
|
+
|
|
476
|
+
1. **It is a race, not a late log.** Every spec involved `await`s correctly. Hunting for an
|
|
477
|
+
unawaited promise finds nothing and sends you the wrong way.
|
|
478
|
+
2. **The reported file is not the cause.** vitest attributes the unhandled rejection to whichever
|
|
479
|
+
file's worker was running ("It doesn't mean the error was thrown inside the file itself").
|
|
480
|
+
The file it named logs nothing of its own.
|
|
481
|
+
3. **A wrong explanation survives five green runs.** The first diagnosis here — a keep-alive
|
|
482
|
+
socket holding a handle — was plausible, fitted the observation, and was WRONG; a bisect with
|
|
483
|
+
the suspected file excluded still reproduced it. At ~1 in 10, a handful of green runs is not
|
|
484
|
+
evidence of anything. Bisect, or do not claim a cause.
|
|
485
|
+
|
|
486
|
+
### Where the output actually came from
|
|
487
|
+
|
|
488
|
+
Worth recording, because `tests/setup.ts` looks like it already handles this and does not — and
|
|
489
|
+
because the obvious remedy is the wrong one.
|
|
490
|
+
|
|
491
|
+
**Only raw `console.*` calls feed the RPC. Nest `Logger` output does not.** vitest replaces
|
|
492
|
+
`globalThis.console` and nothing else; it does not patch `process.stdout`/`stderr`. Nest's
|
|
493
|
+
`ConsoleLogger` writes straight to those streams, so its output reaches the terminal without ever
|
|
494
|
+
becoming an `onUserConsoleLog` call. Verified both ways: a `[Nest] … DEBUG …` line carries no
|
|
495
|
+
`stdout | <file>` prefix, while every attributed line traces to a bare `console.*`.
|
|
496
|
+
|
|
497
|
+
The consequence is the useful part: **silencing the Nest logger does nothing for this flake.**
|
|
498
|
+
`Logger.overrideLogger(['error','fatal'])` — and any per-spec capture of `logger.error` — is noise
|
|
499
|
+
reduction, not a fix. Reaching for it because the loud lines look like the culprit is a dead end.
|
|
500
|
+
|
|
501
|
+
The writes that actually fed the RPC:
|
|
502
|
+
|
|
503
|
+
| Source | Call |
|
|
504
|
+
|--------|------|
|
|
505
|
+
| `common/helpers/config.helper.ts` | `console.info` — "Configured for: …", "No local config.json found!" |
|
|
506
|
+
| `common/services/config.service.ts` | `console.warn` from `mergeConfig` |
|
|
507
|
+
| `common/services/rate-limit-store.ts` | `console.warn` from `InMemoryRateLimitStore.hit` |
|
|
508
|
+
| `modules/hub/hub-buffer.ts` | `console.debug` from `readShared` |
|
|
509
|
+
| `modules/better-auth/…-email-verification.service.ts` | `console.log` from `logAuthUrlForDevelopment` |
|
|
510
|
+
|
|
511
|
+
### Separately: capture the error output a spec EXPECTS
|
|
512
|
+
|
|
513
|
+
Independent of the flake, and still worth doing — `captureExpectedLogs()` in
|
|
514
|
+
`tests/helpers/expected-log-output.ts`. A spec that deliberately drives a failure path (SMTP down,
|
|
515
|
+
Redis refused, GridFS chunks missing) otherwise prints `logger.error` plus stack traces on a GREEN
|
|
516
|
+
run. That is the same "output that is always there is output nobody reads" argument the rest of
|
|
517
|
+
this file makes, and the helper RETURNS what was logged so the message becomes an assertion rather
|
|
518
|
+
than noise. Four specs use it; three gained a real assertion in the process, and one —
|
|
519
|
+
`'re-throws a send failure after logging it'` — had never asserted the half of its name after the
|
|
520
|
+
comma.
|
|
521
|
+
|
|
522
|
+
Do not reach for a global silence instead: `Logger.overrideLogger([])` would also silence the
|
|
523
|
+
failing run, removing the diagnostics you need exactly when something breaks.
|
|
524
|
+
|
|
525
|
+
## A catch that asserts nothing makes its test pass on every outcome
|
|
526
|
+
|
|
527
|
+
**Rule: a `catch` block in a test either asserts something that could be false, or rethrows. Never
|
|
528
|
+
`expect(error).toBeDefined()`, and never an empty body.**
|
|
529
|
+
|
|
530
|
+
The shape, which reads as careful error handling and is the opposite:
|
|
531
|
+
|
|
532
|
+
```typescript
|
|
533
|
+
try {
|
|
534
|
+
const response = await testHelper.rest('/iam/two-factor/enable', { statusCode: 200 });
|
|
535
|
+
expect(response).toBeDefined();
|
|
536
|
+
} catch (error) {
|
|
537
|
+
expect(error).toBeDefined(); // ← both branches assert "something happened"
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
`testHelper.rest()` throws when the status does not match — that is what passing `statusCode` is
|
|
542
|
+
for. The catch swallows exactly that throw. The case is green whether the endpoint works, answers
|
|
543
|
+
404, 500s, or is not routed at all.
|
|
544
|
+
|
|
545
|
+
### What it was hiding, measured
|
|
546
|
+
|
|
547
|
+
Removing the swallow from `better-auth-plugins.story.test.ts` turns **17 of its 45 cases red**. All
|
|
548
|
+
are status-code mismatches — `404` where `200` was declared, `400` where `401` was, and the reverse.
|
|
549
|
+
None describes a broken user path. They are expectations written against assumptions nobody could
|
|
550
|
+
check, because the catch hid the answer for the whole life of the test.
|
|
551
|
+
|
|
552
|
+
### Why they were not simply fixed
|
|
553
|
+
|
|
554
|
+
Writing down what each endpoint currently answers would be **worse than leaving them**: it turns a
|
|
555
|
+
test that checks nothing into one that certifies possibly-wrong behaviour, and freezes it. Deciding
|
|
556
|
+
what each endpoint *should* answer is real investigation — 40 blocks across 6 suites — and belongs
|
|
557
|
+
in its own change.
|
|
558
|
+
|
|
559
|
+
`tests/unit/swallowing-catch-baseline.spec.ts` therefore locks the count at its current value
|
|
560
|
+
instead. It may fall, never rise; a new suite gaining the pattern fails separately from the total,
|
|
561
|
+
because a flat total can still hide a migration into a fresh file. When you fix a batch, lower the
|
|
562
|
+
baseline in the same commit — the failure message tells you the number.
|
|
563
|
+
|
|
564
|
+
### The sibling shape: a guard that is never false
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
it('should build the frontend URL', () => {
|
|
568
|
+
if (config.callbackURL) { // ← set in NO environment of this repo
|
|
569
|
+
expect(url).toContain('token=');
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
Two such cases lived in `better-auth-email-verification.story.test.ts` and asserted nothing for
|
|
575
|
+
their entire life. They were deleted once `tests/unit/verification-link.spec.ts` covered the
|
|
576
|
+
behaviour unconditionally. **A guard that depends on configuration must be checked against the
|
|
577
|
+
configuration that actually exists** — `grep` for the key in `config.env.ts` before trusting one.
|
|
578
|
+
|
|
373
579
|
## Known coverage gap: no load-test baseline for the auth endpoints
|
|
374
580
|
|
|
375
581
|
There is no `tests/k6/` in this repository — no config, no endpoint scripts, no baselines. That is
|
|
@@ -392,6 +598,33 @@ The vector predates 11.38.0 — Better-Auth already hashed on reset — so this
|
|
|
392
598
|
factor roughly doubling, not a new exposure. It is not scaffolded here because a load profile
|
|
393
599
|
belongs with a running API and an agreed traffic shape, not bolted onto a static review.
|
|
394
600
|
|
|
601
|
+
## A test that reads `CI` answers differently on a runner
|
|
602
|
+
|
|
603
|
+
**Rule: when a spec drives behaviour that depends on the `CI` environment variable, it must PIN the
|
|
604
|
+
variable — never inherit it.** Otherwise the assertion means one thing on a laptop and another on a
|
|
605
|
+
runner, and the laptop is where it gets written.
|
|
606
|
+
|
|
607
|
+
`scripts/check-overrides.mjs` is the case: an unverified suppression is TOLERATED locally and
|
|
608
|
+
ESCALATED to a hard failure under `CI`, deliberately, because a skip nobody reads is
|
|
609
|
+
indistinguishable from a check that ran. `tests/unit/check-overrides.guard.spec.ts` spawns that
|
|
610
|
+
guard, so every case it runs inherits whatever `CI` the surrounding process has.
|
|
611
|
+
|
|
612
|
+
One case did not pin it, asserted `status === 0`, and passed locally for its whole life. It failed
|
|
613
|
+
the release CI run for 11.40.0 — the first time it had ever executed on a runner. The fix is one
|
|
614
|
+
line (`env: { CI: '' }`); the point is that nothing would have found it earlier, because the
|
|
615
|
+
environment that breaks it is the one nobody runs tests in by hand.
|
|
616
|
+
|
|
617
|
+
**Before a release, run the unit suite the way a runner sees it:**
|
|
618
|
+
|
|
619
|
+
```bash
|
|
620
|
+
CI=true npx vitest run --config vitest.config.ts --reporter=dot
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Deliberately NOT wired into `check`. It would run all 2381 unit tests a second time — about 40s on
|
|
624
|
+
every local check — to protect against a class that currently has exactly one member, and CI itself
|
|
625
|
+
already catches it at the cost of one failed PR run. Revisit that trade if a second env-dependent
|
|
626
|
+
spec appears; the balance is about how many, not about principle.
|
|
627
|
+
|
|
395
628
|
## Consumer gate: the starter runs BEFORE publish, not after
|
|
396
629
|
|
|
397
630
|
`pnpm run check:consumer` builds the tarball (`pnpm pack`), installs it into a **throwaway copy** of
|
package/CLAUDE.md
CHANGED
|
@@ -24,7 +24,8 @@ The following documents must be kept up to date when making changes that affect
|
|
|
24
24
|
| `.claude/rules/configurable-features.md` | Adding new configurable features |
|
|
25
25
|
| `src/templates/*.ejs` + `tests/unit/email-templates.spec.ts` | Changing a mail template — a template is never imported, type-checked or linted, so the only thing binding it to its caller is the variable names inside it. Add the caller's data shape to the spec |
|
|
26
26
|
| `FRAMEWORK-API.md` | Auto-regenerated by `pnpm run build` — verify after adding interfaces, CrudService methods, or core modules |
|
|
27
|
-
| `.claude/rules/
|
|
27
|
+
| `.claude/rules/package-management.md` | Changing the `overrides:` set, adding or removing an `auditConfig.ignoreGhsas` suppression, or changing the `check:overrides` guard. Same reasoning as the row below: the guard exists because an override and a suppression both decay in silence — and a guard nobody knows about decays exactly the same way |
|
|
28
|
+
| `.claude/rules/testing.md` | Changing the test-runner split, the per-run DB lifecycle or its **drop guard**, the run governor, the infrastructure containers, the regression-evidence gate, or **`disableConsoleIntercept`** (both runners set it; removing it to get per-file console attribution back re-arms an `EnvironmentTeardownError` that fails `check` roughly 1 run in 10 with every test green). This row exists because the drop guard — the one thing standing between a test run and a developer's data — went undocumented for its whole life: nothing in this table pointed at the file that describes everything around it |
|
|
28
29
|
|
|
29
30
|
**Rule:** When a code change adds, removes, or modifies a feature listed in `docs/REQUEST-LIFECYCLE.md` (Features Overview, diagrams, decorator reference, configuration, etc.), update the document in the same commit or PR.
|
|
30
31
|
|
|
@@ -131,6 +132,17 @@ pnpm run lint:fix # Auto-fix
|
|
|
131
132
|
pnpm run format # oxfmt format
|
|
132
133
|
|
|
133
134
|
# Import-cycle / SWC safety (part of `check`)
|
|
135
|
+
# Supply-chain guards (part of `check`)
|
|
136
|
+
pnpm run check:overrides # Cross-checks every pnpm override and every auditConfig.ignoreGhsas
|
|
137
|
+
# suppression against live advisory data. The only step that catches an
|
|
138
|
+
# override whose target drifted BELOW the advisory's fixed-in version:
|
|
139
|
+
# `pnpm audit` reports the advisory, but nothing otherwise says that a
|
|
140
|
+
# supposed remedy for it is already sitting in pnpm-workspace.yaml.
|
|
141
|
+
# Also catches a suppression upstream has since patched — those are
|
|
142
|
+
# invisible to `pnpm audit` by design, so nothing else can.
|
|
143
|
+
# See .claude/rules/package-management.md → "Overrides AGE"
|
|
144
|
+
pnpm peers check # pnpm 11 builtin: unmet/missing peer dependencies, read from the lockfile
|
|
145
|
+
|
|
134
146
|
pnpm run check:swc-tdz # SWC→CJS build, loads EVERY module as its own entry point.
|
|
135
147
|
# The only step that catches a temporal-dead-zone crash from an
|
|
136
148
|
# import cycle — tsc, vitest and oxlint are all blind to it.
|
package/FRAMEWORK-API.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @lenne.tech/nest-server — Framework API Reference
|
|
2
2
|
|
|
3
|
-
> Auto-generated from source code
|
|
3
|
+
> Auto-generated from source code as of 2026-09-04 (v11.40.0)
|
|
4
4
|
> File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code
|
|
5
5
|
|
|
6
6
|
## CoreModule.forRoot()
|
|
@@ -109,7 +109,7 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
|
|
|
109
109
|
|
|
110
110
|
### IAi
|
|
111
111
|
|
|
112
|
-
- `allowedBaseUrlHosts?`: `string[] | undefined` — Optional SSRF allowlist for connection base URLs. When set (non-empty), the
|
|
112
|
+
- `allowedBaseUrlHosts?`: `string | string[] | undefined` — Optional SSRF allowlist for connection base URLs. When set (non-empty), the
|
|
113
113
|
- `audit?`: `boolean | undefined` (default: `false`) — Persist an audit record (`aiInteractions`) for every prompt run (admin-readable).
|
|
114
114
|
- `budget?`: `{ period?: "day" | "month" | "none"; tenant?: { maxPrompts?: number; maxToken...` — Token/prompt budgets for AI prompts, enforced before a run (HTTP 429 + translated
|
|
115
115
|
- `capabilityDriftCheck?`: `boolean | undefined` (default: `false`) — Opt-in boot self-check: after startup, probe each enabled connection that declares
|
package/dist/config.env.js
CHANGED
|
@@ -7,6 +7,7 @@ const path_1 = require("path");
|
|
|
7
7
|
const role_enum_1 = require("./core/common/enums/role.enum");
|
|
8
8
|
const config_helper_1 = require("./core/common/helpers/config.helper");
|
|
9
9
|
dotenv.config({ quiet: true });
|
|
10
|
+
const productionSmtpPort = parseInt(process.env.SMTP_PORT || '587', 10);
|
|
10
11
|
const config = {
|
|
11
12
|
ci: {
|
|
12
13
|
auth: {
|
|
@@ -591,8 +592,9 @@ const config = {
|
|
|
591
592
|
user: process.env.SMTP_USER,
|
|
592
593
|
},
|
|
593
594
|
host: process.env.SMTP_HOST,
|
|
594
|
-
port:
|
|
595
|
-
|
|
595
|
+
port: productionSmtpPort,
|
|
596
|
+
requireTLS: process.env.SMTP_REQUIRE_TLS !== 'false',
|
|
597
|
+
secure: (0, config_helper_1.resolveSmtpSecure)(process.env.SMTP_SECURE, productionSmtpPort),
|
|
596
598
|
},
|
|
597
599
|
},
|
|
598
600
|
env: 'production',
|