@lenne.tech/nest-server 11.13.2 → 11.13.3

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 (21) hide show
  1. package/dist/config.env.js +230 -1
  2. package/dist/config.env.js.map +1 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  4. package/dist/core/modules/better-auth/better-auth.config.js +34 -0
  5. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  6. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +1 -0
  7. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +7 -0
  8. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  9. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -0
  10. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +27 -0
  11. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  12. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +14 -16
  13. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  14. package/dist/tsconfig.build.tsbuildinfo +1 -1
  15. package/package.json +4 -4
  16. package/src/config.env.ts +259 -2
  17. package/src/core/common/interfaces/server-options.interface.ts +7 -0
  18. package/src/core/modules/better-auth/better-auth.config.ts +51 -0
  19. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +16 -0
  20. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +40 -0
  21. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +24 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.13.2",
3
+ "version": "11.13.3",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -54,10 +54,10 @@
54
54
  "prepack": "npm run prestart:prod",
55
55
  "prepublishOnly": "npm run lint && npm run test:ci",
56
56
  "preversion": "npm run lint",
57
- "vitest": "NODE_ENV=local vitest run --config vitest-e2e.config.ts",
57
+ "vitest": "NODE_ENV=e2e vitest run --config vitest-e2e.config.ts",
58
58
  "vitest:ci": "NODE_ENV=ci vitest run --config vitest-e2e.config.ts",
59
- "vitest:cov": "NODE_ENV=local vitest run --coverage --config vitest-e2e.config.ts",
60
- "vitest:watch": "NODE_ENV=local vitest --config vitest-e2e.config.ts",
59
+ "vitest:cov": "NODE_ENV=e2e vitest run --coverage --config vitest-e2e.config.ts",
60
+ "vitest:watch": "NODE_ENV=e2e vitest --config vitest-e2e.config.ts",
61
61
  "vitest:unit": "vitest run --config vitest.config.ts",
62
62
  "test:unit:watch": "vitest --config vitest.config.ts",
63
63
  "test:types": "tsc --noEmit --skipLibCheck -p tests/types/tsconfig.json",
package/src/config.env.ts CHANGED
@@ -11,6 +11,145 @@ import { IServerOptions } from './core/common/interfaces/server-options.interfac
11
11
  */
12
12
  dotenv.config();
