@lenne.tech/nest-server 11.36.4 → 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/configurable-features.md +1 -1
- 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/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -0
- package/dist/core/modules/auth/guards/roles.guard.js +33 -0
- package/dist/core/modules/auth/guards/roles.guard.js.map +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/error-code/error-codes.d.ts +3 -3
- package/dist/core/modules/error-code/error-codes.js +3 -3
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js +4 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js +9 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +25 -1
- package/migration-guides/11.32.x-to-11.33.x.md +37 -4
- package/migration-guides/11.36.4-to-11.36.5.md +184 -0
- package/migration-guides/11.36.x-to-11.37.0.md +344 -0
- package/package.json +17 -4
- package/src/core/modules/ai/core-ai-mcp.controller.ts +9 -3
- package/src/core/modules/auth/guards/roles.guard.ts +68 -0
- 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/error-code/error-codes.ts +13 -3
- package/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md +8 -7
- package/src/core/modules/system-setup/README.md +43 -9
- package/src/core/modules/system-setup/core-system-setup.controller.ts +4 -1
- package/src/core/modules/system-setup/core-system-setup.service.ts +54 -5
|
@@ -620,6 +620,30 @@ async updateUser(...): Promise<User> { ... }
|
|
|
620
620
|
async getPublicUsers(): Promise<User[]> { ... }
|
|
621
621
|
```
|
|
622
622
|
|
|
623
|
+
**Public does not mean anonymous (since 11.36.5).** On a public endpoint (`S_EVERYONE`, or no
|
|
624
|
+
`@Roles` at all) `RolesGuard` still tries to IDENTIFY the caller before granting access —
|
|
625
|
+
Better-Auth first, Passport JWT second, every failure swallowed. Access is granted either way: a
|
|
626
|
+
missing, expired or malformed token can never turn a public endpoint into a 401. The effect is that
|
|
627
|
+
`@CurrentUser()`, `serviceOptions.currentUser` and `securityCheck(user)` see a signed-in caller, so
|
|
628
|
+
a public endpoint can personalise — a search ranking by the caller's own location, a list marking
|
|
629
|
+
the caller's own entries.
|
|
630
|
+
|
|
631
|
+
Before 11.36.5 the guard returned early and `request.user` stayed unset. That mattered **only in
|
|
632
|
+
legacy-JWT deployments**: with Better-Auth enabled, `CoreBetterAuthMiddleware` is applied via
|
|
633
|
+
`forRoutes('(.*)')` and had already set `request.user` before any guard ran. `BetterAuthRolesGuard`
|
|
634
|
+
keeps its early return for exactly that reason — it only runs when Better-Auth is enabled, where the
|
|
635
|
+
middleware has already done the work.
|
|
636
|
+
|
|
637
|
+
Two consequences worth knowing when you own a public endpoint:
|
|
638
|
+
|
|
639
|
+
- **Owner-restricted output widens for its owner.** `@Restricted(S_SELF)` / `@Restricted(S_CREATOR)`
|
|
640
|
+
fields evaluate against `currentUser`; with `undefined` they always stripped. They now appear for
|
|
641
|
+
the caller who owns the record. That is the declared policy finally being applied, not a leak —
|
|
642
|
+
but it may be the first time anyone sees those fields on that route.
|
|
643
|
+
- **An anonymous caller stays ABSENT, not falsy.** Passport answers `false` for "no credentials";
|
|
644
|
+
the guard normalises that away so `@CurrentUser()` yields `undefined` as before. Pinned by
|
|
645
|
+
`tests/public-endpoint-identity.e2e-spec.ts`.
|
|
646
|
+
|
|
623
647
|
**Important:** `@Roles()` already handles JWT authentication internally. Do NOT add `@UseGuards(AuthGuard(JWT))` — it is redundant.
|
|
624
648
|
|
|
625
649
|
#### System Roles (S_ prefix)
|
|
@@ -634,7 +658,7 @@ When `X-Tenant-Id` header is present and a system role grants access, membership
|
|
|
634
658
|
|
|
635
659
|
| System Role | Check Logic | Use Case |
|
|
636
660
|
|-------------|-------------|----------|
|
|
637
|
-
| `S_EVERYONE` | Always true | Public endpoints |
|
|
661
|
+
| `S_EVERYONE` | Always true — access granted unconditionally; the caller is still identified when a valid token is present (see above) | Public endpoints |
|
|
638
662
|
| `S_NO_ONE` | Always false | Permanently locked |
|
|
639
663
|
| `S_USER` | `currentUser` exists | Any authenticated user |
|
|
640
664
|
| `S_VERIFIED` | `user.verified \|\| user.verifiedAt \|\| user.emailVerified` | Email-verified users |
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
| **Bugfixes** | Migration lock gained heartbeat + stale-break + bounded wait; `runOnInit` cron dedup no longer keyed on each replica's boot clock and no longer crashes the process on a failed startup run; BullMQ worker no longer swallows a tick delivered during bootstrap; rate limiters degrade instead of 500-ing on a Redis outage; Hub buffer mirror failures no longer recurse through the Logger they buffer; the email-verification cooldown timer can no longer expire a later cooldown early; a presigned S3 download no longer serves the file under a different name than the streamed one (§14) |
|
|
11
11
|
| **Under the hood (§14)** | Two new MongoDB collections (`s3-files`, `filesystem-files` — include them in backups), new public exports, a bucket-less `s3` block is now ignored, NestJS 11.1.28 → 11.1.29, plus two dependency MAJORS: `js-sha256` 0.12.0 → 1.0.0 (**digests unchanged — no password-hash break**) and `graphql-query-complexity` 1.1.1 → 2.0.0 |
|
|
12
12
|
| **Dependency Housekeeping** | The framework's own `pnpm audit` went from 20 findings to 0. Those are `pnpm-workspace.yaml` overrides, which **do not reach npm consumers** — run your own audit (§13) |
|
|
13
|
-
| **Migration Effort** | **Start with §A and §B — they are the only changes that can break a single-replica project, and they break it silently** (no crash, just 401/403 on `/files/**` and `/tus`). Then **§11a / §11b if your config already contains a `redis` or an `s3` key** — the framework reads both now, and an existing `s3` key makes the boot FAIL until you act. Everything else is opt-in: read §1 if you call or override the rate limiters, §2 if you override Hub services, §4 before your next multi-replica deploy, §10 if you copied the avatar upload from `src/server`, **§11c if you rate-limit behind a reverse proxy** (a boot warning will tell you), §13 for what the security overrides do and do not do for you |
|
|
13
|
+
| **Migration Effort** | **Start with §A and §B — they are the only changes that can break a single-replica project, and they break it silently** (no crash, just 401/403 on `/files/**` and `/tus`). Then **§11a / §11b if your config already contains a `redis` or an `s3` key** — the framework reads both now, and an existing `s3` key makes the boot FAIL until you act. Everything else is opt-in: read §1 if you call or override the rate limiters, §2 if you override Hub services, §4 before your next multi-replica deploy, §10 if you copied the avatar upload from `src/server`, **§11c if you rate-limit behind a reverse proxy** (a boot warning will tell you), §13 for what the security overrides do and do not do for you, and **§5 if any test suite of yours resets the database to replay system setup** — emptying `users` is no longer enough |
|
|
14
14
|
|
|
15
15
|
Most of this release exists to make one deployment shape possible: **more than one replica**. Nothing there is required to keep running on one.
|
|
16
16
|
|
|
@@ -547,9 +547,42 @@ creating, an instance now claims the setup by upserting `{ _id: 'initial-admin'
|
|
|
547
547
|
`system-setup-locks` collection — an atomic operation exactly one instance wins. The others log a
|
|
548
548
|
debug line and skip. If creation then fails, the marker is removed so a later boot can retry.
|
|
549
549
|
|
|
550
|
-
**Action: none.** The unique email index already prevented duplicate *users*; this
|
|
551
|
-
failures and the ambiguity about which replica actually did the setup. A new
|
|
552
|
-
`system-setup-locks` appears in the database with a single small document.
|
|
550
|
+
**Action: none in production.** The unique email index already prevented duplicate *users*; this
|
|
551
|
+
removes the noisy failures and the ambiguity about which replica actually did the setup. A new
|
|
552
|
+
collection `system-setup-locks` appears in the database with a single small document.
|
|
553
|
+
|
|
554
|
+
**Action (test suites only): if you reset the database to replay system setup.** The marker is
|
|
555
|
+
removed only when creation FAILS. After a SUCCESSFUL setup it stays, which is what you want for a
|
|
556
|
+
deployment: setup must never run twice. But it also means that emptying `users` no longer restores
|
|
557
|
+
the fresh-install state, because `createInitialAdmin()` now guards on two conditions, not one. The
|
|
558
|
+
second `POST /system-setup/init` is then refused until the claim goes stale — **five minutes after
|
|
559
|
+
the claim was taken, i.e. after the successful setup, not five minutes after the refusal.** That is
|
|
560
|
+
why the symptom looks intermittent and gets misfiled as flake: a suite resetting ten minutes after
|
|
561
|
+
boot is not blocked at all, one resetting immediately is.
|
|
562
|
+
|
|
563
|
+
Drop the collection wherever you drop `users`:
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
// deleteMany, not drop: dropping `users` would drop its unique email index — the very
|
|
567
|
+
// index that prevents a duplicate admin, credited above. deleteMany preserves indexes.
|
|
568
|
+
for (const col of ['users', 'account', 'session', 'system-setup-locks']) {
|
|
569
|
+
await db.collection(col).deleteMany({});
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
That is the framework's own reset, executed on every test run in
|
|
574
|
+
`tests/stories/system-setup.e2e-spec.ts` — prefer reading it there over copying this snippet, since
|
|
575
|
+
a spec that runs cannot drift. (The whole-database `dropDatabase()` elsewhere in `tests/` is a
|
|
576
|
+
different job: per-run teardown, not a mid-run reset.)
|
|
577
|
+
|
|
578
|
+
Both refusals raise the same `LTNS_0050`. **Up to 11.36.4** its message read *"System setup not
|
|
579
|
+
available - users already exist"*, so a suite hitting the claim was told the users were the problem
|
|
580
|
+
while the users collection was empty. **From 11.36.5** the message names both conditions and the
|
|
581
|
+
server logs a warning naming `system-setup-locks`, the release-on-failure semantics and the stale
|
|
582
|
+
window. If setup fails right after a reset, check this collection before anything else.
|
|
583
|
+
|
|
584
|
+
Note that `GET /system-setup/status` does **not** help here: `needsSetup` is computed from the user
|
|
585
|
+
count alone, so it reports `true` while init is refused by the claim.
|
|
553
586
|
|
|
554
587
|
---
|
|
555
588
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Migration Guide: 11.36.4 → 11.36.5
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None that removes or renames anything. **One behaviour change** on the legacy-JWT path: a signed-in caller is now identified on public endpoints (§2). One **consumer-visible string**: the `LTNS_0050` message and both its translations (§1) |
|
|
8
|
+
| **New Features** | None |
|
|
9
|
+
| **Bugfixes** | A signed-in caller lost their identity on a public endpoint in legacy-JWT deployments (§2). `LTNS_0050` described only one of the two guards that raise it, so a caller that had just emptied `users` was told users already exist while the collection was empty (§1) |
|
|
10
|
+
| **Migration Effort** | **None for most projects.** `pnpm update`. Read **§2 if you run legacy JWT auth WITHOUT Better-Auth and have public endpoints** — that is the only behaviour change here. Read §1 if you assert on the 403 body of `POST /system-setup/init`, or if a test suite of yours resets the database to replay system setup |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server@11.36.5
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. `LTNS_0050` now names both of the conditions that raise it
|
|
23
|
+
|
|
24
|
+
**Were you affected?** Only if you assert on the exact message text of a 403 from
|
|
25
|
+
`POST /system-setup/init`, or if you reset the database in tests to replay the setup flow.
|
|
26
|
+
|
|
27
|
+
### What changed
|
|
28
|
+
|
|
29
|
+
`createInitialAdmin()` refuses under **two** different conditions, and both raise
|
|
30
|
+
`ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE` / `LTNS_0050`:
|
|
31
|
+
|
|
32
|
+
1. Users already exist.
|
|
33
|
+
2. The initial-admin setup is **claimed** — the marker `{ _id: 'initial-admin' }` in
|
|
34
|
+
`system-setup-locks` is held.
|
|
35
|
+
|
|
36
|
+
Up to 11.36.4 the message described only the first:
|
|
37
|
+
|
|
38
|
+
```diff
|
|
39
|
+
- System setup not available - users already exist
|
|
40
|
+
+ System setup not available - users already exist, or the initial-admin setup is claimed
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Both translations (`de`, `en`) moved with it, as did the `@ApiResponse` description on the
|
|
44
|
+
controller and the sample response in the module README.
|
|
45
|
+
|
|
46
|
+
**The code is unchanged.** It is still `LTNS_0050`, still HTTP 403. If you branch on the error
|
|
47
|
+
**code** — which is what the framework asks you to do — nothing breaks. Only an assertion on the
|
|
48
|
+
literal message string does.
|
|
49
|
+
|
|
50
|
+
### Why this was worth a release
|
|
51
|
+
|
|
52
|
+
The second guard is reachable in an ordinary situation that looks nothing like it: the marker is
|
|
53
|
+
released **only when creation fails**, so it outlives a SUCCESSFUL setup by design. Emptying `users`
|
|
54
|
+
to replay the flow therefore leaves setup refused — and the response said *"users already exist"*
|
|
55
|
+
while `users` was empty. That is a message pointing away from the cause, in a flow that runs once per
|
|
56
|
+
deployment and is rarely instrumented. It cost a consumer project a full CI cycle to find.
|
|
57
|
+
|
|
58
|
+
Since 11.36.5 the claim path also logs a warning naming the collection, the release-on-failure
|
|
59
|
+
semantics and the stale window, so the server log answers the question even when the response body is
|
|
60
|
+
all you kept.
|
|
61
|
+
|
|
62
|
+
### If you reset the database in tests
|
|
63
|
+
|
|
64
|
+
Emptying `users` is not enough. Drop the claim collection alongside it:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// deleteMany, not drop: dropping `users` would drop its unique email index,
|
|
68
|
+
// which is what prevents a duplicate admin.
|
|
69
|
+
for (const col of ['users', 'account', 'session', 'system-setup-locks']) {
|
|
70
|
+
await db.collection(col).deleteMany({});
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That is the framework's own reset, executed on every test run in
|
|
75
|
+
`tests/stories/system-setup.e2e-spec.ts`.
|
|
76
|
+
|
|
77
|
+
Two things that will otherwise mislead you:
|
|
78
|
+
|
|
79
|
+
- **`GET /system-setup/status` does not see the claim.** `needsSetup` is computed from the user
|
|
80
|
+
count alone, so it reports `true` while init is refused.
|
|
81
|
+
- **The stale window is measured from when the claim was taken**, not from your reset — five minutes
|
|
82
|
+
after the successful setup. A suite resetting ten minutes after boot is not blocked at all, one
|
|
83
|
+
resetting immediately is. That is why the symptom looks intermittent and gets misfiled as flake.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 2. A signed-in caller is now IDENTIFIED on a public endpoint
|
|
88
|
+
|
|
89
|
+
**Were you affected?** Only if **both** are true: you run **legacy JWT auth without Better-Auth**,
|
|
90
|
+
and you have endpoints carrying `@Roles(RoleEnum.S_EVERYONE)` (or no `@Roles` at all).
|
|
91
|
+
|
|
92
|
+
**With Better-Auth enabled, nothing changes for you.** `CoreBetterAuthMiddleware` is applied via
|
|
93
|
+
`forRoutes('(.*)')`, so it already ran on public routes and set `request.user` before any guard.
|
|
94
|
+
`BetterAuthRolesGuard` is deliberately unchanged for that reason.
|
|
95
|
+
|
|
96
|
+
### What changed
|
|
97
|
+
|
|
98
|
+
`RolesGuard` returned `true` for a public endpoint **without ever looking at the token**, so
|
|
99
|
+
`request.user` stayed unset and a signed-in caller arrived anonymous:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
@Get('search')
|
|
103
|
+
@Roles(RoleEnum.S_EVERYONE) // public — intended, anonymous visitors may search
|
|
104
|
+
search(@CurrentUser() user) {
|
|
105
|
+
// `user` was ALWAYS undefined, even with a valid Bearer token on the request
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Since 11.36.5 the guard identifies first — Better-Auth, then Passport JWT — with **every** failure
|
|
110
|
+
swallowed. A missing, expired or malformed token still means "anonymous": this can never turn a
|
|
111
|
+
public endpoint into a 401. **Access control is untouched.** Only identification is restored.
|
|
112
|
+
|
|
113
|
+
An anonymous caller still yields `undefined`, not a falsy-but-present value — worth stating because
|
|
114
|
+
Passport itself answers `false`, and code written as `if ('user' in request)` would have noticed.
|
|
115
|
+
|
|
116
|
+
### What to check before updating
|
|
117
|
+
|
|
118
|
+
Nothing needs changing to keep working. This is a list of places where behaviour may legitimately
|
|
119
|
+
widen, in order of how likely they are to surprise you:
|
|
120
|
+
|
|
121
|
+
1. **`@Restricted(S_SELF)` / `@Restricted(S_CREATOR)` fields un-hide for their owner.** They
|
|
122
|
+
evaluate against `currentUser`; with `undefined` they always stripped. On a public endpoint
|
|
123
|
+
returning records the caller owns, those fields now appear in the response — for that caller
|
|
124
|
+
only. The decorator sanctions it, but if you treated "this route is public, therefore always
|
|
125
|
+
anonymous, therefore owner-only fields never appear" as a redaction boundary, that assumption
|
|
126
|
+
no longer holds. Audit public endpoints whose response models carry owner-restricted fields.
|
|
127
|
+
2. **`checkRights()` starts receiving a user on these routes.** Both common shapes flip:
|
|
128
|
+
`if (!user) return data;` was a bypass on public routes and is now enforced; `if (!user) throw …`
|
|
129
|
+
refused and now passes.
|
|
130
|
+
3. **Ownership stamping.** `CrudService` writes `createdBy: serviceOptions?.currentUser?.id`. A
|
|
131
|
+
public create called by a signed-in caller now records ownership where it previously wrote
|
|
132
|
+
`undefined` — which later unlocks `S_CREATOR`-restricted fields on that record.
|
|
133
|
+
4. **`securityCheck(user)` on your models** now sees a real user on public endpoints, so a branch
|
|
134
|
+
you wrote as "the anonymous path" stops being the only one taken.
|
|
135
|
+
|
|
136
|
+
Find the places worth looking at:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# public endpoints that branch on the absence of a user
|
|
140
|
+
grep -rn "S_EVERYONE" src/server/ --include="*.ts" | grep -v ".spec.ts"
|
|
141
|
+
grep -rn "if (!currentUser\|if (!user" src/server/ --include="*.ts" | grep -v ".spec.ts"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### If you relied on the old behaviour
|
|
145
|
+
|
|
146
|
+
A public endpoint that must be anonymous for **everyone** should not read `currentUser` at all.
|
|
147
|
+
If you built on "public implies anonymous", make it explicit — the guard no longer enforces it
|
|
148
|
+
for you as a side effect.
|
|
149
|
+
|
|
150
|
+
### Also reachable now: `/ai/mcp` in legacy-JWT deployments
|
|
151
|
+
|
|
152
|
+
`POST /ai/mcp` is `@Roles(S_EVERYONE)` and self-authenticating. In a legacy-JWT deployment it
|
|
153
|
+
previously answered 401 for everyone, because `req.user` was unset and no other resolution path
|
|
154
|
+
applied. It now authenticates any holder of a valid legacy access token. This is not an elevation —
|
|
155
|
+
the MCP server is still built per user, with the tool registry's role filter and the per-owner
|
|
156
|
+
session binding intact — but a surface you may have considered unreachable is now live. If you do
|
|
157
|
+
not want it, set `ai.mcp: false`.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Compatibility Notes
|
|
162
|
+
|
|
163
|
+
| Pattern | Status |
|
|
164
|
+
|---------|--------|
|
|
165
|
+
| Branching on `error.code === 'LTNS_0050'` | Unaffected — the code did not change |
|
|
166
|
+
| Asserting the literal 403 message string | **Update the expected string** (§1) |
|
|
167
|
+
| Displaying the translated message to users | Unaffected, but the text is longer and now mentions the claim |
|
|
168
|
+
| Overriding `CoreSystemSetupService.createInitialAdmin()` | Unaffected — the guard order and the thrown exception are unchanged |
|
|
169
|
+
| A test suite that resets only `users` | Was already broken since 11.33; now the message and the server log say so (§1) |
|
|
170
|
+
| Legacy JWT + public endpoints reading `currentUser` | **Now receives a user** (§2) |
|
|
171
|
+
| Better-Auth enabled (hybrid or IAM-only) | Unaffected by §2 — the middleware already identified the caller |
|
|
172
|
+
| Public endpoint returning owner-restricted fields | Those fields now reach the owner (§2.1) — audit before shipping |
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Troubleshooting
|
|
177
|
+
|
|
178
|
+
**`POST /system-setup/init` returns 403 and `users` is empty.**
|
|
179
|
+
The claim is held. Check `db.getCollection('system-setup-locks').find()`. Either another instance is
|
|
180
|
+
running the setup, or a previous successful setup left the marker. Drop the collection, or wait five
|
|
181
|
+
minutes from when the claim was taken.
|
|
182
|
+
|
|
183
|
+
**A test asserting the 403 message fails after the update.**
|
|
184
|
+
Expected — see §1. Prefer asserting the error **code** (`LTNS_0050`), which is stable.
|
|
@@ -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)
|