@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
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# Migration Guide: 11.36.x → 11.37.0
|
|
2
|
+
|
|
3
|
+
> **This MINOR carries breaking changes.** That is not a slip. In this package the MAJOR digit
|
|
4
|
+
> tracks the NestJS major it targets — 11.x is NestJS 11 — so it moves only when NestJS does.
|
|
5
|
+
> Breaking changes of our own ship in a minor, which is why every one of them is spelled out
|
|
6
|
+
> below. Read §1 before you upgrade — and do not wait for `pnpm install` to tell you: on pnpm's
|
|
7
|
+
> defaults it will quietly install the new peers for you, at a version you never pinned.
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
| Category | Details |
|
|
12
|
+
|----------|---------|
|
|
13
|
+
| **Breaking Changes** | **`better-auth` is no longer a dependency of this package — it is a peer dependency you must declare yourself** (§1). It moves to **1.7.x** and cannot be held back (§2). `@better-auth/core` joins the contract as a third required peer (§1) |
|
|
14
|
+
| **New Features** | `account.issuer` is backfilled automatically on the first boot after the upgrade, so existing password users keep signing in (§3) |
|
|
15
|
+
| **Bugfixes** | Legacy→IAM account migration wrote an account better-auth 1.7 could not find, which turned the very sign-in that triggered the migration into a 401 (§3) |
|
|
16
|
+
| **Migration Effort** | **Every project: one package.json change (§1) and one deployment note (§3).** Add ~15 minutes if you use social or SSO logins (§4), and read §5 before running two nest-server versions against one database |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Quick Migration
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add better-auth@1.7.1 @better-auth/passkey@1.7.1 @better-auth/core@1.7.1
|
|
24
|
+
pnpm add @lenne.tech/nest-server@11.37.0
|
|
25
|
+
pnpm run build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then read §3 — it concerns your **data**, not your code, and no build will tell you about it.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 1. `better-auth` is now a peer dependency
|
|
33
|
+
|
|
34
|
+
**Were you affected?** Every project — and the failure mode depends on your package manager,
|
|
35
|
+
which is why you cannot rely on the install to flag it.
|
|
36
|
+
|
|
37
|
+
| Setting | What `pnpm install` does |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `autoInstallPeers: true` (**pnpm's default**) | installs the peers **silently**, resolving anywhere inside `>=1.7.1 <1.8.0` |
|
|
40
|
+
| `autoInstallPeers: false` | fails with a missing-peer error |
|
|
41
|
+
| `strictPeerDependencies: true` | fails on a version outside the range |
|
|
42
|
+
|
|
43
|
+
Most projects are on the default, so a green install proves nothing: you end up with an
|
|
44
|
+
unpinned better-auth that can drift on the next install — exactly the split this section exists
|
|
45
|
+
to prevent. Declare all three yourself, pinned, and verify with:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pnpm why better-auth # must show YOUR declaration, not just a peer-resolved copy
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### What changed
|
|
52
|
+
|
|
53
|
+
Up to 11.36.x, `package.json` carried better-auth as an exactly pinned **dependency**:
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"better-auth": "1.6.26",
|
|
58
|
+
"@better-auth/passkey": "1.6.26"
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
From 11.37.0 they are **peer dependencies**, and `@better-auth/core` joins them:
|
|
63
|
+
|
|
64
|
+
```jsonc
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"better-auth": ">=1.7.0 <1.8.0",
|
|
67
|
+
"@better-auth/passkey": ">=1.7.0 <1.8.0",
|
|
68
|
+
"@better-auth/core": ">=1.7.0 <1.8.0"
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
All three are **non-optional**. That is deliberate and differs from the five peers this package
|
|
73
|
+
already had (`ioredis`, `bullmq`, `@aws-sdk/*`, `@tus/s3-store`), which are `optional: true` and
|
|
74
|
+
lazily imported. better-auth is imported statically at the top of
|
|
75
|
+
`src/core/modules/better-auth/better-auth.config.ts` — if it is missing, the server does not
|
|
76
|
+
degrade, it fails to boot.
|
|
77
|
+
|
|
78
|
+
Add them to your own `package.json`, exactly pinned:
|
|
79
|
+
|
|
80
|
+
```jsonc
|
|
81
|
+
"dependencies": {
|
|
82
|
+
"better-auth": "1.7.1",
|
|
83
|
+
"@better-auth/passkey": "1.7.1",
|
|
84
|
+
"@better-auth/core": "1.7.1"
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Why
|
|
89
|
+
|
|
90
|
+
An exact pin inside a library makes the library the owner of that version. A project could not
|
|
91
|
+
raise better-auth without overriding this package, and in a fullstack setup that is fatal: the
|
|
92
|
+
frontend (`@lenne.tech/nuxt-extensions`) has always declared better-auth as a **peer**, so the app
|
|
93
|
+
could move to 1.7 while the api was pinned to 1.6.26 — a client and a server speaking different
|
|
94
|
+
versions of the same protocol. See §2 for what that actually broke.
|
|
95
|
+
|
|
96
|
+
The version now belongs to the project, which is the only place that can keep client and server in
|
|
97
|
+
step. The narrow range is not overcaution: better-auth breaks in **minor** releases (1.7 removed
|
|
98
|
+
the `./plugins/oidc-provider` and `./plugins/mcp/client` subpath exports and changed the 2FA
|
|
99
|
+
response shape), so `^1.7.0` would invite the same split at 1.8.
|
|
100
|
+
|
|
101
|
+
### Vendor mode
|
|
102
|
+
|
|
103
|
+
Vendor-mode projects already carry better-auth in their own `package.json` — that is what vendoring
|
|
104
|
+
means — so the *mechanism* does not change. The work does:
|
|
105
|
+
|
|
106
|
+
- bump `better-auth` and `@better-auth/passkey` from `1.6.26` to `1.7.1`, and
|
|
107
|
+
- **add `@better-auth/core`**, which has never been in your manifest.
|
|
108
|
+
|
|
109
|
+
`/lt-dev:backend:update-nest-server-core` rewrites `src/core/**` and does **not** touch
|
|
110
|
+
`package.json` — these three lines are yours to change. For a NEW vendor-mode project, the `lt`
|
|
111
|
+
CLI must be new enough to carry all three in `src/config/vendor-runtime-deps.json`; an older CLI
|
|
112
|
+
vendors a core that imports packages the generated manifest never declares.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 2. better-auth moves to 1.7.x
|
|
117
|
+
|
|
118
|
+
**Were you affected?** Every project, and **fullstack projects most of all**.
|
|
119
|
+
|
|
120
|
+
`>=1.7.0 <1.8.0` has no 1.6 in it. This is not a version you can defer.
|
|
121
|
+
|
|
122
|
+
### What this fixes
|
|
123
|
+
|
|
124
|
+
better-auth 1.7 gives `twoFactor.enable` a discriminated result carrying
|
|
125
|
+
`method: "otp" | "totp"`. A 1.7 client narrows on that discriminant before reading `totpURI` and
|
|
126
|
+
`backupCodes`. A 1.6.26 server never sends the field, so the narrowing fails and **every 2FA
|
|
127
|
+
activation is rejected** — with a generic error, against a server that logs nothing unusual.
|
|
128
|
+
|
|
129
|
+
If your app is on `@lenne.tech/nuxt-extensions` with better-auth 1.7 and your api was still on
|
|
130
|
+
11.36.x, that is what your users were hitting.
|
|
131
|
+
|
|
132
|
+
### What it costs
|
|
133
|
+
|
|
134
|
+
Two API changes in better-auth 1.7 reach code you may have written yourself:
|
|
135
|
+
|
|
136
|
+
- `context.internalAdapter.createUser(user)` now takes a second argument:
|
|
137
|
+
`createUser(user, { method })`, where `method` is the provisioning source
|
|
138
|
+
(`'email-password' | 'admin' | 'oauth' | …`).
|
|
139
|
+
- `context.internalAdapter.linkAccount()` requires `issuer` — see §3.
|
|
140
|
+
|
|
141
|
+
Both are compile errors, so `pnpm run build` finds them. Anything writing to the `account`
|
|
142
|
+
collection **directly** will not be caught by the compiler — see §3.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 3. `account.issuer`: the part no build will tell you about
|
|
147
|
+
|
|
148
|
+
**Were you affected?** Every project with existing users. This is a **data** change.
|
|
149
|
+
|
|
150
|
+
### What changed
|
|
151
|
+
|
|
152
|
+
Up to 1.6 a credential account was identified by `providerId` + `userId`. From 1.7 an account is
|
|
153
|
+
keyed by **(issuer, accountId)**, and the sign-in route filters on it verbatim:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
account.providerId === 'credential' && account.issuer === credentialIssuer && account.accountId === user.id
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Every account row written by better-auth 1.6 has **no `issuer` field at all**. `undefined` never
|
|
160
|
+
equals `'local:credential'`, so after the upgrade better-auth cannot find those accounts: **every
|
|
161
|
+
existing password user is locked out**, with a 401 that says nothing about why.
|
|
162
|
+
|
|
163
|
+
### What this package does about it
|
|
164
|
+
|
|
165
|
+
On the first boot after the upgrade, `CoreBetterAuthService.onModuleInit()` backfills the field:
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
db.collection('account').updateMany(
|
|
169
|
+
{ issuer: { $exists: false }, providerId: 'credential' },
|
|
170
|
+
{ $set: { issuer: createLocalAccountIssuer('credential') } },
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
It is idempotent — the filter only matches rows still missing the field — and it logs how many rows
|
|
175
|
+
it touched. If it fails, it logs an **error** and the server still starts, because a server that
|
|
176
|
+
boots with a loud error beats one that will not boot at all. Check your logs for
|
|
177
|
+
`Could not backfill account.issuer` after the first deployment.
|
|
178
|
+
|
|
179
|
+
Credential accounts only. See §4 for why.
|
|
180
|
+
|
|
181
|
+
### If you write to the `account` collection yourself
|
|
182
|
+
|
|
183
|
+
Any code that inserts an account row directly through the Mongo driver bypasses better-auth's types
|
|
184
|
+
and therefore the compiler. It must now set the issuer:
|
|
185
|
+
|
|
186
|
+
```diff
|
|
187
|
+
await accountsCollection.insertOne({
|
|
188
|
+
accountId: userIdHex,
|
|
189
|
+
+ issuer: createLocalAccountIssuer('credential'),
|
|
190
|
+
password: passwordHash,
|
|
191
|
+
providerId: 'credential',
|
|
192
|
+
userId: userMongoId,
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { createLocalAccountIssuer } from '@better-auth/core/db';
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Derive it, never hand-write `'local:credential'`.** The format is better-auth's to change, and a
|
|
201
|
+
literal copy would keep compiling while silently no longer matching the accounts better-auth writes
|
|
202
|
+
itself.
|
|
203
|
+
|
|
204
|
+
This bug was live in this package: `CoreBetterAuthUserMapper.migrateAccountToIam()` wrote a
|
|
205
|
+
credential account without the issuer, so a legacy user migrating to IAM got a 401 on the very
|
|
206
|
+
sign-in that triggered the migration. It is fixed in 11.37.0, and 12 e2e tests that were red on
|
|
207
|
+
better-auth 1.7 pin it.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 4. Social and SSO logins are NOT backfilled
|
|
212
|
+
|
|
213
|
+
**Were you affected?** Only projects using OAuth or SSO providers — but for those this is a
|
|
214
|
+
**pre-upgrade** step, not a post-upgrade cleanup. See the numbered consequences below.
|
|
215
|
+
|
|
216
|
+
The backfill deliberately stops at credential accounts. `local:credential` is a pure function of
|
|
217
|
+
the provider id and therefore derivable. An OAuth account's issuer is not: it is either the
|
|
218
|
+
provider's **real OIDC issuer** or the synthetic `local:oauth:<providerId>` fallback, decided per
|
|
219
|
+
provider and overridable through the provider config.
|
|
220
|
+
|
|
221
|
+
Guessing there would not fail loudly. It would write a key that looks valid, and produce a **second
|
|
222
|
+
account** for the same user on the next social sign-in.
|
|
223
|
+
|
|
224
|
+
So those rows are counted and reported instead. If your logs say
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
At least one non-credential account has no "issuer"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
then act **before** the first social sign-in after the upgrade. Such a sign-in does **not** simply
|
|
231
|
+
fail, which is the part that makes this urgent rather than merely broken:
|
|
232
|
+
|
|
233
|
+
1. better-auth cannot match the row by `(issuer, accountId)`, so it falls back to matching the
|
|
234
|
+
user by the **provider-asserted email** and implicitly links a **second** account row. The
|
|
235
|
+
identity is now established from an email rather than from the provider key — a weaker binding.
|
|
236
|
+
2. If the provider-side email has changed since the row was written, no user matches and
|
|
237
|
+
better-auth creates a **new user**. The original account is orphaned with its data.
|
|
238
|
+
3. The orphaned row keeps its `accessToken` / `refreshToken`. Unlinking removes the *new* row, so
|
|
239
|
+
those provider credentials survive every unlink and every "revoke access" action.
|
|
240
|
+
|
|
241
|
+
For a provider using the synthetic fallback:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
db.collection('account').updateMany(
|
|
245
|
+
{ issuer: { $exists: false }, providerId: 'google' },
|
|
246
|
+
{ $set: { issuer: 'local:oauth:google' } },
|
|
247
|
+
)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
For a provider with a real OIDC issuer, use that issuer verbatim. Confirm which of the two applies
|
|
251
|
+
by reading a row better-auth wrote itself after the upgrade.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 5. Deploying: do not run 11.36.x and 11.37.x against one database
|
|
256
|
+
|
|
257
|
+
The backfill is safe to run repeatedly and safe to run while 11.37.x instances serve traffic. It is
|
|
258
|
+
**not** safe to leave an 11.36.x instance running against the same database afterwards: 11.x keeps
|
|
259
|
+
writing credential accounts without an issuer, and those rows are invisible to 11.37.x until the next
|
|
260
|
+
restart backfills them.
|
|
261
|
+
|
|
262
|
+
For a rolling deployment, either complete the rollout before relying on new sign-ups, or restart
|
|
263
|
+
one 11.37.x instance after the last 11.36.x instance is gone.
|
|
264
|
+
|
|
265
|
+
**Rollback** is safe in the other direction: 1.6 ignores the extra `issuer` field, so a backfilled
|
|
266
|
+
database still works with 11.36.x.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## 6. Compatibility Notes
|
|
271
|
+
|
|
272
|
+
Patterns this release does and does not disturb. All of these are override points projects
|
|
273
|
+
legitimately use.
|
|
274
|
+
|
|
275
|
+
| Pattern | Status | Notes |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| Subclassing `CoreBetterAuthService` | Compatible | `backfillAccountIssuers()` and `ensureIndices()` are `protected` — override either to change behaviour |
|
|
278
|
+
| **Overriding `onModuleInit()`** | **Action required** | It now runs two steps. An override that does not call `super.onModuleInit()` silently skips the backfill, and your existing password users stay locked out with no error anywhere. Call `super`, or call both `protected` methods yourself |
|
|
279
|
+
| Subclassing `CoreSystemSetupService` | Compatible | See §7 if you override `createInitialAdmin()` — it now passes a provisioning source |
|
|
280
|
+
| Subclassing `CoreBetterAuthUserMapper` | Compatible | An override of `migrateAccountToIam()` that builds the account document itself MUST add `issuer: createLocalAccountIssuer('credential')`, or the migrated user gets a 401 on the sign-in that triggered the migration |
|
|
281
|
+
| Custom `account` collection name / `issuer` field | Compatible | The backfill resolves both from the running better-auth instance and warns when they differ from the defaults |
|
|
282
|
+
| `betterAuth.options.user.validateUserInfo` | **Action required** | See §7 |
|
|
283
|
+
| Reading the `account` collection directly | Compatible | Framework reads deliberately filter on `providerId` alone, so they keep working on un-backfilled rows. Do not add `issuer` to such a filter |
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 7. `validateUserInfo` and the initial admin
|
|
288
|
+
|
|
289
|
+
**Were you affected?** Only projects that configure `betterAuth.options.user.validateUserInfo`.
|
|
290
|
+
|
|
291
|
+
better-auth 1.7 requires a *provisioning source* on `internalAdapter.createUser()`, and
|
|
292
|
+
`CoreSystemSetupService` now passes `{ method: 'admin' }`. Two consequences:
|
|
293
|
+
|
|
294
|
+
1. Your hook still runs — the source does not bypass it. It receives
|
|
295
|
+
`{ method: 'admin', action: 'create-user' }`, so a domain allowlist or invite-code gate can
|
|
296
|
+
branch on `method` and let the first-admin provisioning through.
|
|
297
|
+
2. **When the hook is configured, initial-admin setup fails.** better-auth additionally calls
|
|
298
|
+
`getCurrentAuthContext()`, which throws outside an endpoint context. System setup runs from
|
|
299
|
+
`OnApplicationBootstrap` or a plain controller — never inside better-auth's request pipeline —
|
|
300
|
+
so the call ends in `FORBIDDEN / validation_context_missing` and no admin is created.
|
|
301
|
+
|
|
302
|
+
If you use `validateUserInfo`, provision the first administrator another way: create it through a
|
|
303
|
+
request-scoped IAM route, or seed it before enabling the hook.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## 8. Troubleshooting
|
|
308
|
+
|
|
309
|
+
| Symptom | Cause | Fix |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| **Every password user gets 401 after the upgrade.** Correct password, no useful log line | Their `account` rows predate better-auth 1.7 and carry no `issuer` | The backfill runs on the first boot. Check the log for `Backfilled account.issuer on N credential account(s)`. If absent, see the next two rows |
|
|
312
|
+
| No backfill log at all, and users still cannot sign in | The backfill did not run: better-auth disabled, no DB connection, or an `onModuleInit()` override without `super` (§6) | Check `betterAuth.enabled`, then your override |
|
|
313
|
+
| `Backfilled N/M credential accounts. X user(s) CANNOT sign in` | The bulk write hit a duplicate `(issuer, accountId)` pair, usually from an earlier partial migration | Find them: `db.account.aggregate([{$group:{_id:{i:'$issuer',a:'$accountId'},n:{$sum:1}}},{$match:{n:{$gt:1}}}])`, resolve the duplicates, restart |
|
|
314
|
+
| `Backfilling the account issuer against a customised schema` | You renamed the account model or the issuer field | Expected. Verify the names match what better-auth writes |
|
|
315
|
+
| `Could not backfill the account issuer: …` | The database rejected the operation | Boot is not blocked, but affected users cannot sign in. Fix the cause and restart — the backfill retries until it completes |
|
|
316
|
+
| Social login creates a second account, or a brand-new empty user | An OAuth row without an issuer (§4) | Set the issuer per provider. The already-created duplicate must be merged manually |
|
|
317
|
+
| Setup fails with `FORBIDDEN / validation_context_missing` | `validateUserInfo` is configured (§7) | Provision the first admin through a request-scoped path |
|
|
318
|
+
| `pnpm install` succeeded but versions drift later | pnpm auto-installed the peers (§1) | Declare all three explicitly and pin them; verify with `pnpm why better-auth` |
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Module Documentation
|
|
323
|
+
|
|
324
|
+
| Document | Relevance |
|
|
325
|
+
|---|---|
|
|
326
|
+
| [`src/core/modules/better-auth/README.md`](../src/core/modules/better-auth/README.md) | Module overview, the boot-time backfill, troubleshooting |
|
|
327
|
+
| [`src/core/modules/better-auth/INTEGRATION-CHECKLIST.md`](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) | Integration steps — **step 0 (installing the three peers) is new in 11.37.0** |
|
|
328
|
+
| [`src/core/modules/better-auth/CUSTOMIZATION.md`](../src/core/modules/better-auth/CUSTOMIZATION.md) | Registration patterns and override points |
|
|
329
|
+
| [`src/core/modules/system-setup/README.md`](../src/core/modules/system-setup/README.md) | Initial-admin provisioning (§7) |
|
|
330
|
+
| [`.claude/rules/better-auth.md`](../.claude/rules/better-auth.md) | Development rules for the module |
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Checklist
|
|
335
|
+
|
|
336
|
+
- [ ] `better-auth`, `@better-auth/passkey`, `@better-auth/core` declared in your own `package.json`, pinned to the same version (§1)
|
|
337
|
+
- [ ] `pnpm run build` green — it finds the `createUser` and `linkAccount` signature changes (§2)
|
|
338
|
+
- [ ] Every direct write to the `account` collection sets `issuer` via `createLocalAccountIssuer` (§3)
|
|
339
|
+
- [ ] First boot after deployment checked for `Backfilled account.issuer` and for `Could not backfill` (§3)
|
|
340
|
+
- [ ] Social/SSO projects: log checked for un-backfilled accounts, issuer set per provider (§4)
|
|
341
|
+
- [ ] Fullstack projects: api and app on the **same** better-auth version (§1)
|
|
342
|
+
- [ ] Rollout does not leave 11.36.x and 11.37.x on one database (§5)
|
|
343
|
+
- [ ] `onModuleInit()` overrides call `super.onModuleInit()` (§6)
|
|
344
|
+
- [ ] Projects using `validateUserInfo`: first-admin provisioning path checked (§7)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.37.0",
|
|
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",
|
|
@@ -83,14 +83,13 @@
|
|
|
83
83
|
"url": "https://github.com/lenneTech/nest-server/issues"
|
|
84
84
|
},
|
|
85
85
|
"engines": {
|
|
86
|
-
"node": ">= 22",
|
|
86
|
+
"node": ">= 22.12",
|
|
87
87
|
"pnpm": "^11.0.0"
|
|
88
88
|
},
|
|
89
89
|
"packageManager": "pnpm@11.13.1+sha512.b2fc7683b8a6525414e7d13e1ba28caaddde96bf66ec540bfaeb7e702b81f3e0be4d1f295edf7f9fe0396740a8dce4509c582ddf79891f4543fea32d37645f25",
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@apollo/server": "5.5.1",
|
|
92
92
|
"@as-integrations/express5": "1.1.2",
|
|
93
|
-
"@better-auth/passkey": "1.6.26",
|
|
94
93
|
"@getbrevo/brevo": "6.0.3",
|
|
95
94
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
96
95
|
"@nestjs/apollo": "13.4.5",
|
|
@@ -108,7 +107,6 @@
|
|
|
108
107
|
"@tus/server": "2.4.4",
|
|
109
108
|
"@types/supertest": "7.2.1",
|
|
110
109
|
"bcrypt": "6.0.0",
|
|
111
|
-
"better-auth": "1.6.26",
|
|
112
110
|
"class-transformer": "0.5.1",
|
|
113
111
|
"class-validator": "0.15.1",
|
|
114
112
|
"compression": "1.8.1",
|
|
@@ -144,7 +142,10 @@
|
|
|
144
142
|
"peerDependencies": {
|
|
145
143
|
"@aws-sdk/client-s3": ">=3.1045.0 <4",
|
|
146
144
|
"@aws-sdk/s3-request-presigner": ">=3.1045.0 <4",
|
|
145
|
+
"@better-auth/core": ">=1.7.1 <1.8.0",
|
|
146
|
+
"@better-auth/passkey": ">=1.7.1 <1.8.0",
|
|
147
147
|
"@tus/s3-store": ">=2.0.5 <3",
|
|
148
|
+
"better-auth": ">=1.7.1 <1.8.0",
|
|
148
149
|
"bullmq": ">=5.16.0 <7",
|
|
149
150
|
"ioredis": ">=5.0.0 <7"
|
|
150
151
|
},
|
|
@@ -155,9 +156,18 @@
|
|
|
155
156
|
"@aws-sdk/s3-request-presigner": {
|
|
156
157
|
"optional": true
|
|
157
158
|
},
|
|
159
|
+
"@better-auth/core": {
|
|
160
|
+
"optional": false
|
|
161
|
+
},
|
|
162
|
+
"@better-auth/passkey": {
|
|
163
|
+
"optional": false
|
|
164
|
+
},
|
|
158
165
|
"@tus/s3-store": {
|
|
159
166
|
"optional": true
|
|
160
167
|
},
|
|
168
|
+
"better-auth": {
|
|
169
|
+
"optional": false
|
|
170
|
+
},
|
|
161
171
|
"bullmq": {
|
|
162
172
|
"optional": true
|
|
163
173
|
},
|
|
@@ -168,6 +178,8 @@
|
|
|
168
178
|
"devDependencies": {
|
|
169
179
|
"@aws-sdk/client-s3": "3.1115.0",
|
|
170
180
|
"@aws-sdk/s3-request-presigner": "3.1115.0",
|
|
181
|
+
"@better-auth/core": "1.7.1",
|
|
182
|
+
"@better-auth/passkey": "1.7.1",
|
|
171
183
|
"@compodoc/compodoc": "2.0.0",
|
|
172
184
|
"@nestjs/cli": "11.0.24",
|
|
173
185
|
"@nestjs/schematics": "11.1.0",
|
|
@@ -186,6 +198,7 @@
|
|
|
186
198
|
"@types/passport": "1.0.17",
|
|
187
199
|
"@vitest/coverage-v8": "4.1.11",
|
|
188
200
|
"ansi-colors": "4.1.3",
|
|
201
|
+
"better-auth": "1.7.1",
|
|
189
202
|
"bullmq": "6.2.0",
|
|
190
203
|
"find-file-up": "2.0.1",
|
|
191
204
|
"husky": "9.1.7",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**For integrating BetterAuth into projects using `@lenne.tech/nest-server`.**
|
|
4
4
|
|
|
5
|
-
> **Estimated time:** 10-15 minutes
|
|
5
|
+
> **Estimated time:** 10-15 minutes (plus Step 0 on a fresh integration)
|
|
6
6
|
|
|
7
7
|
**Need customization?** See [CUSTOMIZATION.md](./CUSTOMIZATION.md) for:
|
|
8
8
|
|
|
@@ -12,6 +12,29 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## Step 0: Install the Peer Dependencies (required from 11.37.0)
|
|
16
|
+
|
|
17
|
+
From `@lenne.tech/nest-server` 11.37.0, better-auth is **not** a dependency of this package. It is a
|
|
18
|
+
non-optional **peer dependency**, so your project declares and owns the version:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add better-auth@1.7.1 @better-auth/passkey@1.7.1 @better-auth/core@1.7.1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Pin all three to the same exact version** — no `^`, no `~`. They are one release train, and a
|
|
25
|
+
mixed set fails in ways the type checker does not catch.
|
|
26
|
+
|
|
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
|
|
29
|
+
> green while the version is unpinned and free to drift. Verify with `pnpm why better-auth`.
|
|
30
|
+
|
|
31
|
+
**Why peers rather than dependencies:** in a fullstack project the frontend talks to better-auth
|
|
32
|
+
too. When this package owned the version, the API and the app could silently end up on different
|
|
33
|
+
better-auth versions with incompatible payload shapes. Owning it in your own manifest makes that a
|
|
34
|
+
single, visible decision.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
15
38
|
## Choose Your Scenario
|
|
16
39
|
|
|
17
40
|
| Scenario | Use When | CoreModule Signature | Steps |
|
|
@@ -30,6 +30,7 @@ const config = {
|
|
|
30
30
|
- [Features](#features)
|
|
31
31
|
- [Quick Integration](#quick-integration-for-claude-code--ai-assistants)
|
|
32
32
|
- [Project Integration Guide](#project-integration-guide-required-steps)
|
|
33
|
+
- [Boot-Time Behaviour](#boot-time-behaviour)
|
|
33
34
|
- [Configuration](#configuration)
|
|
34
35
|
- [REST API Endpoints](#rest-api-endpoints)
|
|
35
36
|
- [GraphQL API](#graphql-api)
|
|
@@ -1015,6 +1016,61 @@ const config = {
|
|
|
1015
1016
|
- Full flexibility for specialized plugins
|
|
1016
1017
|
- No package updates needed for new Better-Auth plugins
|
|
1017
1018
|
|
|
1019
|
+
## Boot-Time Behaviour
|
|
1020
|
+
|
|
1021
|
+
`CoreBetterAuthService.onModuleInit()` runs two steps against the database before the application
|
|
1022
|
+
starts listening. Both are `protected`, so a subclass can override either — **an override of
|
|
1023
|
+
`onModuleInit()` that does not call `super.onModuleInit()` skips both.**
|
|
1024
|
+
|
|
1025
|
+
### 1. `ensureIndices()` — performance
|
|
1026
|
+
|
|
1027
|
+
Idempotent indices on `session`, `users`, `account` and `verification`, including a TTL index that
|
|
1028
|
+
expires unconsumed verification and password-reset documents. Failures are logged at `warn` and do
|
|
1029
|
+
not block the boot: these help speed, not correctness.
|
|
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.
|
|
1037
|
+
|
|
1038
|
+
| Property | Behaviour |
|
|
1039
|
+
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1040
|
+
| Scope | Credential accounts only. OAuth/SSO rows are reported, never rewritten — see below |
|
|
1041
|
+
| Idempotency | Structural: only rows missing the field are selected |
|
|
1042
|
+
| Repeat cost | A completion marker in `better-auth-backfills` short-circuits later boots to one `_id` lookup. Neither backfill query is indexable, so without it every boot would scan the whole collection |
|
|
1043
|
+
| Schema overrides | Collection and field names are resolved from the running better-auth instance; a customised name is logged at `warn` |
|
|
1044
|
+
| Partial failure | The write is unordered, so one bad row cannot shadow the rest. The marker is **not** written, and the next boot retries |
|
|
1045
|
+
| Failure | Logged at `error`; the boot continues. A server that starts with a loud error beats one that will not start |
|
|
1046
|
+
|
|
1047
|
+
**Log lines to expect on the first boot after upgrading:**
|
|
1048
|
+
|
|
1049
|
+
| Line | Meaning |
|
|
1050
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
1051
|
+
| `Backfilled account.issuer on N credential account(s)` | Success — those N users can sign in again |
|
|
1052
|
+
| `Backfilled N/M credential accounts. X user(s) CANNOT sign in` | Partial. Usually a duplicate `(issuer, accountId)` pair from an earlier migration. Resolve and restart |
|
|
1053
|
+
| `Could not backfill the account issuer: …` | The operation failed entirely. Affected users cannot sign in until it succeeds |
|
|
1054
|
+
| `At least one non-credential account has no "issuer"` | See below — act before the next social sign-in |
|
|
1055
|
+
| `Backfilling the account issuer against a customised schema` | You renamed the account model or issuer field |
|
|
1056
|
+
|
|
1057
|
+
**Why OAuth/SSO rows are not backfilled.** `local:credential` is a pure function of the provider id
|
|
1058
|
+
and therefore derivable. An OAuth issuer is not — it is either the provider's real OIDC issuer or
|
|
1059
|
+
the synthetic `local:oauth:<providerId>` fallback, decided per provider. Guessing would not fail
|
|
1060
|
+
loudly: better-auth falls back to matching the user by the provider-asserted email and implicitly
|
|
1061
|
+
links a **second** account row; if that email has changed, it creates a **new user** and orphans the
|
|
1062
|
+
old account together with its provider tokens, which no unlink will ever remove. So those rows are
|
|
1063
|
+
reported and the decision stays with the project. Set the issuer per provider **before** the first
|
|
1064
|
+
social sign-in after the upgrade — see
|
|
1065
|
+
[migration-guides/11.36.x-to-11.37.0.md](../../../../migration-guides/11.36.x-to-11.37.0.md) §4.
|
|
1066
|
+
|
|
1067
|
+
> **Do not add `issuer` to this package's own reads of the `account` collection**
|
|
1068
|
+
> (`syncPasswordChangeToIam`, `migrateAccountToIam`, `getMigrationStatus`). They filter on
|
|
1069
|
+
> `providerId` alone on purpose, so they keep working on rows the backfill has not reached yet —
|
|
1070
|
+
> which matters most when the backfill has failed.
|
|
1071
|
+
|
|
1072
|
+
---
|
|
1073
|
+
|
|
1018
1074
|
## Module Setup
|
|
1019
1075
|
|
|
1020
1076
|
See the [Project Integration Guide](#project-integration-guide-required-steps) at the top of this document for complete step-by-step instructions.
|
|
@@ -1895,6 +1951,14 @@ When a user is deleted:
|
|
|
1895
1951
|
db.account.findOne({ userId: ObjectId('...'), providerId: 'credential' });
|
|
1896
1952
|
```
|
|
1897
1953
|
|
|
1954
|
+
From better-auth 1.7 the row must ALSO carry `issuer: 'local:credential'`. A row matching only
|
|
1955
|
+
`providerId` is not usable — better-auth keys accounts by `(issuer, accountId)` and will not find
|
|
1956
|
+
it, so sign-in answers 401 while this query happily reports that the account exists:
|
|
1957
|
+
|
|
1958
|
+
```javascript
|
|
1959
|
+
db.account.findOne({ issuer: 'local:credential', providerId: 'credential', userId: ObjectId('...') });
|
|
1960
|
+
```
|
|
1961
|
+
|
|
1898
1962
|
3. Check server logs for sync warnings:
|
|
1899
1963
|
```
|
|
1900
1964
|
[CoreUserService] Failed to sync password to IAM...
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createLocalAccountIssuer } from '@better-auth/core/db';
|
|
1
2
|
import { Injectable, Logger, Optional } from '@nestjs/common';
|
|
2
3
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
4
|
import * as bcrypt from 'bcrypt';
|
|
@@ -509,10 +510,16 @@ export class CoreBetterAuthUserMapper {
|
|
|
509
510
|
// Store account matching Better-Auth's format:
|
|
510
511
|
// - userId: ObjectId referencing users._id
|
|
511
512
|
// - accountId: string version of users._id
|
|
513
|
+
// - issuer: from better-auth >= 1.7 accounts are keyed by (issuer,
|
|
514
|
+
// accountId), and credential accounts carry a synthetic issuer. Writing
|
|
515
|
+
// the row without it produces an account better-auth cannot find, so the
|
|
516
|
+
// migrated user gets a 401 on the very sign-in that triggered the
|
|
517
|
+
// migration. Always derive it from the helper, never hand-write it.
|
|
512
518
|
await accountsCollection.insertOne({
|
|
513
519
|
accountId: userIdHex,
|
|
514
520
|
createdAt: now,
|
|
515
521
|
id: this.generateId(),
|
|
522
|
+
issuer: createLocalAccountIssuer('credential'),
|
|
516
523
|
password: passwordHash,
|
|
517
524
|
providerId: 'credential',
|
|
518
525
|
updatedAt: now,
|
|
@@ -71,3 +71,29 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
|
|
|
71
71
|
* Declared `@Optional()` in the CoreBetterAuthService constructor.
|
|
72
72
|
*/
|
|
73
73
|
export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* better-auth's default name for the account collection, and for the `issuer` field inside it.
|
|
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.
|
|
84
|
+
*/
|
|
85
|
+
export const DEFAULT_ACCOUNT_MODEL_NAME = 'account';
|
|
86
|
+
export const DEFAULT_ACCOUNT_ISSUER_FIELD = 'issuer';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 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).
|
|
91
|
+
*
|
|
92
|
+
* The marker exists for cost, not for correctness: the backfill is idempotent and safe to repeat,
|
|
93
|
+
* 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.
|
|
97
|
+
*/
|
|
98
|
+
export const BACKFILL_MARKER_COLLECTION = 'better-auth-backfills';
|
|
99
|
+
export const ACCOUNT_ISSUER_BACKFILL_ID = 'account-issuer-backfill-v1';
|