13
13
  const config: { [env: string]: IServerOptions } = {
14
+ // ===========================================================================
15
+ // CI environment
16
+ // ===========================================================================
17
+ ci: {
18
+ auth: {
19
+ legacyEndpoints: { enabled: true },
20
+ },
21
+ automaticObjectIdFiltering: true,
22
+ betterAuth: {
23
+ // Email verification disabled for test environment (no real mailbox available)
24
+ emailVerification: false,
25
+ // JWT enabled by default (zero-config)
26
+ jwt: { enabled: true, expiresIn: '15m' },
27
+ // Passkey auto-activated when URLs can be resolved (env: 'local' → localhost defaults)
28
+ passkey: { enabled: true, origin: 'http://localhost:3001', rpId: 'localhost', rpName: 'Nest Server Local' },
29
+ rateLimit: { enabled: true, max: 100, windowSeconds: 60 },
30
+ secret: 'BETTER_AUTH_SECRET_LOCAL_32_CHARS_M',
31
+ // Social providers disabled in local environment (no credentials)
32
+ socialProviders: {
33
+ apple: { clientId: '', clientSecret: '', enabled: false },
34
+ github: { clientId: '', clientSecret: '', enabled: false },
35
+ google: { clientId: '', clientSecret: '', enabled: false },
36
+ },
37
+ // Trusted origins for Passkey (localhost defaults)
38
+ trustedOrigins: ['http://localhost:3000', 'http://localhost:3001'],
39
+ // 2FA enabled for local testing
40
+ twoFactor: { appName: 'Nest Server Local', enabled: true },
41
+ },
42
+ compression: true,
43
+ cookies: false,
44
+ cronJobs: {
45
+ sayHello: {
46
+ cronTime: CronExpression.EVERY_10_SECONDS,
47
+ disabled: false,
48
+ runOnInit: false,
49
+ runParallel: 1,
50
+ throwException: false,
51
+ timeZone: 'Europe/Berlin',
52
+ },
53
+ },
54
+ email: {
55
+ defaultSender: {
56
+ email: 'oren.satterfield@ethereal.email',
57
+ name: 'Nest Server Local',
58
+ },
59
+ mailjet: {
60
+ api_key_private: 'MAILJET_API_KEY_PRIVATE',
61
+ api_key_public: 'MAILJET_API_KEY_PUBLIC',
62
+ },
63
+ passwordResetLink: 'http://localhost:4200/user/password-reset',
64
+ smtp: {
65
+ auth: {
66
+ pass: 'K4DvD8U31VKseT7vQC',
67
+ user: 'oren.satterfield@ethereal.email',
68
+ },
69
+ host: 'mailhog.lenne.tech',
70
+ port: 1025,
71
+ secure: false,
72
+ },
73
+ verificationLink: 'http://localhost:4200/user/verification',
74
+ },
75
+ env: 'ci',
76
+ // Disable auto-registration to allow Server ErrorCodeModule with SRV_* codes
77
+ errorCode: {
78
+ autoRegister: false,
79
+ },
80
+ execAfterInit: 'npm run docs:bootstrap',
81
+ filter: {
82
+ maxLimit: null,
83
+ },
84
+ graphQl: {
85
+ driver: {
86
+ introspection: true,
87
+ },
88
+ maxComplexity: 1000,
89
+ },
90
+ healthCheck: {
91
+ configs: {
92
+ database: {
93
+ enabled: true,
94
+ },
95
+ },
96
+ enabled: true,
97
+ },
98
+ hostname: '127.0.0.1',
99
+ ignoreSelectionsForPopulate: true,
100
+ jwt: {
101
+ // Each secret should be unique and not reused in other environments,
102
+ // also the JWT secret should be different from the Refresh secret!
103
+ // crypto.randomBytes(512).toString('base64') (see https://nodejs.org/api/crypto.html#crypto)
104
+ refresh: {
105
+ renewal: true,
106
+ // Each secret should be unique and not reused in other environments,
107
+ // also the JWT secret should be different from the Refresh secret!
108
+ // crypto.randomBytes(512).toString('base64') (see https://nodejs.org/api/crypto.html#crypto)
109
+ secret: 'SECRET_OR_PRIVATE_KEY_LOCAL_REFRESH',
110
+ signInOptions: {
111
+ expiresIn: '7d',
112
+ },
113
+ },
114
+ sameTokenIdPeriod: 2000,
115
+ secret: 'SECRET_OR_PRIVATE_KEY_LOCAL',
116
+ signInOptions: {
117
+ expiresIn: '15m',
118
+ },
119
+ },
120
+ loadLocalConfig: true,
121
+ logExceptions: true,
122
+ mongoose: {
123
+ collation: {
124
+ locale: 'de',
125
+ },
126
+ modelDocumentation: true,
127
+ uri: 'mongodb://127.0.0.1/nest-server-ci',
128
+ },
129
+ port: 3000,
130
+ security: {
131
+ checkResponseInterceptor: {
132
+ checkObjectItself: false,
133
+ debug: false,
134
+ ignoreUndefined: true,
135
+ mergeRoles: true,
136
+ removeUndefinedFromResultArray: true,
137
+ throwError: false,
138
+ },
139
+ checkSecurityInterceptor: true,
140
+ mapAndValidatePipe: true,
141
+ },
142
+ sha256: true,
143
+ staticAssets: {
144
+ options: { prefix: '' },
145
+ path: join(__dirname, '..', 'public'),
146
+ },
147
+ templates: {
148
+ engine: 'ejs',
149
+ path: join(__dirname, 'templates'),
150
+ },
151
+ },
152
+
14
153
  // ===========================================================================
15
154
  // Development environment
16
155
  // ===========================================================================
@@ -117,9 +256,9 @@ const config: { [env: string]: IServerOptions } = {
117
256
  },
118
257
 
119
258
  // ===========================================================================
120
- // Local environment (env: 'local' → auto URLs + Passkey)
259
+ // E2E environment
121
260
  // ===========================================================================
122
- local: {
261
+ e2e: {
123
262
  auth: {
124
263
  legacyEndpoints: { enabled: true },
125
264
  },
@@ -177,6 +316,124 @@ const config: { [env: string]: IServerOptions } = {
177
316
  },
178
317
  verificationLink: 'http://localhost:4200/user/verification',
179
318
  },
319
+ env: 'e2e',
320
+ // Disable auto-registration to allow Server ErrorCodeModule with SRV_* codes
321
+ errorCode: {
322
+ autoRegister: false,
323
+ },
324
+ execAfterInit: 'npm run docs:bootstrap',
325
+ filter: {
326
+ maxLimit: null,
327
+ },
328
+ graphQl: {
329
+ driver: {
330
+ introspection: true,
331
+ },
332
+ maxComplexity: 1000,
333
+ },
334
+ healthCheck: {
335
+ configs: {
336
+ database: {
337
+ enabled: true,
338
+ },
339
+ },
340
+ enabled: true,
341
+ },
342
+ hostname: '127.0.0.1',
343
+ ignoreSelectionsForPopulate: true,
344
+ jwt: {
345
+ // Each secret should be unique and not reused in other environments,
346
+ // also the JWT secret should be different from the Refresh secret!
347
+ // crypto.randomBytes(512).toString('base64') (see https://nodejs.org/api/crypto.html#crypto)
348
+ refresh: {
349
+ renewal: true,
350
+ // Each secret should be unique and not reused in other environments,
351
+ // also the JWT secret should be different from the Refresh secret!
352
+ // crypto.randomBytes(512).toString('base64') (see https://nodejs.org/api/crypto.html#crypto)
353
+ secret: 'SECRET_OR_PRIVATE_KEY_LOCAL_REFRESH',
354
+ signInOptions: {
355
+ expiresIn: '7d',
356
+ },
357
+ },
358
+ sameTokenIdPeriod: 2000,
359
+ secret: 'SECRET_OR_PRIVATE_KEY_LOCAL',
360
+ signInOptions: {
361
+ expiresIn: '15m',
362
+ },
363
+ },
364
+ loadLocalConfig: true,
365
+ logExceptions: true,
366
+ mongoose: {
367
+ collation: {
368
+ locale: 'de',
369
+ },
370
+ modelDocumentation: true,
371
+ uri: 'mongodb://127.0.0.1/nest-server-e2e',
372
+ },
373
+ port: 3000,
374
+ security: {
375
+ checkResponseInterceptor: {
376
+ checkObjectItself: false,
377
+ debug: false,
378
+ ignoreUndefined: true,
379
+ mergeRoles: true,
380
+ removeUndefinedFromResultArray: true,
381
+ throwError: false,
382
+ },
383
+ checkSecurityInterceptor: true,
384
+ mapAndValidatePipe: true,
385
+ },
386
+ sha256: true,
387
+ staticAssets: {
388
+ options: { prefix: '' },
389
+ path: join(__dirname, '..', 'public'),
390
+ },
391
+ templates: {
392
+ engine: 'ejs',
393
+ path: join(__dirname, 'templates'),
394
+ },
395
+ },
396
+
397
+ // ===========================================================================
398
+ // Local environment (env: 'local' → auto URLs + Passkey)
399
+ // ===========================================================================
400
+ local: {
401
+ auth: {
402
+ legacyEndpoints: { enabled: true },
403
+ },
404
+ automaticObjectIdFiltering: true,
405
+ compression: true,
406
+ cronJobs: {
407
+ sayHello: {
408
+ cronTime: CronExpression.EVERY_10_SECONDS,
409
+ disabled: false,
410
+ runOnInit: false,
411
+ runParallel: 1,
412
+ throwException: false,
413
+ timeZone: 'Europe/Berlin',
414
+ },
415
+ },
416
+ email: {
417
+ defaultSender: {
418
+ email: 'oren.satterfield@ethereal.email',
419
+ name: 'Nest Server Local',
420
+ },
421
+ mailjet: {
422
+ api_key_private: 'MAILJET_API_KEY_PRIVATE',
423
+ api_key_public: 'MAILJET_API_KEY_PUBLIC',
424
+ },
425
+ passwordResetLink: 'http://localhost:4200/user/password-reset',
426
+ smtp: {
427
+ auth: {
428
+ pass: 'K4DvD8U31VKseT7vQC',
429
+ user: 'oren.satterfield@ethereal.email',
430
+ },
431
+ host: 'mailhog.lenne.tech',
432
+ port: 1025,
433
+ secure: false,
434
+ },
435
+ verificationLink: 'http://localhost:4200/user/verification',
436
+ },
180
437
  env: 'local',
181
438
  // Disable auto-registration to allow Server ErrorCodeModule with SRV_* codes
182
439
  errorCode: {
@@ -524,6 +524,13 @@ export interface IBetterAuthRateLimit {
524
524
  */
525
525
  max?: number;
526
526
 
527
+ /**
528
+ * Maximum number of entries in the in-memory rate limit store.
529
+ * When exceeded, the oldest entries are evicted to prevent unbounded memory growth.
530
+ * @default 10000
531
+ */
532
+ maxEntries?: number;
533
+
527
534
  /**
528
535
  * Custom message when rate limit is exceeded
529
536
  * default: 'Too many requests, please try again later.'
@@ -14,6 +14,21 @@ import { IBetterAuth } from '../../common/interfaces/server-options.interface';
14
14
  */
15
15
  export type BetterAuthInstance = ReturnType<typeof betterAuth>;
16
16
 
17
+ // ---------------------------------------------------------------------------
18
+ // Performance-optimized password hashing using Node.js native crypto.scrypt
19
+ //
20
+ // Better-Auth's default uses @noble/hashes scrypt which runs on the main
21
+ // event loop. Under concurrent load this blocks all requests while hashing.
22
+ // Node.js crypto.scrypt() offloads the work to the libuv thread pool,
23
+ // allowing the event loop to remain responsive.
24
+ //
25
+ // Parameters match Better-Auth's defaults exactly:
26
+ // N=16384, r=16, p=1, dkLen=64, 16-byte salt
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const SCRYPT_PARAMS = { maxmem: 128 * 16384 * 16 * 2, N: 16384, p: 1, r: 16 };
30
+ const SCRYPT_KEY_LENGTH = 64;
31
+
17
32
  /**
18
33
  * Generates a cryptographically secure random secret.
19
34
  * Used as fallback when no BETTER_AUTH_SECRET is configured.
@@ -27,6 +42,38 @@ function generateSecureSecret(): string {
27
42
  return crypto.randomBytes(32).toString('base64');
28
43
  }
29
44
 
45
+ /**
46
+ * Hash a password using Node.js native crypto.scrypt (libuv thread pool).
47
+ * Output format matches Better-Auth: "salt:hash" (both hex encoded).
48
+ */
49
+ async function nativeScryptHash(password: string): Promise<string> {
50
+ const salt = crypto.randomBytes(16).toString('hex');
51
+ const normalized = password.normalize('NFKC');
52
+ const key = await new Promise<Buffer>((resolve, reject) => {
53
+ crypto.scrypt(normalized, salt, SCRYPT_KEY_LENGTH, SCRYPT_PARAMS, (err, derivedKey) => {
54
+ if (err) reject(err);
55
+ else resolve(derivedKey);
56
+ });
57
+ });
58
+ return `${salt}:${key.toString('hex')}`;
59
+ }
60
+
61
+ /**
62
+ * Verify a password against a Better-Auth scrypt hash using Node.js native crypto.scrypt.
63
+ */
64
+ async function nativeScryptVerify(data: { hash: string; password: string }): Promise<boolean> {
65
+ const [salt, storedKey] = data.hash.split(':');
66
+ if (!salt || !storedKey) return false;
67
+ const normalized = data.password.normalize('NFKC');
68
+ const key = await new Promise<Buffer>((resolve, reject) => {
69
+ crypto.scrypt(normalized, salt, SCRYPT_KEY_LENGTH, SCRYPT_PARAMS, (err, derivedKey) => {
70
+ if (err) reject(err);
71
+ else resolve(derivedKey);
72
+ });
73
+ });
74
+ return crypto.timingSafeEqual(key, Buffer.from(storedKey, 'hex'));
75
+ }
76
+
30
77
  /**
31
78
  * Cached auto-generated secret for the current server instance.
32
79
  * Generated once at a module load to ensure consistency within a single run.
@@ -268,6 +315,10 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
268
315
  // Can be disabled by setting config.emailAndPassword.enabled = false
269
316
  emailAndPassword: {
270
317
  enabled: config.emailAndPassword?.enabled !== false,
318
+ password: {
319
+ hash: nativeScryptHash,
320
+ verify: nativeScryptVerify,
321
+ },
271
322
  },
272
323
  plugins,
273
324
  secret: validation.resolvedSecret || config.secret,
@@ -409,11 +409,27 @@ export class CoreBetterAuthEmailVerificationService {
409
409
  return elapsed < cooldown;
410
410
  }
411
411
 
412
+ /**
413
+ * Maximum entries in the lastSendTimes map to prevent unbounded growth.
414
+ * At 10,000 entries with email strings as keys, this uses ~1-2 MB max.
415
+ */
416
+ private static readonly MAX_SEND_TIMES_ENTRIES = 10000;
417
+
412
418
  /**
413
419
  * Track that a verification email was sent to this address
414
420
  */
415
421
  protected trackSend(email: string): void {
416
422
  const key = email.toLowerCase();
423
+
424
+ // Evict oldest entry if map is at capacity (before adding new one)
425
+ if (!this.lastSendTimes.has(key) && this.lastSendTimes.size >= CoreBetterAuthEmailVerificationService.MAX_SEND_TIMES_ENTRIES) {
426
+ // Map preserves insertion order - first key is the oldest
427
+ const oldestKey = this.lastSendTimes.keys().next().value;
428
+ if (oldestKey) {
429
+ this.lastSendTimes.delete(oldestKey);
430
+ }
431
+ }
432
+
417
433
  this.lastSendTimes.set(key, Date.now());
418
434
 
419
435
  // Schedule cleanup to prevent memory leak
@@ -46,6 +46,7 @@ interface RateLimitEntry {
46
46
  const DEFAULT_CONFIG: Required<IBetterAuthRateLimit> = {
47
47
  enabled: false,
48
48
  max: 10,
49
+ maxEntries: 10000,
49
50
  message: 'Too many requests, please try again later.',
50
51
  skipEndpoints: ['/session', '/callback'],
51
52
  strictEndpoints: ['/sign-in', '/sign-up', '/forgot-password', '/reset-password'],
@@ -143,6 +144,11 @@ export class CoreBetterAuthRateLimiter {
143
144
  let entry = this.store.get(key);
144
145
 
145
146
  if (!entry || now >= entry.resetTime) {
147
+ // Evict oldest entries if store exceeds maxEntries
148
+ if (!entry && this.store.size >= this.config.maxEntries) {
149
+ this.evictOldest();
150
+ }
151
+
146
152
  // Create new entry or reset expired one
147
153
  entry = {
148
154
  count: 1,
@@ -234,6 +240,40 @@ export class CoreBetterAuthRateLimiter {
234
240
  }
235
241
  }
236
242
 
243
+ /**
244
+ * Evict the oldest entries when the store exceeds maxEntries.
245
+ * First removes all expired entries, then removes entries closest to expiry
246
+ * until the store is at 90% capacity.
247
+ */
248
+ private evictOldest(): void {
249
+ const now = Date.now();
250
+ let evicted = 0;
251
+
252
+ // First pass: remove all expired entries
253
+ for (const [key, entry] of this.store.entries()) {
254
+ if (now >= entry.resetTime) {
255
+ this.store.delete(key);
256
+ evicted++;
257
+ }
258
+ }
259
+
260
+ // If still over limit, remove entries with earliest resetTime (oldest)
261
+ if (this.store.size >= this.config.maxEntries) {
262
+ const targetSize = Math.floor(this.config.maxEntries * 0.9);
263
+ const entries = [...this.store.entries()].sort((a, b) => a[1].resetTime - b[1].resetTime);
264
+
265
+ for (const [key] of entries) {
266
+ if (this.store.size <= targetSize) break;
267
+ this.store.delete(key);
268
+ evicted++;
269
+ }
270
+ }
271
+
272
+ if (evicted > 0) {
273
+ this.logger.warn(`Evicted ${evicted} rate limit entries (store was at capacity: ${this.config.maxEntries})`);
274
+ }
275
+ }
276
+
237
277
  /**
238
278
  * Determine if an endpoint should skip rate limiting
239
279
  */
@@ -370,27 +370,38 @@ export class CoreBetterAuthUserMapper {
370
370
  return false;
371
371
  }
372
372
 
373
+ // Better-Auth stores account.userId as ObjectId that references users._id
374
+ const userMongoId = legacyUser._id as ObjectId;
375
+
376
+ // FAST PATH: Check if credential account already exists BEFORE expensive bcrypt
377
+ // For already-migrated users this avoids ~130ms of bcrypt.compare() per sign-in
378
+ const existingAccount = await accountsCollection.findOne({
379
+ providerId: 'credential',
380
+ userId: userMongoId,
381
+ });
382
+
383
+ if (existingAccount) {
384
+ return true;
385
+ }
386
+
387
+ // No password provided - cannot verify, cannot migrate
388
+ if (!plainPassword) {
389
+ return false;
390
+ }
391
+
373
392
  // IMPORTANT: Verify the provided password matches the legacy hash
374
393
  // This prevents migration with a wrong password
375
394
  // Legacy Auth uses two formats for backwards compatibility:
376
395
  // 1. bcrypt(password) - direct hash
377
396
  // 2. bcrypt(sha256(password)) - sha256 then bcrypt
378
- if (plainPassword) {
379
- const directMatch = await bcrypt.compare(plainPassword, legacyUser.password);
380
- const sha256Match = await bcrypt.compare(sha256(plainPassword), legacyUser.password);
381
- if (!directMatch && !sha256Match) {
382
- // Security: Wrong password provided for migration - reject
383
- this.logger.warn(`Migration password verification failed for ${maskEmail(userEmail)}`);
384
- return false;
385
- }
386
- } else {
387
- // No password provided - cannot verify, cannot migrate
397
+ const directMatch = await bcrypt.compare(plainPassword, legacyUser.password);
398
+ const sha256Match = !directMatch ? await bcrypt.compare(sha256(plainPassword), legacyUser.password) : false;
399
+ if (!directMatch && !sha256Match) {
400
+ // Security: Wrong password provided for migration - reject
401
+ this.logger.warn(`Migration password verification failed for ${maskEmail(userEmail)}`);
388
402
  return false;
389
403
  }
390
404
 
391
- // Better-Auth stores account.userId as ObjectId that references users._id
392
- // The id field is a secondary string identifier used in API responses
393
- const userMongoId = legacyUser._id as ObjectId;
394
405
  const userIdHex = userMongoId.toHexString();
395
406
 
396
407
  // Update user with Better-Auth fields if not already present
@@ -413,17 +424,6 @@ export class CoreBetterAuthUserMapper {
413
424
  );
414
425
  }
415
426
 
416
- // Check if credential account already exists
417
- // Better-Auth stores userId as ObjectId referencing users._id
418
- const existingAccount = await accountsCollection.findOne({
419
- providerId: 'credential',
420
- userId: userMongoId,
421
- });
422
-
423
- if (existingAccount) {
424
- return true;
425
- }
426
-
427
427
  // Create the credential account with Better-Auth compatible scrypt hash
428
428
  const passwordHash = await this.hashPasswordForBetterAuth(plainPassword);
429
429