@lenne.tech/nest-server 11.36.5 → 11.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/.claude/rules/better-auth.md +32 -0
  2. package/.claude/rules/framework-compatibility.md +1 -0
  3. package/.claude/rules/testing.md +4 -4
  4. package/.claude/rules/versioning.md +6 -0
  5. package/CLAUDE.md +22 -5
  6. package/FRAMEWORK-API.md +1 -1
  7. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +2 -0
  8. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  9. package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +4 -0
  10. package/dist/core/modules/better-auth/core-better-auth.constants.js +5 -1
  11. package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
  12. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +2 -0
  13. package/dist/core/modules/better-auth/core-better-auth.service.js +68 -0
  14. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  15. package/dist/core/modules/system-setup/core-system-setup.service.js +3 -1
  16. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  17. package/dist/tsconfig.build.tsbuildinfo +1 -1
  18. package/migration-guides/11.36.x-to-11.37.0.md +344 -0
  19. package/package.json +17 -4
  20. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +24 -1
  21. package/src/core/modules/better-auth/README.md +64 -0
  22. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -0
  23. package/src/core/modules/better-auth/core-better-auth.constants.ts +26 -0
  24. package/src/core/modules/better-auth/core-better-auth.service.ts +179 -3
  25. package/src/core/modules/system-setup/core-system-setup.service.ts +33 -5
@@ -1,3 +1,4 @@
1
+ import { createLocalAccountIssuer } from '@better-auth/core/db';
1
2
  import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
2
3
  import { InjectConnection } from '@nestjs/mongoose';
3
4
  import { Request } from 'express';
@@ -14,7 +15,15 @@ import { BetterAuthInstance } from './better-auth.config';
14
15
  import { isJwtShaped } from './core-better-auth-token.helper';
15
16
  import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
16
17
  import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
17
- import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants';
18
+ import {
19
+ ACCOUNT_ISSUER_BACKFILL_ID,
20
+ BACKFILL_MARKER_COLLECTION,
21
+ BETTER_AUTH_CONFIG,
22
+ BETTER_AUTH_COOKIE_DOMAIN,
23
+ BETTER_AUTH_INSTANCE,
24
+ DEFAULT_ACCOUNT_ISSUER_FIELD,
25
+ DEFAULT_ACCOUNT_MODEL_NAME,
26
+ } from './core-better-auth.constants';
18
27
 
19
28
  /**
20
29
  * Result of a session validation
@@ -94,12 +103,31 @@ export class CoreBetterAuthService implements OnModuleInit {
94
103
  }
95
104
 
96
105
  /**
97
- * Ensure performance indices exist on the session, users, account and verification collections.
98
- * Indices are idempotent — calling createIndex on an existing index is a no-op.
106
+ * Boot steps for the better-auth integration.
107
+ *
108
+ * Two jobs live here and they are deliberately separate methods: ensuring indices is a
109
+ * PERFORMANCE concern that repeats forever and degrades gracefully (hence `warn`), while the
110
+ * issuer backfill is a CORRECTNESS concern that should happen once and locks users out when it
111
+ * does not (hence `error`). Keeping them in one method made those two severities look like one
112
+ * decision.
113
+ *
114
+ * Order matters: the backfill's update can use the `{ providerId: 1, userId: 1 }` index created
115
+ * below, so indices come first.
99
116
  */
100
117
  async onModuleInit(): Promise<void> {
101
118
  if (!this.isEnabled() || !this.connection?.db) return;
102
119
 
120
+ await this.ensureIndices();
121
+ await this.backfillAccountIssuers();
122
+ }
123
+
124
+ /**
125
+ * Ensure performance indices exist on the session, users, account and verification collections.
126
+ * Indices are idempotent — calling createIndex on an existing index is a no-op.
127
+ */
128
+ protected async ensureIndices(): Promise<void> {
129
+ if (!this.connection?.db) return;
130
+
103
131
  try {
104
132
  const db = this.connection.db;
105
133
 
@@ -130,6 +158,154 @@ export class CoreBetterAuthService implements OnModuleInit {
130
158
  }
131
159
  }
132
160
 
161
+ /**
162
+ * Backfills `account.issuer` on rows written before better-auth 1.7.
163
+ *
164
+ * From 1.7 an account is keyed by (issuer, accountId), and sign-in filters on
165
+ * it verbatim:
166
+ *
167
+ * account.providerId === 'credential' && account.issuer === credentialIssuer
168
+ *
169
+ * A row written by 1.6 has no `issuer` at all, so that comparison can never
170
+ * hold: every existing password user would be locked out by the upgrade
171
+ * itself, with a 401 and nothing in the logs to explain it. This closes that
172
+ * gap on the first boot after the upgrade.
173
+ *
174
+ * Idempotent — the filter only matches rows still missing the field, so the
175
+ * second start updates nothing.
176
+ *
177
+ * Scope is deliberately limited to credential accounts, where the issuer is
178
+ * derivable without guessing. OAuth accounts are NOT touched: their issuer
179
+ * depends on the provider (a real OIDC issuer, or the synthetic
180
+ * `local:oauth:<id>` fallback), and writing the wrong one would not fail
181
+ * loudly — it would create a second account on the next social sign-in. Those
182
+ * rows are reported instead, so the decision stays with the project.
183
+ *
184
+ * ONE THING NOT TO "TIDY UP": this package's own reads of the `account`
185
+ * collection filter on `providerId` alone — `syncPasswordChangeToIam`,
186
+ * `migrateAccountToIam` and `getMigrationStatus` in
187
+ * core-better-auth-user.mapper.ts. Adding `issuer` to those filters looks like
188
+ * consistency and is a regression. This backfill is deliberately NON-FATAL: if
189
+ * it fails, the server still boots and logs an error. Reads that do not require
190
+ * the field keep working on un-backfilled rows — `getMigrationStatus` would
191
+ * otherwise report zero migrated users, and `syncPasswordChangeToIam` would
192
+ * stop finding the account it is meant to update. Only better-auth's own
193
+ * sign-in path needs the issuer, and that one is better-auth's code, not ours.
194
+ */
195
+ protected async backfillAccountIssuers(): Promise<void> {
196
+ if (!this.isEnabled() || !this.connection?.db) return;
197
+
198
+ const db = this.connection.db;
199
+
200
+ // The consumer can rename both of these through `betterAuth.options.account`, which
201
+ // better-auth.config.ts spreads onto the resolved config verbatim. Hardcoding them turns this
202
+ // whole method into a silent no-op on such a project: nothing matches, `modifiedCount` is 0,
203
+ // neither log line fires, and every password user is locked out while the operator's upgrade
204
+ // checklist is satisfied by silence in both directions.
205
+ const accountOptions = (this.authInstance as any)?.options?.account;
206
+ const modelName: string = accountOptions?.modelName ?? DEFAULT_ACCOUNT_MODEL_NAME;
207
+ const issuerField: string = accountOptions?.fields?.issuer ?? DEFAULT_ACCOUNT_ISSUER_FIELD;
208
+
209
+ try {
210
+ const accounts = db.collection(modelName);
211
+
212
+ // A completion marker, checked before anything expensive. Without it both queries below run
213
+ // on EVERY boot of EVERY replica, forever — and neither is indexable (`$exists: false` cannot
214
+ // appear in a partialFilterExpression, `$ne` is not selective), so each is a full pass over
215
+ // the account collection, awaited before the app starts listening. With the marker the steady
216
+ // state is a single `_id` lookup. `system-setup-locks` in CoreSystemSetupService is the
217
+ // precedent for this kind of once-per-deployment boot state.
218
+ const markers = db.collection(BACKFILL_MARKER_COLLECTION);
219
+ if (await markers.findOne({ _id: ACCOUNT_ISSUER_BACKFILL_ID as any })) {
220
+ this.logger.debug(`account.${issuerField} backfill already completed — skipping.`);
221
+ return;
222
+ }
223
+
224
+ if (modelName !== DEFAULT_ACCOUNT_MODEL_NAME || issuerField !== DEFAULT_ACCOUNT_ISSUER_FIELD) {
225
+ this.logger.warn(
226
+ `Backfilling the account issuer against a customised schema (collection "${modelName}", ` +
227
+ `field "${issuerField}"). Verify these match what better-auth actually writes.`,
228
+ );
229
+ }
230
+
231
+ const pending = await accounts
232
+ .find({ [issuerField]: { $exists: false }, providerId: 'credential' }, { projection: { _id: 1 } })
233
+ .toArray();
234
+
235
+ let backfilled = 0;
236
+
237
+ if (pending.length) {
238
+ // `ordered: false` is load-bearing. better-auth declares a UNIQUE index on
239
+ // (issuer, accountId); a single pre-existing duplicate would abort an ordered write and
240
+ // leave every row after it untouched — those users stay locked out, with one log line and
241
+ // a green boot. Unordered, one bad row costs only itself.
242
+ const result = await accounts.bulkWrite(
243
+ pending.map((doc) => ({
244
+ updateOne: {
245
+ filter: { _id: doc._id },
246
+ update: { $set: { [issuerField]: createLocalAccountIssuer('credential') } },
247
+ },
248
+ })),
249
+ { ordered: false },
250
+ );
251
+
252
+ backfilled = result.modifiedCount;
253
+
254
+ if (backfilled > 0) {
255
+ this.logger.log(
256
+ `Backfilled account.${issuerField} on ${backfilled} credential account(s) for better-auth >= 1.7. ` +
257
+ 'Without it these users could not sign in.',
258
+ );
259
+ }
260
+
261
+ if (backfilled < pending.length) {
262
+ // Do NOT write the marker in this case — the next boot must retry the remainder.
263
+ this.logger.error(
264
+ `Backfilled ${backfilled}/${pending.length} credential accounts. ` +
265
+ `${pending.length - backfilled} user(s) CANNOT sign in until this is resolved manually. ` +
266
+ 'The most likely cause is a duplicate (issuer, accountId) pair from an earlier partial migration.',
267
+ );
268
+ return;
269
+ }
270
+ } else {
271
+ this.logger.debug(`No account.${issuerField} backfill needed — no credential rows predate better-auth 1.7.`);
272
+ }
273
+
274
+ // Only a complete run earns the marker.
275
+ await markers.updateOne(
276
+ { _id: ACCOUNT_ISSUER_BACKFILL_ID as any },
277
+ { $set: { backfilled, completedAt: new Date(), issuerField, modelName } },
278
+ { upsert: true },
279
+ );
280
+
281
+ // Existence probe rather than a count: `$ne` cannot use an index, so counting to the end is a
282
+ // guaranteed collection scan for a log line. "At least one" is all the message needs to say.
283
+ const staleOther = await accounts.findOne(
284
+ { [issuerField]: { $exists: false }, providerId: { $ne: 'credential' } },
285
+ { projection: { _id: 1 } },
286
+ );
287
+
288
+ if (staleOther) {
289
+ this.logger.warn(
290
+ `At least one non-credential account has no "${issuerField}". better-auth >= 1.7 will not match it by ` +
291
+ '(issuer, accountId) — and such a sign-in does NOT simply fail: it falls back to matching the user by ' +
292
+ 'the provider-asserted email and implicitly links a SECOND account row. If the provider-side email has ' +
293
+ 'changed, a NEW user is created instead and the existing account is orphaned, together with the provider ' +
294
+ 'tokens on the old row, which no unlink will ever remove. Set the issuer per provider BEFORE the first ' +
295
+ "social sign-in after this upgrade: the provider's real OIDC issuer, or the synthetic " +
296
+ '"local:oauth:<providerId>".',
297
+ );
298
+ }
299
+ } catch (error) {
300
+ // Correctness, not performance — say so loudly, but do not stop the boot:
301
+ // a server that starts with a warning beats one that will not start at all.
302
+ this.logger.error(
303
+ `Could not backfill the account issuer: ${error instanceof Error ? error.message : 'unknown'}. ` +
304
+ 'Existing password users may be unable to sign in until this succeeds.',
305
+ );
306
+ }
307
+ }
308
+
133
309
  /**
134
310
  * Checks if better-auth is enabled and initialized
135
311
  * Returns true only if:
@@ -1,3 +1,4 @@
1
+ import { createLocalAccountIssuer } from '@better-auth/core/db';
1
2
  import { ForbiddenException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
2
3
  import { InjectConnection } from '@nestjs/mongoose';
3
4
  import { isEmail } from 'class-validator';
@@ -271,11 +272,32 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
271
272
  const normalizedPassword = this.userMapper.normalizePasswordForIam(input.password);
272
273
 
273
274
  // Create user via internalAdapter (bypasses disableSignUp)
274
- const iamUser = await context.internalAdapter.createUser({
275
- email: input.email,
276
- emailVerified: true,
277
- name: input.name || input.email.split('@')[0],
278
- });
275
+ //
276
+ // better-auth >= 1.7 requires a provisioning source. Without it, `createUser` throws
277
+ // FORBIDDEN/validation_source_missing as soon as a project configures
278
+ // `betterAuth.options.user.validateUserInfo`. `admin` is the honest value — the system
279
+ // provisions the FIRST admin, nobody signs up — and it is what better-auth's own admin
280
+ // plugin passes.
281
+ //
282
+ // This does NOT bypass `validateUserInfo`. The hook still runs and receives
283
+ // `{ method: 'admin', action: 'create-user' }`, so a project gate can still reject the
284
+ // initial admin; branch on `method` there if the setup should be exempt.
285
+ //
286
+ // KNOWN LIMITATION, and the reason this is spelled out: when `validateUserInfo` is
287
+ // configured, better-auth additionally calls `getCurrentAuthContext()`, which throws outside
288
+ // an endpoint context. System setup runs from `OnApplicationBootstrap` / a plain controller,
289
+ // never inside better-auth's request pipeline, so the call fails with
290
+ // FORBIDDEN/validation_context_missing and no initial admin is created. Fail-closed, but
291
+ // opaque. A project using that hook must provision the first admin another way — see
292
+ // migration-guides/11.36.x-to-11.37.0.md §7.
293
+ const iamUser = await context.internalAdapter.createUser(
294
+ {
295
+ email: input.email,
296
+ emailVerified: true,
297
+ name: input.name || input.email.split('@')[0],
298
+ },
299
+ { method: 'admin' },
300
+ );
279
301
 
280
302
  if (!iamUser) {
281
303
  throw new Error('Failed to create IAM user');
@@ -283,8 +305,14 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
283
305
 
284
306
  // Hash password and create credential account
285
307
  const hashedPassword = await context.password.hash(normalizedPassword);
308
+ // better-auth >= 1.7 keys accounts by (issuer, accountId) and requires the
309
+ // issuer. Credential accounts have no real issuer, so better-auth derives a
310
+ // synthetic one — always via this helper, never a hand-written literal:
311
+ // the format is better-auth's to change, and a copy of it would silently
312
+ // stop matching the accounts better-auth writes itself.
286
313
  await context.internalAdapter.linkAccount({
287
314
  accountId: iamUser.id,
315
+ issuer: createLocalAccountIssuer('credential'),
288
316
  password: hashedPassword,
289
317
  providerId: 'credential',
290
318
  userId: iamUser.id,