@lenne.tech/nest-server 11.41.7 → 11.41.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/.claude/rules/better-auth.md +26 -22
  2. package/.claude/rules/testing.md +4 -4
  3. package/CLAUDE.md +5 -3
  4. package/FRAMEWORK-API.md +1 -1
  5. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.d.ts +2 -0
  6. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js +13 -0
  7. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js.map +1 -0
  8. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +3 -2
  9. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  10. package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +1 -0
  11. package/dist/core/modules/better-auth/core-better-auth.constants.js +2 -1
  12. package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
  13. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +1 -0
  14. package/dist/core/modules/better-auth/core-better-auth.service.js +46 -3
  15. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  16. package/dist/core/modules/system-setup/core-system-setup.service.js +3 -2
  17. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  18. package/dist/tsconfig.build.tsbuildinfo +1 -1
  19. package/migration-guides/11.41.7-to-11.41.8.md +135 -0
  20. package/package.json +7 -9
  21. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +2 -2
  22. package/src/core/modules/better-auth/README.md +20 -6
  23. package/src/core/modules/better-auth/core-better-auth-account-issuer.helper.ts +25 -0
  24. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -7
  25. package/src/core/modules/better-auth/core-better-auth.constants.ts +20 -11
  26. package/src/core/modules/better-auth/core-better-auth.service.ts +87 -3
  27. package/src/core/modules/system-setup/core-system-setup.service.ts +12 -8
@@ -0,0 +1,135 @@
1
+ # Migration Guide: 11.41.7 → 11.41.8
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | --------------------- | --------------------------------------------------------------------------------------------- |
7
+ | **Security** | better-auth 1.7.7 is now the minimum — critical account takeover in < 1.7.7 (§1) |
8
+ | **Breaking Changes** | None at runtime: 11.41.8 still works on better-auth 1.7.1 (§2). The peer floor rises to 1.7.7, so pnpm reports an unmet peer until you raise the pins |
9
+ | **Data** | Nothing to migrate. The boot drops one index that better-auth 1.7.0–1.7.2 left behind (§2) |
10
+ | **Tooling** | The override guard reports unused overrides; husky and its hooks are gone (§3) |
11
+ | **Migration Effort** | Raise three pins in the API — and two in the app of a fullstack project, in the same change |
12
+
13
+ ## Quick Migration
14
+
15
+ ```bash
16
+ pnpm update @lenne.tech/nest-server
17
+ pnpm add better-auth@1.7.7 @better-auth/passkey@1.7.7 @better-auth/core@1.7.7
18
+ pnpm run build && pnpm test
19
+ ```
20
+
21
+ Fullstack projects raise the app in the same change — `@lenne.tech/nuxt-extensions` 1.18.4 and
22
+ `better-auth` / `@better-auth/passkey` 1.7.7 in `projects/app` — see §1.
23
+
24
+ Vendor-mode projects pick up the core update, and raise the same pins.
25
+
26
+ ## What's New in 11.41.8
27
+
28
+ ### 1. better-auth 1.7.7 is the minimum
29
+
30
+ [GHSA-965c-763c-88jm](https://github.com/better-auth/better-auth/security/advisories/GHSA-965c-763c-88jm)
31
+ (critical): an OAuth state value can be used as a Magic Link to sign in as another user. It covers
32
+ `better-auth >= 1.4.0-beta.18, < 1.7.7`; 1.7.7 is fixed. A project is exposed when it registers the
33
+ `magicLink` plugin — through `betterAuth.options.plugins` — next to a social or Generic OAuth
34
+ provider, with OAuth state stored in the database (the default when a database is configured). An
35
+ attacker who knows a user's email address then gets a normal session for that user, without access
36
+ to the mailbox.
37
+
38
+ At the time of writing the advisory lives only in better-auth's own repository, not yet in the
39
+ database `pnpm audit` reads, so **your audit does not report it**. Do not wait for it to.
40
+
41
+ The framework's peer range is now `>=1.7.7 <1.8.0` for `better-auth`, `@better-auth/passkey` and
42
+ `@better-auth/core` (the floor is forced anyway: `@better-auth/passkey` 1.7.7 itself requires
43
+ `better-auth ^1.7.7`). A project still on 1.7.1 gets an unmet-peer warning, and stays vulnerable
44
+ if it uses Magic Link.
45
+
46
+ **What to do:**
47
+
48
+ ```bash
49
+ # projects/api (or a standalone API)
50
+ pnpm add better-auth@1.7.7 @better-auth/passkey@1.7.7 @better-auth/core@1.7.7
51
+
52
+ # projects/app of a fullstack project — in the SAME change
53
+ pnpm add @lenne.tech/nuxt-extensions@1.18.4 better-auth@1.7.7 @better-auth/passkey@1.7.7
54
+ ```
55
+
56
+ API and app must resolve the same better-auth version: they speak one protocol, and a split
57
+ breaks flows like 2FA activation with errors that name neither side. In a monorepo,
58
+ `check-workspace-consistency` fails the check when they differ.
59
+
60
+ Upstream's upgrade note for 1.7.7, worth passing on to whoever deploys: upgrade every server that
61
+ shares verification storage at the same time, have users request new Magic Links, and expect
62
+ OAuth / SAML sign-ins that were in progress during the deploy to need a restart. No database
63
+ migration is required. Custom `verification.storeIdentifier.overrides` rules, and storage code that
64
+ inspects verification identifiers, must now expect the `magic-link:` and `auth-state:` prefixes.
65
+
66
+ ### 2. better-auth dropped its issuer schema again — what that means for your data
67
+
68
+ better-auth 1.7.0 keyed accounts by `(issuer, accountId)`, and nest-server 11.37–11.41.7 followed:
69
+ every account it wrote carried an `issuer`, and the boot backfilled it on older rows. better-auth
70
+ 1.7.3 restored the 1.6 schema — accounts keyed by `(providerId, accountId)` — and no longer reads or
71
+ writes the field. 11.41.8 follows whichever line is INSTALLED, so the upgrade stays compatible in
72
+ both directions:
73
+
74
+ | You run | nest-server 11.41.8 does |
75
+ | ------- | ------------------------ |
76
+ | better-auth 1.7.7 (the target) | writes no `issuer`; on boot drops the unique `(issuer, accountId)` index better-auth 1.7.0–1.7.2 left behind, and forgets the old backfill marker |
77
+ | better-auth 1.7.1 (pins not raised yet) | behaves exactly like 11.41.7: writes the `issuer`, runs the backfill. Still vulnerable (§1) — raise the pins |
78
+
79
+ **Why the index has to go.** With the field no longer written, the index treats every new account
80
+ as having the same, missing issuer, so a user who holds the same account ID at two providers can no
81
+ longer link the second one. MongoDB has no `NOT NULL`, so dropping the index is the whole cleanup —
82
+ upstream's 1.7 upgrade guide says the same. The log shows it once:
83
+ `Dropped the unique index "account_issuer_accountId_uidx" on account(issuer, accountId)`.
84
+
85
+ **Existing `issuer` values stay.** better-auth 1.7.3+ ignores them, and keeping them is what makes a
86
+ rollback safe.
87
+
88
+ **Rolling back to 11.41.7** (better-auth 1.7.1) works for password users: that version's backfill
89
+ runs again on boot — 11.41.8 deleted its completion marker for exactly this — and gives the issuer
90
+ back to every credential account written in the meantime. better-auth recreates its index on first
91
+ use. Social accounts LINKED while on 11.41.8 stay without an issuer; 11.41.7 reports them at boot,
92
+ and they need the issuer set per provider before their next social sign-in.
93
+
94
+ **Vendor mode on better-auth 1.7.1** builds as well: the core compiles against both lines.
95
+
96
+ **Code that imported `createLocalAccountIssuer`** from `@better-auth/core/db` stops compiling with
97
+ better-auth 1.7.3+, which removed it. Remove the call; accounts no longer carry an issuer.
98
+
99
+ ### 3. Tooling (this repository)
100
+
101
+ - **The override guard reports unused overrides again.** `scripts/check-overrides.mjs` searched the
102
+ whole lockfile, where pnpm echoes every override into a top-level `overrides:` block — so an
103
+ override whose package had left the tree still counted as "in the tree". It now reads only the
104
+ resolved part. A project that copied the guard (nest-server-starter ships it since 11.41.7) may
105
+ see new `UNUSED` lines after updating it: warnings, not failures — remove the dead entry.
106
+ - **husky is gone.** Its `prepare` script had been removed in 2024, so the hooks never ran; the
107
+ `check` script is the gate. `.husky/`, `.lintstagedrc.json`, `extras/sync-version.ts` and the
108
+ devDependencies `husky` and `find-file-up` (used only by that hook) were removed. Nothing ships
109
+ in the package from any of them.
110
+
111
+ ## Compatibility Notes
112
+
113
+ | Pattern | Status |
114
+ | ------- | ------ |
115
+ | `better-auth` / `@better-auth/*` pinned at 1.7.1 | Unmet peer — raise to 1.7.7 (§1) |
116
+ | API on 1.7.7, app still on 1.7.1 | Breaks auth flows — raise both in one change (§1) |
117
+ | `magicLink` plugin with an OAuth provider, on < 1.7.7 | Account takeover — raise now (§1) |
118
+ | nest-server 11.41.8 with better-auth still at 1.7.1 | Works as before (§2); unmet-peer warning, still vulnerable |
119
+ | Rollback to 11.41.7 after upgrading | Works — the backfill runs again (§2) |
120
+ | Own code calling `createLocalAccountIssuer` | Remove it — gone in better-auth 1.7.3+ (§2) |
121
+
122
+ ## Troubleshooting
123
+
124
+ | Symptom | Cause | Fix |
125
+ | ------- | ----- | --- |
126
+ | `unmet peer better-auth@">=1.7.7 <1.8.0": found 1.7.1` | Old pin | `pnpm add better-auth@1.7.7 @better-auth/passkey@1.7.7 @better-auth/core@1.7.7` |
127
+ | 2FA activation or passkey sign-in fails with a generic client error after updating | API and app on different better-auth versions | Pin the same version in both |
128
+ | `check-workspace-consistency` fails on better-auth | Same | Same |
129
+ | New `UNUSED` lines from `check:overrides` | The guard sees dead overrides now (§3) | Remove the entry it names |
130
+ | `Could not drop the legacy unique (issuer, accountId) index` at boot | The database user may not drop indexes | `db.account.dropIndex("account_issuer_accountId_uidx")` once, by hand |
131
+ | `E11000 duplicate key … account_issuer_accountId_uidx` when linking an account | The legacy index is still there | Same |
132
+
133
+ ## Module Documentation
134
+
135
+ - [BetterAuth INTEGRATION-CHECKLIST](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) — Step 0, the peer dependencies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.41.7",
3
+ "version": "11.41.8",
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",
@@ -144,10 +144,10 @@
144
144
  "@aws-sdk/client-s3": ">=3.1045.0 <4",
145
145
  "@aws-sdk/lib-storage": ">=3.1045.0 <4",
146
146
  "@aws-sdk/s3-request-presigner": ">=3.1045.0 <4",
147
- "@better-auth/core": ">=1.7.1 <1.8.0",
148
- "@better-auth/passkey": ">=1.7.1 <1.8.0",
147
+ "@better-auth/core": ">=1.7.7 <1.8.0",
148
+ "@better-auth/passkey": ">=1.7.7 <1.8.0",
149
149
  "@tus/s3-store": ">=2.0.5 <3",
150
- "better-auth": ">=1.7.1 <1.8.0",
150
+ "better-auth": ">=1.7.7 <1.8.0",
151
151
  "bullmq": ">=5.16.0 <7",
152
152
  "ioredis": ">=5.0.0 <7"
153
153
  },
