@lenne.tech/nest-server 11.42.8 → 11.42.9

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.
Files changed (66) hide show
  1. package/.claude/rules/better-auth.md +24 -0
  2. package/.claude/rules/configurable-features.md +5 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/FRAMEWORK-API.md +3 -2
  5. package/dist/core/common/decorators/api-methods.decorator.d.ts +2 -0
  6. package/dist/core/common/decorators/api-methods.decorator.js +8 -0
  7. package/dist/core/common/decorators/api-methods.decorator.js.map +1 -0
  8. package/dist/core/common/helpers/swagger.helper.js +312 -1
  9. package/dist/core/common/helpers/swagger.helper.js.map +1 -1
  10. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  11. package/dist/core/modules/ai/core-ai-mcp.controller.js +15 -1
  12. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  13. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +8 -1
  14. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  15. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +7 -0
  16. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js +12 -0
  17. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -0
  18. package/dist/core/modules/better-auth/better-auth.config.js +12 -0
  19. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  20. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +2 -2
  21. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  22. package/dist/core/modules/better-auth/core-better-auth-openapi.helper.d.ts +62 -0
  23. package/dist/core/modules/better-auth/core-better-auth-openapi.helper.js +95 -0
  24. package/dist/core/modules/better-auth/core-better-auth-openapi.helper.js.map +1 -0
  25. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +10 -2
  26. package/dist/core/modules/better-auth/core-better-auth.service.js +89 -0
  27. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  28. package/dist/core/modules/tus/core-tus.controller.js +3 -0
  29. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  30. package/dist/core/modules/tus/core-tus.service.d.ts +3 -0
  31. package/dist/core/modules/tus/core-tus.service.js +58 -25
  32. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  33. package/dist/core/modules/tus/interfaces/tus-config.interface.js +1 -0
  34. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  35. package/dist/core/modules/tus/tus.module.d.ts +2 -0
  36. package/dist/core/modules/tus/tus.module.js +23 -1
  37. package/dist/core/modules/tus/tus.module.js.map +1 -1
  38. package/dist/core.module.js +1 -1
  39. package/dist/core.module.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/tsconfig.build.tsbuildinfo +1 -1
  44. package/docs/REQUEST-LIFECYCLE.md +9 -1
  45. package/migration-guides/11.42.8-to-11.42.9.md +186 -0
  46. package/package.json +1 -1
  47. package/src/core/common/decorators/api-methods.decorator.ts +25 -0
  48. package/src/core/common/helpers/swagger.helper.ts +454 -1
  49. package/src/core/common/interfaces/server-options.interface.ts +46 -8
  50. package/src/core/common/interfaces/swagger-setup-options.interface.ts +5 -2
  51. package/src/core/modules/ai/README.md +7 -0
  52. package/src/core/modules/ai/core-ai-mcp.controller.ts +19 -2
  53. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +12 -2
  54. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +29 -0
  55. package/src/core/modules/better-auth/README.md +34 -0
  56. package/src/core/modules/better-auth/better-auth.config.ts +27 -1
  57. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +4 -17
  58. package/src/core/modules/better-auth/core-better-auth-openapi.helper.ts +219 -0
  59. package/src/core/modules/better-auth/core-better-auth.service.ts +166 -2
  60. package/src/core/modules/tus/README.md +50 -14
  61. package/src/core/modules/tus/core-tus.controller.ts +6 -0
  62. package/src/core/modules/tus/core-tus.service.ts +97 -26
  63. package/src/core/modules/tus/interfaces/tus-config.interface.ts +1 -0
  64. package/src/core/modules/tus/tus.module.ts +43 -1
  65. package/src/core.module.ts +2 -1
  66. package/src/index.ts +1 -0
@@ -460,6 +460,29 @@ Covered by mutations `account-issuer-backfill-missing` (boot, legacy line),
460
460
  `tests/regression-mutations.json`. The legacy line is reached through a mocked helper — this
461
461
  repository installs 1.7.3+.
462
462
 
463
+ ## 9. The internal `openAPI()` plugin — never public, and the one source of the documented routes
464
+
465
+ `buildPlugins()` registers Better-Auth's `openAPI({ disableDefaultReference: true })` unless the project
466
+ put `openAPI()` into `betterAuth.plugins` itself (`projectProvidesOpenApiPlugin()`). It exists for one
467
+ reason: Better-Auth exposes its OpenAPI generator only through that plugin (`generator` is in the type
468
+ exports of `better-auth/plugins` but NOT in its runtime exports, verified on 1.7.7), and
469
+ `setupSwagger()` needs it to document the routes Better-Auth serves itself.
470
+
471
+ | Piece | Where | Rule |
472
+ |---|---|---|
473
+ | Plugin registration | `better-auth.config.ts` → `buildPlugins()` | only when the project did not register it |
474
+ | Description | `CoreBetterAuthService.onApplicationBootstrap()` → `getOpenApiSchema()` | built once; a failure costs Swagger the generated routes, never the boot. Its own hook, because `onModuleInit()` returns early without a DB connection |
475
+ | `/iam/open-api/*` | `serverOnlyPlugin()` in `better-auth.config.ts` | **not on the router at all** while nest-server owns the plugin: both endpoints carry `metadata.SERVER_ONLY`, which better-call skips when it builds the router, while `auth.api.generateOpenAPISchema()` still runs in-process. NEVER replace this with a path check in the middleware or controller — that is what the first version did, and it sees the request as SENT while Better-Auth matches the path after `new URL()` removed dot segments: `/iam/./open-api/…`, `/iam/%2e/open-api/…` and `/iam/sign-in/email/../../open-api/…` all served the full schema anonymously. A path deny-list over Better-Auth's routes has this shape in general |
476
+ | What the document leaves out | `switchedOffBetterAuthPaths()` (nest-server switches) + `unavailableBetterAuthOperations()` (Better-Auth's own options, read from the LIVE instance via `$context` at bootstrap — `CoreBetterAuthService.getUnavailableOperations()`), both in `core-better-auth-openapi.helper.ts` | applied to explicit and generated routes alike. Better-Auth's generator lists every registered endpoint regardless of the options it checks per request, so each row mirrors one such check; `tests/unit/better-auth-openapi-availability.spec.ts` pins the rows AND asserts the checks still exist in better-auth's shipped source — on a better-auth upgrade that test is the place to look |
477
+ | Security of a generated route | `CoreBetterAuthService.sessionGuardedOperations()` + `HANDLER_SESSION_PATHS` | the generator marks EVERY route bearer-only; the document instead says what Better-Auth enforces — a session middleware in the endpoint's `use` chain (matched by identity against `better-auth/api`'s exports) or one of the social-account routes that check in the handler means the global requirement, anything else `security: []`. Middlewares are matched recursively (`organization()` nests `sessionMiddleware` in its own). Routes of any plugin outside `BUILT_IN_BETTER_AUTH_PLUGIN_IDS` always keep the global requirement — `admin()` checks the session inside a middleware of its own, so its routes (`/admin/ban-user`) were documented public until the second review round caught it. No endpoint matching any middleware (two copies of better-auth) falls back to "all authenticated" with a warning: a public route documented as authenticated costs a header, the reverse sends clients without one. A new handler-checked route in a better-auth upgrade would be documented as public — the source-marker contract in `better-auth-openapi-availability.spec.ts` covers the known three |
478
+ | What the controller handles | `CONTROLLER_HANDLED_PATHS` (same file) | the middleware and the Swagger helper read the same list — an explicit route always wins over a generated one |
479
+
480
+ Pinned by `tests/better-auth-openapi.e2e-spec.ts` (404 against the assembled `ServerModule` for the
481
+ canonical path AND six dot-segment spellings, sent raw with `http.request` because supertest and fetch
482
+ normalise them away; mutation `openapi-plugin-routable` drops `serverOnlyPlugin()` and turns all seven
483
+ red) and the "framework routes" cases in `tests/unit/swagger-helper.spec.ts`, which run against the
484
+ REAL generator output rather than a fixture.
485
+
463
486
  ## Summary
464
487
 
465
488
  | Principle | Requirement |
@@ -472,3 +495,4 @@ repository installs 1.7.3+.
472
495
  | DI Tokens | Import-free leaf file only — never in `*.module.ts` / `*.service.ts` (§6) |
473
496
  | Session cookie | Always the opaque session token — never the body JWT (§7) |
474
497
  | `account` reads / writes | Reads filter on `providerId` alone, never `issuer`. Writes set `issuer` only on better-auth 1.7.0–1.7.2, via the helper (§8) |
498
+ | `openAPI()` plugin | Registered internally for `setupSwagger()`, kept off the router (`SERVER_ONLY`) unless the project registered it — never by a path check (§9) |
@@ -306,8 +306,8 @@ This pattern is currently applied to:
306
306
  | BetterAuth 2FA Plugin | `betterAuth.twoFactor` | Boolean Shorthand | `appName: 'Nest Server'` |
307
307
  | BetterAuth Passkey Plugin | `betterAuth.passkey` | Boolean Shorthand | `rpName: 'Nest Server'` |
308
308
  | BetterAuth Cross-Subdomain Cookies | `betterAuth.crossSubDomainCookies` | Boolean Shorthand | `domain: auto (appUrl → baseUrl without api. prefix)` |
309
- | BetterAuth Disable Sign-Up | `betterAuth.emailAndPassword.disableSignUp` | Explicit Boolean | `false` (sign-up enabled) |
310
- | BetterAuth Native Password Reset | `betterAuth.emailAndPassword.passwordReset` | Explicit Boolean | **`true` — ON by default, and there is no other off switch.** Better-Auth treats the PRESENCE of an `emailAndPassword.sendResetPassword` hook as the enable flag, and `CoreBetterAuthModule` always injects one (delegating to `CoreBetterAuthEmailVerificationService.sendPasswordResetEmail()`). So `POST /iam/request-password-reset` is a live, **unauthenticated**, token-minting, mail-sending endpoint on every deployment — before 11.36.1 it answered `RESET_PASSWORD_DISABLED` because no hook was wired. Set `false` to withhold the hook and restore that refusal, for deployments whose reset policy is support-mediated or SSO-primary. **Two things bound the abuse and both need checking:** a per-ADDRESS cooldown in the mailer (reuses `emailVerification.resendCooldownSeconds`, own namespace so a pending verification mail cannot block a reset), and the IP-axis `betterAuth.rateLimit` in front of the route — which is OFF unless configured. `/request-password-reset` is in `strictEndpoints`, so it gets the halved limit. Implementation: `better-auth.config.ts` (graft), `core-better-auth-email-verification.service.ts` (delivery) |
309
+ | BetterAuth Disable Sign-Up | `betterAuth.emailAndPassword.disableSignUp` | Explicit Boolean | `false` (sign-up enabled). `true` also removes `POST /iam/sign-up/email` from the document `setupSwagger()` builds — the route answers `SIGNUP_DISABLED` to every caller then, so documenting it only misleads. The rule is `CoreBetterAuthService.isSignUpEnabled()`'s (`=== true`); a project controller extending `CoreBetterAuthController` that overrides `signUp` is covered, and so is the route Better-Auth generates for it. Implementation: `switchedOffBetterAuthPaths()` in `core-better-auth-openapi.helper.ts`, applied by `swagger.helper.ts` |
310
+ | BetterAuth Native Password Reset | `betterAuth.emailAndPassword.passwordReset` | Explicit Boolean | **`true` — ON by default, and there is no other off switch.** Better-Auth treats the PRESENCE of an `emailAndPassword.sendResetPassword` hook as the enable flag, and `CoreBetterAuthModule` always injects one (delegating to `CoreBetterAuthEmailVerificationService.sendPasswordResetEmail()`). So `POST /iam/request-password-reset` is a live, **unauthenticated**, token-minting, mail-sending endpoint on every deployment — before 11.36.1 it answered `RESET_PASSWORD_DISABLED` because no hook was wired. Set `false` to withhold the hook and restore that refusal, for deployments whose reset policy is support-mediated or SSO-primary. **Two things bound the abuse and both need checking:** a per-ADDRESS cooldown in the mailer (reuses `emailVerification.resendCooldownSeconds`, own namespace so a pending verification mail cannot block a reset), and the IP-axis `betterAuth.rateLimit` in front of the route — which is OFF unless configured. `/request-password-reset` is in `strictEndpoints`, so it gets the halved limit. Implementation: `better-auth.config.ts` (graft), `core-better-auth-email-verification.service.ts` (delivery) **Swagger (11.42.9):** `false` also removes `/request-password-reset`, `/reset-password` and `/reset-password/{token}` from the document `setupSwagger()` builds — without the hook no token can be minted, so none of them can succeed |
311
311
  | BetterAuth Password-Reset Brevo Template | `betterAuth.emailVerification.passwordResetBrevoTemplateId` | Presence Implies Enabled | `undefined` → SMTP/EJS path. When set, the reset mail goes out through the Brevo transactional API with that template id. **Deliberately does NOT fall back to `brevoTemplateId`**: that one is the VERIFICATION mail, and reusing it would tell somebody who asked to reset their password to confirm their address instead. A Brevo send that resolves to `null` (its way of reporting failure unless `brevo.throwOnError` is set) falls THROUGH to SMTP rather than returning, so an outage does not leave a locked-out user with no mail at all. Template variables: `name`, `link`, `appName` |
312
312
  | BetterAuth Revoke Sessions on Password Reset | `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` | Explicit Boolean | `false` (existing sessions survive a reset). Forwarded to Better-Auth's native flag, which honours it on **all three** reset routes (password reset, email-OTP, phone-number). **Off by default because it is a behaviour change for an existing deployment** — after a reset the user is signed out everywhere, including on the devices they still hold. Turn it on where a reset is what somebody reaches for after a suspected takeover: otherwise the attacker keeps their session and the new password changes nothing for them. Matched with `=== true`, so a truthy-but-not-`true` value arriving as JSON through `NSC__*` / `NEST_SERVER_CONFIG` does not silently enable a sign-out-everywhere behaviour. **Prefer the named field over `betterAuth.options`**: it is validated (`=== true`), typed and discoverable. Until 11.36.1 the reason was harsher — `config.options` was spread SHALLOWLY over the resolved config (only `advanced` was deep-merged), so an `options.emailAndPassword` REPLACED the whole block including the scrypt `password.hash` / `password.verify` pair the framework installs, and every credential in the database stopped verifying, at runtime and without a boot error. Since 11.36.1 `emailAndPassword` is deep-merged too, with `password` merged key-by-key on top of the framework's pair (an explicit override still wins; an explicitly-`undefined` half is ignored, because Better-Auth resolves `password?.hash || hashPassword` and would otherwise switch hasher for writes while scrypt still handled reads). That merge is pinned against the REAL `createBetterAuthInstance()` by `tests/unit/better-auth-password-reset-wiring.spec.ts`, with registered mutation `betterauth-emailandpassword-shallow-merge` as its evidence. Implementation: `better-auth.config.ts` (`emailAndPassword` block) |
313
313
  | 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) |
@@ -333,6 +333,8 @@ This pattern is currently applied to:
333
333
  | TUS Roles | `tus.roles` | Config-Driven Role List | `[RoleEnum.S_USER]` (was `S_EVERYONE` — breaking). A TUS upload writes into the SAME store the download routes guard, and the termination extension (on by default) can delete from it, so anonymous writes into a store only privileged callers may read is the wrong way round. Applied by `TusModule.applyRoles()` onto the registered controller class **and** onto `handleTus` / `handleTusWithId`. Same rejection rule as the file roles: `[]` / non-array / non-string → warning + `DEFAULT_TUS_CONFIG.roles`. **`OPTIONS` is deliberately exempt**: `handleTusOptions` / `handleTusOptionsWithId` keep their own handler-level `@Roles(RoleEnum.S_EVERYONE)`, because that is the CORS preflight — browsers send it WITHOUT credentials, and it returns server capabilities only. Gating it would make every browser upload fail before the first byte. `CoreTusController` also carries `@SkipTenantCheck()`. A custom controller is covered as long as it INHERITS the handlers; one that re-declares `@All()` / `@Roles()` carries its own metadata and thereby opts out — the documented way to hard-code a policy config must not be able to change. **Set `roles: [RoleEnum.S_EVERYONE]` explicitly if you accept attachments on a public form.** **Since 11.35.0 `tus.roles` is no longer the only gate**: `onUploadCreate` records the authenticated creator under `TUS_OWNER_METADATA_KEY` (`ltOwnerId`, always OVERWRITING any client-supplied value) and `onIncomingRequest` refuses HEAD/PATCH/DELETE naming an upload the caller does not own — with **404**, the file module's refusal policy. Before that, any authenticated caller who learned an upload id could APPEND BYTES to somebody else's upload, which are then migrated into the file store under the victim's filename. The finished file also gains `metadata.ownerId`, without which the documented per-file ownership rule could never authorize a tus-uploaded file (admin-only in practice). An OWNER-LESS upload stays reachable — uploads predating 11.35.0 have none, and neither does a deliberately public form. `readRequestUserId()` / `assertUploadOwnership()` are `protected`. **Since 11.41.9 the tenant is recorded too, but ONLY with `multiTenancy` configured** (not `enabled: false`) and a tenant guard registered: `onUploadCreate` validates the tenant header against the caller's active memberships through `core-tenant-context.registry.ts` (the routes carry `@SkipTenantCheck()`, so the guard did not), refuses a non-member with **403**, writes it under `TUS_TENANT_METADATA_KEY` (`ltTenantId`, always overwritten like the owner key) and the finished file gains `metadata.tenantId`. No header → tenant-less, as before; without `multiTenancy` nothing is read or written. A project hooks in through `TusModule.forRoot({ service })` (a `CoreTusService` subclass, constructed by the module as `(connection, options)` — project providers come from `options.moduleRef` at call time) and the `protected onUploadCreate(req, upload)`, the one point where `Upload-Length` is known and nothing is stored yet; an override must return `super.onUploadCreate()`. Note that `@tus/server` v2 hands its hooks a WHATWG `ServerRequest`, so a guard's `req.user` is reachable only via `runtime.node.req`. **CORS (11.42.1)**: the tus handler's `Access-Control-Allow-Origin` comes from `buildCorsConfig()` (or `tus.allowedOrigins`), plus `allowedCredentials` — without it `@tus/server` answered `*`, which browsers refuse on a credentialed request, so cookie-authenticated uploads across split app/API origins failed in every browser while the server answered 201; no credentialed CORS configured → the `*` default stays. **compression (11.42.2)**: `@tus/server` finishes responses with `res.end(callback)`, which `compression`'s two-argument `res.end(chunk, encoding)` patch reads as a chunk (`ERR_INVALID_ARG_TYPE … Received function`); with the starter's `filter: () => true, threshold: 0` every finished upload HUNG without an answer AFTER the file was stored (headers already sent, so no error response could follow). `CoreTusService` wraps `server.handle()` and normalises `res.end` per response (`normalizeEndCallback()`, callback on `finish`/`close`), so no `main.ts` change is needed. Implementation: `src/core/modules/tus/tus.module.ts`, `src/core/modules/tus/core-tus.service.ts`, `src/core/modules/tus/tus.constants.ts` |
334
334
  | File Storage | `file.storage` + `file.storageDir` + `s3` | Explicit Enum with DERIVED default | `'filesystem' \| 'gridfs' \| 's3'`. **Unset → derived**, most capable first: `'s3'` when `s3.bucket` is set, else `'gridfs'` when `mongoose.uri` is set, else `'filesystem'`. **Set → enforced**: an unavailable store FAILS THE BOOT (`assertFileStorageAvailable()`), it never falls back — a silent fallback puts files in a store the operator does not believe they are in, unrecoverably. A DERIVED driver is enforced too (`s3.bucket` set but `s3Service` not forwarded to `super()` → boot error). Metadata always lives in MongoDB whichever driver holds the bytes (`fs.files` / `s3-files` / `filesystem-files`) — it has to stay queryable for `findFileInfo()` and `checkRights()`. Reads consult ALL stores so switching drivers is forward-only with no migration; writes go to the active driver only. `'filesystem'` is pod-local: not shared between replicas, lost on restart unless `storageDir` is a mounted volume. `s3`: `bucket` (required — the one thing S3 cannot default, hence the eligibility test), `region: 'us-east-1'`, `forcePathStyle: false`, `autoCreateBucket: false` (off by design: production buckets come from infrastructure code, whose credentials usually cannot CreateBucket), `stagingBucket: bucket`, `presignedDownloads: false` (`true`/`{}` → `expiresInSeconds: 300`), `endpoint` (MinIO/RustFS), `accessKeyId`/`secretAccessKey` (omit → AWS default credential chain), `enabled: false` to pre-configure. Requires the OPTIONAL peer `@aws-sdk/client-s3`, plus `@aws-sdk/s3-request-presigner` for presigned downloads. **Uploads are never buffered on the S3 path** (11.41.7): a known length (`FileUploadSource.size`, TUS, a buffer) goes up as one streamed PutObject up to `CoreS3Service.MAX_SINGLE_PUT_BYTES` (5 GiB — AWS S3 refuses more in one request, `EntityTooLarge`) and as a multipart upload above it, an unknown one — every GraphQL and streamed REST upload — as a multipart upload via the OPTIONAL peer `@aws-sdk/lib-storage`. Missing peer → one boot warning and the old in-memory buffering, NOT a boot failure (multipart optimises a path that works without it). `size` must be the file's EXACT length — never a multipart request's `Content-Length`; anything but a positive integer counts as unknown (`isKnownUploadSize()`), and the recorded length of an unknown-length upload comes from a HEAD of the stored object. `presignedDownloads` makes `GET /files/id/:id` answer `302` to a time-limited S3 URL instead of streaming — the URL is a bearer capability, authorized once at issue time. The resolved driver is logged at boot (`[CoreFileStorage] File storage: …`) |
335
335
  | TUS S3 Staging | `tus.s3Staging` | Explicit Boolean (default ON when S3 configured) | `true` when `s3` is configured, otherwise inert. Stages in-progress uploads in `s3.stagingBucket` via `@tus/s3-store` instead of `tus.uploadDir` on local disk, so resumable uploads survive replica restarts and need no sticky sessions. Set `false` to force local disk. Missing OPTIONAL peer `@tus/s3-store` → warning + fall back to local disk (NOT a boot failure). **Give the staging bucket a lifecycle rule expiring incomplete multipart uploads** — the framework's own expiration cleanup is skipped in S3 mode (S3 is the right place for that policy), so aborted uploads otherwise accumulate parts nothing removes |
336
+ | TUS Configuration Source, Path & Extensions | `tus` (server config) / `TusModule.forRoot({ config })` | Enabled by Default + explicit-config-wins | **Since 11.42.9 `TusModule.forRoot()` without `config` reads the server config's `tus` key** (`ConfigService.get('tus')`, the zero-config rule `CoreBetterAuthModule.forRoot()` follows); an explicit `config` wins. Before, the key was never read: `tus: false`, `allowedTypes`, `maxSize`, `roles` in `config.env.ts` did nothing — real projects had upload restrictions they believed were enforced. **`path`** has three readings (`applyPath()`): unset → follows the controller's route; a new route (`/uploads`) → mounts the registered controller there (writes `PATH_METADATA`, config wins like the roles; the declared route is remembered per class and restored by a later `forRoot()` without `path`); a path ENDING with the controller's route (`/api/tus`, `/files/tus`) → the PUBLIC URL: the controller stays and upload URLs use it. The third reading is load-bearing: it was the only working setting under a global prefix or a prefix-stripping proxy before 11.42.9, and treating it as a route doubled the prefix (`/api/api/tus`, 404 on every upload — caught in the second pre-release review). The upload URL (`Location`) is built from the path the creation request arrived at (`uploadCollectionPath(req)`), or from `path` when that is longer and ends with it (proxy strip); `@tus/server` built it from `path` alone, which broke every resume below a global prefix, a URI version or a project route. **`relativeLocation`** (new): the framework owns `generateUrl`, which takes precedence over `@tus/server`'s option of that name, so it is honoured from `tus.relativeLocation` AND from the tus server's own options (a vendored core may pass it there). **Extensions**: `@tus/server` consults no flag, so `creation`, `creationWithUpload` and `termination` are enforced by `CoreTusService` — the store's advertised `extensions` are narrowed, and `assertExtensionEnabled()` answers 501 inside the server's `onIncomingRequest` / `onUploadCreate` options (not in the overridable hook, so an override without `super` cannot reopen it). `checksum` / `concatenation` have no effect: neither built-in store supports them. `configure(false)` now yields a DISABLED config (before it kept the defaults and started a tus server behind a module with no endpoint). Implementation: `tus.module.ts`, `core-tus.service.ts` |
337
+ | Health Check | `healthCheck` | Presence Implies Enabled | Absent → no endpoints. Any object registers `GET /health-check` + the `healthCheck` query; **`enabled: false` leaves them out (honoured since 11.42.9 — before, it was declared and ignored)**. Individual checks under `configs.*` each have their own `enabled`. Implementation: `core.module.ts` |
336
338
  | 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 |
337
339
  | 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` |
338
340
  | Body Parser Limits | `bodyParser.json.limit`, `bodyParser.urlencoded.limit` | Explicit Value (Express/body-parser default) — invalid → default, never unbounded | **Unset = 100 kB each, exactly as NestJS registers the parsers** (body-parser's own default — a limit nobody chose, and before 11.42.4 the only one in force: over it a request got a bare `request entity too large`, before auth and routing, GraphQL included). A number is bytes, a string a 1024-based size (`'500kb'`, `'2mb'`, units `b`/`kb`/`mb`/`gb`). The two limits are separate on purpose — raising JSON must not raise form posts. **Not raised by default because the parser runs before authentication** and `JSON.parse` blocks the event loop for the whole body: ~0.5 ms for 100 kB, ~16 ms for 1 MB, ~55 ms for 2 MB per request on a realistic document shape. Applied by `CoreBodyParserInitializer` (CoreModule provider) in `onModuleInit` by **swapping the handle of the global `jsonParser`/`urlencodedParser` layers NestJS registered, in place** — NestJS registers them inside `app.init()` before any init hook, and a parser appended later would sit behind the routes. Same position, `extended: true` for URL-encoded, `req.rawBody` kept when the app came from `NestFactory.create(…, { rawBody: true })` (read from the container's context options; under `Test.createTestingModule()` those options never reach the container, so a test app with `rawBody: true` gets no `req.rawBody` from a CONFIGURED parser). Because it runs during module init it **holds under `Test.createTestingModule()`**, which never runs `main.ts` — the reason a `useBodyParser()` in `main.ts` was not enough. A configured value also replaces a global parser registered in `main.ts`; a **path-scoped** one (`server.use('/upload', json({ limit }))`) is left alone and stays the way to give one path its own limit (the parser runs before routing; GraphQL shares one path). **Keep `server.useBodyParser('json')` in `main.ts` next to a path-scoped parser**: NestJS registers its global parser only when no layer with the same FUNCTION NAME exists (`isMiddlewareApplied()`), so a path-scoped `json()` registered before `app.init()` suppresses it and every other path gets an empty body (a sign-in fails with a validation error). Since 11.42.6 the initializer warns at boot about exactly that (`warnMissingGlobalParser()`). It deliberately does not re-insert the parser: NestJS would have placed it after everything `main.ts` registered, a position no longer knowable during module init, and a raw-body webhook or proxy registered after the path-scoped parser must keep seeing the unread stream. An invalid value (`0`, negative, `NaN`, a word) is refused with an error log and the parser keeps its limit. **Independent of configuration**, every 413 from a global parser is answered as `PayloadTooLargeException` with `#LTNS_0304: Request body too large [<n> bytes, limit <m> bytes]` (size only when a `Content-Length` was sent) and logged as a WARN naming method, path without query string, and the config key — no boot warning, because the framework cannot know which routes expect large bodies; the first oversized request is the earliest point anything can be said. Other parser errors pass through unchanged. Pinned by `tests/unit/body-parser.spec.ts` and `tests/body-parser.e2e-spec.ts` (mutations `body-parser-*`). Implementation: `common/services/core-body-parser.initializer.ts` |
@@ -341,7 +343,7 @@ This pattern is currently applied to:
341
343
  | 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()` |
342
344
  | AI Run Time Limit | `ai.maxRunMs` | **Numeric Sentinel — Family A** (`0` = no limit) | `0` (omitted = unbounded, the previous behaviour). Wall-clock ceiling for ONE prompt run, checked before each agent-loop iteration; once exceeded the run stops and answers with whatever it has. **Without it a run's only bound is `maxIterations` MULTIPLIED BY the connection's per-call timeout** — 8 iterations at the 120s default is a request that can legitimately hold a socket, its message buffer and a request context for 16 minutes, and compaction can add a call per iteration on top. Guarded as `maxRunMs > 0`, never `<= 0`: a value arriving as a non-numeric string through `NSC__AI__MAX_RUN_MS` (the reader coerces only `'true'`, `'false'` and numbers) yields `NaN`, and `NaN > 0` is `false`, so a misconfiguration degrades to "no limit" rather than to an instantly-expired deadline that would break every prompt. Set it to something a client would actually wait for. Implementation: `core-ai.service.ts` -> `runDeadlineExceeded()` |
343
345
  | 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 — under multi-tenancy (11.42.0+) a tool's roles resolve like `@Roles()` in `CoreTenantGuard`: global roles against `user.roles`, tenant roles against the request's membership role, ADMIN bypass only where `adminBypass` grants it, so chat and MCP never exceed the user's own rights; an MCP session reads the user per request, so a revoked role stops working without a reconnect; 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 } })` |
344
- | Swagger API-Token View | `setupSwagger(app, { apiTokenView })` (helper option, not server config) | Boolean Shorthand / Presence Implies Enabled | **Off without the option** (11.42.7+). `true` / `{}` serves a second document with only the routes that release a scope via `@ApiTokenScopes()`: UI at `swagger-api-tokens`, JSON at `/api-docs-api-tokens-json`, title `<title> — API tokens`, bearer as the only scheme, unused schemas pruned; the tenant header stays while `apiTokens.userTokens` is on (a personal token chooses the company like a session) and is dropped with company tokens only. Keep its path OUTSIDE the full UI's path: Swagger UI serves its assets below its own path. The full document's enrichment (global bearer + session-cookie requirement, `security: []` on public routes, `x-api-token-scopes` + description line, `TenantHeader` parameter) is not optional — it is what `setupSwagger()` is for, and it reads the same metadata as the guards. Implementation: `common/helpers/swagger.helper.ts` |
346
+ | Swagger API-Token View | `setupSwagger(app, { apiTokenView })` (helper option, not server config) | Boolean Shorthand / Presence Implies Enabled | **Off without the option** (11.42.7+). `true` / `{}` serves a second document with only the routes that release a scope via `@ApiTokenScopes()`: UI at `swagger-api-tokens`, JSON at `/api-docs-api-tokens-json`, title `<title> — API tokens`, bearer as the only scheme, unused schemas pruned; the tenant header stays while `apiTokens.userTokens` is on (a personal token chooses the company like a session) and is dropped with company tokens only. Keep its path OUTSIDE the full UI's path: Swagger UI serves its assets below its own path. The full document's enrichment (global bearer + session-cookie requirement, `security: []` on public routes, `x-api-token-scopes` + description line, `TenantHeader` parameter) is not optional — it is what `setupSwagger()` is for, and it reads the same metadata as the guards. Implementation: `common/helpers/swagger.helper.ts` **Better-Auth routes (11.42.9):** the full document also lists every route Better-Auth serves itself under the base path — whatever the configured plugins add — from Better-Auth's own generator, under the project's Better-Auth controller (its tags and `operationTags`, ids `<Controller>_betterAuth_<id>`, schemas prefixed `BetterAuth`, OpenAPI 3.1 constructs converted to 3.0). Their security is what Better-Auth enforces, not the generator's blanket bearer-only: a route behind one of Better-Auth's session middlewares (or one of the three social-account routes in `HANDLER_SESSION_PATHS`, which check in the handler) gets the global requirement, every other route `security: []` — except routes of plugins nest-server does not register itself (`BUILT_IN_BETTER_AUTH_PLUGIN_IDS`), which always keep the global requirement — and if the instance's middlewares cannot be identified (two copies of better-auth), all of them keep the global requirement rather than risk documenting a session route as public. They carry the "not allowed" API-token line, and never reach the API-token view. The `openAPI()` plugin this needs is registered internally; its routes answer 404 (see `.claude/rules/better-auth.md` §9). **Only usable operations (11.42.9):** an operation that answers an error to every caller under the running configuration is left out — `@ApiMethods()` on `@All()` handlers (TUS), legacy `/auth/*` while `auth.legacyEndpoints` keeps REST closed, Better-Auth routes refused by the configuration or the live instance, Hub endpoints of switched-off panels. `/ai/mcp` and the MCP OAuth endpoints `mountAiMcpOAuth()` mounted are documented. An explicit `@ApiBearerAuth()` keeps a guard-public route from being documented as public. Rules: `isSwitchedOffOperation()` in `swagger.helper.ts` |
345
347
 
346
348
  ## Module Override Pattern (via `ICoreModuleOverrides`)
347
349
 
@@ -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 109 that need no MongoDB
269
+ pnpm run check:mutations -- --no-infra # only the 115 that need no MongoDB
270
270
  pnpm run check:mutations -- --since=<ref> # only mutations touching files changed since <ref>
271
271
  ```
272
272
 
@@ -285,7 +285,7 @@ when the registry, a vitest config or a setup file changed, since those can move
285
285
  commit, and in CI alongside the publish without blocking it. The price of caching is a cache that
286
286
  has to model each spec's full dependency closure correctly, and getting that wrong produces a stale
287
287
  PASS for a test that has since gone vacuous — exactly what the gate is there to prevent. Bad trade
288
- at 172 mutations.
288
+ at 184 mutations.
289
289
 
290
290
  **Decided 2026-10-03 (repo owner): keep the full run.** Measured at 128 mutations, the CI job for
291
291
  11.41.6 took **14 minutes**. The trigger to revisit is that wall time, not the mutation count —
@@ -330,9 +330,9 @@ exactly the environment where the answer matters.
330
330
 
331
331
  ### The cost is vitest's cold start, not the tests
332
332
 
333
- Worth knowing before optimising the wrong thing: the specs behind all 63 e2e mutations add up to
333
+ Worth knowing before optimising the wrong thing: the specs behind all 69 e2e mutations add up to
334
334
  **~40 seconds**. The step takes ~740s. The remaining ~700s is paying vitest's startup — process
335
- spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 172 times.
335
+ spawn, transform, module graph, mongod connect, DB create and drop — once per mutation, 184 times.
336
336
  That work is largely single-threaded I/O and barely scales with cores: the full registry measures
337
337
  **744s on a 12-core laptop and 777s on a 4-vCPU CI runner**.
338
338
 
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 as of 2026-10-07 (v11.42.8)
3
+ > Auto-generated from source code as of 2026-10-07 (v11.42.9)
4
4
  > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code
5
5
 
6
6
  ## CoreModule.forRoot()
@@ -204,7 +204,8 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
204
204
  - `enabled?`: `boolean | undefined` (default: `true (enabled by default)`) — Whether tus uploads are enabled.
205
205
  - `expiration?`: `boolean | ITusExpirationConfig | undefined` (default: `{ expiresIn: '24h' }`) — Expiration extension configuration.
206
206
  - `maxSize?`: `number | undefined` (default: `50 * 1024 * 1024 * 1024 (50 GB)`) — Maximum upload size in bytes
207
- - `path?`: `string | undefined` (default: `'/tus'`) — Base path for tus endpoints
207
+ - `path?`: `string | undefined` (default: `'/tus'`) — Base path for tus endpoints (11.42.9+):
208
+ - `relativeLocation?`: `boolean | undefined` (default: `false`) — Hand out upload URLs (`Location`) without scheme and host — `/tus/<id>` instead of
208
209
  - `roles?`: `string[] | undefined` (default: `['s_user']`) — Roles allowed to use the tus endpoints (create, write, read offset, terminate).
209
210
  - `s3Staging?`: `boolean | undefined` (default: `true (when S3 is usable)`) — Stage upload chunks in the configured S3 bucket (`IServerOptions.s3`,
210
211
  - `termination?`: `boolean | undefined` (default: `true`) — Termination extension configuration.
@@ -0,0 +1,2 @@
1
+ export declare const API_METHODS_KEY = "lt:apiMethods";
2
+ export declare const ApiMethods: (...methods: string[]) => import("@nestjs/common").CustomDecorator<string>;
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ApiMethods = exports.API_METHODS_KEY = void 0;
4
+ const common_1 = require("@nestjs/common");
5
+ exports.API_METHODS_KEY = 'lt:apiMethods';
6
+ const ApiMethods = (...methods) => (0, common_1.SetMetadata)(exports.API_METHODS_KEY, methods.map((method) => method.toLowerCase()));
7
+ exports.ApiMethods = ApiMethods;
8
+ //# sourceMappingURL=api-methods.decorator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-methods.decorator.js","sourceRoot":"","sources":["../../../../src/core/common/decorators/api-methods.decorator.ts"],"names":[],"mappings":";;;AAAA,2CAA6C;AAGhC,QAAA,eAAe,GAAG,eAAe,CAAC;AAiBxC,MAAM,UAAU,GAAG,CAAC,GAAG,OAAiB,EAAE,EAAE,CACjD,IAAA,oBAAW,EACT,uBAAe,EACf,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,CAC9C,CAAC;AAJS,QAAA,UAAU,cAInB"}
@@ -5,12 +5,25 @@ exports.setupSwagger = setupSwagger;
5
5
  exports.buildSwaggerDocument = buildSwaggerDocument;
6
6
  exports.buildApiTokenSwaggerDocument = buildApiTokenSwaggerDocument;
7
7
  const common_1 = require("@nestjs/common");
8
+ const constants_1 = require("@nestjs/common/constants");
8
9
  const core_1 = require("@nestjs/core");
10
+ const schedule_1 = require("@nestjs/schedule");
9
11
  const swagger_1 = require("@nestjs/swagger");
12
+ const ai_mcp_oauth_registry_1 = require("../../modules/ai/helpers/ai-mcp-oauth.registry");
10
13
  const core_api_token_helpers_1 = require("../../modules/api-token/core-api-token.helpers");
11
14
  const core_api_token_constants_1 = require("../../modules/api-token/core-api-token.constants");
15
+ const core_auth_controller_1 = require("../../modules/auth/core-auth.controller");
16
+ const legacy_endpoints_helper_1 = require("../../modules/auth/helpers/legacy-endpoints.helper");
12
17
  const better_auth_cookie_prefix_helper_1 = require("../../modules/better-auth/better-auth-cookie-prefix.helper");
18
+ const core_better_auth_openapi_helper_1 = require("../../modules/better-auth/core-better-auth-openapi.helper");
19
+ const core_better_auth_user_mapper_1 = require("../../modules/better-auth/core-better-auth-user.mapper");
20
+ const core_better_auth_controller_1 = require("../../modules/better-auth/core-better-auth.controller");
21
+ const core_better_auth_service_1 = require("../../modules/better-auth/core-better-auth.service");
22
+ const core_hub_actions_controller_1 = require("../../modules/hub/core-hub-actions.controller");
23
+ const core_hub_controller_1 = require("../../modules/hub/core-hub.controller");
24
+ const hub_config_helper_1 = require("../../modules/hub/hub-config.helper");
13
25
  const core_tenant_decorators_1 = require("../../modules/tenant/core-tenant.decorators");
26
+ const api_methods_decorator_1 = require("../decorators/api-methods.decorator");
14
27
  const core_tenant_helpers_1 = require("../../modules/tenant/core-tenant.helpers");
15
28
  const role_enum_1 = require("../enums/role.enum");
16
29
  const config_service_1 = require("../services/config.service");
@@ -84,14 +97,28 @@ function buildSwaggerDocument(app, options, config = config_service_1.ConfigServ
84
97
  };
85
98
  }
86
99
  const handlers = indexHandlers(app);
100
+ const betterAuthRoutes = resolveBetterAuthRoutes(app, handlers, config);
101
+ const availability = resolveAvailability(app, config, betterAuthRoutes);
102
+ const switchedOff = [];
87
103
  forEachOperation(document, (path, method, operation) => {
88
104
  const reference = resolveHandler(operation.operationId, handlers);
89
105
  if (!reference) {
90
106
  logger.warn(`No handler found for operation ${operation.operationId ?? `${method.toUpperCase()} ${path}`}`);
91
107
  return;
92
108
  }
109
+ if (betterAuthRoutes && isBetterAuthController(reference.controller)) {
110
+ betterAuthRoutes.tags ??= [...(operation.tags ?? [])];
111
+ const base = declaredControllerBase(reference.controller);
112
+ if (base && path !== base && !path.startsWith(`${base}/`)) {
113
+ betterAuthRoutes.relocated = true;
114
+ }
115
+ }
116
+ if (isSwitchedOffOperation(reference, method, availability)) {
117
+ switchedOff.push([path, method]);
118
+ return;
119
+ }
93
120
  const access = describeAccess(reference, multiTenancy);
94
- if (access.isPublic) {
121
+ if (access.isPublic && !declaresSecurity(reference)) {
95
122
  operation.security = [];
96
123
  }
97
124
  if (apiTokens.enabled) {
@@ -121,9 +148,293 @@ function buildSwaggerDocument(app, options, config = config_service_1.ConfigServ
121
148
  }
122
149
  }
123
150
  });
151
+ for (const [path, method] of switchedOff) {
152
+ const item = document.paths[path];
153
+ delete item[method];
154
+ if (!operationsOf(document.paths[path]).length) {
155
+ delete document.paths[path];
156
+ }
157
+ }
158
+ const added = { apiTokensEnabled: apiTokens.enabled, operationTags: options.operationTags };
159
+ if (betterAuthRoutes?.schema) {
160
+ if (betterAuthRoutes.relocated) {
161
+ logger.warn("Better-Auth's own routes are not documented: under a global prefix or URI versioning the API " +
162
+ 'middleware does not forward them, so none of them would be reachable at the documented path.');
163
+ }
164
+ else {
165
+ addBetterAuthRoutes(document, betterAuthRoutes, added);
166
+ }
167
+ }
168
+ const mcpOAuth = (0, ai_mcp_oauth_registry_1.getMountedMcpOAuth)();
169
+ if (mcpOAuth) {
170
+ addMcpOAuthRoutes(document, mcpOAuth, added);
171
+ }
124
172
  document.tags = orderTags(document, options.tags);
125
173
  return document;
126
174
  }
175
+ const LEGACY_REST_HANDLERS = new Set(['logout', 'refreshToken', 'signIn', 'signUp']);
176
+ const HUB_AVAILABILITY = {
177
+ aiJson: (availability) => availability.ai,
178
+ authMigrationJson: (availability) => availability.betterAuthMapper,
179
+ cron: (availability) => availability.scheduler,
180
+ cronJson: (availability) => availability.scheduler,
181
+ dbJson: (_availability, hub) => hub.db !== false,
182
+ emailPreview: (_availability, hub) => hub.emailPreview,
183
+ emailsJson: (_availability, hub) => hub.emailPreview,
184
+ logsJson: (_availability, hub) => hub.collectors.logs !== false,
185
+ mailboxHtml: (_availability, hub) => hub.mailbox !== false,
186
+ mailboxJson: (_availability, hub) => hub.mailbox !== false,
187
+ migrationsDown: (_availability, hub) => hub.migrations !== false,
188
+ migrationsJson: (_availability, hub) => hub.migrations !== false,
189
+ migrationsRun: (_availability, hub) => hub.migrations !== false,
190
+ queriesJson: (_availability, hub) => hub.collectors.queries !== false,
191
+ tracesJson: (_availability, hub) => hub.collectors.traces !== false,
192
+ };
193
+ function resolveAvailability(app, config, betterAuth) {
194
+ const ai = config.ai;
195
+ return {
196
+ ai: ai === true || (typeof ai === 'object' && ai !== null && ai.enabled !== false),
197
+ betterAuth,
198
+ betterAuthMapper: isProvided(app, core_better_auth_user_mapper_1.CoreBetterAuthUserMapper),
199
+ config,
200
+ hub: (0, hub_config_helper_1.isHubEnabled)(config.hub) ? (0, hub_config_helper_1.normalizeHubConfig)(config.hub, { env: config.env ?? '', version: '' }) : undefined,
201
+ scheduler: isProvided(app, schedule_1.SchedulerRegistry),
202
+ };
203
+ }
204
+ function isProvided(app, token) {
205
+ try {
206
+ return !!app.get(token, { strict: false });
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ }
212
+ function isSwitchedOffOperation(reference, method, availability) {
213
+ const methods = Reflect.getMetadata(api_methods_decorator_1.API_METHODS_KEY, reference.handler);
214
+ if (methods && !methods.includes(method)) {
215
+ return true;
216
+ }
217
+ const { controller, handlerName } = reference;
218
+ if (extendsClass(controller, core_auth_controller_1.CoreAuthController) && LEGACY_REST_HANDLERS.has(handlerName)) {
219
+ return !(0, legacy_endpoints_helper_1.isLegacyEndpointEnabled)(availability.config.auth?.legacyEndpoints, 'rest');
220
+ }
221
+ if (availability.betterAuth && isBetterAuthController(controller)) {
222
+ const relativePath = declaredHandlerPath(reference.handler);
223
+ return (relativePath !== undefined && (0, core_better_auth_openapi_helper_1.isUnavailableOperation)(availability.betterAuth.switchedOff, method, relativePath));
224
+ }
225
+ if (availability.hub &&
226
+ (extendsClass(controller, core_hub_controller_1.CoreHubController) || extendsClass(controller, core_hub_actions_controller_1.CoreHubActionsController)) &&
227
+ HUB_AVAILABILITY[handlerName]) {
228
+ return !HUB_AVAILABILITY[handlerName](availability, availability.hub);
229
+ }
230
+ return false;
231
+ }
232
+ function extendsClass(controller, base) {
233
+ return controller === base || controller.prototype instanceof base;
234
+ }
235
+ function declaredHandlerPath(handler) {
236
+ const path = Reflect.getMetadata(constants_1.PATH_METADATA, handler);
237
+ return typeof path === 'string' ? `/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || '/' : undefined;
238
+ }
239
+ function declaredControllerBase(controller) {
240
+ const controllerPath = Reflect.getMetadata(constants_1.PATH_METADATA, controller);
241
+ const trimmed = typeof controllerPath === 'string' ? controllerPath.replace(/^\/+|\/+$/g, '') : '';
242
+ return trimmed ? `/${trimmed}` : undefined;
243
+ }
244
+ function declaresSecurity(reference) {
245
+ return !!(Reflect.getMetadata('swagger/apiSecurity', reference.handler) ??
246
+ Reflect.getMetadata('swagger/apiSecurity', reference.controller));
247
+ }
248
+ function addMcpOAuthRoutes(document, mounted, context) {
249
+ const routes = [
250
+ [
251
+ 'get',
252
+ '/.well-known/oauth-authorization-server',
253
+ 'authorizationServerMetadata',
254
+ 'OAuth 2.1 authorization server metadata (RFC 8414) for MCP clients.',
255
+ ],
256
+ [
257
+ 'get',
258
+ `/.well-known/oauth-protected-resource${mounted.mcpPath}`,
259
+ 'protectedResourceMetadata',
260
+ `Protected resource metadata (RFC 9728) of the MCP endpoint \`${mounted.mcpPath}\`.`,
261
+ ],
262
+ ['get', '/authorize', 'authorize', 'Authorization endpoint (authorization code with PKCE S256).'],
263
+ ['post', '/authorize', 'authorizePost', 'Authorization endpoint, form post variant.'],
264
+ ['post', '/token', 'token', 'Token endpoint: exchanges an authorization code or refresh token.'],
265
+ ];
266
+ if (mounted.registration) {
267
+ routes.push(['post', '/register', 'register', 'Dynamic client registration (RFC 7591).']);
268
+ }
269
+ if (mounted.revocation) {
270
+ routes.push(['post', '/revoke', 'revoke', 'Token revocation (RFC 7009).']);
271
+ }
272
+ for (const [method, path, name, description] of routes) {
273
+ const operation = {
274
+ description: context.apiTokensEnabled ? appendLine(description, apiTokenLine(undefined)) : description,
275
+ operationId: `McpOAuth_${name}`,
276
+ responses: { default: { description: 'See the MCP authorization specification' } },
277
+ security: [],
278
+ summary: description.split(':')[0].replace(/\.$/, ''),
279
+ tags: ['MCP OAuth'],
280
+ };
281
+ if (context.operationTags) {
282
+ const tags = context.operationTags({
283
+ controller: 'McpOAuth',
284
+ handler: name,
285
+ isPublic: true,
286
+ method,
287
+ path,
288
+ scopes: undefined,
289
+ tags: [...(operation.tags ?? [])],
290
+ });
291
+ if (tags) {
292
+ operation.tags = tags;
293
+ }
294
+ }
295
+ document.paths[path] = { ...document.paths[path], [method]: operation };
296
+ }
297
+ }
298
+ function isBetterAuthController(controller) {
299
+ return controller === core_better_auth_controller_1.CoreBetterAuthController || controller.prototype instanceof core_better_auth_controller_1.CoreBetterAuthController;
300
+ }
301
+ function resolveBetterAuthRoutes(app, handlers, config) {
302
+ const controller = [...handlers.values()].flat().find(isBetterAuthController);
303
+ if (!controller) {
304
+ return undefined;
305
+ }
306
+ let service;
307
+ try {
308
+ service = app.get(core_better_auth_service_1.CoreBetterAuthService, { strict: false });
309
+ }
310
+ catch {
311
+ service = undefined;
312
+ }
313
+ const betterAuth = typeof config.betterAuth === 'object' && config.betterAuth !== null ? config.betterAuth : {};
314
+ return {
315
+ basePath: service?.getBasePath?.() || betterAuth.basePath || '/iam',
316
+ controller,
317
+ schema: service?.getOpenApiSchema?.(),
318
+ switchedOff: new Set([...(0, core_better_auth_openapi_helper_1.switchedOffBetterAuthPaths)(betterAuth), ...(service?.getUnavailableOperations?.() ?? [])]),
319
+ };
320
+ }
321
+ function addBetterAuthRoutes(document, routes, context) {
322
+ const renamed = new Map();
323
+ const controllerTags = routes.tags ?? defaultControllerTags(routes.controller);
324
+ for (const [relativePath, item] of Object.entries(routes.schema?.paths ?? {})) {
325
+ if ((0, core_better_auth_openapi_helper_1.isControllerHandledPath)(relativePath) || (0, core_better_auth_openapi_helper_1.isBetterAuthOpenApiPath)(relativePath)) {
326
+ continue;
327
+ }
328
+ const path = `${routes.basePath}${relativePath}`;
329
+ for (const [method, source] of operationsOf(item)) {
330
+ const existing = document.paths[path];
331
+ if (existing?.[method] || (0, core_better_auth_openapi_helper_1.isUnavailableOperation)(routes.switchedOff, method, relativePath)) {
332
+ continue;
333
+ }
334
+ const operation = toOpenApi30(renameSchemaRefs(structuredClone(source), renamed));
335
+ const betterAuthId = operation.operationId ?? `${method}${relativePath}`;
336
+ operation.operationId = `${routes.controller.name}_betterAuth_${betterAuthId}`;
337
+ const isPublic = Array.isArray(operation.security) && operation.security.length === 0;
338
+ if (!isPublic) {
339
+ delete operation.security;
340
+ }
341
+ operation.tags = [...controllerTags];
342
+ if (context.apiTokensEnabled) {
343
+ operation.description = appendLine(operation.description, apiTokenLine(undefined));
344
+ }
345
+ if (context.operationTags) {
346
+ const tags = context.operationTags({
347
+ controller: routes.controller.name,
348
+ handler: betterAuthId,
349
+ isPublic,
350
+ method,
351
+ path,
352
+ scopes: undefined,
353
+ tags: [...operation.tags],
354
+ });
355
+ if (tags) {
356
+ operation.tags = tags;
357
+ }
358
+ }
359
+ document.paths[path] = { ...document.paths[path], [method]: operation };
360
+ }
361
+ }
362
+ addRenamedSchemas(document, routes.schema?.components?.schemas ?? {}, renamed);
363
+ }
364
+ function defaultControllerTags(controller) {
365
+ const tags = Reflect.getMetadata('swagger/apiUseTags', controller);
366
+ return tags?.length ? [...tags] : [controller.name.replace(/Controller$/, '')];
367
+ }
368
+ function renameSchemaRefs(value, renamed) {
369
+ const prefix = '#/components/schemas/';
370
+ const visit = (node) => {
371
+ if (Array.isArray(node)) {
372
+ node.forEach(visit);
373
+ }
374
+ else if (typeof node === 'object' && node !== null) {
375
+ const record = node;
376
+ for (const [key, child] of Object.entries(record)) {
377
+ if (key === '$ref' && typeof child === 'string' && child.startsWith(prefix)) {
378
+ const name = child.slice(prefix.length);
379
+ const target = renamed.get(name) ?? `BetterAuth${name}`;
380
+ renamed.set(name, target);
381
+ record.$ref = `${prefix}${target}`;
382
+ }
383
+ else {
384
+ visit(child);
385
+ }
386
+ }
387
+ }
388
+ };
389
+ visit(value);
390
+ return value;
391
+ }
392
+ function addRenamedSchemas(document, schemas, renamed) {
393
+ const added = new Set();
394
+ let pending = [...renamed.keys()];
395
+ while (pending.length) {
396
+ for (const name of pending) {
397
+ added.add(name);
398
+ if (!schemas[name]) {
399
+ continue;
400
+ }
401
+ document.components = document.components ?? {};
402
+ document.components.schemas = document.components.schemas ?? {};
403
+ document.components.schemas[renamed.get(name)] ??= toOpenApi30(renameSchemaRefs(structuredClone(schemas[name]), renamed));
404
+ }
405
+ pending = [...renamed.keys()].filter((name) => !added.has(name));
406
+ }
407
+ }
408
+ function toOpenApi30(value) {
409
+ const visit = (node) => {
410
+ if (Array.isArray(node)) {
411
+ node.forEach(visit);
412
+ }
413
+ else if (typeof node === 'object' && node !== null) {
414
+ const record = node;
415
+ if (Array.isArray(record.type)) {
416
+ const types = record.type;
417
+ const nonNull = types.filter((type) => type !== 'null');
418
+ if (nonNull.length > 1) {
419
+ record.anyOf = nonNull.map((type) => ({ type }));
420
+ delete record.type;
421
+ }
422
+ else if (nonNull.length === 1) {
423
+ record.type = nonNull[0];
424
+ }
425
+ else {
426
+ delete record.type;
427
+ }
428
+ if (types.includes('null')) {
429
+ record.nullable = true;
430
+ }
431
+ }
432
+ Object.values(record).forEach(visit);
433
+ }
434
+ };
435
+ visit(value);
436
+ return value;
437
+ }
127
438
  function buildApiTokenSwaggerDocument(document, options = {}, config = config_service_1.ConfigService.configFastButReadOnly ?? {}) {
128
439
  const keepsTenantHeader = (0, core_api_token_helpers_1.resolveApiTokenConfig)(config).userTokens;
129
440
  const view = structuredClone(document);