@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.
- package/.claude/rules/better-auth.md +32 -0
- package/.claude/rules/framework-compatibility.md +1 -0
- package/.claude/rules/testing.md +4 -4
- package/.claude/rules/versioning.md +6 -0
- package/CLAUDE.md +22 -5
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +2 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +4 -0
- package/dist/core/modules/better-auth/core-better-auth.constants.js +5 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js +68 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js +3 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.36.x-to-11.37.0.md +344 -0
- package/package.json +17 -4
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +24 -1
- package/src/core/modules/better-auth/README.md +64 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -0
- package/src/core/modules/better-auth/core-better-auth.constants.ts +26 -0
- package/src/core/modules/better-auth/core-better-auth.service.ts +179 -3
- 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 {
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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,
|