@luckystack/login 0.1.8 → 0.1.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.
@@ -1,219 +1,219 @@
1
- # Credentials auth
2
-
3
- Deep-dive on the email + password authentication flow exposed by `@luckystack/login`. This document covers the dispatcher contract, input normalization, validation, the underlying register / login functions, and the hooks fired along the way. All references point at the canonical implementation in [`./src/login.ts`](../src/login.ts).
4
-
5
- ---
6
-
7
- ## The two-in-one dispatcher
8
-
9
- The HTTP `/auth/api/credentials` route — wired by `@luckystack/server` — calls a single entry point:
10
-
11
- ```ts
12
- import { loginWithCredentials } from '@luckystack/login';
13
-
14
- const result = await loginWithCredentials({
15
- email,
16
- password,
17
- name, // present only when the client is registering
18
- confirmPassword, // present only when the client is registering
19
- });
20
- ```
21
-
22
- `loginWithCredentials` is a thin dispatcher. It inspects the shape of the body to decide whether the caller is logging in or registering:
23
-
24
- ```ts
25
- if (creds.name && creds.confirmPassword) {
26
- return registerWithCredentials({ ... });
27
- }
28
- return loginWithCredentialsCore({ ... });
29
- ```
30
-
31
- The rule is intentionally minimal: presence of both `name` and `confirmPassword` triggers the register branch; any other shape (just `email` + `password`) triggers the login branch. There is no `mode` parameter, no second route, and no separate URL — one POST handles both flows so the front-end can ship one form component.
32
-
33
- When you wire a non-HTTP auth surface (CLI, custom socket event, an admin "impersonate" tool) and you already know which side you want, skip the dispatcher and call `loginWithCredentialsCore` or `registerWithCredentials` directly. The dispatcher is purely a convenience for the unified HTTP route.
34
-
35
- ## Signatures
36
-
37
- ```ts
38
- // Dispatcher
39
- const loginWithCredentials = async (params: {
40
- email?: string;
41
- password?: string;
42
- name?: string;
43
- confirmPassword?: string;
44
- }) => Promise<
45
- | { status: true; reason: string; newToken?: string; session: SessionLayout }
46
- | { status: false; reason: string }
47
- >;
48
-
49
- // Login-only entry point
50
- const loginWithCredentialsCore = async (input: {
51
- email: string;
52
- password: string;
53
- }) => Promise<
54
- | { status: true; reason: 'login.loggedIn'; newToken: string; session: SessionLayout }
55
- | { status: false; reason: string }
56
- >;
57
-
58
- // Register-only entry point
59
- const registerWithCredentials = async (input: {
60
- email: string;
61
- password: string;
62
- name: string;
63
- confirmPassword: string;
64
- }) => Promise<
65
- | { status: true; reason: 'login.userCreated'; session: SessionLayout }
66
- | { status: false; reason: string }
67
- >;
68
- ```
69
-
70
- `reason` is always an i18n key — the installer-side translator turns it into a human-readable message. Never render it raw. See [`docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md) for the full list of canonical reason keys.
71
-
72
- ## Input normalization
73
-
74
- `normalizeCredentials(params)` runs first, producing a `NormalizedCredentials` object that the rest of the flow consumes. Each transformation exists for a specific reason:
75
-
76
- | Field | Transformation | Why |
77
- | ----------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
78
- | `email` | `trim().toLowerCase()` | Case-insensitive lookup; trailing/leading whitespace stripped so a copy-paste from a chat client doesn't break the find. |
79
- | `password` | Left raw | Bcrypt operates on bytes. The password never reaches HTML, JSON-as-HTML, or a SQL string interpolation, so HTML-escaping it is harmful. |
80
- | `name` | `validator.escape(name)` when present | The name IS surfaced to the UI as text. Pre-escape `<`, `>`, `&`, `"`, `'` to neutralize stored XSS in any consumer that renders it raw. |
81
- | `confirmPassword` | Left raw | Must be byte-identical to `password` for the `password !== confirmPassword` equality check. Escaping only one half breaks special chars. |
82
-
83
- The "escape `confirmPassword` too" mistake is the subject of a long-standing comment in `normalizeCredentials` — that bug previously made any password containing `&`, `<`, `>`, `"`, or `'` fail registration silently. Don't reintroduce it.
84
-
85
- ## Shape validation
86
-
87
- `validateCredentialsShape(creds)` is the next gate. It returns `{ status: false, reason }` on the first failure, or `null` when every check passes:
88
-
89
- 1. **Empty check** — `email` and `password` must both be non-empty. Reason: `login.empty`.
90
- 2. **Email length** — capped at `auth.emailMaxLength`. Reason: `login.emailCharacterLimit`.
91
- 3. **Name length** — only checked when `name` is present (register branch). Capped at `auth.nameMaxLength`. Reason: `login.nameCharacterLimit`.
92
- 4. **Email format** — `validator.isEmail(email)` after normalization. Reason: `login.invalidEmailFormat`.
93
- 5. **Password policy** — `validatePassword(password)` from [`./src/passwordPolicy.ts`](../src/passwordPolicy.ts). Returns a reason key (`login.passwordCharacterMinimum`, `login.passwordRequiresUppercase`, `login.passwordTooCommon`, etc.) or `null`.
94
-
95
- Length limits live in `ProjectConfig.auth` and are resolved at call time, so consumers can override them through `registerProjectConfig` without restarting the framework.
96
-
97
- ## Register flow
98
-
99
- `registerWithCredentials` is the canonical "new user" path:
100
-
101
- 1. **Confirm-password match** — `password !== confirmPassword` fails with `login.passwordNotMatch`.
102
- 2. **`preRegister` hook** — fires with `{ email, provider: 'credentials', name }`. A handler can return a `HookStopSignal` (`{ stop: true, errorCode: '...' }`) to abort; the signal's `errorCode` becomes the `reason`.
103
- 3. **Duplicate-email guard** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. A match returns `login.emailExists`.
104
- 4. **Hash the password** — `bcrypt.genSalt(auth.bcryptRounds)` → `bcrypt.hash(plaintext, salt)`. The default `bcryptRounds` is 10; tighten it on production deployments behind a sufficient CPU budget.
105
- 5. **Create the user** — `userAdapter.create({...})` with the assembled record. `avatarFallback` is a random hex color so the avatar component has something to render until the user uploads an image.
106
- 6. **`postRegister` hook** — fires with `{ userId, provider: 'credentials' }`.
107
- 7. **Return** — `{ status: true, reason: 'login.userCreated', session: sanitizeUserForSession(user) }`.
108
-
109
- Note that registration does NOT auto-login. The dispatcher returns the sanitized user record so the front-end can show a "welcome" screen, but `newToken` is undefined — the user follows the register response with an explicit login (or the front-end posts the same credentials through the login branch). Auto-login on register is something a consumer can layer on top by chaining the two calls.
110
-
111
- ## Login flow
112
-
113
- `loginWithCredentialsCore` is the "existing user" path:
114
-
115
- 1. **`preLogin` hook** — fires with `{ email, provider: 'credentials' }`. Same stop-signal contract as `preRegister`.
116
- 2. **User lookup** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. Missing user returns `login.userNotFound`.
117
- 3. **Password check** — `bcrypt.compare(plaintext, user.password)`. Mismatch returns `login.wrongPassword`. (We do not differentiate "no user" from "wrong password" in production — see [`docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md) for the anti-enumeration design. The two reason keys exist for dev-mode diagnostics only.)
118
- 4. **Mint token** — 32 random bytes, hex-encoded, 64 chars total.
119
- 5. **Best-effort `lastLogin` update** — `userAdapter.update(id, { lastLogin: new Date() })` wrapped in `tryCatch`. Silently no-ops if the column doesn't exist on the consumer's schema.
120
- 6. **Build the session** — `sanitizeUserForSession(user)` strips the password hash, then `token`, `lastLogin`, `previousLogin` are layered on. The avatar resolution step below is run before saving.
121
- 7. **Avatar resolution** — if `<uploadsDir>/<userId>.webp` exists, set `session.avatar = '<userId>.webp'`. The path is resolved at call time via `getUploadsDir()` so consumer overrides win.
122
- 8. **Save the session** — `saveSession(token, user, true)`. The `true` flag triggers `preSessionCreate` / `postSessionCreate`, runs single-session enforcement, mints a CSRF token, and broadcasts the new session over Socket.io.
123
- 9. **`postLogin` hook** — `{ userId, provider: 'credentials', isNewUser: false, token }`.
124
- 10. **Return** — `{ status: true, reason: 'login.loggedIn', newToken, session }`.
125
-
126
- `isNewUser` is always `false` for credentials login. It exists in the payload because `loginCallback` (OAuth) shares the same `postLogin` hook and DOES distinguish first-time-vs-returning, and we want a single audit hook to handle both.
127
-
128
- ## Session sanitization
129
-
130
- `sanitizeUserForSession<T extends { password?: unknown }>(user)` strips the `password` key via destructuring:
131
-
132
- ```ts
133
- const sanitizeUserForSession = <T extends { password?: unknown }>(user: T): Omit<T, 'password'> => {
134
- const { password: _password, ...safeUser } = user;
135
- return safeUser;
136
- };
137
- ```
138
-
139
- The bcrypt hash should never leave the server. The session is broadcast to clients (`socketEventNames.updateSession`), so a `password` field on it would be a stored-credential leak. Every login path runs through this helper before `saveSession`.
140
-
141
- ## `lastLogin` and `previousLogin`
142
-
143
- Both fields are surfaced on the session layout so the UI can show "Last signed in 3 days ago" without an extra round-trip:
144
-
145
- - `previousLogin` — the value of `user.lastLogin` BEFORE we update it. Equals `null` for the very first login.
146
- - `lastLogin` — the timestamp we just wrote.
147
-
148
- Both are best-effort. The framework wraps the adapter update in `tryCatch` so older schemas missing the column won't break login. If your `User` model doesn't have a `lastLogin` column, the fields will be `undefined` on the session and the UI should treat them as optional.
149
-
150
- ## Reason-key reference (credentials only)
151
-
152
- | Reason key | Meaning |
153
- | ----------------------------------- | -------------------------------------------------------------------- |
154
- | `login.empty` | Email or password was blank after normalization. |
155
- | `login.invalidEmailFormat` | `validator.isEmail` rejected the address. |
156
- | `login.emailCharacterLimit` | Email exceeded `auth.emailMaxLength`. |
157
- | `login.nameCharacterLimit` | Name exceeded `auth.nameMaxLength`. |
158
- | `login.passwordCharacterMinimum` | Password shorter than `auth.passwordPolicy.minLength`. |
159
- | `login.passwordCharacterLimit` | Password longer than `auth.passwordPolicy.maxLength`. |
160
- | `login.passwordRequiresUppercase` | Policy requires `[A-Z]`. |
161
- | `login.passwordRequiresLowercase` | Policy requires `[a-z]`. |
162
- | `login.passwordRequiresNumber` | Policy requires `\d`. |
163
- | `login.passwordRequiresSpecial` | Policy requires a non-alphanumeric character. |
164
- | `login.passwordTooCommon` | Plaintext appears in the bundled common-passwords blocklist. |
165
- | `login.passwordNotMatch` | Register branch only — `password !== confirmPassword`. |
166
- | `login.emailExists` | Register branch — `findByEmail` returned an existing user. |
167
- | `login.createUserFailed` | Adapter `create` resolved without throwing but returned falsy. |
168
- | `login.userNotFound` | Login branch — no user for `{ email, provider: 'credentials' }`. |
169
- | `login.wrongPassword` | Login branch — bcrypt compare returned false. |
170
- | `login.loggedIn` | Success — login. |
171
- | `login.userCreated` | Success — register. |
172
- | `api.internalServerError` | Adapter threw or another infrastructure error bubbled up. |
173
-
174
- Custom reason keys can be returned by `preLogin` / `preRegister` handlers via the stop-signal contract.
175
-
176
- ## When to call `loginWithCredentialsCore` / `registerWithCredentials` directly
177
-
178
- The dispatcher only exists because the HTTP route is a single POST endpoint that wants one entry point. Prefer the direct functions whenever you control the call site:
179
-
180
- - **Custom CLI** — `lucky user create --email ...` wires `registerWithCredentials` directly. No body-shape inspection needed.
181
- - **Admin "create user" panel** — `registerWithCredentials` from the admin endpoint, gated by an admin guard.
182
- - **Programmatic test setup** — your integration tests likely seed users via `registerWithCredentials` because they know the user doesn't exist yet.
183
- - **Auto-login after register** — call `registerWithCredentials` then `loginWithCredentialsCore` in sequence.
184
-
185
- The dispatcher is safe to use from anywhere — it just adds an unnecessary `if/else` when you already know the branch.
186
-
187
- ## Hook recipes
188
-
189
- ```ts
190
- // Block unverified users
191
- registerHook('preLogin', async ({ email, provider }) => {
192
- if (provider !== 'credentials') return;
193
- const user = await prisma.user.findFirst({ where: { email } });
194
- if (user && !user.verified) {
195
- return { stop: true, errorCode: 'login.notVerified' };
196
- }
197
- });
198
-
199
- // Notify on every new registration
200
- registerHook('postRegister', async ({ userId, provider }) => {
201
- await sendInternalNotification(`new ${provider} user: ${userId}`);
202
- });
203
-
204
- // Defer creating ancillary records until after the user is created
205
- registerHook('postRegister', async ({ userId }) => {
206
- await prisma.userPreferences.create({ data: { userId, theme: 'dark' } });
207
- });
208
- ```
209
-
210
- See [`./hooks.md`](./hooks.md) for the full hook reference and the stop-signal contract.
211
-
212
- ## Related
213
-
214
- - [`./password-reset.md`](./password-reset.md) — forgot-password + in-session password change.
215
- - [`./user-adapter.md`](./user-adapter.md) — replacing the Prisma-backed user store.
216
- - [`./hooks.md`](./hooks.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` payload reference.
217
- - [`./session-management.md`](./session-management.md) — what `saveSession(token, user, true)` does after a successful login.
218
- - [`./oauth-providers.md`](./oauth-providers.md) — the OAuth alternative to credentials auth.
219
- - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
1
+ # Credentials auth
2
+
3
+ Deep-dive on the email + password authentication flow exposed by `@luckystack/login`. This document covers the dispatcher contract, input normalization, validation, the underlying register / login functions, and the hooks fired along the way. All references point at the canonical implementation in [`./src/login.ts`](../src/login.ts).
4
+
5
+ ---
6
+
7
+ ## The two-in-one dispatcher
8
+
9
+ The HTTP `/auth/api/credentials` route — wired by `@luckystack/server` — calls a single entry point:
10
+
11
+ ```ts
12
+ import { loginWithCredentials } from '@luckystack/login';
13
+
14
+ const result = await loginWithCredentials({
15
+ email,
16
+ password,
17
+ name, // present only when the client is registering
18
+ confirmPassword, // present only when the client is registering
19
+ });
20
+ ```
21
+
22
+ `loginWithCredentials` is a thin dispatcher. It inspects the shape of the body to decide whether the caller is logging in or registering:
23
+
24
+ ```ts
25
+ if (creds.name && creds.confirmPassword) {
26
+ return registerWithCredentials({ ... });
27
+ }
28
+ return loginWithCredentialsCore({ ... });
29
+ ```
30
+
31
+ The rule is intentionally minimal: presence of both `name` and `confirmPassword` triggers the register branch; any other shape (just `email` + `password`) triggers the login branch. There is no `mode` parameter, no second route, and no separate URL — one POST handles both flows so the front-end can ship one form component.
32
+
33
+ When you wire a non-HTTP auth surface (CLI, custom socket event, an admin "impersonate" tool) and you already know which side you want, skip the dispatcher and call `loginWithCredentialsCore` or `registerWithCredentials` directly. The dispatcher is purely a convenience for the unified HTTP route.
34
+
35
+ ## Signatures
36
+
37
+ ```ts
38
+ // Dispatcher
39
+ const loginWithCredentials = async (params: {
40
+ email?: string;
41
+ password?: string;
42
+ name?: string;
43
+ confirmPassword?: string;
44
+ }) => Promise<
45
+ | { status: true; reason: string; newToken?: string; session: SessionLayout }
46
+ | { status: false; reason: string }
47
+ >;
48
+
49
+ // Login-only entry point
50
+ const loginWithCredentialsCore = async (input: {
51
+ email: string;
52
+ password: string;
53
+ }) => Promise<
54
+ | { status: true; reason: 'login.loggedIn'; newToken: string; session: SessionLayout }
55
+ | { status: false; reason: string }
56
+ >;
57
+
58
+ // Register-only entry point
59
+ const registerWithCredentials = async (input: {
60
+ email: string;
61
+ password: string;
62
+ name: string;
63
+ confirmPassword: string;
64
+ }) => Promise<
65
+ | { status: true; reason: 'login.userCreated'; session: SessionLayout }
66
+ | { status: false; reason: string }
67
+ >;
68
+ ```
69
+
70
+ `reason` is always an i18n key — the installer-side translator turns it into a human-readable message. Never render it raw. See [`docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md) for the full list of canonical reason keys.
71
+
72
+ ## Input normalization
73
+
74
+ `normalizeCredentials(params)` runs first, producing a `NormalizedCredentials` object that the rest of the flow consumes. Each transformation exists for a specific reason:
75
+
76
+ | Field | Transformation | Why |
77
+ | ----------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
78
+ | `email` | `trim().toLowerCase()` | Case-insensitive lookup; trailing/leading whitespace stripped so a copy-paste from a chat client doesn't break the find. |
79
+ | `password` | Left raw | Bcrypt operates on bytes. The password never reaches HTML, JSON-as-HTML, or a SQL string interpolation, so HTML-escaping it is harmful. |
80
+ | `name` | `validator.escape(name)` when present | The name IS surfaced to the UI as text. Pre-escape `<`, `>`, `&`, `"`, `'` to neutralize stored XSS in any consumer that renders it raw. |
81
+ | `confirmPassword` | Left raw | Must be byte-identical to `password` for the `password !== confirmPassword` equality check. Escaping only one half breaks special chars. |
82
+
83
+ The "escape `confirmPassword` too" mistake is the subject of a long-standing comment in `normalizeCredentials` — that bug previously made any password containing `&`, `<`, `>`, `"`, or `'` fail registration silently. Don't reintroduce it.
84
+
85
+ ## Shape validation
86
+
87
+ `validateCredentialsShape(creds)` is the next gate. It returns `{ status: false, reason }` on the first failure, or `null` when every check passes:
88
+
89
+ 1. **Empty check** — `email` and `password` must both be non-empty. Reason: `login.empty`.
90
+ 2. **Email length** — capped at `auth.emailMaxLength`. Reason: `login.emailCharacterLimit`.
91
+ 3. **Name length** — only checked when `name` is present (register branch). Capped at `auth.nameMaxLength`. Reason: `login.nameCharacterLimit`.
92
+ 4. **Email format** — `validator.isEmail(email)` after normalization. Reason: `login.invalidEmailFormat`.
93
+ 5. **Password policy** — `validatePassword(password)` from [`./src/passwordPolicy.ts`](../src/passwordPolicy.ts). Returns a reason key (`login.passwordCharacterMinimum`, `login.passwordRequiresUppercase`, `login.passwordTooCommon`, etc.) or `null`.
94
+
95
+ Length limits live in `ProjectConfig.auth` and are resolved at call time, so consumers can override them through `registerProjectConfig` without restarting the framework.
96
+
97
+ ## Register flow
98
+
99
+ `registerWithCredentials` is the canonical "new user" path:
100
+
101
+ 1. **Confirm-password match** — `password !== confirmPassword` fails with `login.passwordNotMatch`.
102
+ 2. **`preRegister` hook** — fires with `{ email, provider: 'credentials', name }`. A handler can return a `HookStopSignal` (`{ stop: true, errorCode: '...' }`) to abort; the signal's `errorCode` becomes the `reason`.
103
+ 3. **Duplicate-email guard** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. A match returns `login.emailExists`.
104
+ 4. **Hash the password** — `bcrypt.genSalt(auth.bcryptRounds)` → `bcrypt.hash(plaintext, salt)`. The default `bcryptRounds` is 10; tighten it on production deployments behind a sufficient CPU budget.
105
+ 5. **Create the user** — `userAdapter.create({...})` with the assembled record. `avatarFallback` is a random hex color so the avatar component has something to render until the user uploads an image.
106
+ 6. **`postRegister` hook** — fires with `{ userId, provider: 'credentials' }`.
107
+ 7. **Return** — `{ status: true, reason: 'login.userCreated', session: sanitizeUserForSession(user) }`.
108
+
109
+ Note that registration does NOT auto-login. The dispatcher returns the sanitized user record so the front-end can show a "welcome" screen, but `newToken` is undefined — the user follows the register response with an explicit login (or the front-end posts the same credentials through the login branch). Auto-login on register is something a consumer can layer on top by chaining the two calls.
110
+
111
+ ## Login flow
112
+
113
+ `loginWithCredentialsCore` is the "existing user" path:
114
+
115
+ 1. **`preLogin` hook** — fires with `{ email, provider: 'credentials' }`. Same stop-signal contract as `preRegister`.
116
+ 2. **User lookup** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. Missing user returns `login.userNotFound`.
117
+ 3. **Password check** — `bcrypt.compare(plaintext, user.password)`. Mismatch returns `login.wrongPassword`. (We do not differentiate "no user" from "wrong password" in production — see [`docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md) for the anti-enumeration design. The two reason keys exist for dev-mode diagnostics only.)
118
+ 4. **Mint token** — 32 random bytes, hex-encoded, 64 chars total.
119
+ 5. **Best-effort `lastLogin` update** — `userAdapter.update(id, { lastLogin: new Date() })` wrapped in `tryCatch`. Silently no-ops if the column doesn't exist on the consumer's schema.
120
+ 6. **Build the session** — `sanitizeUserForSession(user)` strips the password hash, then `token`, `lastLogin`, `previousLogin` are layered on. The avatar resolution step below is run before saving.
121
+ 7. **Avatar resolution** — if `<uploadsDir>/<userId>.webp` exists, set `session.avatar = '<userId>.webp'`. The path is resolved at call time via `getUploadsDir()` so consumer overrides win.
122
+ 8. **Save the session** — `saveSession(token, user, true)`. The `true` flag triggers `preSessionCreate` / `postSessionCreate`, runs single-session enforcement, mints a CSRF token, and broadcasts the new session over Socket.io.
123
+ 9. **`postLogin` hook** — `{ userId, provider: 'credentials', isNewUser: false, token }`.
124
+ 10. **Return** — `{ status: true, reason: 'login.loggedIn', newToken, session }`.
125
+
126
+ `isNewUser` is always `false` for credentials login. It exists in the payload because `loginCallback` (OAuth) shares the same `postLogin` hook and DOES distinguish first-time-vs-returning, and we want a single audit hook to handle both.
127
+
128
+ ## Session sanitization
129
+
130
+ `sanitizeUserForSession<T extends { password?: unknown }>(user)` strips the `password` key via destructuring:
131
+
132
+ ```ts
133
+ const sanitizeUserForSession = <T extends { password?: unknown }>(user: T): Omit<T, 'password'> => {
134
+ const { password: _password, ...safeUser } = user;
135
+ return safeUser;
136
+ };
137
+ ```
138
+
139
+ The bcrypt hash should never leave the server. The session is broadcast to clients (`socketEventNames.updateSession`), so a `password` field on it would be a stored-credential leak. Every login path runs through this helper before `saveSession`.
140
+
141
+ ## `lastLogin` and `previousLogin`
142
+
143
+ Both fields are surfaced on the session layout so the UI can show "Last signed in 3 days ago" without an extra round-trip:
144
+
145
+ - `previousLogin` — the value of `user.lastLogin` BEFORE we update it. Equals `null` for the very first login.
146
+ - `lastLogin` — the timestamp we just wrote.
147
+
148
+ Both are best-effort. The framework wraps the adapter update in `tryCatch` so older schemas missing the column won't break login. If your `User` model doesn't have a `lastLogin` column, the fields will be `undefined` on the session and the UI should treat them as optional.
149
+
150
+ ## Reason-key reference (credentials only)
151
+
152
+ | Reason key | Meaning |
153
+ | ----------------------------------- | -------------------------------------------------------------------- |
154
+ | `login.empty` | Email or password was blank after normalization. |
155
+ | `login.invalidEmailFormat` | `validator.isEmail` rejected the address. |
156
+ | `login.emailCharacterLimit` | Email exceeded `auth.emailMaxLength`. |
157
+ | `login.nameCharacterLimit` | Name exceeded `auth.nameMaxLength`. |
158
+ | `login.passwordCharacterMinimum` | Password shorter than `auth.passwordPolicy.minLength`. |
159
+ | `login.passwordCharacterLimit` | Password longer than `auth.passwordPolicy.maxLength`. |
160
+ | `login.passwordRequiresUppercase` | Policy requires `[A-Z]`. |
161
+ | `login.passwordRequiresLowercase` | Policy requires `[a-z]`. |
162
+ | `login.passwordRequiresNumber` | Policy requires `\d`. |
163
+ | `login.passwordRequiresSpecial` | Policy requires a non-alphanumeric character. |
164
+ | `login.passwordTooCommon` | Plaintext appears in the bundled common-passwords blocklist. |
165
+ | `login.passwordNotMatch` | Register branch only — `password !== confirmPassword`. |
166
+ | `login.emailExists` | Register branch — `findByEmail` returned an existing user. |
167
+ | `login.createUserFailed` | Adapter `create` resolved without throwing but returned falsy. |
168
+ | `login.userNotFound` | Login branch — no user for `{ email, provider: 'credentials' }`. |
169
+ | `login.wrongPassword` | Login branch — bcrypt compare returned false. |
170
+ | `login.loggedIn` | Success — login. |
171
+ | `login.userCreated` | Success — register. |
172
+ | `api.internalServerError` | Adapter threw or another infrastructure error bubbled up. |
173
+
174
+ Custom reason keys can be returned by `preLogin` / `preRegister` handlers via the stop-signal contract.
175
+
176
+ ## When to call `loginWithCredentialsCore` / `registerWithCredentials` directly
177
+
178
+ The dispatcher only exists because the HTTP route is a single POST endpoint that wants one entry point. Prefer the direct functions whenever you control the call site:
179
+
180
+ - **Custom CLI** — `lucky user create --email ...` wires `registerWithCredentials` directly. No body-shape inspection needed.
181
+ - **Admin "create user" panel** — `registerWithCredentials` from the admin endpoint, gated by an admin guard.
182
+ - **Programmatic test setup** — your integration tests likely seed users via `registerWithCredentials` because they know the user doesn't exist yet.
183
+ - **Auto-login after register** — call `registerWithCredentials` then `loginWithCredentialsCore` in sequence.
184
+
185
+ The dispatcher is safe to use from anywhere — it just adds an unnecessary `if/else` when you already know the branch.
186
+
187
+ ## Hook recipes
188
+
189
+ ```ts
190
+ // Block unverified users
191
+ registerHook('preLogin', async ({ email, provider }) => {
192
+ if (provider !== 'credentials') return;
193
+ const user = await prisma.user.findFirst({ where: { email } });
194
+ if (user && !user.verified) {
195
+ return { stop: true, errorCode: 'login.notVerified' };
196
+ }
197
+ });
198
+
199
+ // Notify on every new registration
200
+ registerHook('postRegister', async ({ userId, provider }) => {
201
+ await sendInternalNotification(`new ${provider} user: ${userId}`);
202
+ });
203
+
204
+ // Defer creating ancillary records until after the user is created
205
+ registerHook('postRegister', async ({ userId }) => {
206
+ await prisma.userPreferences.create({ data: { userId, theme: 'dark' } });
207
+ });
208
+ ```
209
+
210
+ See [`./hooks.md`](./hooks.md) for the full hook reference and the stop-signal contract.
211
+
212
+ ## Related
213
+
214
+ - [`./password-reset.md`](./password-reset.md) — forgot-password + in-session password change.
215
+ - [`./user-adapter.md`](./user-adapter.md) — replacing the Prisma-backed user store.
216
+ - [`./hooks.md`](./hooks.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` payload reference.
217
+ - [`./session-management.md`](./session-management.md) — what `saveSession(token, user, true)` does after a successful login.
218
+ - [`./oauth-providers.md`](./oauth-providers.md) — the OAuth alternative to credentials auth.
219
+ - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).