@@ -184,8 +184,8 @@
184
184
  "@aws-sdk/client-s3": "3.1145.0",
185
185
  "@aws-sdk/lib-storage": "3.1145.0",
186
186
  "@aws-sdk/s3-request-presigner": "3.1145.0",
187
- "@better-auth/core": "1.7.1",
188
- "@better-auth/passkey": "1.7.1",
187
+ "@better-auth/core": "1.7.7",
188
+ "@better-auth/passkey": "1.7.7",
189
189
  "@compodoc/compodoc": "2.0.0",
190
190
  "@nestjs/cli": "11.0.24",
191
191
  "@nestjs/schematics": "11.1.0",
@@ -203,11 +203,9 @@
203
203
  "@types/passport": "1.0.17",
204
204
  "@vitest/coverage-v8": "4.1.11",
205
205
  "ansi-colors": "4.1.3",
206
- "better-auth": "1.7.1",
206
+ "better-auth": "1.7.7",
207
207
  "bullmq": "6.3.11",
208
208
  "cross-env": "10.1.0",
209
- "find-file-up": "2.0.1",
210
- "husky": "9.1.7",
211
209
  "ioredis": "6.0.0",
212
210
  "nodemon": "3.1.14",
213
211
  "npm-watch": "0.13.0",
@@ -18,14 +18,14 @@ From `@lenne.tech/nest-server` 11.37.0, better-auth is **not** a dependency of t
18
18
  non-optional **peer dependency**, so your project declares and owns the version:
19
19
 
20
20
  ```bash
21
- pnpm add better-auth@1.7.1 @better-auth/passkey@1.7.1 @better-auth/core@1.7.1
21
+ pnpm add better-auth@1.7.7 @better-auth/passkey@1.7.7 @better-auth/core@1.7.7
22
22
  ```
23
23
 
24
24
  **Pin all three to the same exact version** — no `^`, no `~`. They are one release train, and a
25
25
  mixed set fails in ways the type checker does not catch.
26
26
 
27
27
  > **Do not rely on `pnpm install` to remind you.** On pnpm's default `autoInstallPeers: true` the
28
- > packages are installed silently at whatever version satisfies `>=1.7.1 <1.8.0` — your build stays
28
+ > packages are installed silently at whatever version satisfies `>=1.7.7 <1.8.0` — your build stays
29
29
  > green while the version is unpinned and free to drift. Verify with `pnpm why better-auth`.
30
30
 
31
31
  **Why peers rather than dependencies:** in a fullstack project the frontend talks to better-auth
@@ -1028,12 +1028,26 @@ Idempotent indices on `session`, `users`, `account` and `verification`, includin
1028
1028
  expires unconsumed verification and password-reset documents. Failures are logged at `warn` and do
1029
1029
  not block the boot: these help speed, not correctness.
1030
1030
 
1031
- ### 2. `backfillAccountIssuers()` — correctness (11.37.0+)
1032
-
1033
- From better-auth 1.7 an account is keyed by `(issuer, accountId)` and the sign-in route filters on
1034
- it verbatim. Rows written by better-auth 1.6 have no `issuer` field, so **every existing password
1035
- user would be locked out by the upgrade**, with a bare 401 and nothing in the log to explain it.
1036
- This step repairs those rows on the first boot after the upgrade.
1031
+ ### 2. The account issuer — correctness (depends on the INSTALLED better-auth)
1032
+
1033
+ better-auth 1.7.0–1.7.2 keyed accounts by `(issuer, accountId)`; 1.7.3 restored the 1.6 schema,
1034
+ `(providerId, accountId)`. The boot runs ONE of two steps, decided by what is installed
1035
+ (`core-better-auth-account-issuer.helper.ts`):
1036
+
1037
+ - **better-auth 1.7.3+ (11.41.8+ requires it): `dropLegacyAccountIssuerIndex()`.** Databases that ran
1038
+ 1.7.0–1.7.2 (nest-server 11.37–11.41.7) carry a unique `(issuer, accountId)` index. With the field
1039
+ no longer written it refuses a second provider with the same account ID, so the boot drops it —
1040
+ recognised by shape, so a renamed collection or field is covered. Existing `issuer` values stay;
1041
+ they are inert. The backfill marker is deleted, so a rollback to a better-auth that needs the
1042
+ issuer runs the backfill again. Log line: `Dropped the unique index "…" on account(issuer,
1043
+ accountId)`. A failure is logged at `error` and the boot continues.
1044
+ - **better-auth 1.7.0–1.7.2: `backfillAccountIssuers()`, described below.** Kept for a project that
1045
+ has not raised its better-auth pin yet.
1046
+
1047
+ From better-auth 1.7.0 to 1.7.2 an account is keyed by `(issuer, accountId)` and the sign-in route
1048
+ filters on it verbatim. Rows written by better-auth 1.6 have no `issuer` field, so **every existing
1049
+ password user would be locked out by the upgrade**, with a bare 401 and nothing in the log to
1050
+ explain it. The backfill repairs those rows on the first boot after the upgrade.
1037
1051
 
1038
1052
  | Property | Behaviour |
