@lenne.tech/nest-server 11.41.7 → 11.41.9

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 (44) hide show
  1. package/.claude/rules/better-auth.md +26 -22
  2. package/.claude/rules/configurable-features.md +1 -1
  3. package/.claude/rules/testing.md +4 -4
  4. package/CLAUDE.md +5 -3
  5. package/FRAMEWORK-API.md +1 -1
  6. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.d.ts +2 -0
  7. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js +13 -0
  8. package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js.map +1 -0
  9. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +3 -2
  10. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  11. package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +1 -0
  12. package/dist/core/modules/better-auth/core-better-auth.constants.js +2 -1
  13. package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +1 -0
  15. package/dist/core/modules/better-auth/core-better-auth.service.js +46 -3
  16. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  17. package/dist/core/modules/system-setup/core-system-setup.service.js +3 -2
  18. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  19. package/dist/core/modules/tus/core-tus.service.d.ts +11 -0
  20. package/dist/core/modules/tus/core-tus.service.js +54 -14
  21. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  22. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  23. package/dist/core/modules/tus/tus.constants.js +2 -1
  24. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  25. package/dist/core/modules/tus/tus.module.d.ts +2 -0
  26. package/dist/core/modules/tus/tus.module.js +14 -8
  27. package/dist/core/modules/tus/tus.module.js.map +1 -1
  28. package/dist/tsconfig.build.tsbuildinfo +1 -1
  29. package/docs/REQUEST-LIFECYCLE.md +1 -1
  30. package/migration-guides/11.41.7-to-11.41.8.md +135 -0
  31. package/migration-guides/11.41.8-to-11.41.9.md +111 -0
  32. package/package.json +7 -9
  33. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +2 -2
  34. package/src/core/modules/better-auth/README.md +20 -6
  35. package/src/core/modules/better-auth/core-better-auth-account-issuer.helper.ts +25 -0
  36. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -7
  37. package/src/core/modules/better-auth/core-better-auth.constants.ts +20 -11
  38. package/src/core/modules/better-auth/core-better-auth.service.ts +87 -3
  39. package/src/core/modules/system-setup/core-system-setup.service.ts +12 -8
  40. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +36 -11
  41. package/src/core/modules/tus/README.md +79 -14
  42. package/src/core/modules/tus/core-tus.service.ts +125 -30
  43. package/src/core/modules/tus/tus.constants.ts +12 -0
  44. package/src/core/modules/tus/tus.module.ts +41 -8
@@ -165,7 +165,7 @@ JWT-based authentication for existing projects:
165
165
  | **REST Endpoints** | `GET /files/id/:id`, `GET /files/:filename` (core, gated by `file.downloadRoles`, default ADMIN); `POST /files/upload`, `DELETE /files/:id` (project-specific) |
166
166
  | **GraphQL Endpoints** | `getFileInfo` (`file.downloadRoles`), `uploadFile` / `uploadFiles` (`file.uploadRoles`), `deleteFile` (`file.deleteRoles`) — all default ADMIN |
167
167
  | **File access control** | Roles are the coarse filter; per-file rules go in `CoreFileService.checkRights()` using metadata written at upload time. Cover BOTH the `id` and the `filename` branch — the filename route authorizes on the by-name lookup alone when presigned S3 downloads are on, and `deleteFileByName()` always does. Working reference: `src/server/modules/file/file.service.ts` (with `file.downloadRoles: [S_USER]` in `src/config.env.ts`, so the rule is actually reached). Both file classes carry `@SkipTenantCheck()` — GridFS is not tenant-scoped |
168
- | **TUS Module** | Resumable uploads via tus.io protocol (creation, termination, expiration), gated by `tus.roles` (default `S_USER`); `OPTIONS` stays public for the CORS preflight |
168
+ | **TUS Module** | Resumable uploads via tus.io protocol (creation, termination, expiration), gated by `tus.roles` (default `S_USER`); `OPTIONS` stays public for the CORS preflight. Records owner and, with `multiTenancy` only, the validated tenant at upload creation; a custom service plugs in via `TusModule.forRoot({ service })` |
169
169
  | **GridFS Migration** | Completed TUS uploads auto-migrate to GridFS |
170
170
  | **CORS Support** | Automatic CORS headers for browser uploads |
171
171
 
