@lenne.tech/nest-server 11.41.6 → 11.41.8

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 (55) hide show
  1. package/.claude/rules/better-auth.md +26 -22
  2. package/.claude/rules/configurable-features.md +1 -1
  3. package/.claude/rules/package-management.md +8 -0
  4. package/.claude/rules/testing.md +13 -10
  5. package/CLAUDE.md +6 -4
  6. package/FRAMEWORK-API.md +1 -1
  7. package/dist/core/common/helpers/file.helper.js.map +1 -1
  8. package/dist/core/common/services/core-s3.service.d.ts +8 -0
  9. package/dist/core/common/services/core-s3.service.js +44 -3
  10. package/dist/core/common/services/core-s3.service.js.map +1 -1
  11. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +1 -0
  12. package/dist/core/modules/ai/core-ai-mcp.controller.js +10 -0
  13. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.d.ts +2 -0
  15. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js +13 -0
  16. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js.map +1 -0
  17. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +3 -2
  18. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +1 -0
  20. package/dist/core/modules/better-auth/core-better-auth.constants.js +2 -1
  21. package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
  22. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +1 -0
  23. package/dist/core/modules/better-auth/core-better-auth.service.js +46 -3
  24. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  25. package/dist/core/modules/file/core-file.service.js +3 -2
  26. package/dist/core/modules/file/core-file.service.js.map +1 -1
  27. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +1 -0
  28. package/dist/core/modules/file/s3-file.helper.d.ts +1 -0
  29. package/dist/core/modules/file/s3-file.helper.js +10 -4
  30. package/dist/core/modules/file/s3-file.helper.js.map +1 -1
  31. package/dist/core/modules/system-setup/core-system-setup.service.js +3 -2
  32. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  33. package/dist/tsconfig.build.tsbuildinfo +1 -1
  34. package/docs/REQUEST-LIFECYCLE.md +1 -1
  35. package/docs/security-overrides.md +1 -1
  36. package/migration-guides/11.41.6-to-11.41.7.md +192 -0
  37. package/migration-guides/11.41.7-to-11.41.8.md +135 -0
  38. package/package.json +24 -22
  39. package/src/core/common/helpers/file.helper.ts +4 -0
  40. package/src/core/common/helpers/gridfs.helper.ts +1 -1
  41. package/src/core/common/services/core-s3.service.ts +125 -7
  42. package/src/core/modules/ai/README.md +8 -0
  43. package/src/core/modules/ai/core-ai-mcp.controller.ts +33 -0
  44. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +2 -2
  45. package/src/core/modules/better-auth/README.md +20 -6
  46. package/src/core/modules/better-auth/core-better-auth-account-issuer.helper.ts +25 -0
  47. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -7
  48. package/src/core/modules/better-auth/core-better-auth.constants.ts +20 -11
  49. package/src/core/modules/better-auth/core-better-auth.service.ts +87 -3
  50. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +39 -11
  51. package/src/core/modules/file/README.md +30 -5
  52. package/src/core/modules/file/core-file.service.ts +15 -6
  53. package/src/core/modules/file/interfaces/file-upload.interface.ts +18 -0
  54. package/src/core/modules/file/s3-file.helper.ts +28 -9
  55. package/src/core/modules/system-setup/core-system-setup.service.ts +12 -8
@@ -74,6 +74,16 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
74
74
  */
75
75
  protected presigner?: typeof import('@aws-sdk/s3-request-presigner');
76
76
 
77
+ /**
78
+ * Lazy-imported `@aws-sdk/lib-storage`, used to upload a stream whose length is unknown.
79
+ *
80
+ * Three states, and the third is the point: `undefined` means "not tried yet", a module means
81
+ * "available", and `null` means "tried and absent". Without the `null` the import would be
82
+ * retried on every single upload, and since the absence is permanent that is a resolver miss per
83
+ * request plus a repeated warning in the log.
84
+ */
85
+ protected multipartUploader?: null | typeof import('@aws-sdk/lib-storage');
86
+
77
87
  constructor(protected readonly configService: ConfigService) {
78
88
  const raw = this.configService.getFastButReadOnly<IS3Config | undefined>('s3');
79
89
  if (!raw || raw.enabled === false) {
@@ -189,6 +199,10 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
189
199
  ...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}),
190
200
  });
191
201
 
202
+ // Resolved at boot rather than on the first upload, so a missing optional peer is reported
203
+ // where operators look — the startup log — instead of during the first large upload.
204
+ await this.loadMultipartUploader();
205
+
192
206
  await this.verifyBuckets();
193
207
  }
194
208
 
@@ -263,10 +277,26 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
263
277
  /**
264
278
  * Upload an object to the main bucket.
265
279
  *
266
- * A stream needs its length: without `Content-Length` the SDK switches to aws-chunked
267
- * encoding and then fails on a missing `x-amz-decoded-content-length`, so a stream of
268
- * unknown size is read into memory first. Pass `contentLength` whenever the size is
269
- * known — that streams the body straight through instead of buffering it.
280
+ * A `PutObject` needs the body's length: without `Content-Length` the SDK switches to
281
+ * aws-chunked encoding and then fails on a missing `x-amz-decoded-content-length`. So pass
282
+ * `contentLength` whenever the size is known — that streams the body straight through as a
283
+ * single request.
284
+ *
285
+ * A stream of UNKNOWN length goes through a multipart upload instead (`@aws-sdk/lib-storage`),
286
+ * which is what that package exists for: it reads the stream in parts and needs no total up
287
+ * front. It used to be read into memory instead, which made the memory cost of an upload equal
288
+ * to its size — a multi-GB object then either threw "Array buffer allocation failed" or got the
289
+ * container OOM-killed at 100 % progress, taking every other in-flight request with it. That is
290
+ * not a hypothetical shape: any streamed HTTP upload has it, because a multipart request's
291
+ * `Content-Length` covers the whole envelope and therefore cannot be used as the object length.
292
+ *
293
+ * A KNOWN length above {@link CoreS3Service.MAX_SINGLE_PUT_BYTES} goes through the multipart
294
+ * upload as well: AWS S3 refuses a single PutObject that large with `EntityTooLarge`, which for a
295
+ * finished TUS upload means after the client has sent every byte.
296
+ *
297
+ * Without the optional peer dependency both cases behave exactly as before: an unknown length is
298
+ * read into memory, a known one goes up as one PutObject whatever its size (MinIO and RustFS
299
+ * accept that; AWS S3 does not above 5 GiB).
270
300
  */
271
301
  async putObject(
272
302
  key: string,
@@ -278,6 +308,15 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
278
308
 
279
309
  let payload: Buffer | Readable | string = body;
280
310
  let length = contentLength;
311
+ // Multipart for a stream of UNKNOWN length — nothing else carries it without buffering — and for
312
+ // one whose known length exceeds what S3 accepts in a single request.
313
+ if (this.isStream(body) && (length === undefined || length > CoreS3Service.MAX_SINGLE_PUT_BYTES)) {
314
+ const uploader = await this.loadMultipartUploader();
315
+ if (uploader) {
316
+ await this.putObjectMultipart(uploader, key, body, contentType, length);
317
+ return;
318
+ }
319
+ }
281
320
  if (this.isStream(body) && length === undefined) {
282
321
  payload = await this.collect(body);
283
322
  length = payload.length;
@@ -318,6 +357,69 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
318
357
  await send();
319
358
  }
320
359
 
360
+ /**
361
+ * The multipart uploader, or `undefined` when the optional peer dependency is absent.
362
+ *
363
+ * Absence is NOT an error here, unlike the missing `@aws-sdk/client-s3` in `onModuleInit`:
364
+ * multipart is an optimisation of a path that already works by buffering, so a project that
365
+ * never installs it keeps the previous behaviour instead of failing to boot. The cost of that
366
+ * choice is memory, which is why the caller's doc states it.
367
+ */
368
+ protected async loadMultipartUploader(): Promise<typeof import('@aws-sdk/lib-storage') | undefined> {
369
+ if (this.multipartUploader !== undefined) {
370
+ return this.multipartUploader ?? undefined;
371
+ }
372
+ try {
373
+ this.multipartUploader = await import('@aws-sdk/lib-storage');
374
+ } catch {
375
+ // `null`, not `undefined`: the field doubles as the "already tried" marker, and a failed
376
+ // import must not be retried on every upload.
377
+ this.multipartUploader = null;
378
+ this.logger.warn(
379
+ 'Streaming an S3 upload of unknown length falls back to buffering it in memory because the ' +
380
+ 'optional peer dependency "@aws-sdk/lib-storage" is not installed. Run: pnpm add @aws-sdk/lib-storage',
381
+ );
382
+ }
383
+ return this.multipartUploader ?? undefined;
384
+ }
385
+
386
+ /**
387
+ * Upload a stream of unknown length as a multipart upload.
388
+ *
389
+ * Memory stays bounded by the uploader's defaults — parts of 5 MiB, at most four in flight —
390
+ * whatever the object's size. A stream that ends within the first part is sent as one ordinary
391
+ * PutObject by `Upload` itself, so a small file costs no multipart round trips.
392
+ *
393
+ * No `guardBodyStream` here, and that is deliberate: the guard exists because the SDK pipes a
394
+ * `PutObject` body into its request without listening for the source's `error`, so a failed
395
+ * source surfaces as a successful upload of a truncated object. `Upload` does listen — a source
396
+ * error rejects `done()` and the already-uploaded parts are aborted — so wrapping it would add a
397
+ * second error path without adding a guarantee.
398
+ */
399
+ protected async putObjectMultipart(
400
+ uploader: typeof import('@aws-sdk/lib-storage'),
401
+ key: string,
402
+ body: Readable,
403
+ contentType?: string,
404
+ contentLength?: number,
405
+ ): Promise<void> {
406
+ const { client, config } = this.requireInit();
407
+ const upload = new uploader.Upload({
408
+ client,
409
+ params: {
410
+ Body: body,
411
+ Bucket: config.bucket,
412
+ // A known total lets `Upload` size its parts (`ceil(total / 10 000)`, at least 5 MiB) so the
413
+ // 10 000-part limit holds, and refuse a stream that yields a different NUMBER of parts than
414
+ // that total implies (it checks the part count, not the byte count). Not sent per part.
415
+ ...(contentLength === undefined ? {} : { ContentLength: contentLength }),
416
+ ...(contentType ? { ContentType: contentType } : {}),
417
+ Key: key,
418
+ },
419
+ });
420
+ await upload.done();
421
+ }
422
+
321
423
  /**
322
424
  * Shield the SDK from a REQUEST-BODY stream that can fail, and keep the cause.
323
425
  *
@@ -397,6 +499,12 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
397
499
  */
398
500
  static readonly MAX_COPY_OBJECT_BYTES = 5 * 1024 * 1024 * 1024;
399
501
 
502
+ /**
503
+ * Largest object a single `PutObject` can carry (AWS S3 hard limit). {@link CoreS3Service.putObject}
504
+ * sends a known length above it as a multipart upload instead.
505
+ */
506
+ static readonly MAX_SINGLE_PUT_BYTES = 5 * 1024 * 1024 * 1024;
507
+
400
508
  /**
401
509
  * Build the `CopySource` value for {@link CoreS3Service.copyObject}
402
510
  */
@@ -453,13 +561,23 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
453
561
  * Whether an object exists in the main bucket
454
562
  */
455
563
  async objectExists(key: string): Promise<boolean> {
564
+ return (await this.statObject(key)) !== null;
565
+ }
566
+
567
+ /**
568
+ * Size and content type of an object in the main bucket, or `null` when it does not exist.
569
+ *
570
+ * One HEAD request. This is how the length of an object uploaded WITHOUT a known length is
571
+ * learned afterwards: the stored object is the authority on its own size.
572
+ */
573
+ async statObject(key: string): Promise<null | { contentLength?: number; contentType?: string }> {
456
574
  const { client, config, sdk } = this.requireInit();
457
575
  try {
458
- await client.send(new sdk.HeadObjectCommand({ Bucket: config.bucket, Key: key }));
459
- return true;
576
+ const result = await client.send(new sdk.HeadObjectCommand({ Bucket: config.bucket, Key: key }));
577
+ return { contentLength: result.ContentLength, contentType: result.ContentType };
460
578
  } catch (error: any) {
461
579
  if (error?.$metadata?.httpStatusCode === 404 || error?.name === 'NotFound') {
462
- return false;
580
+ return null;
463
581
  }
464
582
  throw error;
465
583
  }
@@ -621,6 +621,14 @@ the server log; the response carries only a stable `#LTNS_0901` code.
621
621
  framework's existing auth) — the MCP session is bound to that user, and
622
622
  `tools/list` / `tools/call` are filtered to and executed with their permissions.
623
623
  - Unauthenticated requests get `401` with a `WWW-Authenticate` header.
624
+ - A session id the server does not know — which every restart produces, because the
625
+ transport map is process memory — gets **`404`**, and so does another user's id. 404
626
+ is the status the MCP spec has a client react to by sending a fresh `initialize`, so
627
+ clients recover from a deploy on their own rather than sitting dead until someone
628
+ reconnects by hand. A request with **no** session id and no `initialize` body still
629
+ gets `400`: there is nothing to recover, and a 404 would start a reconnect loop.
630
+ - Answering a foreign id `404` rather than `403` is deliberate — a 403 would confirm
631
+ that the id exists, and the id travels in a response header and through client logs.
624
632
 
625
633
  ### Multi-replica / sticky sessions
626
634
 
@@ -121,6 +121,27 @@ export class CoreAiMcpController implements OnModuleDestroy {
121
121
  this.foreignSession(res, owner);
122
122
  return;
123
123
  }
124
+
125
+ // A session id the client still holds that no replica knows — the state every restart
126
+ // produces, because the transport map is process memory. 404 is the only status a client
127
+ // reacts to by starting a fresh session itself (the spec requires it to), and it is what the
128
+ // SDK's own server returns for a mismatched id.
129
+ //
130
+ // Falling through to a new transport instead, as this did until now, looked like a silent
131
+ // recovery and was not one: after a restart the client's next request is a `tools/call` or
132
+ // `tools/list`, never an `initialize`, so the fresh transport's `validateSession()` answered
133
+ // `400 Bad Request: Server not initialized` — the same dead session, one layer down, and a
134
+ // 400 is a status no client recovers from. Found in a consumer project whose own older copy
135
+ // of this controller had the identical defect: after every deploy, every connected client
136
+ // stayed dead until somebody reconnected by hand.
137
+ //
138
+ // An `initialize` carrying a stale id still gets a new session rather than a 404: a client
139
+ // that keeps sending the old id would otherwise loop. No SDK client does that — it skips
140
+ // `initialize` entirely once its transport has an id — but the case costs one check.
141
+ if (!this.isInitializeRequest(req.body)) {
142
+ res.status(404).json({ error: 'Unknown or expired MCP session' });
143
+ return;
144
+ }
124
145
  }
125
146
 
126
147
  if (!entry) {
@@ -216,6 +237,18 @@ export class CoreAiMcpController implements OnModuleDestroy {
216
237
  return null;
217
238
  }
218
239
 
240
+ /**
241
+ * Whether the body is an `initialize` — the one request that legitimately starts a session.
242
+ *
243
+ * A batch counts when any member is an initialize, matching how the SDK transport reads it.
244
+ */
245
+ protected isInitializeRequest(body: any): boolean {
246
+ if (Array.isArray(body)) {
247
+ return body.some((message: any) => message?.method === 'initialize');
248
+ }
249
+ return body?.method === 'initialize';
250
+ }
251
+
219
252
  /**
220
253
  * Whether the OAuth 2.1 layer is enabled via `ai.mcp.oauth`.
221
254
  */
@@ -18,14 +18,14 @@ From `@lenne.tech/nest-server` 11.37.0, better-auth is **not** a dependency of t
18
18
  non-optional **peer dependency**, so your project declares and owns the version:
19
19
 
20
20
  ```bash
21
- pnpm add better-auth@1.7.1 @better-auth/passkey@1.7.1 @better-auth/core@1.7.1
21
+ pnpm add better-auth@1.7.7 @better-auth/passkey@1.7.7 @better-auth/core@1.7.7
22
22
  ```
23
23
 
24
24
  **Pin all three to the same exact version** — no `^`, no `~`. They are one release train, and a
25
25
  mixed set fails in ways the type checker does not catch.
26
26
 
27
27
  > **Do not rely on `pnpm install` to remind you.** On pnpm's default `autoInstallPeers: true` the
28
- > packages are installed silently at whatever version satisfies `>=1.7.1 <1.8.0` — your build stays
28
+ > packages are installed silently at whatever version satisfies `>=1.7.7 <1.8.0` — your build stays
29
29
  > green while the version is unpinned and free to drift. Verify with `pnpm why better-auth`.
30
30
 
31
31
  **Why peers rather than dependencies:** in a fullstack project the frontend talks to better-auth
@@ -1028,12 +1028,26 @@ Idempotent indices on `session`, `users`, `account` and `verification`, includin
1028
1028
  expires unconsumed verification and password-reset documents. Failures are logged at `warn` and do
1029
1029
  not block the boot: these help speed, not correctness.
1030
1030
 
1031
- ### 2. `backfillAccountIssuers()` — correctness (11.37.0+)
1032
-
1033
- From better-auth 1.7 an account is keyed by `(issuer, accountId)` and the sign-in route filters on
1034
- it verbatim. Rows written by better-auth 1.6 have no `issuer` field, so **every existing password
1035
- user would be locked out by the upgrade**, with a bare 401 and nothing in the log to explain it.
1036
- This step repairs those rows on the first boot after the upgrade.
1031
+ ### 2. The account issuer — correctness (depends on the INSTALLED better-auth)
1032
+
1033
+ better-auth 1.7.0–1.7.2 keyed accounts by `(issuer, accountId)`; 1.7.3 restored the 1.6 schema,
1034
+ `(providerId, accountId)`. The boot runs ONE of two steps, decided by what is installed
1035
+ (`core-better-auth-account-issuer.helper.ts`):
1036
+
1037
+ - **better-auth 1.7.3+ (11.41.8+ requires it): `dropLegacyAccountIssuerIndex()`.** Databases that ran
1038
+ 1.7.0–1.7.2 (nest-server 11.37–11.41.7) carry a unique `(issuer, accountId)` index. With the field
1039
+ no longer written it refuses a second provider with the same account ID, so the boot drops it —
1040
+ recognised by shape, so a renamed collection or field is covered. Existing `issuer` values stay;
1041
+ they are inert. The backfill marker is deleted, so a rollback to a better-auth that needs the
1042
+ issuer runs the backfill again. Log line: `Dropped the unique index "…" on account(issuer,
1043
+ accountId)`. A failure is logged at `error` and the boot continues.
1044
+ - **better-auth 1.7.0–1.7.2: `backfillAccountIssuers()`, described below.** Kept for a project that
1045
+ has not raised its better-auth pin yet.
1046
+
1047
+ From better-auth 1.7.0 to 1.7.2 an account is keyed by `(issuer, accountId)` and the sign-in route
1048
+ filters on it verbatim. Rows written by better-auth 1.6 have no `issuer` field, so **every existing
1049
+ password user would be locked out by the upgrade**, with a bare 401 and nothing in the log to
1050
+ explain it. The backfill repairs those rows on the first boot after the upgrade.
1037
1051
 
1038
1052
  | Property | Behaviour |
1039
1053
  | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -0,0 +1,25 @@
1
+ import * as betterAuthCoreDb from '@better-auth/core/db';
2
+
3
+ /**
4
+ * The synthetic issuer better-auth 1.7.0–1.7.2 stores on credential accounts — or `undefined` when
5
+ * the installed better-auth no longer uses one.
6
+ *
7
+ * better-auth 1.7.0 keyed accounts by `(issuer, accountId)` and exported `createLocalAccountIssuer`
8
+ * to derive the credential issuer. 1.7.3 restored the 1.6 schema — accounts keyed by
9
+ * `(providerId, accountId)` — and removed both. This framework works with either line: its peer
10
+ * range starts at 1.7.7, but a project that has not raised its pin yet must keep signing users in.
11
+ * So every place that writes or repairs an account asks THIS function instead of importing the
12
+ * helper: a static import does not even compile against 1.7.3+, and an issuer written there would
13
+ * be dead data.
14
+ *
15
+ * Resolved at call time from the module namespace, never cached at load, so a test can swap it.
16
+ */
17
+ export function legacyCredentialAccountIssuer(): string | undefined {
18
+ const create = (betterAuthCoreDb as unknown as Record<string, unknown>).createLocalAccountIssuer;
19
+ return typeof create === 'function' ? (create as (providerId: string) => string)('credential') : undefined;
20
+ }
21
+
22
+ /** Whether the installed better-auth keys accounts by `(issuer, accountId)` — 1.7.0 to 1.7.2. */
23
+ export function usesLegacyAccountIssuer(): boolean {
24
+ return legacyCredentialAccountIssuer() !== undefined;
25
+ }
@@ -1,4 +1,3 @@
1
- import { createLocalAccountIssuer } from '@better-auth/core/db';
2
1
  import { Injectable, Logger, Optional } from '@nestjs/common';
3
2
  import { InjectConnection } from '@nestjs/mongoose';
4
3
  import * as bcrypt from 'bcrypt';
@@ -19,6 +18,7 @@ const scryptPromise = (password: string, salt: string, keylen: number, options:
19
18
 
20
19
  import { RoleEnum } from '../../common/enums/role.enum';
21
20
  import { maskEmail } from '../../common/helpers/logging.helper';
21
+ import { legacyCredentialAccountIssuer } from './core-better-auth-account-issuer.helper';
22
22
 
23
23
  /**
24
24
  * Interface for Better-Auth session user
@@ -531,16 +531,16 @@ export class CoreBetterAuthUserMapper {
531
531
  // Store account matching Better-Auth's format:
532
532
  // - userId: ObjectId referencing users._id
533
533
  // - accountId: string version of users._id
534
- // - issuer: from better-auth >= 1.7 accounts are keyed by (issuer,
535
- // accountId), and credential accounts carry a synthetic issuer. Writing
536
- // the row without it produces an account better-auth cannot find, so the
537
- // migrated user gets a 401 on the very sign-in that triggered the
538
- // migration. Always derive it from the helper, never hand-write it.
534
+ // - issuer: ONLY while the installed better-auth is 1.7.0–1.7.2, which key accounts by
535
+ // (issuer, accountId) — a row without it is not found, and the migrated user gets a 401 on
536
+ // the very sign-in that triggered the migration. 1.7.3+ went back to (providerId,
537
+ // accountId) and writes no issuer. Always derived from the helper, never hand-written.
538
+ const credentialIssuer = legacyCredentialAccountIssuer();
539
539
  await accountsCollection.insertOne({
540
540
  accountId: userIdHex,
541
541
  createdAt: now,
542
542
  id: this.generateId(),
543
- issuer: createLocalAccountIssuer('credential'),
543
+ ...(credentialIssuer ? { issuer: credentialIssuer } : {}),
544
544
  password: passwordHash,
545
545
  providerId: 'credential',
546
546
  updatedAt: now,
@@ -73,27 +73,36 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
73
73
  export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
74
74
 
75
75
  /**
76
- * better-auth's default name for the account collection, and for the `issuer` field inside it.
76
+ * better-auth's default names for the account collection and for fields inside it.
77
77
  *
78
- * Both are overridable by a consumer through `betterAuth.options.account.modelName` /
79
- * `.fields.issuer`, which `better-auth.config.ts` spreads onto the resolved config verbatim. Any
80
- * framework code touching that collection directly MUST resolve the real names from the running
81
- * instance and fall back to these — a hardcoded name silently addresses a collection better-auth
82
- * does not use, which on the issuer backfill means every password user stays locked out while the
83
- * operation reports success by saying nothing.
78
+ * All are overridable by a consumer through `betterAuth.options.account.modelName` / `.fields.*`,
79
+ * which `better-auth.config.ts` spreads onto the resolved config verbatim. Any framework code
80
+ * touching that collection directly MUST resolve the real names from the running instance and fall
81
+ * back to these — a hardcoded name silently addresses a collection or an index better-auth does not
82
+ * use, and the operation then reports success by saying nothing.
83
+ *
84
+ * `issuer` is only used by better-auth 1.7.0–1.7.2; 1.7.3 restored the 1.6 schema (accounts keyed by
85
+ * `(providerId, accountId)` again). Its name is still needed on both lines: for the backfill while a
86
+ * project runs 1.7.0–1.7.2, and afterwards to recognise the unique index those versions left behind —
87
+ * see `CoreBetterAuthService.dropLegacyAccountIssuerIndex()`.
84
88
  */
85
89
  export const DEFAULT_ACCOUNT_MODEL_NAME = 'account';
86
90
  export const DEFAULT_ACCOUNT_ISSUER_FIELD = 'issuer';
91
+ export const DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD = 'accountId';
87
92
 
88
93
  /**
89
94
  * Collection holding one-shot completion markers for boot-time data migrations, and the marker id
90
- * of the `account.issuer` backfill (better-auth 1.7).
95
+ * of the `account.issuer` backfill.
96
+ *
97
+ * The backfill only runs while the installed better-auth still keys accounts by issuer (1.7.0–1.7.2,
98
+ * see `core-better-auth-account-issuer.helper.ts`). On 1.7.3+ the boot DELETES this marker instead:
99
+ * should the project roll back to a better-auth that needs the issuer, the backfill then runs again
100
+ * and repairs the accounts written in the meantime, rather than being skipped by a stale marker.
91
101
  *
92
102
  * The marker exists for cost, not for correctness: the backfill is idempotent and safe to repeat,
93
103
  * but its filters (`$exists: false`, `$ne`) cannot use an index, so without a marker every boot of
94
- * every replica pays a full pass over the account collection — forever, and inside the `await` that
95
- * precedes `app.listen()`. Versioned in the id so a future backfill of the same field can run
96
- * again without clearing this one.
104
+ * every replica pays a full pass over the account collection. Versioned in the id so a future
105
+ * backfill of the same field can run again without clearing this one.
97
106
  */
98
107
  export const BACKFILL_MARKER_COLLECTION = 'better-auth-backfills';
99
108
  export const ACCOUNT_ISSUER_BACKFILL_ID = 'account-issuer-backfill-v1';
@@ -1,4 +1,3 @@
1
- import { createLocalAccountIssuer } from '@better-auth/core/db';
2
1
  import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
3
2
  import { InjectConnection } from '@nestjs/mongoose';
4
3
  import { Request } from 'express';
@@ -12,6 +11,7 @@ import { ConfigService } from '../../common/services/config.service';
12
11
  import { ErrorCode } from '../error-code/error-codes';
13
12
  import { resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
14
13
  import { BetterAuthInstance } from './better-auth.config';
14
+ import { legacyCredentialAccountIssuer, usesLegacyAccountIssuer } from './core-better-auth-account-issuer.helper';
15
15
  import { isJwtShaped } from './core-better-auth-token.helper';
16
16
  import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
17
17
  import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
@@ -21,6 +21,7 @@ import {
21
21
  BETTER_AUTH_CONFIG,
22
22
  BETTER_AUTH_COOKIE_DOMAIN,
23
23
  BETTER_AUTH_INSTANCE,
24
+ DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD,
24
25
  DEFAULT_ACCOUNT_ISSUER_FIELD,
25
26
  DEFAULT_ACCOUNT_MODEL_NAME,
26
27
  } from './core-better-auth.constants';
@@ -113,12 +114,21 @@ export class CoreBetterAuthService implements OnModuleInit {
113
114
  *
114
115
  * Order matters: the backfill's update can use the `{ providerId: 1, userId: 1 }` index created
115
116
  * below, so indices come first.
117
+ *
118
+ * Which account step runs depends on the INSTALLED better-auth, not on this package's version:
119
+ * 1.7.0–1.7.2 key accounts by issuer and need the backfill; 1.7.3+ dropped the issuer and need the
120
+ * unique index those versions left behind removed instead. A project that has not raised its
121
+ * better-auth pin yet therefore keeps working exactly as before.
116
122
  */
117
123
  async onModuleInit(): Promise<void> {
118
124
  if (!this.isEnabled() || !this.connection?.db) return;
119
125
 
120
126
  await this.ensureIndices();
121
- await this.backfillAccountIssuers();
127
+ if (usesLegacyAccountIssuer()) {
128
+ await this.backfillAccountIssuers();
129
+ } else {
130
+ await this.dropLegacyAccountIssuerIndex();
131
+ }
122
132
  }
123
133
 
124
134
  /**
@@ -195,6 +205,11 @@ export class CoreBetterAuthService implements OnModuleInit {
195
205
  protected async backfillAccountIssuers(): Promise<void> {
196
206
  if (!this.isEnabled() || !this.connection?.db) return;
197
207
 
208
+ // Only better-auth 1.7.0–1.7.2 key accounts by issuer. On 1.7.3+ there is nothing to backfill —
209
+ // and nothing to derive the value from.
210
+ const credentialIssuer = legacyCredentialAccountIssuer();
211
+ if (!credentialIssuer) return;
212
+
198
213
  const db = this.connection.db;
199
214
 
200
215
  // The consumer can rename both of these through `betterAuth.options.account`, which
@@ -243,7 +258,7 @@ export class CoreBetterAuthService implements OnModuleInit {
243
258
  pending.map((doc) => ({
244
259
  updateOne: {
245
260
  filter: { _id: doc._id },
246
- update: { $set: { [issuerField]: createLocalAccountIssuer('credential') } },
261
+ update: { $set: { [issuerField]: credentialIssuer } },
247
262
  },
248
263
  })),
249
264
  { ordered: false },
@@ -306,6 +321,75 @@ export class CoreBetterAuthService implements OnModuleInit {
306
321
  }
307
322
  }
308
323
 
324
+ /**
325
+ * Drops the unique `(issuer, accountId)` index better-auth 1.7.0–1.7.2 created on the account
326
+ * collection, and forgets the issuer-backfill marker. Runs on better-auth 1.7.3+ only.
327
+ *
328
+ * better-auth 1.7.0 keyed accounts by `(issuer, accountId)`, and its MongoDB adapter created that
329
+ * index lazily. 1.7.3 restored the 1.6 schema — `(providerId, accountId)` — and no longer writes
330
+ * `issuer`. The index stays behind in every database that ran those versions (this framework
331
+ * required them from 11.37.0 to 11.41.7), and with the field gone it treats every new account as
332
+ * having the same, missing issuer: a user who holds the same account ID at two providers can no
333
+ * longer link the second one. MongoDB has no `NOT NULL`, so dropping the index is the whole
334
+ * cleanup — upstream's 1.7 upgrade guide, "Account identity keeps the provider key". Existing
335
+ * `issuer` values are inert and stay, which also keeps a rollback possible.
336
+ *
337
+ * The marker is deleted for the same rollback: back on a better-auth that needs the issuer, the
338
+ * backfill must run again and repair the accounts written in the meantime instead of being
339
+ * skipped as "already completed".
340
+ *
341
+ * The index is recognised by its SHAPE — unique, keyed exactly on the issuer and account-id fields
342
+ * — not by name: better-auth derives the name from the collection and field names (by default
343
+ * `account_issuer_accountId_uidx`), and a consumer can rename both through
344
+ * `betterAuth.options.account`.
345
+ *
346
+ * Idempotent and cheap: one `listIndexes` and one `deleteOne` per boot. Never fatal — a failure is
347
+ * logged as an error and the server still starts.
348
+ */
349
+ protected async dropLegacyAccountIssuerIndex(): Promise<void> {
350
+ if (!this.isEnabled() || !this.connection?.db) return;
351
+
352
+ const db = this.connection.db;
353
+ const accountOptions = (this.authInstance as any)?.options?.account;
354
+ const modelName: string = accountOptions?.modelName ?? DEFAULT_ACCOUNT_MODEL_NAME;
355
+ const issuerField: string = accountOptions?.fields?.issuer ?? DEFAULT_ACCOUNT_ISSUER_FIELD;
356
+ const accountIdField: string = accountOptions?.fields?.accountId ?? DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD;
357
+
358
+ try {
359
+ await db.collection(BACKFILL_MARKER_COLLECTION).deleteOne({ _id: ACCOUNT_ISSUER_BACKFILL_ID as any });
360
+
361
+ const accounts = db.collection(modelName);
362
+ let indexes: { key?: Record<string, unknown>; name?: string; unique?: boolean }[];
363
+ try {
364
+ indexes = await accounts.indexes();
365
+ } catch (error) {
366
+ // A collection that does not exist yet has no index to drop (NamespaceNotFound).
367
+ if ((error as { code?: number })?.code === 26) return;
368
+ throw error;
369
+ }
370
+
371
+ const legacy = indexes.filter(
372
+ (index) =>
373
+ index.unique === true &&
374
+ !!index.name &&
375
+ Object.keys(index.key ?? {}).join(',') === `${issuerField},${accountIdField}`,
376
+ );
377
+ for (const index of legacy) {
378
+ await accounts.dropIndex(index.name as string);
379
+ this.logger.log(
380
+ `Dropped the unique index "${index.name}" on ${modelName}(${issuerField}, ${accountIdField}): ` +
381
+ 'better-auth >= 1.7.3 no longer writes the issuer, and the index would refuse new accounts.',
382
+ );
383
+ }
384
+ } catch (error) {
385
+ this.logger.error(
386
+ `Could not drop the legacy unique (${issuerField}, ${accountIdField}) index on "${modelName}": ` +
387
+ `${error instanceof Error ? error.message : 'unknown'}. Linking an account whose ID another ` +
388
+ 'provider already uses will fail until it is dropped by hand.',
389
+ );
390
+ }
391
+ }
392
+
309
393
  /**
310
394
  * Checks if better-auth is enabled and initialized
311
395
  * Returns true only if:
@@ -88,6 +88,31 @@ If some files are genuinely public and others are not, do not solve it with a ro
88
88
  record a visibility flag in the metadata and branch in `checkRights()`, or expose a separate public
89
89
  route for exactly the public files and leave the core routes gated.
90
90
 
91
+ ### 6. Switching to the S3 driver — in this order
92
+
93
+ Each step fails on its own if skipped, and only the first one fails loudly:
94
+
95
+ 1. **Forward the services to `super()`.** `FileService` must call
96
+ `super(connection, 'fs', { configService, s3Service })`, as the reference implementation does. With
97
+ `super(connection)` the S3 store counts as unavailable and `file.storage: 's3'` fails the boot
98
+ with "that storage is not available".
99
+ 2. **Install the S3 packages as `dependencies`, not `devDependencies`:** `@aws-sdk/client-s3` and
100
+ `@aws-sdk/lib-storage` (same version), plus `@aws-sdk/s3-request-presigner` for
101
+ `s3.presignedDownloads`. A production image installed with `--prod` otherwise boots without them.
102
+ Vendor mode has them installed already (the vendored core references their types) — check which
103
+ section they are in.
104
+ 3. **Count stored files across all three metadata collections.** `fs.files` is GridFS only; the
105
+ filesystem driver records in `filesystem-files` and S3 in `s3-files`. Project code that
106
+ aggregates sizes or counts directly — a quota, a statistic — must cover all three, or it reads 0
107
+ after the switch and the quota never refuses anything. `findFileInfo()` already merges every
108
+ store.
109
+ 4. **Then switch the driver** — `file.storage: 's3'`, or leave it unset and let `s3.bucket` derive
110
+ it. Files written before stay readable: reads consult every store.
111
+
112
+ An `s3` block with a `bucket` turns `CoreS3Service` on whatever `file.storage` says (TUS, where enabled, stages
113
+ uploads in S3 as soon as it is on), so it needs `@aws-sdk/client-s3` at boot. To prepare the block
114
+ without activating it, set `enabled: false`.
115
+
91
116
  ## Pick your project class first — the whole model is one dial with five settings
92
117
 
93
118
  `file.access` is the per-file rule as a DECLARATION instead of code. Until 11.35.0 the framework shipped
@@ -201,14 +226,17 @@ personal or medical, a per-file rule is not optional.
201
226
 
202
227
  ## Common Mistakes
203
228
 
204
- | Mistake | Symptom | Fix |
205
- | -------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
206
- | Overriding `getFileById` to add Swagger decorators | `file.downloadRoles` silently ignored | Don't override; document at class level |
207
- | Overriding without re-declaring `@Get(...)` | Route 404s for everyone, no error logged | Don't override |
208
- | `downloadRoles: []` | Warning logged, default applied | Use a non-empty array; `[]` would read as "no roles required" and open the route |
209
- | Expecting `metadata` back from `getFileInfo()` | `undefined` | Use `getRawFileInfo()` inside `checkRights()` |
210
- | `downloadRoles: ['member']` with multiTenancy | Works from code, fails from `<img>` | Both file classes carry `@SkipTenantCheck()`; roles resolve against `user.roles`. Use `checkRights()` for tenant rules |
211
- | Signed-in user can upload via TUS but not download | 403 on their own file | `tus.roles` and `file.downloadRoles` are separate. Add an owner to the metadata and authorize per file |
212
- | Rule narrows only `'id'` / `'filename'` | `findFileInfo()` returns every file | Refuse `'filterArgs'`; force a per-user filter server-side with `{ force: true }` |
213
- | Treating the file id as unguessable | Enumerable inventory | Ids share a per-process random part and an incrementing counter — authorize every read, do not rely on the id |
214
- | Approving the caller's `filterArgs` as "narrowed" | Bypass via a different filter shape | `filterArgs` is client-controlled. Override the filter; never approve it |
229
+ | Mistake | Symptom | Fix |
230
+ | -------------------------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
231
+ | Overriding `getFileById` to add Swagger decorators | `file.downloadRoles` silently ignored | Don't override; document at class level |
232
+ | Overriding without re-declaring `@Get(...)` | Route 404s for everyone, no error logged | Don't override |
233
+ | `downloadRoles: []` | Warning logged, default applied | Use a non-empty array; `[]` would read as "no roles required" and open the route |
234
+ | Expecting `metadata` back from `getFileInfo()` | `undefined` | Use `getRawFileInfo()` inside `checkRights()` |
235
+ | `downloadRoles: ['member']` with multiTenancy | Works from code, fails from `<img>` | Both file classes carry `@SkipTenantCheck()`; roles resolve against `user.roles`. Use `checkRights()` for tenant rules |
236
+ | Signed-in user can upload via TUS but not download | 403 on their own file | `tus.roles` and `file.downloadRoles` are separate. Add an owner to the metadata and authorize per file |
237
+ | Rule narrows only `'id'` / `'filename'` | `findFileInfo()` returns every file | Refuse `'filterArgs'`; force a per-user filter server-side with `{ force: true }` |
238
+ | Treating the file id as unguessable | Enumerable inventory | Ids share a per-process random part and an incrementing counter — authorize every read, do not rely on the id |
239
+ | Approving the caller's `filterArgs` as "narrowed" | Bypass via a different filter shape | `filterArgs` is client-controlled. Override the filter; never approve it |
240
+ | `super(connection)` in `FileService` with `file.storage: 's3'` | Boot fails: "that storage is not available" | Forward `{ configService, s3Service }` — step 6 |
241
+ | S3 packages in `devDependencies` | Production image cannot load S3 | Move them to `dependencies` — step 6 |
242
+ | A quota aggregating only `fs.files` | Reads 0 on the S3 / filesystem driver | Aggregate `fs.files`, `filesystem-files` and `s3-files` — step 6 |