@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.
@@ -0,0 +1,286 @@
1
+ # Session management
2
+
3
+ Deep-dive on `saveSession` / `getSession` / `deleteSession`, sliding expiration, single-session enforcement, and the swappable `SessionAdapter`. Canonical sources: [`./src/session.ts`](../src/session.ts) and [`./src/sessionAdapter.ts`](../src/sessionAdapter.ts).
4
+
5
+ ---
6
+
7
+ ## Storage layout (defaults — Redis adapter)
8
+
9
+ | Key | Type | Contents |
10
+ | -------------------------------------------------- | ---------- | --------------------------------------------------------------------------------- |
11
+ | `${projectName}-session:<token>` | string | JSON-encoded `BaseSessionLayout` (id, email, name, csrfToken, custom fields). |
12
+ | `${projectName}-activeUsers:<userId>` | set | Tokens currently associated with this user. Read by single-session enforcement. |
13
+ | `${projectName}-oauth-state:<provider>:<state>` | string | `'1'` sentinel + TTL. Consumed atomically on OAuth callback. (See `login.ts`.) |
14
+ | `${projectName}-pwreset:<token>` | string | `userId` bound to the reset token. (See [`./password-reset.md`](./password-reset.md).) |
15
+
16
+ `projectName` comes from `getProjectName()` in `@luckystack/core` — a single source of truth so the namespace is identical across sessions, activeUsers, OAuth state, password reset, and rate-limit.
17
+
18
+ The keys are exported as helpers for downstream admin tooling that reads Redis directly:
19
+
20
+ ```ts
21
+ import { sessionKeyFor, activeUsersKeyFor } from '@luckystack/login';
22
+
23
+ // In an admin script:
24
+ const raw = await redis.get(sessionKeyFor(token));
25
+ const tokens = await redis.smembers(activeUsersKeyFor(userId));
26
+ ```
27
+
28
+ For new code, prefer `getSessionAdapter()` so the keys stay encapsulated and a non-Redis adapter doesn't break the tooling.
29
+
30
+ ## API surface
31
+
32
+ ```ts
33
+ saveSession(token: string, data: SessionLayout, newUser?: boolean): Promise<void>;
34
+ getSession(token: string | null): Promise<SessionLayout | null>;
35
+ deleteSession(token: string): Promise<boolean>;
36
+ getAllSessions(): Promise<SessionLayout[]>;
37
+ revokeUserSessions(userId: string, exceptToken?: string | null): Promise<number>;
38
+ sessionKeyFor(token: string): string;
39
+ activeUsersKeyFor(userId: string): string;
40
+ ```
41
+
42
+ `SessionLayout` is the framework's `BaseSessionLayout` from `@luckystack/core`, augmentable via module declaration — see [`./oauth-providers.md`](./oauth-providers.md) for the `extraSessionFields` pattern.
43
+
44
+ ## `saveSession` — write path
45
+
46
+ The full sequence on a `newUser: true` save:
47
+
48
+ 1. **`preSessionCreate` dispatch** — `{ token, user, persistent: !session.basedToken }`. A handler can return a stop signal; the warn-log surfaces the `errorCode` and the function returns without writing.
49
+ 2. **CSRF token mint** — if `data.csrfToken` is not present, mint a fresh 32-byte hex token and write it onto the data. Subsequent writes preserve it so the client doesn't need to re-fetch on every session update. Rotated on logout (via `deleteSession`).
50
+ 3. **Adapter write** — `adapter.setRaw(token, JSON.stringify(data), ttl)`. TTL = `session.expiryDays * 86400`.
51
+ 4. **`trackActive`** — `adapter.trackActive(userId, token, ttl)` so the active-tokens set is in sync.
52
+ 5. **Single-session enforcement** — see the next section.
53
+ 6. **Broadcast** — if a socket is in the token room (`io.sockets.adapter.rooms.has(token)`), emit `socketEventNames.updateSession` with the new JSON. Drives client-side session refresh without a round-trip.
54
+ 7. **`postSessionCreate` dispatch** — same payload as the pre-version.
55
+
56
+ On a `newUser: false` save (used for in-place session edits — admin promoting a user, settings update, etc.), steps 1 / 5 / 7 are skipped. Steps 2, 3, 4, 6 still run.
57
+
58
+ `saveSession` is wrapped in `tryCatch` end-to-end; errors are logged but never thrown out of the function. A failed save does not block the request that triggered it — but does mean the session is stale, so the next `getSession` will re-read from Redis if it can.
59
+
60
+ ## Sliding expiration — `getSession`
61
+
62
+ ```
63
+ preSessionRefresh (can stop the TTL extend) → adapter.expire(token, newTtl) → postSessionRefresh
64
+ ```
65
+
66
+ Each authenticated `getSession` call extends the TTL by `session.expiryDays`. That means an active user is never logged out by the framework — only an idle one whose sliding window expires.
67
+
68
+ The hook gate exists for use cases like:
69
+
70
+ - **Admin freezing a session** — `preSessionRefresh` returns a stop signal while the admin reviews the account; the session keeps its current TTL but is not extended further, so it expires naturally even while the user keeps refreshing the page.
71
+ - **Sliding-window analytics** — `postSessionRefresh` receives `{ token, userId, oldTtl, newTtl, applied }` and is the canonical place to track "user is active right now" without re-reading the session.
72
+
73
+ `applied` reflects whether `adapter.expire` actually extended the TTL (true) or was skipped (false — either because the record was already gone, or because `preSessionRefresh` stopped the extend). Audit handlers should branch on `applied` to distinguish "user is active" from "user has a hold on their session".
74
+
75
+ Return value: the parsed `SessionLayout` with the `token` re-injected, or `null` when the token is missing / record is gone / the JSON failed to parse.
76
+
77
+ ## `deleteSession` — write path with visible stacktrace
78
+
79
+ ```ts
80
+ getLogger().warn('[session] deleteSession invoked', {
81
+ tokenPrefix: token.slice(0, 8),
82
+ stack: new Error('deleteSession invoked').stack,
83
+ });
84
+ ```
85
+
86
+ The function intentionally warn-logs with a stacktrace BEFORE doing anything — same reasoning as the `logout` warn-log: spurious deletes silently kick users, and the stacktrace is the smoking gun.
87
+
88
+ After the warn-log:
89
+
90
+ 1. **Read raw** — `adapter.getRaw(token)` to extract `userId` from the JSON. Used as the hook payload's `userId` and to clean up the active-users set.
91
+ 2. **`preSessionDelete` dispatch** — `{ token, userId: resolvedUserId | null }`. Stop signal warn-logs and returns `false`.
92
+ 3. **Logout connected sockets** — if `getIoInstance()` is up and there are sockets in the token room, call `logout(...)` for each one with `skipSessionDelete: true` (we're already inside the delete; don't recurse). See [`./logout-flow.md`](./logout-flow.md).
93
+ 4. **`adapter.untrackActive`** — removes the token from the active-users set.
94
+ 5. **`adapter.delete`** — removes the session record itself.
95
+ 6. **`postSessionDelete` dispatch** — same payload as pre.
96
+ 7. **Return** `true` on success, `false` when stopped or when an error bubbled.
97
+
98
+ When `getRaw` returned null (the record was already gone), the function still dispatches the pre/post hooks (with `userId: null`) and calls `adapter.delete` for idempotency, but skips the socket-walk and `untrackActive` because there's nothing to clean up.
99
+
100
+ ## `getAllSessions` — graceful degradation
101
+
102
+ ```ts
103
+ if (!adapter.listAll) {
104
+ getLogger().warn(`[session] getAllSessions: adapter '${adapter.name}' does not implement listAll — returning empty`);
105
+ return [];
106
+ }
107
+ ```
108
+
109
+ Some adapters can't enumerate by design — signed-JWT-stateless adapters have no storage to walk; log-only / audit-only adapters might shed reads. Rather than throw, the function warn-logs once and returns `[]`. Admin code should treat the empty result as "this adapter is not scannable" rather than "this user has no sessions".
110
+
111
+ The Redis adapter implements `listAll` via `SCAN` over `${projectName}-session:*`, collecting `{ token, raw }` pairs. The walk paginates with `COUNT 100` so it doesn't block the event loop on large stores.
112
+
113
+ ## `revokeUserSessions(userId, exceptToken?)`
114
+
115
+ Pure orchestration on top of `deleteSession`:
116
+
117
+ ```ts
118
+ const tokens = await adapter.listActive(userId);
119
+ const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;
120
+ await Promise.all(targets.map((token) => deleteSession(token)));
121
+ return targets.length;
122
+ ```
123
+
124
+ `exceptToken` is the "keep me signed in" escape hatch:
125
+
126
+ - After a password change: call with the current request's token so the user stays signed in on the device they just changed it on.
127
+ - After account deletion / "sign out everywhere": omit `exceptToken` to revoke every session including the caller's.
128
+
129
+ The return value is the number of sessions revoked, which lets the caller surface "Signed out of N other devices" in the UI.
130
+
131
+ ## Single-session enforcement
132
+
133
+ When a `newUser: true` `saveSession` runs, the function decides which (if any) previous sessions to kick. The decision tree:
134
+
135
+ ```
136
+ let effectivePerUser = sessionCfg.allowMultiple ? 'multiple' : sessionCfg.perUser;
137
+ const cap = sessionCfg.maxConcurrentPerUser;
138
+ const previousTokens = (await adapter.listActive(userId)).filter(t => t !== token);
139
+
140
+ if (effectivePerUser === 'single') {
141
+ tokensToKick = previousTokens; // kick all
142
+ } else if (
143
+ effectivePerUser === 'multiple' &&
144
+ cap !== null &&
145
+ previousTokens.length + 1 > cap &&
146
+ sessionCfg.onConflict === 'revokeOld'
147
+ ) {
148
+ const excess = previousTokens.length + 1 - cap;
149
+ tokensToKick = previousTokens.slice(0, excess); // kick oldest until under cap
150
+ }
151
+ // else (multiple, no cap; or multiple + 'rejectNew') — nothing to kick here
152
+ ```
153
+
154
+ Reading the config:
155
+
156
+ | Slot | Possible values | Behavior |
157
+ | ------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
158
+ | `session.perUser` | `'single'` / `'multiple'` | `'single'` (default) — only one session at a time. `'multiple'` — pair with `maxConcurrentPerUser`. |
159
+ | `session.allowMultiple` (legacy) | `boolean` | When `false`, collapses to `'single'` regardless of `perUser`. Backward-compat shim. |
160
+ | `session.maxConcurrentPerUser` | `number \| null` | Cap on simultaneous sessions when `perUser === 'multiple'`. `null` = unlimited. |
161
+ | `session.onConflict` | `'revokeOld'` / `'rejectNew'` | What to do when the cap is reached. `'revokeOld'` kicks oldest; `'rejectNew'` refuses the new login. |
162
+ | `session.notifyOldDeviceOnRevoke` | `boolean` | Emit `socketEventNames.sessionReplaced` to the kicked socket before the disconnect. |
163
+ | `session.basedToken` | `boolean` | Used to compute `persistent: !basedToken` for `preSessionCreate` / `postSessionCreate`. |
164
+ | `session.expiryDays` | `number` | Multiplied by 86400 to get the TTL in seconds. |
165
+
166
+ `onConflict === 'rejectNew'` is enforced at the API layer (the login endpoint refuses the request), not inside `saveSession` — by the time we reach `saveSession` the decision to allow the login has already been made.
167
+
168
+ For each token in `tokensToKick`:
169
+
170
+ ```ts
171
+ const sockets = io.sockets.adapter.rooms.get(previousToken);
172
+ if (sockets) {
173
+ if (sessionCfg.notifyOldDeviceOnRevoke) {
174
+ io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({
175
+ reason: 'session-replaced',
176
+ userId,
177
+ }));
178
+ }
179
+ for (const socketId of sockets) {
180
+ const socket = io.sockets.sockets.get(socketId);
181
+ if (socket) {
182
+ await logout({ token: previousToken, socket, userId });
183
+ }
184
+ }
185
+ } else {
186
+ // No active sockets — just clean up the adapter state.
187
+ await adapter.delete(previousToken);
188
+ await adapter.untrackActive(userId, previousToken);
189
+ }
190
+ ```
191
+
192
+ The two branches matter: with sockets, the disconnect-flow runs end-to-end. Without sockets (the user was offline), only the storage cleanup runs — there's no socket to emit to and no presence state to tear down.
193
+
194
+ ### Ordering caveat
195
+
196
+ The single-session-enforcement logic kicks "the oldest" tokens by dropping the head of `previousTokens.slice(0, excess)`. There is no LRU metadata in the adapter contract — adapters return insertion-order for set-like structures, which is good enough for the common case but not a strict guarantee.
197
+
198
+ If you need precise LRU semantics (e.g. a "kick the device the user hasn't used the most recently" UX), implement a custom `SessionAdapter` that tracks per-token last-touch timestamps in a sorted structure (Redis `ZSET`, Postgres `last_seen DESC`, DynamoDB GSI on `lastSeenAt`).
199
+
200
+ ## `SessionAdapter` contract
201
+
202
+ ```ts
203
+ interface SessionAdapter {
204
+ name: string;
205
+ getRaw(token: string): Promise<string | null>;
206
+ setRaw(token: string, value: string, ttlSeconds: number): Promise<void>;
207
+ delete(token: string): Promise<void>;
208
+ expire(token: string, ttlSeconds: number): Promise<boolean>;
209
+ ttl(token: string): Promise<number | null>;
210
+ trackActive(userId: string, token: string, ttlSeconds: number): Promise<void>;
211
+ untrackActive(userId: string, token: string): Promise<void>;
212
+ listActive(userId: string): Promise<string[]>;
213
+ listAll?(): Promise<{ token: string; raw: string }[]>;
214
+ }
215
+ ```
216
+
217
+ Semantics:
218
+
219
+ | Method | Required behavior |
220
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------- |
221
+ | `getRaw` | Return the stored string for the token, or `null` when missing. |
222
+ | `setRaw` | Persist the value with the given TTL. Implementations MUST honour the TTL — sessions without one would never expire. |
223
+ | `delete` | Idempotent removal. No-op when the record is already gone. |
224
+ | `expire` | Refresh the TTL only if the record exists. Return `true` when refreshed, `false` otherwise. MUST NOT create on miss. |
225
+ | `ttl` | Remaining TTL in seconds, or `null` when the record is missing. |
226
+ | `trackActive` | Add `token` to the per-user set. Set itself MUST have the same TTL (the longest active session keeps the set alive). |
227
+ | `untrackActive` | Remove `token` from the per-user set. Idempotent. |
228
+ | `listActive` | Return the current per-user set. Empty array (never throw) when missing. |
229
+ | `listAll` | Optional admin walk. Omit when the backend cannot enumerate. |
230
+
231
+ `name` is a human-readable identifier surfaced in logs. The default Redis adapter is `'redis'`.
232
+
233
+ ## Writing a custom adapter
234
+
235
+ ```ts
236
+ import { registerSessionAdapter, type SessionAdapter } from '@luckystack/login';
237
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
238
+
239
+ const dynamoAdapter: SessionAdapter = {
240
+ name: 'dynamodb',
241
+ async getRaw(token) { /* GetItem */ },
242
+ async setRaw(token, value, ttlSeconds) { /* PutItem with TTL attribute */ },
243
+ async delete(token) { /* DeleteItem */ },
244
+ async expire(token, ttlSeconds) { /* UpdateItem with condition exists */ },
245
+ async ttl(token) { /* GetItem read TTL attribute */ },
246
+ async trackActive(userId, token, ttlSeconds) { /* UpdateItem ADD to a SS attribute */ },
247
+ async untrackActive(userId, token) { /* UpdateItem DELETE from SS */ },
248
+ async listActive(userId) { /* GetItem read SS */ },
249
+ // listAll omitted — DynamoDB Scan is too expensive in production
250
+ };
251
+
252
+ registerSessionAdapter(dynamoAdapter);
253
+ ```
254
+
255
+ Common adapter shapes consumers have shipped:
256
+
257
+ - **DynamoDB / Cosmos DB** — serverless / edge deployments where Redis is operationally inconvenient.
258
+ - **Postgres `sessions` table** — when you already operate Postgres and don't want a second datastore. Per-token row with `userId`, `data` (JSON column), `expiresAt`.
259
+ - **Signed-JWT-stateless** — `getRaw` decodes the JWT, `setRaw` is a no-op (the JWT IS the storage), `trackActive` writes to a small Redis hash so single-session enforcement still works. `listAll` is omitted.
260
+ - **In-memory mock** — for integration tests. A `Map<string, { value, expiresAt }>` with a setInterval-based expiration sweep.
261
+
262
+ Call `registerSessionAdapter(...)` once at boot from `luckystack/server/index.ts`, BEFORE the first login request. Last-write-wins on subsequent calls (you can swap adapters in tests this way).
263
+
264
+ ## `getSessionAdapter`
265
+
266
+ Reads the currently-active adapter. Defaults to `redisSessionAdapter` when no `registerSessionAdapter` call has happened. The default adapter is exported as `redisSessionAdapter` for cases where consumer code wants to compose around it (e.g. a wrapper adapter that delegates to Redis but also writes to an audit log).
267
+
268
+ ## Why a separate broadcast on `updateSession`
269
+
270
+ When `saveSession` writes a `newUser: false` update — for instance an admin granting a user the `admin` flag from a settings page — the broadcast pushes the new session JSON to every socket already in the token room. The client-side `SessionProvider` listens for `socketEventNames.updateSession` and merges the payload into its in-memory session state.
271
+
272
+ This means:
273
+
274
+ - Permission changes apply instantly across all the user's open tabs.
275
+ - Profile updates (name, avatar) reflect in the navbar without a refresh.
276
+ - Custom session fields (e.g. `googleCalendarToken` set via `extraSessionFields`) flow to the client the same way.
277
+
278
+ The broadcast is gated on `io.sockets.adapter.rooms.has(token)` so a save targeting an offline user doesn't waste an emit.
279
+
280
+ ## Related
281
+
282
+ - [`./logout-flow.md`](./logout-flow.md) — `logout` and `deleteSession`'s socket-walk.
283
+ - [`./hooks.md`](./hooks.md) — `preSessionCreate` / `postSessionCreate` / `preSessionDelete` / `postSessionDelete` / `preSessionRefresh` / `postSessionRefresh`.
284
+ - [`./credentials-auth.md`](./credentials-auth.md) — what calls `saveSession(token, user, true)`.
285
+ - [`./oauth-providers.md`](./oauth-providers.md) — `extraSessionFields` and module-augmentation of `BaseSessionLayout`.
286
+ - Architecture: [`/docs/ARCHITECTURE_SESSION.md`](../../../docs/ARCHITECTURE_SESSION.md).
@@ -0,0 +1,353 @@
1
+ # User adapter
2
+
3
+ Deep-dive on the `UserAdapter` extension point — the abstraction between auth flows and the consumer's user data store. Canonical source: [`./src/userAdapter.ts`](../src/userAdapter.ts).
4
+
5
+ ---
6
+
7
+ ## Why a User Adapter
8
+
9
+ `@luckystack/login` ships a default that reads and writes the framework's recommended Prisma `User` model:
10
+
11
+ ```prisma
12
+ model User {
13
+ id String @id @default(cuid())
14
+ email String @unique
15
+ provider String // enum: PROVIDERS in your schema
16
+ password String?
17
+ name String?
18
+ avatar String?
19
+ avatarFallback String?
20
+ language String?
21
+ // lastLogin? DateTime? // optional — best-effort updated on each login
22
+ }
23
+ ```
24
+
25
+ That default covers single-tenant apps with the canonical schema. The adapter exists for everything else:
26
+
27
+ - **Multi-tenant** — every query should be scoped by `tenantId` read from `AsyncLocalStorage`.
28
+ - **Soft delete** — `findByEmail` / `findById` should filter `deletedAt: null`.
29
+ - **Renamed columns** — your schema calls it `displayName` instead of `name`, or `emailAddress` instead of `email`.
30
+ - **Additional required columns** — your `User` model has a non-nullable `organizationId` that the framework can't supply.
31
+ - **Non-Prisma data layer** — Drizzle, Kysely, raw `pg`, a REST microservice, a GraphQL gateway.
32
+ - **Read-replica routing** — `findByEmail` hits the replica, `create` / `update` hit the primary.
33
+
34
+ The adapter has exactly four methods. Implement them and call `registerUserAdapter(adapter)` at boot.
35
+
36
+ ## API surface
37
+
38
+ ```ts
39
+ interface UserAdapter {
40
+ findByEmail: (params: { email: string; provider: string }) => Promise<UserRecord | null>;
41
+ findById: (id: string) => Promise<UserRecord | null>;
42
+ create: (input: UserAdapterCreateInput) => Promise<UserRecord>;
43
+ update: (id: string, patch: Partial<UserRecord>) => Promise<UserRecord>;
44
+ }
45
+
46
+ registerUserAdapter(adapter: UserAdapter): UserAdapter;
47
+ getUserAdapter(): UserAdapter;
48
+ isUserAdapterRegistered(): boolean;
49
+ defaultPrismaUserAdapter(): UserAdapter;
50
+ ```
51
+
52
+ `registerUserAdapter` is last-write-wins. `getUserAdapter` returns the registered adapter, or falls back to `defaultPrismaUserAdapter()` lazily on first call. `isUserAdapterRegistered()` returns `true` iff a custom adapter has been registered — useful when an integration package wants to bail with a helpful error if the consumer hasn't yet swapped the default.
53
+
54
+ ## Required methods
55
+
56
+ ### `findByEmail({ email, provider })`
57
+
58
+ Lookup a user by their (email, provider) pair. The pair is the unique identity in the framework — the same email registered via `'credentials'` and via `'google'` are two different `UserRecord`s. Return `null` when no row matches.
59
+
60
+ Called from:
61
+
62
+ - `registerWithCredentials` — duplicate-email guard.
63
+ - `loginWithCredentialsCore` — find-the-user-to-authenticate.
64
+ - `loginCallback` (OAuth) — find-or-create branch.
65
+ - `sendPasswordResetEmail` — locate the credentials user behind a reset request.
66
+
67
+ ### `findById(id)`
68
+
69
+ Lookup a user by id. Returns `null` when missing. Called by consumer-side code that needs a fresh user read by id; the framework itself uses `findByEmail` everywhere except for hooks that pass `userId` and want the live record.
70
+
71
+ ### `create(input)`
72
+
73
+ Insert a new user. `UserAdapterCreateInput` is intentionally loose (string-typed fields) because the framework cannot statically know consumer-side Prisma enums for `provider` / `language`:
74
+
75
+ ```ts
76
+ interface UserAdapterCreateInput {
77
+ email: string;
78
+ provider: string;
79
+ name?: string | null;
80
+ password?: string | null; // bcrypt hash for credentials; null for OAuth
81
+ avatar?: string | null;
82
+ avatarFallback?: string | null; // hex color string '#aabbcc' for the avatar initials fallback
83
+ language?: string | null;
84
+ }
85
+ ```
86
+
87
+ Return the freshly-inserted `UserRecord`. The framework wraps the call in `tryCatch` and surfaces a `login.createUserFailed` reason key when the call resolves to a falsy value (or `api.internalServerError` when it throws).
88
+
89
+ ### `update(id, patch)`
90
+
91
+ Patch an existing user. The framework calls this for:
92
+
93
+ - `lastLogin: new Date()` on every successful login (best-effort, wrapped in `tryCatch`).
94
+ - `password: hashedNew` on `updatePasswordHash` from the password-reset / password-change flows.
95
+ - `avatar` after image upload (consumer-side).
96
+
97
+ `patch` is typed as `Partial<UserRecord>`. The framework casts to `never` at the call site for the `lastLogin` and `password` updates so a schema missing those fields doesn't break compilation — the adapter is responsible for accepting (or ignoring) unknown keys gracefully.
98
+
99
+ ## `UserRecord` shape
100
+
101
+ ```ts
102
+ interface UserRecord extends BaseSessionLayout {
103
+ password?: string | null;
104
+ provider?: string | null;
105
+ lastLogin?: Date | null;
106
+ }
107
+ ```
108
+
109
+ `BaseSessionLayout` (from `@luckystack/core`) covers the standard fields: `id`, `email`, `name`, `avatar`, `avatarFallback`, `admin`, `language`. `UserRecord` adds three things the session layout deliberately doesn't expose:
110
+
111
+ - `password` — the bcrypt hash. Stripped before the session is built (`sanitizeUserForSession`).
112
+ - `provider` — the auth provider. The session knows this only when the consumer needs it; some apps don't.
113
+ - `lastLogin` — best-effort timestamp. Mirrored on the session as both `lastLogin` (the just-written value) and `previousLogin` (the value before the update).
114
+
115
+ Consumers who add columns can either:
116
+
117
+ 1. **Augment `BaseSessionLayout`** via module declaration so the new field flows to the session, OR
118
+ 2. **Keep the field internal** to the adapter implementation and not expose it on `UserRecord`.
119
+
120
+ The first option is right for "data the UI should see" (preferences, role, organization id). The second is right for "data the auth layer doesn't care about" (audit fields, internal flags).
121
+
122
+ ## `defaultPrismaUserAdapter`
123
+
124
+ The framework default. Implementation:
125
+
126
+ ```ts
127
+ export const defaultPrismaUserAdapter = (): UserAdapter => {
128
+ const user = (): PrismaUserDelegate => prisma.user as unknown as PrismaUserDelegate;
129
+
130
+ return {
131
+ findByEmail: async ({ email, provider }) =>
132
+ user().findFirst({ where: { email, provider } }),
133
+ findById: async (id) =>
134
+ user().findUnique({ where: { id } }),
135
+ create: async (input) =>
136
+ user().create({
137
+ data: {
138
+ email: input.email,
139
+ provider: input.provider,
140
+ name: input.name,
141
+ password: input.password ?? null,
142
+ avatar: input.avatar ?? '',
143
+ avatarFallback: input.avatarFallback,
144
+ language: input.language,
145
+ },
146
+ }),
147
+ update: async (id, patch) =>
148
+ user().update({ where: { id }, data: patch }),
149
+ };
150
+ };
151
+ ```
152
+
153
+ The single `as unknown as PrismaUserDelegate` cast is the only one of its kind in `@luckystack/login` and is intentional + load-bearing. It is the abstraction boundary between the framework's loose `UserAdapterCreateInput` (string-typed fields) and the consumer's strict Prisma model (which may declare enums for `provider` / `language`). The framework cannot know those enum types statically, so it type-erases once at this seam and trusts the runtime `prisma.user` to satisfy the documented `PrismaUserDelegate` shape:
154
+
155
+ ```ts
156
+ interface PrismaUserDelegate {
157
+ findFirst: (args: { where: { email: string; provider: string } }) => Promise<UserRecord | null>;
158
+ findUnique: (args: { where: { id: string } }) => Promise<UserRecord | null>;
159
+ create: (args: { data: Record<string, unknown> }) => Promise<UserRecord>;
160
+ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<UserRecord>;
161
+ }
162
+ ```
163
+
164
+ Consumers whose schema diverges should NOT widen this cast further — register their own `UserAdapter` instead.
165
+
166
+ ## Lazy initialization
167
+
168
+ `getUserAdapter` defers default construction until first use:
169
+
170
+ ```ts
171
+ let registeredAdapter: UserAdapter | null = null;
172
+ let cachedDefaultAdapter: UserAdapter | null = null;
173
+
174
+ export const getUserAdapter = (): UserAdapter => {
175
+ if (registeredAdapter) return registeredAdapter;
176
+ if (!cachedDefaultAdapter) {
177
+ cachedDefaultAdapter = defaultPrismaUserAdapter();
178
+ }
179
+ return cachedDefaultAdapter;
180
+ };
181
+ ```
182
+
183
+ This matters because `defaultPrismaUserAdapter` calls `prisma.user` — which is a Prisma proxy that connects on first read. If we instantiated eagerly, modules that just import `@luckystack/login` (without ever logging anyone in) would open a database connection at import time.
184
+
185
+ Lazy + cached means:
186
+
187
+ - Pure imports never connect to the DB.
188
+ - The first login attempt creates one adapter and reuses it forever.
189
+ - Custom adapters bypass the cache entirely.
190
+
191
+ ## Recipes
192
+
193
+ ### Multi-tenant scoping
194
+
195
+ ```ts
196
+ import { AsyncLocalStorage } from 'node:async_hooks';
197
+
198
+ export const tenantContext = new AsyncLocalStorage<{ tenantId: string }>();
199
+
200
+ const tenantedAdapter: UserAdapter = {
201
+ findByEmail: async ({ email, provider }) => {
202
+ const ctx = tenantContext.getStore();
203
+ if (!ctx) throw new Error('No tenant context');
204
+ return prisma.user.findFirst({
205
+ where: { email, provider, tenantId: ctx.tenantId },
206
+ });
207
+ },
208
+ // ... same for findById, create, update
209
+ };
210
+
211
+ registerUserAdapter(tenantedAdapter);
212
+ ```
213
+
214
+ The HTTP middleware that resolves `tenantId` from the host header runs `tenantContext.run({ tenantId }, next)` so the adapter reads it without an explicit parameter.
215
+
216
+ ### Soft delete
217
+
218
+ ```ts
219
+ const softDeleteAdapter: UserAdapter = {
220
+ ...defaultPrismaUserAdapter(),
221
+ findByEmail: async ({ email, provider }) =>
222
+ prisma.user.findFirst({
223
+ where: { email, provider, deletedAt: null },
224
+ }),
225
+ findById: async (id) =>
226
+ prisma.user.findFirst({
227
+ where: { id, deletedAt: null },
228
+ }),
229
+ };
230
+
231
+ registerUserAdapter(softDeleteAdapter);
232
+ ```
233
+
234
+ The spread keeps `create` and `update` identical to the default — only the read methods get the `deletedAt: null` filter.
235
+
236
+ ### Alternative ORM (Drizzle)
237
+
238
+ ```ts
239
+ import { db } from './db';
240
+ import { users } from './schema';
241
+ import { and, eq } from 'drizzle-orm';
242
+
243
+ const drizzleAdapter: UserAdapter = {
244
+ findByEmail: async ({ email, provider }) => {
245
+ const rows = await db.select().from(users)
246
+ .where(and(eq(users.email, email), eq(users.provider, provider)));
247
+ return rows[0] ?? null;
248
+ },
249
+ findById: async (id) => {
250
+ const rows = await db.select().from(users).where(eq(users.id, id));
251
+ return rows[0] ?? null;
252
+ },
253
+ create: async (input) => {
254
+ const [row] = await db.insert(users).values(input).returning();
255
+ return row;
256
+ },
257
+ update: async (id, patch) => {
258
+ const [row] = await db.update(users).set(patch).where(eq(users.id, id)).returning();
259
+ return row;
260
+ },
261
+ };
262
+
263
+ registerUserAdapter(drizzleAdapter);
264
+ ```
265
+
266
+ ### Additional required column
267
+
268
+ If your schema requires `organizationId` on create, override only the `create` method:
269
+
270
+ ```ts
271
+ const orgScopedAdapter: UserAdapter = {
272
+ ...defaultPrismaUserAdapter(),
273
+ create: async (input) => {
274
+ const orgId = tenantContext.getStore()?.tenantId;
275
+ if (!orgId) throw new Error('No org context for create');
276
+ return prisma.user.create({
277
+ data: { ...input, organizationId: orgId },
278
+ });
279
+ },
280
+ };
281
+
282
+ registerUserAdapter(orgScopedAdapter);
283
+ ```
284
+
285
+ ### Read-replica routing
286
+
287
+ ```ts
288
+ const replicaAdapter: UserAdapter = {
289
+ findByEmail: async (args) => prismaReplica.user.findFirst({ where: args }),
290
+ findById: async (id) => prismaReplica.user.findUnique({ where: { id } }),
291
+ create: async (input) => prismaPrimary.user.create({ data: { ...input } }),
292
+ update: async (id, patch) => prismaPrimary.user.update({ where: { id }, data: patch }),
293
+ };
294
+
295
+ registerUserAdapter(replicaAdapter);
296
+ ```
297
+
298
+ The framework reads via `findByEmail` on every login, so routing reads to a replica is a meaningful production optimization. Reads after a write (e.g. `findByEmail` immediately after `create` during register) should still hit the primary — handle that with a read-your-writes timestamp or a per-request "stick to primary for N ms" flag inside your adapter.
299
+
300
+ ## `lastLogin` semantics
301
+
302
+ The default adapter accepts `lastLogin` in `update` because it casts to `Record<string, unknown>` for the `data` field. The framework writes `{ lastLogin: new Date() }` after a successful credentials or OAuth login, wrapped in `tryCatch`:
303
+
304
+ ```ts
305
+ await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin } as never));
306
+ ```
307
+
308
+ The cast to `never` is intentional — it tells TypeScript "we know `lastLogin` may not be in `UserRecord`, accept it anyway". If your schema doesn't have a `lastLogin` column, the call either:
309
+
310
+ - Fails inside Prisma's runtime field validation → `tryCatch` swallows the error, the login succeeds.
311
+ - Succeeds silently if your adapter ignores unknown keys (recommended).
312
+
313
+ The session's `lastLogin` and `previousLogin` are populated from this update — if it fails, both fields will be `undefined` on the session and the UI should treat them as optional.
314
+
315
+ For audit-grade tracking, register a `postLogin` hook instead of relying on the column:
316
+
317
+ ```ts
318
+ registerHook('postLogin', async ({ userId, provider }) => {
319
+ await prisma.loginEvent.create({
320
+ data: { userId, provider, timestamp: new Date() },
321
+ });
322
+ });
323
+ ```
324
+
325
+ The hook gives you a row-per-login with full context, vs. the column which is a single mutating timestamp.
326
+
327
+ ## Composing on top of the default
328
+
329
+ A common pattern is "the default but with one method overridden":
330
+
331
+ ```ts
332
+ const base = defaultPrismaUserAdapter();
333
+
334
+ registerUserAdapter({
335
+ ...base,
336
+ findByEmail: async ({ email, provider }) => {
337
+ const user = await base.findByEmail({ email, provider });
338
+ if (!user) return null;
339
+ if (user.disabledAt) return null; // app-level "disabled" flag
340
+ return user;
341
+ },
342
+ });
343
+ ```
344
+
345
+ This avoids re-implementing the other three methods just to add a single filter.
346
+
347
+ ## Related
348
+
349
+ - [`./credentials-auth.md`](./credentials-auth.md) — what calls `findByEmail` / `create` for credentials flows.
350
+ - [`./oauth-providers.md`](./oauth-providers.md) — `loginCallback` uses the same adapter surface.
351
+ - [`./password-reset.md`](./password-reset.md) — `updatePasswordHash` calls `update` with `{ password }`.
352
+ - [`./session-management.md`](./session-management.md) — the adapter does NOT touch sessions; sessions are a separate adapter.
353
+ - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).