@@ -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
@@ -0,0 +1,111 @@
1
+ # Migration Guide: 11.41.8 → 11.41.9
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
7
+ | **New Features** | A custom TUS service via `TusModule.forRoot({ service })`, and an overridable `onUploadCreate()` (§1) |
8
+ | **Behaviour Changes** | Only with `multiTenancy` configured: TUS uploads record the validated tenant, and a tenant header naming a tenant the caller does not belong to is refused with 403 (§2) |
9
+ | **Breaking Changes** | None for projects without `multiTenancy` |
10
+ | **Migration Effort** | None — `pnpm update`. Projects that want upload rules (quota, validation) adopt §1 |
11
+
12
+ ## Quick Migration
13
+
14
+ ```bash
15
+ pnpm update @lenne.tech/nest-server
16
+ pnpm run build && pnpm test
17
+ ```
18
+
19
+ Vendor-mode projects pick up the core update; nothing else to change.
20
+
21
+ ## What's New in 11.41.9
22
+
23
+ ### 1. A custom TUS service, and a hook before the first byte
24
+
25
+ Until now `TusModule` constructed `CoreTusService` directly in both of its provider factories, so a
26
+ project could not plug in a subclass at all — and `onUploadCreate` sat inline inside a private method,
27
+ so even a subclass could not have changed it. Both are open now:
28
+
29
+ ```typescript
30
+ import { CoreTusService } from '@lenne.tech/nest-server';
31
+ import { Upload } from '@tus/server';
32
+
33
+ export class TusService extends CoreTusService {
34
+ protected override async onUploadCreate(req: any, upload: Upload) {
35
+ const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
36
+ if (!(await quota?.hasRoomFor(req, upload.size))) {
37
+ const error = new Error('Storage quota exceeded');
38
+ (error as any).status_code = 413;
39
+ throw error;
40
+ }
41
+ return super.onUploadCreate(req, upload);
42
+ }
43
+ }
44
+
45
+ // server.module.ts
46
+ TusModule.forRoot({ controller: TusController, service: TusService });
47
+ ```
48
+
49
+ `onUploadCreate(req, upload)` runs once per upload, after the client declared `Upload-Length` and
50
+ before anything is stored — the one point at which a quota can refuse an upload without the store
51
+ having grown first.
52
+
53
+ Three rules, all in the TUS [INTEGRATION-CHECKLIST](../src/core/modules/tus/INTEGRATION-CHECKLIST.md)
54
+ Step 2:
55
+
56
+ - **Keep the constructor signature `(connection, options?)`.** The module constructs the service
57
+ itself, with exactly those arguments. Project providers are reached at call time through
58
+ `this.options?.moduleRef`, which the module now passes in.
59
+ - **Return `super.onUploadCreate()`.** It records the owner and the tenant and enforces
60
+ `allowedTypes`; skipping it produces owner-less uploads and accepts every file type.
61
+ - **Throw an `Error` with `status_code`** to refuse — that is how `@tus/server` turns it into a
62
+ response.
63
+
64
+ ### 2. With multi-tenancy: TUS uploads carry their tenant
65
+
66
+ **Projects without `multiTenancy` see no change** — no header is read, no field is written.
67
+
68
+ With `multiTenancy` configured (and not `enabled: false`), TUS uploads used to carry no tenant at
69
+ all: the routes carry `@SkipTenantCheck()`, the file stores sit outside Mongoose, and so every
70
+ TUS-uploaded file bypassed tenant filtering and any per-tenant storage accounting. Now an upload that
71
+ sends the tenant header is validated against the caller's active memberships — the same check
72
+ GraphQL subscriptions use — and the finished file gets `metadata.tenantId`:
73
+
74
+ | Request | 11.41.8 | 11.41.9 |
75
+ | ---------------------------------------------------- | --------------------- | ----------------------- |
76
+ | No tenant header | tenant-less | tenant-less (unchanged) |
77
+ | Header naming a tenant the caller belongs to | tenant-less | `metadata.tenantId` set |
78
+ | Header naming a tenant the caller does NOT belong to | accepted, tenant-less | **403**, nothing stored |
79
+
80
+ The tenant is recorded under the framework-owned metadata key `TUS_TENANT_METADATA_KEY`
81
+ (`ltTenantId`), next to the owner key. Like the owner key it is always overwritten, so a client
82
+ cannot put a tenant into its own `Upload-Metadata`.
83
+
84
+ **What this changes downstream:** the file module's `'tenant'` access preset and any
85
+ `checkRights()` rule reading `metadata.tenantId` now see the tenant on TUS-uploaded files. Under the
86
+ `'tenant'` preset such files used to be refused for every non-admin (no tenant on the file); new
87
+ uploads are now readable within their tenant. Files uploaded before 11.41.9 keep no tenant.
88
+
89
+ ## Compatibility Notes
90
+
91
+ | Pattern | Status |
92
+ | --------------------------------------------------------------------- | ----------------------------------------------------------------- |
93
+ | `TusModule.forRoot()` / `forRoot({ controller })` | Unchanged |
94
+ | Project without `multiTenancy` | Unchanged — nothing read, nothing written (§2) |
95
+ | Multi-tenant client sending a tenant header it is a member of | Works; files now carry `tenantId` (§2) |
96
+ | Multi-tenant client sending a header for a foreign tenant | Now 403 (§2) |
97
+ | Own `TusModule` replacement that provides a `CoreTusService` subclass | Keeps working; `forRoot({ service })` is now the simpler way (§1) |
98
+
99
+ ## Troubleshooting
100
+
101
+ | Symptom | Cause | Fix |
102
+ | ---------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------- |
103
+ | TUS `POST` answers 403 after the update | The tenant header names a tenant the caller is not an active member of | Send the caller's own tenant, or no header (§2) |
104
+ | An `onUploadCreate` override never runs | The subclass is not registered | `TusModule.forRoot({ service: TusService })` (§1) |
105
+ | Dependencies are `undefined` in the service subclass | Declared as constructor parameters | Resolve via `this.options?.moduleRef` (§1) |
106
+ | Uploads have no `ownerId` after adding an override | `onUploadCreate` override does not call `super` | `return super.onUploadCreate(req, upload)` (§1) |
107
+
108
+ ## Module Documentation
109
+
110
+ - [TUS README](../src/core/modules/tus/README.md) — Custom Service, Validate an Upload Before It Starts, Tenant Scoping
111
+ - [TUS INTEGRATION-CHECKLIST](../src/core/modules/tus/INTEGRATION-CHECKLIST.md) — Step 2
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.9",
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: