@luckystack/login 0.1.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/docs/hooks.md ADDED
@@ -0,0 +1,444 @@
1
+ # Lifecycle hooks
2
+
3
+ Deep-dive on the authentication and session lifecycle hooks that `@luckystack/login` declares, fires, and lets consumers subscribe to. The hook bus itself lives in `@luckystack/core` (`registerHook` / `dispatchHook`); this package extends the core's `HookPayloads` interface via TypeScript module augmentation and dispatches against it from `login.ts`, `logout.ts`, `session.ts`, and `forgotPassword.ts`.
4
+
5
+ Canonical sources:
6
+
7
+ - Payload types: [`./src/hookPayloads.ts`](../src/hookPayloads.ts)
8
+ - Dispatch sites: [`./src/login.ts`](../src/login.ts), [`./src/logout.ts`](../src/logout.ts), [`./src/session.ts`](../src/session.ts), [`./src/forgotPassword.ts`](../src/forgotPassword.ts)
9
+ - Bus implementation: [`@luckystack/core/src/hooks/registry.ts`](../../core/src/hooks/registry.ts)
10
+
11
+ ---
12
+
13
+ ## Module augmentation
14
+
15
+ `@luckystack/login` does not define a parallel hook bus. It augments the shared `HookPayloads` interface that `@luckystack/core` exposes:
16
+
17
+ ```ts
18
+ // packages/login/src/hookPayloads.ts
19
+ declare module '@luckystack/core' {
20
+ interface HookPayloads {
21
+ preLogin: PreLoginPayload;
22
+ postLogin: PostLoginPayload;
23
+ preRegister: PreRegisterPayload;
24
+ postRegister: PostRegisterPayload;
25
+ preLogout: PreLogoutPayload;
26
+ postLogout: PostLogoutPayload;
27
+ preSessionCreate: PreSessionCreatePayload;
28
+ postSessionCreate: PostSessionCreatePayload;
29
+ preSessionDelete: PreSessionDeletePayload;
30
+ postSessionDelete: PostSessionDeletePayload;
31
+ passwordResetRequested: PasswordResetRequestedPayload;
32
+ passwordResetCompleted: PasswordResetCompletedPayload;
33
+ passwordChanged: PasswordChangedPayload;
34
+ }
35
+ }
36
+ ```
37
+
38
+ This module is referenced from `index.ts` as a side-effect import:
39
+
40
+ ```ts
41
+ import './hookPayloads';
42
+ ```
43
+
44
+ The side-effect import has no runtime cost (the file only contains type declarations) but makes the dependency explicit for readers and ensures the augmentation lands in any TypeScript program that imports `@luckystack/login` — without the side-effect import, projects with strict tree-shaking on type-only imports could lose the declaration.
45
+
46
+ `preSessionRefresh` / `postSessionRefresh` are owned by `@luckystack/core` (declared in `packages/core/src/hooks/types.ts`) but dispatched from this package's `getSession`. They are listed here because subscribing to them from `@luckystack/login`-aware code is identical to the auth hooks.
47
+
48
+ ## Hook table
49
+
50
+ | Hook | Aborts on stop signal | Fires from | Async? |
51
+ | -------------------------- | ----------------------------------- | ---------------------------------------------------------------- | ------ |
52
+ | `preLogin` | Yes — login refused with `errorCode` | `loginWithCredentialsCore`, `loginCallback` | Yes |
53
+ | `postLogin` | No — `void` return | `loginWithCredentialsCore` (success), `loginCallback` (success) | Yes |
54
+ | `preRegister` | Yes — register refused | `registerWithCredentials`, `loginCallback` (new OAuth user) | Yes |
55
+ | `postRegister` | No | `registerWithCredentials`, `loginCallback` (new OAuth user) | Yes |
56
+ | `preLogout` | Yes — logout aborted, socket gets `'error'` | `logout` | Yes |
57
+ | `postLogout` | No | `logout` (success) | Yes |
58
+ | `preSessionCreate` | Yes — save skipped | `saveSession(token, user, true)` | Yes |
59
+ | `postSessionCreate` | No | `saveSession(token, user, true)` (success) | Yes |
60
+ | `preSessionDelete` | Yes — delete refused, returns `false` | `deleteSession` | Yes |
61
+ | `postSessionDelete` | No | `deleteSession` (success) | Yes |
62
+ | `preSessionRefresh` | Yes — TTL extend skipped | `getSession` (every authenticated read) | Yes |
63
+ | `postSessionRefresh` | No | `getSession` (every authenticated read) | Yes |
64
+ | `passwordResetRequested` | No — fired for matched AND unmatched | `sendPasswordResetEmail` | Yes (fire-and-forget via `void dispatchHook(...)`) |
65
+ | `passwordResetCompleted` | No | The consumer-side reset-password completion API | Yes |
66
+ | `passwordChanged` | No | The consumer-side in-session password-change API | Yes |
67
+
68
+ `passwordResetCompleted` and `passwordChanged` are declared by this package but dispatched by the installer-side `reset-password/_api/*` and `settings/_api/changePassword_v1` routes — see the `create-luckystack-app` template. They live here because the payload shape is part of the auth contract.
69
+
70
+ ## Stop-signal contract
71
+
72
+ `pre*` hooks can abort the operation by returning a `HookStopSignal`:
73
+
74
+ ```ts
75
+ import { registerHook } from '@luckystack/core';
76
+
77
+ registerHook('preLogin', async ({ email, provider }) => {
78
+ if (provider !== 'credentials') return;
79
+ const user = await prisma.user.findFirst({ where: { email } });
80
+ if (user && !user.verified) {
81
+ return { stop: true, errorCode: 'login.notVerified' };
82
+ }
83
+ });
84
+ ```
85
+
86
+ What "stop" means depends on the hook:
87
+
88
+ | Hook | Stop behavior |
89
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
90
+ | `preLogin` | `loginWithCredentialsCore` / `loginCallback` returns `{ status: false, reason: signal.errorCode }`. No session minted. |
91
+ | `preRegister` | `registerWithCredentials` / `loginCallback` returns the same `{ status: false, reason }`. No user created. |
92
+ | `preLogout` | `logout` warn-logs, emits `socketEventNames.logout, 'error'` to the socket, and returns. The session is NOT deleted. |
93
+ | `preSessionCreate` | `saveSession` warn-logs and returns. The session is NOT written. The active-users set is not updated. |
94
+ | `preSessionDelete` | `deleteSession` warn-logs and returns `false`. The session record stays in storage; no socket gets logged out. |
95
+ | `preSessionRefresh` | The TTL extend is skipped. `getSession` still returns the session — only the sliding-window refresh is gated. |
96
+
97
+ The `errorCode` is propagated to the API caller as the `reason` field (i18n key). For the registers and logins above, custom error codes flow back through the same channel the framework uses for `login.empty` / `login.wrongPassword` / `login.userNotFound`.
98
+
99
+ Multiple handlers run in registration order; the FIRST stop signal wins. Subsequent handlers in the chain do NOT run after a stop:
100
+
101
+ ```ts
102
+ // From core/registry.ts
103
+ for (const handler of handlers) {
104
+ const result = await handler(payload);
105
+ if (result && result.stop) {
106
+ return { stopped: true, signal: result };
107
+ }
108
+ }
109
+ return { stopped: false };
110
+ ```
111
+
112
+ If you want every handler to run regardless of stops (audit-only), subscribe to the `post*` variant instead — those don't honor stop signals because the side-effect has already happened.
113
+
114
+ ## Payload shapes
115
+
116
+ ### `PreLoginPayload`
117
+
118
+ ```ts
119
+ { email: string; provider: string }
120
+ ```
121
+
122
+ Fired BEFORE the user lookup, password compare, or OAuth find-or-create. `provider` is `'credentials'` for the credentials path or `'google'` / `'github'` / etc. for OAuth. The email is normalized (lowercased + trimmed for credentials; whatever the provider returned for OAuth).
123
+
124
+ ### `PostLoginPayload`
125
+
126
+ ```ts
127
+ { userId: string; provider: string; isNewUser: boolean; token: string }
128
+ ```
129
+
130
+ Fired AFTER `saveSession` succeeds. `isNewUser` is `false` for credentials login (registration auto-fires `postRegister` separately) and `true` for first-time OAuth users. `token` is the freshly-minted 64-char hex session token.
131
+
132
+ ### `PreRegisterPayload`
133
+
134
+ ```ts
135
+ { email: string; provider: string; name?: string }
136
+ ```
137
+
138
+ Fired BEFORE `userAdapter.create`. `name` is only present for credentials registration; OAuth users get the display name from the provider profile.
139
+
140
+ ### `PostRegisterPayload`
141
+
142
+ ```ts
143
+ { userId: string; provider: string }
144
+ ```
145
+
146
+ Fired AFTER `userAdapter.create` succeeds. For OAuth, `postRegister` AND `postLogin` both fire (in that order). For credentials, only `postRegister` fires — the user must follow up with a login call to get `postLogin`.
147
+
148
+ ### `PreLogoutPayload` / `PostLogoutPayload`
149
+
150
+ ```ts
151
+ { userId: string | null; token: string | null }
152
+ ```
153
+
154
+ Both fields are nullable because `logout` is sometimes invoked on a socket that already lost its session (the cleanup path). Handlers must guard against null:
155
+
156
+ ```ts
157
+ registerHook('postLogout', async ({ userId, token }) => {
158
+ if (!userId || !token) return; // cleanup-only call, nothing to do
159
+ await auditLog(userId);
160
+ });
161
+ ```
162
+
163
+ ### `PreSessionCreatePayload` / `PostSessionCreatePayload`
164
+
165
+ ```ts
166
+ { token: string; user: BaseSessionLayout; persistent: boolean }
167
+ ```
168
+
169
+ `persistent` is the inverse of `projectConfig.session.basedToken` — `true` means "this session survives a browser restart" (the default), `false` means "session-cookie only". Audit handlers can use this to differentiate a "remember me" login from a one-shot one.
170
+
171
+ `user` is the freshly-sanitized session layout — password already stripped, avatar already resolved. Hooks should treat it as read-only; mutating it does NOT affect the saved session (the framework writes the original reference, but other hooks expect immutability).
172
+
173
+ ### `PreSessionDeletePayload` / `PostSessionDeletePayload`
174
+
175
+ ```ts
176
+ { token: string; userId: string | null }
177
+ ```
178
+
179
+ `userId` is `null` when the session record was already gone by the time `deleteSession` read it (race condition, double-delete, expired-but-not-yet-untracked). Audit handlers that "log every session ending" should use the token prefix as the correlation key and treat a null userId as "we don't know who anymore — log it anyway".
180
+
181
+ ### `PreSessionRefreshPayload` / `PostSessionRefreshPayload`
182
+
183
+ Declared in `@luckystack/core`:
184
+
185
+ ```ts
186
+ { token: string; userId: string; oldTtl: number; newTtl: number }
187
+ // postSessionRefresh additionally has: applied: boolean
188
+ ```
189
+
190
+ Fired on every authenticated `getSession` — i.e. every API call with a valid session cookie. Be careful with side-effects here; subscribing a write to a slow store on this hook will slow down every request.
191
+
192
+ `applied: boolean` on `postSessionRefresh` reflects whether the TTL was actually extended (true) or skipped (false — either because the record was already gone, or because `preSessionRefresh` stopped the extend).
193
+
194
+ ### `PasswordResetRequestedPayload`
195
+
196
+ ```ts
197
+ {
198
+ email: string;
199
+ matched: boolean;
200
+ userId?: string; // only when matched
201
+ token?: string; // only when matched — DO NOT LOG
202
+ ttlSeconds?: number; // only when matched
203
+ }
204
+ ```
205
+
206
+ Fires from `sendPasswordResetEmail` once per request — for both matched and unmatched emails. The unmatched case fires with `matched: false` and no `userId` / `token` / `ttlSeconds`. Anti-enumeration: audit handlers MUST NOT differentiate UX between matched and unmatched (don't bounce the email faster on misses).
207
+
208
+ `token` IS included so that audit code can record the reset-token hash for later forensic correlation, but logging the raw token to disk would defeat the whole point of one-time-use tokens. If you need to record it, hash it first:
209
+
210
+ ```ts
211
+ registerHook('passwordResetRequested', async ({ token, userId, matched }) => {
212
+ if (!matched) return;
213
+ await prisma.auditLog.create({
214
+ data: {
215
+ action: 'passwordResetRequested',
216
+ userId,
217
+ tokenHash: createHash('sha256').update(token).digest('hex'),
218
+ },
219
+ });
220
+ });
221
+ ```
222
+
223
+ ### `PasswordResetCompletedPayload`
224
+
225
+ ```ts
226
+ { userId: string; revokedOtherSessions: boolean }
227
+ ```
228
+
229
+ Fired from the consumer-side reset-password completion endpoint AFTER the new password is written and (optionally) other sessions are revoked. `revokedOtherSessions` reflects whether the API called `revokeUserSessions(userId)` on completion — the bundled template does this; custom completion endpoints may not.
230
+
231
+ ### `PasswordChangedPayload`
232
+
233
+ ```ts
234
+ { userId: string; verifiedCurrent: boolean; revokedOtherSessions: boolean }
235
+ ```
236
+
237
+ Fired from the in-session "change my password" endpoint. `verifiedCurrent` is `true` when the user provided their current password correctly. Some flows allow admins to set a user's password without verification (`verifiedCurrent: false`); audit handlers should branch on this to emit a different log line for admin-initiated changes.
238
+
239
+ ## Dispatch order around a successful login
240
+
241
+ ```
242
+ preLogin
243
+ ├─ stop? → return { status: false, reason: signal.errorCode }
244
+ └─ continue
245
+ [mint token, build session]
246
+ preSessionCreate
247
+ ├─ stop? → return without saving
248
+ └─ continue
249
+ [adapter.setRaw + trackActive + single-session enforcement + broadcast]
250
+ postSessionCreate
251
+ postLogin
252
+ ```
253
+
254
+ Around a successful new-OAuth-user signup the sequence is:
255
+
256
+ ```
257
+ preLogin
258
+ ├─ stop? → return false
259
+ └─ continue
260
+ [provider userinfo fetch]
261
+ preRegister
262
+ ├─ stop? → return false
263
+ └─ continue
264
+ [userAdapter.create]
265
+ preSessionCreate → postSessionCreate (around saveSession)
266
+ postRegister
267
+ postLogin
268
+ ```
269
+
270
+ `postRegister` fires BEFORE `postLogin` for new OAuth users so audit handlers that "send a welcome email" don't race with a "login successful" notification.
271
+
272
+ ## Dispatch order around logout
273
+
274
+ ```
275
+ preLogout
276
+ ├─ stop? → warn-log + socket.emit(logout, 'error') + return
277
+ └─ continue
278
+ [deleteSession (which fires preSessionDelete / postSessionDelete internally)]
279
+ ├─ stop from preSessionDelete? → warn-log + return false
280
+ └─ continue
281
+ [srem activeUsers, socket.leave(token)]
282
+ postLogout
283
+ [socket.emit(logout, 'success')]
284
+ ```
285
+
286
+ The `postLogout` dispatch happens BEFORE the socket emit because subscribers may want to push a final message to the socket before the client clears its sessionStorage. After the emit, the client redirects to `/login` and stops listening.
287
+
288
+ ## Payload-not-mutated rule
289
+
290
+ Handlers must NOT mutate the payload object. The framework passes the same reference to every handler in the chain — mutating it from handler #1 would surprise handler #2.
291
+
292
+ If you need to attach extra state during login (a tenant id, a feature flag, an enrichment that another package wants to read), write it into your own key space using ids from the payload:
293
+
294
+ ```ts
295
+ // Bad — mutates payload
296
+ registerHook('preLogin', async (payload) => {
297
+ payload.email = payload.email.toLowerCase(); // already done!
298
+ (payload as any).tenant = await resolveTenant(payload.email);
299
+ });
300
+
301
+ // Good — write to your own store
302
+ registerHook('preLogin', async ({ email }) => {
303
+ const tenant = await resolveTenant(email);
304
+ await redis.set(`tenant-context:${email}`, tenant.id, 'EX', 300);
305
+ });
306
+
307
+ registerHook('postLogin', async ({ userId, token }) => {
308
+ const tenant = await redis.get(`tenant-context:somehow`);
309
+ // ...wire it to the user's session
310
+ });
311
+ ```
312
+
313
+ For OAuth providers specifically, the dedicated `extraSessionFields` callback (see [`./oauth-providers.md`](./oauth-providers.md)) is the supported way to add per-session extras — it runs after the userinfo fetch and the merged record is broadcast to the client via the session.
314
+
315
+ ## Subscribing — `registerHook` from `@luckystack/core`
316
+
317
+ ```ts
318
+ import { registerHook } from '@luckystack/core';
319
+
320
+ registerHook('postLogin', async ({ userId, provider, isNewUser, token }) => {
321
+ // ...
322
+ });
323
+ ```
324
+
325
+ Subscriptions are global per Node.js process. Order is registration-order; there is no priority system. Inside a single package, group hook registrations into a single boot file so the order is obvious at a glance.
326
+
327
+ `registerHook` is fire-and-add: there is no `unregisterHook`. Subscriptions live for the lifetime of the process. For test isolation, call `clearAllHooks()` (exported from `@luckystack/core`) in your test setup.
328
+
329
+ ## Sentinel values to watch
330
+
331
+ - `PreLogoutPayload.userId` / `.token` are NULLABLE. Cleanup-path logouts (e.g. socket disconnect after a session expired between writes) fire `preLogout` with both fields null. Handlers must guard.
332
+ - `PreSessionDeletePayload.userId` is nullable for the same race-condition reason. Handlers that "log every session deletion" should fall back to the token prefix.
333
+ - `PasswordResetRequestedPayload.userId` / `.token` are absent when `matched === false`. Use the `matched` flag as the discriminator; do NOT key on truthy `userId` alone.
334
+
335
+ ## Audit handler patterns
336
+
337
+ ```ts
338
+ // Per-user "last login" history table (audit-grade vs the best-effort User.lastLogin column)
339
+ registerHook('postLogin', async ({ userId, provider, isNewUser, token }) => {
340
+ await prisma.loginEvent.create({
341
+ data: {
342
+ userId,
343
+ provider,
344
+ isNewUser,
345
+ tokenPrefix: token.slice(0, 8),
346
+ timestamp: new Date(),
347
+ },
348
+ });
349
+ });
350
+
351
+ // Notify on suspicious sign-in (IP change)
352
+ registerHook('postLogin', async ({ userId }) => {
353
+ const ip = currentRequestIp(); // your own AsyncLocalStorage helper
354
+ const last = await prisma.user.findUnique({ where: { id: userId }, select: { lastIp: true } });
355
+ if (last?.lastIp && last.lastIp !== ip) {
356
+ await notifyAccountOwner(userId, ip);
357
+ }
358
+ await prisma.user.update({ where: { id: userId }, data: { lastIp: ip } });
359
+ });
360
+
361
+ // Block unverified users at the gate
362
+ registerHook('preLogin', async ({ email, provider }) => {
363
+ if (provider !== 'credentials') return;
364
+ const user = await prisma.user.findFirst({ where: { email } });
365
+ if (user && !user.verified) {
366
+ return { stop: true, errorCode: 'login.notVerified' };
367
+ }
368
+ });
369
+
370
+ // Tear down per-user caches on logout
371
+ registerHook('postLogout', async ({ userId }) => {
372
+ if (!userId) return;
373
+ cache.del(`user-prefs:${userId}`);
374
+ cache.del(`user-feed:${userId}`);
375
+ });
376
+
377
+ // Mirror new OAuth users into a CRM (postRegister fires BEFORE postLogin for new OAuth users)
378
+ registerHook('postRegister', async ({ userId, provider }) => {
379
+ await crmClient.createContact({ externalId: userId, source: provider });
380
+ });
381
+
382
+ // Audit every session deletion with full stacktrace correlation
383
+ registerHook('postSessionDelete', async ({ token, userId }) => {
384
+ await prisma.auditLog.create({
385
+ data: {
386
+ action: 'sessionDelete',
387
+ userId, // may be null
388
+ tokenPrefix: token.slice(0, 8),
389
+ timestamp: new Date(),
390
+ },
391
+ });
392
+ });
393
+
394
+ // Block logout while a critical operation is in flight
395
+ registerHook('preLogout', async ({ userId }) => {
396
+ if (!userId) return;
397
+ const pending = await prisma.pendingTransaction.count({ where: { userId } });
398
+ if (pending > 0) {
399
+ return { stop: true, errorCode: 'login.pendingTransactionsBlocking' };
400
+ }
401
+ });
402
+
403
+ // Audit password resets (token hashed before logging)
404
+ registerHook('passwordResetRequested', async ({ email, matched, userId, token }) => {
405
+ if (!matched) {
406
+ await prisma.auditLog.create({
407
+ data: { action: 'passwordResetMissed', email, timestamp: new Date() },
408
+ });
409
+ return;
410
+ }
411
+ await prisma.auditLog.create({
412
+ data: {
413
+ action: 'passwordResetRequested',
414
+ userId,
415
+ tokenHash: createHash('sha256').update(token!).digest('hex'),
416
+ timestamp: new Date(),
417
+ },
418
+ });
419
+ });
420
+ ```
421
+
422
+ ## Why no `preLogin` mutate-payload escape hatch
423
+
424
+ A common request is "let `preLogin` set the user's tenant on the session before save". The framework deliberately rejects that pattern:
425
+
426
+ - It would couple the auth contract to every consumer's tenant model.
427
+ - It would let a misbehaving handler nuke the session shape (drop the userId, change the email).
428
+ - Multiple handlers writing the same field race each other.
429
+
430
+ Use one of the supported extension points instead:
431
+
432
+ - **Per-provider OAuth runtime extras** — `extraSessionFields` on the provider definition.
433
+ - **Custom session field, hook-populated** — augment `BaseSessionLayout` via module declaration, then write the field inside `postSessionCreate` via `saveSession(token, { ...user, tenantId }, false)`. The framework treats the second write as an update.
434
+ - **Out-of-band store** — write to your own Redis namespace and look up by `session.id` on the consumer side.
435
+
436
+ ## Related
437
+
438
+ - [`./credentials-auth.md`](./credentials-auth.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` dispatch sites.
439
+ - [`./oauth-providers.md`](./oauth-providers.md) — `extraSessionFields` and OAuth-specific lifecycle.
440
+ - [`./logout-flow.md`](./logout-flow.md) — `preLogout` / `postLogout` and the spurious-logout warn-log.
441
+ - [`./session-management.md`](./session-management.md) — `preSessionCreate` / `postSessionCreate` / `preSessionDelete` / `postSessionDelete` / `preSessionRefresh` / `postSessionRefresh`.
442
+ - [`./password-reset.md`](./password-reset.md) — `passwordResetRequested` / `passwordResetCompleted` / `passwordChanged`.
443
+ - Core hook bus: [`/packages/core/docs/hooks.md`](../../core/docs/hooks.md).
444
+ - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
@@ -0,0 +1,235 @@
1
+ # Logout flow
2
+
3
+ Deep-dive on `logout` and how it interacts with sockets, sessions, hooks, and presence. Canonical source: [`./src/logout.ts`](../src/logout.ts), with cross-cutting flow in [`./src/session.ts`](../src/session.ts).
4
+
5
+ ---
6
+
7
+ ## Entry point
8
+
9
+ ```ts
10
+ logout({
11
+ token: string | null,
12
+ socket: Socket | undefined,
13
+ userId: string | null,
14
+ skipSessionDelete?: boolean,
15
+ }): Promise<void>;
16
+ ```
17
+
18
+ All four arguments are nullable / optional because `logout` is called from three different contexts that each know about different subsets of state:
19
+
20
+ 1. **The socket's `logout` event handler** — has the socket and token; resolves `userId` from the session lookup.
21
+ 2. **`deleteSession`** — has the token and (after a session read) the userId; needs `skipSessionDelete: true` to avoid recursion.
22
+ 3. **Single-session enforcement inside `saveSession`** — has the previous token + socket + userId, calls `logout` to disconnect the old device when the new login arrives.
23
+
24
+ Missing arguments cause the function to warn-log and return early without mutating state. Specifically:
25
+
26
+ - No `socket` → `getLogger().warn('logout: invalid socket')` and return. The session is NOT deleted in this case — there's nothing to emit to, but more importantly, "no socket" is an unexpected state and silently destroying the session would hide the bug.
27
+ - No `token` → `getLogger().warn('logout: no token provided')` and return.
28
+
29
+ If you need to programmatically log out a user without a live socket, call `deleteSession(token)` directly. `logout` is the socket-side wrapper.
30
+
31
+ ## Sequence
32
+
33
+ ```
34
+ preLogout (can stop) → [deleteSession] → srem activeUsers → socket.leave(token) → postLogout → socket.emit(logout, "success")
35
+ ```
36
+
37
+ Step by step, mirroring the source:
38
+
39
+ ### 1. `preLogout` dispatch
40
+
41
+ ```ts
42
+ const preLogoutResult = await dispatchHook('preLogout', { userId, token });
43
+ if (preLogoutResult.stopped) {
44
+ getLogger().warn('logout: aborted by preLogout hook', {
45
+ errorCode: preLogoutResult.signal.errorCode,
46
+ });
47
+ socket.emit(socketEventNames.logout, 'error');
48
+ return;
49
+ }
50
+ ```
51
+
52
+ Payload: `{ userId: string | null, token: string | null }`. Both are nullable because logout is sometimes invoked on a socket that already lost its session (cleanup path).
53
+
54
+ A handler can return `{ stop: true, errorCode: 'someReason' }`. The framework warn-logs the reason and emits `socketEventNames.logout, 'error'` to the socket — the client treats this the same as a network error and stays signed in. Use the stop signal for things like "require an MFA confirmation before logging out" or "block logout while a transaction is still in flight".
55
+
56
+ ### 2. `deleteSession` (skipped when `skipSessionDelete: true`)
57
+
58
+ ```ts
59
+ if (!skipSessionDelete) {
60
+ await deleteSession(token);
61
+ }
62
+ ```
63
+
64
+ `deleteSession` does its own `preSessionDelete` / `postSessionDelete` dispatch (see [`./session-management.md`](./session-management.md)). The `skipSessionDelete` flag exists specifically for the case where `deleteSession` ITSELF called `logout` — if we re-called `deleteSession` from inside, we'd recurse forever (or at least until the second `getRaw` returned null).
65
+
66
+ ### 3. Remove the token from the active-users set
67
+
68
+ ```ts
69
+ if (userId) {
70
+ await redisClient.srem(activeUsersKeyFor(userId), token);
71
+ }
72
+ ```
73
+
74
+ This is the bookkeeping that `revokeUserSessions` / single-session enforcement reads. The set is keyed by user id, with members being currently-valid tokens. Removing the token before the session is gone-from-Redis is fine because `srem` is idempotent and the source of truth for "is this session valid" is the session record, not the set.
75
+
76
+ The set might already not contain the token (e.g. when `deleteSession` already cleaned up via `adapter.untrackActive`). `srem` is idempotent — extra calls are no-ops, not errors.
77
+
78
+ ### 4. Leave the socket room
79
+
80
+ ```ts
81
+ socket.leave(token);
82
+ ```
83
+
84
+ Every authenticated socket joins a Socket.io room named after its session token. Broadcasts to that room are what drive `updateSession` and `sessionReplaced` events. After leaving, the socket no longer receives session-scoped broadcasts.
85
+
86
+ ### 5. `postLogout` dispatch
87
+
88
+ ```ts
89
+ await dispatchHook('postLogout', { userId, token });
90
+ ```
91
+
92
+ Same payload as `preLogout`. Subscribers do their teardown here. Critical example: `@luckystack/presence` registers a `postLogout` handler that cleans up `disconnectTimers` and `tempDisconnectedSockets`. This keeps the login → presence dependency one-way:
93
+
94
+ - `@luckystack/presence` knows about `@luckystack/login` (uses `getSession` / `deleteSession`).
95
+ - `@luckystack/login` does NOT know about `@luckystack/presence`. Presence is just one of many hook subscribers.
96
+
97
+ ### 6. Socket emit (`"success"` or `"error"`)
98
+
99
+ ```ts
100
+ socket.emit(socketEventNames.logout, "success");
101
+ ```
102
+
103
+ The installer-side `SessionProvider` (in `src/_providers/SessionProvider.tsx`) listens for `socketEventNames.logout` and on `"success"`:
104
+
105
+ 1. Clears `sessionStorage`.
106
+ 2. Removes any cached avatar data URLs.
107
+ 3. Redirects to `/login`.
108
+
109
+ On `"error"`, it does nothing — the user stays signed in. The client treats an error the same as a transient network failure.
110
+
111
+ ## Spurious-logout warn-logging
112
+
113
+ The success path emits a noisy warn-level log with a stacktrace:
114
+
115
+ ```ts
116
+ getLogger().warn('[session] logout success — emitting logout to socket', {
117
+ tokenPrefix: token ? token.slice(0, 8) : null,
118
+ userId,
119
+ socketId: socket.id,
120
+ stack: new Error('logout invoked').stack,
121
+ });
122
+ ```
123
+
124
+ This is deliberate — and the comment in the source explains why:
125
+
126
+ > Spurious logouts are always a bug — log loudly so the originating caller is identifiable from the stacktrace. The client receives this success-emit and clears sessionStorage + redirects to /login, so any unexpected occurrence here is the smoking gun.
127
+
128
+ When a production user reports "I keep getting logged out", grepping the logs for this line + correlating the stacktrace tells you whether it was an explicit logout (good), a `revokeUserSessions` call (good but verify), a single-session enforcement kick (good — the user signed in elsewhere), or an unexpected caller (bug). Keep the warn-level — silencing it loses the diagnostic.
129
+
130
+ Tokens are logged at 8-char prefix length so the full token never lands in a log file.
131
+
132
+ ## `skipSessionDelete: true` — when it's correct
133
+
134
+ Used by exactly one caller: `deleteSession` itself, when it walks the socket room for the deleted session:
135
+
136
+ ```ts
137
+ // inside deleteSession, when sockets are still connected
138
+ const { logout } = await import('./logout');
139
+ const sockets = ioInstance.sockets.adapter.rooms.get(token);
140
+ if (sockets) {
141
+ await Promise.all([...sockets].map(async (socketId) => {
142
+ const socket = ioInstance.sockets.sockets.get(socketId);
143
+ if (!socket) return;
144
+ await logout({
145
+ token,
146
+ socket,
147
+ userId: resolvedUserId,
148
+ skipSessionDelete: true, // we're INSIDE deleteSession; do not recurse
149
+ });
150
+ }));
151
+ }
152
+ ```
153
+
154
+ Without the flag, each `logout` call would re-invoke `deleteSession`, which would re-discover the same socket(s), which would re-call `logout`, which would re-invoke `deleteSession` — infinite loop until Redis returned `null` for the second `getRaw`.
155
+
156
+ If you call `logout` directly from anywhere else, leave `skipSessionDelete` undefined / false. The default behavior — `deleteSession` runs inside `logout` — is correct for every other call site.
157
+
158
+ ## How force-logout flows reuse the same chain
159
+
160
+ `revokeUserSessions(userId, exceptToken?)` is the "sign out everywhere" / "sign out other devices" entry point:
161
+
162
+ ```ts
163
+ const revokeUserSessions = async (userId, exceptToken?) => {
164
+ const adapter = getSessionAdapter();
165
+ const tokens = await adapter.listActive(userId);
166
+ const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;
167
+ await Promise.all(targets.map((token) => deleteSession(token)));
168
+ return targets.length;
169
+ };
170
+ ```
171
+
172
+ Each `deleteSession(token)` finds the socket(s) for that token and calls `logout` on them with `skipSessionDelete: true`. The end result for every device is identical to a normal user-initiated logout: `preLogout` fires, `postLogout` fires, sessionStorage is cleared, redirect to `/login`. There is no second code path that "force-logs out" differently — every disconnect goes through the same socket flow.
173
+
174
+ Use cases:
175
+
176
+ - **Password change** — `revokeUserSessions(user.id, currentToken)` so every other device is logged out, but the device that just changed the password stays signed in.
177
+ - **Account deletion** — `revokeUserSessions(user.id)` (no `exceptToken`) before deleting the user record so no socket is holding onto a stale session.
178
+ - **"Sign out everywhere" button** — same as account deletion but without the deletion.
179
+ - **Admin "boot user" tool** — `revokeUserSessions(targetUserId)` from an admin endpoint.
180
+
181
+ ## Single-session enforcement
182
+
183
+ When a user signs in elsewhere and `session.perUser === 'single'`, `saveSession` calls `logout` for each previous token's socket(s). The optional `session.notifyOldDeviceOnRevoke` flag emits `socketEventNames.sessionReplaced` BEFORE the disconnect so the kicked client can show a "you signed in elsewhere" toast:
184
+
185
+ ```ts
186
+ if (sessionCfg.notifyOldDeviceOnRevoke) {
187
+ io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({
188
+ reason: 'session-replaced',
189
+ userId,
190
+ }));
191
+ }
192
+ // ...then logout fires, which emits socketEventNames.logout, "success"
193
+ ```
194
+
195
+ Both events land on the same socket. The installer-side `SessionProvider` handles `sessionReplaced` first (show the toast), then `logout` (clear sessionStorage, redirect).
196
+
197
+ ## Hook recipes
198
+
199
+ ```ts
200
+ // Audit-log every logout
201
+ registerHook('postLogout', async ({ userId, token }) => {
202
+ if (!userId || !token) return;
203
+ await prisma.auditLog.create({
204
+ data: {
205
+ action: 'logout',
206
+ userId,
207
+ tokenPrefix: token.slice(0, 8),
208
+ timestamp: new Date(),
209
+ },
210
+ });
211
+ });
212
+
213
+ // Block logout while a critical transaction is pending
214
+ registerHook('preLogout', async ({ userId }) => {
215
+ if (!userId) return;
216
+ const pending = await prisma.pendingTransaction.count({ where: { userId } });
217
+ if (pending > 0) {
218
+ return { stop: true, errorCode: 'login.pendingTransactionsBlocking' };
219
+ }
220
+ });
221
+
222
+ // Tear down per-user caches (login does NOT know about this — it's a hook subscriber)
223
+ registerHook('postLogout', async ({ userId }) => {
224
+ if (!userId) return;
225
+ cache.del(`user-prefs:${userId}`);
226
+ cache.del(`user-feed:${userId}`);
227
+ });
228
+ ```
229
+
230
+ ## Related
231
+
232
+ - [`./session-management.md`](./session-management.md) — `deleteSession`, the session adapter, sliding expiration.
233
+ - [`./hooks.md`](./hooks.md) — `preLogout` / `postLogout` payloads and the stop-signal contract.
234
+ - [`./credentials-auth.md`](./credentials-auth.md) — the corresponding login flow.
235
+ - Architecture: [`/docs/ARCHITECTURE_SESSION.md`](../../../docs/ARCHITECTURE_SESSION.md).