1039
1053
  | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -0,0 +1,25 @@
1
+ import * as betterAuthCoreDb from '@better-auth/core/db';
2
+
3
+ /**
4
+ * The synthetic issuer better-auth 1.7.0–1.7.2 stores on credential accounts — or `undefined` when
5
+ * the installed better-auth no longer uses one.
6
+ *
7
+ * better-auth 1.7.0 keyed accounts by `(issuer, accountId)` and exported `createLocalAccountIssuer`
8
+ * to derive the credential issuer. 1.7.3 restored the 1.6 schema — accounts keyed by
9
+ * `(providerId, accountId)` — and removed both. This framework works with either line: its peer
10
+ * range starts at 1.7.7, but a project that has not raised its pin yet must keep signing users in.
11
+ * So every place that writes or repairs an account asks THIS function instead of importing the
12
+ * helper: a static import does not even compile against 1.7.3+, and an issuer written there would
13
+ * be dead data.
14
+ *
15
+ * Resolved at call time from the module namespace, never cached at load, so a test can swap it.
16
+ */
17
+ export function legacyCredentialAccountIssuer(): string | undefined {
18
+ const create = (betterAuthCoreDb as unknown as Record<string, unknown>).createLocalAccountIssuer;
19
+ return typeof create === 'function' ? (create as (providerId: string) => string)('credential') : undefined;
20
+ }
21
+
22
+ /** Whether the installed better-auth keys accounts by `(issuer, accountId)` — 1.7.0 to 1.7.2. */
23
+ export function usesLegacyAccountIssuer(): boolean {
24
+ return legacyCredentialAccountIssuer() !== undefined;
25
+ }
@@ -1,4 +1,3 @@
1
- import { createLocalAccountIssuer } from '@better-auth/core/db';
2
1
  import { Injectable, Logger, Optional } from '@nestjs/common';
3
2
  import { InjectConnection } from '@nestjs/mongoose';
4
3
  import * as bcrypt from 'bcrypt';
@@ -19,6 +18,7 @@ const scryptPromise = (password: string, salt: string, keylen: number, options:
19
18
 
20
19
  import { RoleEnum } from '../../common/enums/role.enum';
21
20
  import { maskEmail } from '../../common/helpers/logging.helper';
21
+ import { legacyCredentialAccountIssuer } from './core-better-auth-account-issuer.helper';
22
22
 
23
23
  /**
24
24
  * Interface for Better-Auth session user
@@ -531,16 +531,16 @@ export class CoreBetterAuthUserMapper {
531
531
  // Store account matching Better-Auth's format:
532
532
  // - userId: ObjectId referencing users._id
533
533
  // - accountId: string version of users._id
534
- // - issuer: from better-auth >= 1.7 accounts are keyed by (issuer,
535
- // accountId), and credential accounts carry a synthetic issuer. Writing
536
- // the row without it produces an account better-auth cannot find, so the
537
- // migrated user gets a 401 on the very sign-in that triggered the
538
- // migration. Always derive it from the helper, never hand-write it.
534
+ // - issuer: ONLY while the installed better-auth is 1.7.0–1.7.2, which key accounts by
535
+ // (issuer, accountId) — a row without it is not found, and the migrated user gets a 401 on
536
+ // the very sign-in that triggered the migration. 1.7.3+ went back to (providerId,
537
+ // accountId) and writes no issuer. Always derived from the helper, never hand-written.
538
+ const credentialIssuer = legacyCredentialAccountIssuer();
539
539
  await accountsCollection.insertOne({
540
540
  accountId: userIdHex,
541
541
  createdAt: now,
542
542
  id: this.generateId(),
543
- issuer: createLocalAccountIssuer('credential'),
543
+ ...(credentialIssuer ? { issuer: credentialIssuer } : {}),
544
544
  password: passwordHash,
545
545
  providerId: 'credential',
546
546
  updatedAt: now,
@@ -73,27 +73,36 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
73
73
  export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
74
74
 
75
75
  /**
76
- * better-auth's default name for the account collection, and for the `issuer` field inside it.
76
+ * better-auth's default names for the account collection and for fields inside it.
77
77
  *
78
- * Both are overridable by a consumer through `betterAuth.options.account.modelName` /
79
- * `.fields.issuer`, which `better-auth.config.ts` spreads onto the resolved config verbatim. Any
80
- * framework code touching that collection directly MUST resolve the real names from the running
81
- * instance and fall back to these — a hardcoded name silently addresses a collection better-auth
82
- * does not use, which on the issuer backfill means every password user stays locked out while the
83
- * operation reports success by saying nothing.
78
+ * All are overridable by a consumer through `betterAuth.options.account.modelName` / `.fields.*`,
79
+ * which `better-auth.config.ts` spreads onto the resolved config verbatim. Any framework code
80
+ * touching that collection directly MUST resolve the real names from the running instance and fall
81
+ * back to these — a hardcoded name silently addresses a collection or an index better-auth does not
82
+ * use, and the operation then reports success by saying nothing.
83
+ *
84
+ * `issuer` is only used by better-auth 1.7.0–1.7.2; 1.7.3 restored the 1.6 schema (accounts keyed by
85
+ * `(providerId, accountId)` again). Its name is still needed on both lines: for the backfill while a
86
+ * project runs 1.7.0–1.7.2, and afterwards to recognise the unique index those versions left behind —
87
+ * see `CoreBetterAuthService.dropLegacyAccountIssuerIndex()`.
84
88
  */
85
89
  export const DEFAULT_ACCOUNT_MODEL_NAME = 'account';
86
90
  export const DEFAULT_ACCOUNT_ISSUER_FIELD = 'issuer';
91
+ export const DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD = 'accountId';
87
92
 
88
93
  /**
89
94
  * Collection holding one-shot completion markers for boot-time data migrations, and the marker id
90
- * of the `account.issuer` backfill (better-auth 1.7).
95
+ * of the `account.issuer` backfill.
96
+ *
97
+ * The backfill only runs while the installed better-auth still keys accounts by issuer (1.7.0–1.7.2,
98
+ * see `core-better-auth-account-issuer.helper.ts`). On 1.7.3+ the boot DELETES this marker instead:
99
+ * should the project roll back to a better-auth that needs the issuer, the backfill then runs again
100
+ * and repairs the accounts written in the meantime, rather than being skipped by a stale marker.
91
101
  *
92
102
  * The marker exists for cost, not for correctness: the backfill is idempotent and safe to repeat,
93
103
  * but its filters (`$exists: false`, `$ne`) cannot use an index, so without a marker every boot of
94
- * every replica pays a full pass over the account collection — forever, and inside the `await` that
95
- * precedes `app.listen()`. Versioned in the id so a future backfill of the same field can run
96
- * again without clearing this one.
104
+ * every replica pays a full pass over the account collection. Versioned in the id so a future
105
+ * backfill of the same field can run again without clearing this one.
97
106
  */
98
107
  export const BACKFILL_MARKER_COLLECTION = 'better-auth-backfills';
99
108
  export const ACCOUNT_ISSUER_BACKFILL_ID = 'account-issuer-backfill-v1';
@@ -1,4 +1,3 @@
1
- import { createLocalAccountIssuer } from '@better-auth/core/db';
2
1
  import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
3
2
  import { InjectConnection } from '@nestjs/mongoose';
4
3
  import { Request } from 'express';
@@ -12,6 +11,7 @@ import { ConfigService } from '../../common/services/config.service';
12
11
  import { ErrorCode } from '../error-code/error-codes';
13
12
  import { resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
14
13
  import { BetterAuthInstance } from './better-auth.config';
14
+ import { legacyCredentialAccountIssuer, usesLegacyAccountIssuer } from './core-better-auth-account-issuer.helper';
15
15
  import { isJwtShaped } from './core-better-auth-token.helper';
16
16
  import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
17
17
  import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
@@ -21,6 +21,7 @@ import {
21
21
  BETTER_AUTH_CONFIG,
22
22
  BETTER_AUTH_COOKIE_DOMAIN,
23
23
  BETTER_AUTH_INSTANCE,
24
+ DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD,
24
25
  DEFAULT_ACCOUNT_ISSUER_FIELD,
25
26
  DEFAULT_ACCOUNT_MODEL_NAME,
26
27
  } from './core-better-auth.constants';
@@ -113,12 +114,21 @@ export class CoreBetterAuthService implements OnModuleInit {
113
114
  *
114
115
  * Order matters: the backfill's update can use the `{ providerId: 1, userId: 1 }` index created
115
116
  * below, so indices come first.
117
+ *
118
+ * Which account step runs depends on the INSTALLED better-auth, not on this package's version:
119
+ * 1.7.0–1.7.2 key accounts by issuer and need the backfill; 1.7.3+ dropped the issuer and need the
120
+ * unique index those versions left behind removed instead. A project that has not raised its
121
+ * better-auth pin yet therefore keeps working exactly as before.
116
122
  */
117
123
  async onModuleInit(): Promise<void> {
118
124
  if (!this.isEnabled() || !this.connection?.db) return;
119
125
 
120
126
  await this.ensureIndices();
121
- await this.backfillAccountIssuers();
127
+ if (usesLegacyAccountIssuer()) {
128
+ await this.backfillAccountIssuers();
129
+ } else {
130
+ await this.dropLegacyAccountIssuerIndex();
131
+ }
122
132
  }
123
133
 
124
134
  /**
@@ -195,6 +205,11 @@ export class CoreBetterAuthService implements OnModuleInit {
195
205
  protected async backfillAccountIssuers(): Promise<void> {
196
206
  if (!this.isEnabled() || !this.connection?.db) return;
197
207
 
208
+ // Only better-auth 1.7.0–1.7.2 key accounts by issuer. On 1.7.3+ there is nothing to backfill —
209
+ // and nothing to derive the value from.
210
+ const credentialIssuer = legacyCredentialAccountIssuer();
211
+ if (!credentialIssuer) return;
212
+
198
213
  const db = this.connection.db;
199
214
 
200
215
  // The consumer can rename both of these through `betterAuth.options.account`, which
@@ -243,7 +258,7 @@ export class CoreBetterAuthService implements OnModuleInit {
243
258
  pending.map((doc) => ({
244
259
  updateOne: {
245
260
  filter: { _id: doc._id },
246
- update: { $set: { [issuerField]: createLocalAccountIssuer('credential') } },
261
+ update: { $set: { [issuerField]: credentialIssuer } },
247
262
  },
248
263
  })),
249
264
  { ordered: false },
@@ -306,6 +321,75 @@ export class CoreBetterAuthService implements OnModuleInit {
306
321
  }
307
322
  }
308
323
 
324
+ /**
325
+ * Drops the unique `(issuer, accountId)` index better-auth 1.7.0–1.7.2 created on the account
326
+ * collection, and forgets the issuer-backfill marker. Runs on better-auth 1.7.3+ only.
327
+ *
328
+ * better-auth 1.7.0 keyed accounts by `(issuer, accountId)`, and its MongoDB adapter created that
329
+ * index lazily. 1.7.3 restored the 1.6 schema — `(providerId, accountId)` — and no longer writes
330
+ * `issuer`. The index stays behind in every database that ran those versions (this framework
331
+ * required them from 11.37.0 to 11.41.7), and with the field gone it treats every new account as
332
+ * having the same, missing issuer: a user who holds the same account ID at two providers can no
333
+ * longer link the second one. MongoDB has no `NOT NULL`, so dropping the index is the whole
334
+ * cleanup — upstream's 1.7 upgrade guide, "Account identity keeps the provider key". Existing
335
+ * `issuer` values are inert and stay, which also keeps a rollback possible.
336
+ *
337
+ * The marker is deleted for the same rollback: back on a better-auth that needs the issuer, the
338
+ * backfill must run again and repair the accounts written in the meantime instead of being
339
+ * skipped as "already completed".
340
+ *
341
+ * The index is recognised by its SHAPE — unique, keyed exactly on the issuer and account-id fields
342
+ * — not by name: better-auth derives the name from the collection and field names (by default
343
+ * `account_issuer_accountId_uidx`), and a consumer can rename both through
344
+ * `betterAuth.options.account`.
345
+ *
346
+ * Idempotent and cheap: one `listIndexes` and one `deleteOne` per boot. Never fatal — a failure is
347
+ * logged as an error and the server still starts.
348
+ */
349
+ protected async dropLegacyAccountIssuerIndex(): Promise<void> {
350
+ if (!this.isEnabled() || !this.connection?.db) return;
351
+
352
+ const db = this.connection.db;
353
+ const accountOptions = (this.authInstance as any)?.options?.account;
354
+ const modelName: string = accountOptions?.modelName ?? DEFAULT_ACCOUNT_MODEL_NAME;
355
+ const issuerField: string = accountOptions?.fields?.issuer ?? DEFAULT_ACCOUNT_ISSUER_FIELD;
356
+ const accountIdField: string = accountOptions?.fields?.accountId ?? DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD;
357
+
358
+ try {
359
+ await db.collection(BACKFILL_MARKER_COLLECTION).deleteOne({ _id: ACCOUNT_ISSUER_BACKFILL_ID as any });
360
+
361
+ const accounts = db.collection(modelName);
362
+ let indexes: { key?: Record<string, unknown>; name?: string; unique?: boolean }[];
363
+ try {
364
+ indexes = await accounts.indexes();
365
+ } catch (error) {
366
+ // A collection that does not exist yet has no index to drop (NamespaceNotFound).
367
+ if ((error as { code?: number })?.code === 26) return;
368
+ throw error;
369
+ }
370
+
371
+ const legacy = indexes.filter(
372
+ (index) =>
373
+ index.unique === true &&
374
+ !!index.name &&
375
+ Object.keys(index.key ?? {}).join(',') === `${issuerField},${accountIdField}`,
376
+ );
377
+ for (const index of legacy) {
378
+ await accounts.dropIndex(index.name as string);
379
+ this.logger.log(
380
+ `Dropped the unique index "${index.name}" on ${modelName}(${issuerField}, ${accountIdField}): ` +
381
+ 'better-auth >= 1.7.3 no longer writes the issuer, and the index would refuse new accounts.',
382
+ );
383
+ }
384
+ } catch (error) {
385
+ this.logger.error(
386
+ `Could not drop the legacy unique (${issuerField}, ${accountIdField}) index on "${modelName}": ` +
387
+ `${error instanceof Error ? error.message : 'unknown'}. Linking an account whose ID another ` +
388
+ 'provider already uses will fail until it is dropped by hand.',
389
+ );
390
+ }
391
+ }
392
+
309
393
  /**
310
394
  * Checks if better-auth is enabled and initialized
311
395
  * Returns true only if:
@@ -1,10 +1,10 @@
1
- import { createLocalAccountIssuer } from '@better-auth/core/db';
2
1
  import { ForbiddenException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
3
2
  import { InjectConnection } from '@nestjs/mongoose';
4
3
  import { isEmail } from 'class-validator';
5
4
  import { Connection } from 'mongoose';
6
5
 
7
6
  import { ConfigService } from '../../common/services/config.service';
7
+ import { legacyCredentialAccountIssuer } from '../better-auth/core-better-auth-account-issuer.helper';
8
8
  import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.mapper';
9
9
  import { CoreBetterAuthService } from '../better-auth/core-better-auth.service';
10
10
  import { ErrorCode } from '../error-code/error-codes';
@@ -305,18 +305,22 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
305
305
 
306
306
  // Hash password and create credential account
307
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.
308
+ // better-auth 1.7.0–1.7.2 key accounts by (issuer, accountId) and require the issuer;
309
+ // credential accounts carry a synthetic one, always derived via the helper, never a
310
+ // hand-written literal. better-auth 1.7.3+ went back to (providerId, accountId) and knows no
311
+ // issuer field, so the helper answers `undefined` there and nothing is written.
312
+ //
313
+ // Cast to the adapter's OWN parameter type: against better-auth 1.7.0–1.7.2 that type requires
314
+ // `issuer`, and a conditional spread reads as optional — a vendored core compiled against
315
+ // those versions would otherwise not build.
316
+ const credentialIssuer = legacyCredentialAccountIssuer();
313
317
  await context.internalAdapter.linkAccount({
314
318
  accountId: iamUser.id,
315
- issuer: createLocalAccountIssuer('credential'),
319
+ ...(credentialIssuer ? { issuer: credentialIssuer } : {}),
316
320
  password: hashedPassword,
317
321
  providerId: 'credential',
318
322
  userId: iamUser.id,
319
- });
323
+ } as Parameters<typeof context.internalAdapter.linkAccount>[0]);
320
324
 
321
325
  // Sync to nest-server users collection
322
326
  const syncedUser = await this.userMapper.linkOrCreateUser({