@luckystack/login 0.10.2 → 0.12.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/AGENTS.md ADDED
@@ -0,0 +1,7 @@
1
+ # @luckystack/login — design context
2
+
3
+ Login handles credentials, OAuth, sessions and their lifecycle; consuming applications own identity storage and may register user/session adapters. Do not store persistent user data in sessions. Sanitize session fields before sending them to browsers; never log full tokens or return password hashes. Source: `src/sessionSanitizer.ts`, `src/session.ts`, `src/userAdapter.ts`.
4
+
5
+ Keep account linking a deliberate product/security choice: changing from provider-scoped accounts to unified email accounts requires database uniqueness and verified identity assumptions (`src/accountStrategy.ts`). Password policy belongs to registration/change/reset, not failed login counting against existing passwords (`src/login.ts`, `src/passwordPolicy.ts`). Keep password-reset and email-change secrets single-use and hashed at rest (`../core/src/oneTimeToken.ts`). An unknown reset email must not change visible behavior (`src/forgotPassword.ts`).
6
+
7
+ OAuth state protects the callback, and post-login redirects must be validated against trusted origins, including any return URL carried in state. Never turn an untrusted redirect into an authenticated bounce; never silently broaden CORS to make one work. Cookie sessions require CSRF and carefully scoped cross-origin rules. Source: `src/login.ts`, `src/redirectResolver.ts`, `../server/src/httpRoutes/authCallbackRoute.ts`. See `docs/redirect-validation.md`, `docs/session-management.md`, `../server/AGENTS.md`.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,18 @@ All notable changes to `@luckystack/login` are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.12.0] - 2026-10-15
9
+
10
+ ### Changed
11
+
12
+ - Requires Node.js 22.13 or newer (was 20). Node 20 stopped receiving security updates in April 2026, and the scaffold's lint toolchain (ESLint 10) needs 22.13.
13
+
14
+ ## [0.11.0] - 2026-10-14
15
+
16
+ ### Changed
17
+
18
+ - Type declarations are emitted per source file by TypeScript 7 (`tsc -b`) instead of one bundled `.d.ts` per entry, so the framework build no longer runs package by package. The exported types are unchanged; relative imports inside the declarations carry `.js`, so `moduleResolution: "node16"`/`"nodenext"` projects resolve them as before.
19
+ - Package design guidance now ships as `AGENTS.md`, not `CLAUDE.md`. Handwritten docs focus on rationale and safety, retaining a minimal existing-project email-code/2FA adoption checklist including persistence and encryption-key prerequisites.
9
20
 
10
21
  ## [0.10.0] - 2026-09-03
11
22
 
package/README.md CHANGED
@@ -1,240 +1,12 @@
1
1
  # @luckystack/login
2
2
 
3
- > Authentication for [LuckyStack](https://github.com/ItsLucky23/LuckyStack-v2). Credentials + OAuth (Google, GitHub, Facebook, Discord, Microsoft), Redis-backed default sessions with a swappable session adapter, single-session enforcement, and lifecycle hooks (`preLogin`, `preRegister`, `preLogout`, `preSessionCreate`, `preSessionDelete` and their `post*` counterparts).
3
+ Authentication and session lifecycle for LuckyStack, with credential, OAuth and adapter-backed identity options. Install with `npm install @luckystack/login socket.io`; add optional `@prisma/client` for the default user adapter and `@luckystack/email` for framework-managed email flows. Register user/session adapters before handling logins. Consult `package.json`, `src/register.ts` and `src/userAdapter.ts` for integration requirements.
4
4
 
5
- ## Install
5
+ Before switching an existing app to unified provider accounts: reconcile duplicate emails and dependent rows; enforce email uniqueness in the database (application checks cannot close the registration race); ensure a custom user adapter implements cross-provider email lookup; then enable the strategy. Changing cookie policy requires a CSRF/origin review. OAuth redirects must pass trusted-origin validation. Password-reset responses must not reveal whether an account exists; never expose tokens or hashes in sessions. See [design context](./AGENTS.md), [session safety](./docs/session-management.md), [redirect trust](./docs/redirect-validation.md) and [account strategy](./docs/user-adapter.md).
6
6
 
7
- ```bash
8
- npm install @luckystack/login socket.io
9
- ```
7
+ ## Existing-project email-code / 2FA adoption
10
8
 
11
- The default user adapter uses Prisma; install optional peer `@prisma/client` when you use it. Framework-mode email flows additionally need optional peer `@luckystack/email`. Projects with custom `UserAdapter` and `SessionAdapter` implementations can use another data layer and session store.
12
-
13
- ## Quickstart
14
-
15
- ```ts
16
- import { loginWithCredentials, getSession, logout } from '@luckystack/login';
17
- import { registerHook } from '@luckystack/core';
18
-
19
- // Block login for unverified users:
20
- registerHook('preLogin', async ({ email }) => {
21
- const user = await prisma.user.findFirst({ where: { email } });
22
- if (user && !user.verified) {
23
- return { stop: true, errorCode: 'login.notVerified' };
24
- }
25
- });
26
-
27
- // Inside an /auth route handler:
28
- const result = await loginWithCredentials({ email, password });
29
- // → { status: true, reason, newToken, session } on success
30
- // → { status: false, reason } on failure (including hook-stop with the hook's errorCode)
31
- ```
32
-
33
- The default `redisSessionAdapter` stores sessions under `${PROJECT_NAME}-session:<token>` and applies sliding expiration (every authenticated read extends the TTL by `ProjectConfig.session.expiryDays`). Register a custom `SessionAdapter` when sessions should live in another store.
34
-
35
- ## Hooks
36
-
37
- `pre*` hooks fire before the side-effect and may return a `HookStopSignal` to abort. `post*` hooks fire after success. Payload types live in `./hookPayloads.ts` and are merged into `@luckystack/core`'s `HookPayloads` via module augmentation.
38
-
39
- | Hook | Aborts | Fires from |
40
- | --- | --- | --- |
41
- | `preLogin` / `postLogin` | yes | `loginWithCredentials`, `loginCallback` |
42
- | `preRegister` / `postRegister` | yes | `loginWithCredentials` (register branch), `loginCallback` (new OAuth user) |
43
- | `preLogout` / `postLogout` | yes | `logout` |
44
- | `preSessionCreate` / `postSessionCreate` | yes | `saveSession({ newUser: true })` |
45
- | `preSessionDelete` / `postSessionDelete` | yes | `deleteSession` |
46
-
47
- ## OAuth provider registry
48
-
49
- ```ts
50
- import {
51
- registerOAuthProviders,
52
- googleProvider,
53
- githubProvider,
54
- credentialsProvider,
55
- } from '@luckystack/login';
56
-
57
- registerOAuthProviders([
58
- credentialsProvider(),
59
- googleProvider({
60
- clientId: process.env.GOOGLE_CLIENT_ID,
61
- clientSecret: process.env.GOOGLE_CLIENT_SECRET,
62
- callbackUrl: `http://localhost:80/auth/callback/google`,
63
- }),
64
- githubProvider({
65
- clientId: process.env.GITHUB_CLIENT_ID,
66
- clientSecret: process.env.GITHUB_CLIENT_SECRET,
67
- callbackUrl: `http://localhost:80/auth/callback/github`,
68
- }),
69
- ]);
70
- ```
71
-
72
- Built-in helpers: `googleProvider`, `githubProvider`, `discordProvider`, `facebookProvider`, `microsoftProvider`, `credentialsProvider`. Each non-credentials helper takes `{ clientId, clientSecret, callbackUrl }` and returns a fully-typed `FullOAuthProvider` with the provider's authorize/token/userinfo URLs and standard scopes prefilled. Pass anything that satisfies the `OAuthProvider` interface to register custom providers — see [`packages/login/src/oauthProviders.ts`](./src/oauthProviders.ts) for the canonical shape.
73
-
74
- ### Provider options
75
-
76
- Every helper accepts the base shape plus optional overrides for self-hosted instances and provider-specific tunables.
77
-
78
- | Option | Applies to | Default | When to override |
79
- | --- | --- | --- | --- |
80
- | `clientId` | all | — | Required. The OAuth app's client identifier. |
81
- | `clientSecret` | all | — | Required. The OAuth app's client secret. |
82
- | `callbackUrl` | all | — | Required. Must match the URL registered with the provider — your BACKEND origin + `/auth/callback/<name>` (dev `http://localhost:80/auth/callback/<name>`, prod `https://your-domain.com/auth/callback/<name>`). |
83
- | `endpoints?.authorizationURL` | all | provider-default | GitHub Enterprise host, Microsoft custom-tenant authorize URL, internal auth proxy, etc. |
84
- | `endpoints?.tokenExchangeURL` | all | provider-default | Same use cases as `authorizationURL`. |
85
- | `endpoints?.userInfoURL` | all | provider-default | Self-hosted GitHub Enterprise / Microsoft Graph mirror, etc. |
86
- | `apiVersion?` | `facebookProvider`, `microsoftProvider` | facebook: `v18.0`, microsoft: `v2.0` | Pin to a known-good Graph API version when the upstream rolls out breaking changes. |
87
- | `tenant?` | `microsoftProvider` | `'common'` | Restrict logins to a specific Azure AD tenant ID. Use a UUID or `'organizations'` / `'consumers'`. |
88
- | `graphApiVersion?` | `microsoftProvider` | `'v1.0'` | Pin the Microsoft Graph version used to fetch the user profile (separate from the OAuth `apiVersion`). |
89
-
90
- Example — self-hosted GitHub Enterprise:
91
-
92
- ```ts
93
- githubProvider({
94
- clientId: process.env.GITHUB_CLIENT_ID,
95
- clientSecret: process.env.GITHUB_CLIENT_SECRET,
96
- callbackUrl: `http://localhost:80/auth/callback/github`,
97
- endpoints: {
98
- authorizationURL: 'https://github.acme.example/login/oauth/authorize',
99
- tokenExchangeURL: 'https://github.acme.example/login/oauth/access_token',
100
- userInfoURL: 'https://github.acme.example/api/v3/user',
101
- },
102
- });
103
- ```
104
-
105
- Example — single-tenant Microsoft Entra ID:
106
-
107
- ```ts
108
- microsoftProvider({
109
- clientId: process.env.MICROSOFT_CLIENT_ID,
110
- clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
111
- callbackUrl: `http://localhost:80/auth/callback/microsoft`,
112
- tenant: process.env.MICROSOFT_TENANT_ID, // 'common' for any tenant; UUID for a single tenant.
113
- apiVersion: 'v2.0', // OAuth endpoint version
114
- graphApiVersion: 'v1.0', // Graph endpoint version
115
- });
116
- ```
117
-
118
- ## User adapter
119
-
120
- By default the package reads / writes the Prisma `User` model directly. To bind auth flows to a different table (multi-tenant, alternative ORM, soft-deleted users, etc.), register your own `UserAdapter`:
121
-
122
- ```ts
123
- import { registerUserAdapter, defaultPrismaUserAdapter } from '@luckystack/login';
124
-
125
- registerUserAdapter({
126
- ...defaultPrismaUserAdapter,
127
- findByEmail: async (email) => {
128
- const user = await prisma.user.findFirst({ where: { email, deletedAt: null } });
129
- return user;
130
- },
131
- });
132
- ```
133
-
134
- ## Account strategy: per-provider vs unified
135
-
136
- `auth.providerAccountStrategy` (in `registerProjectConfig`) controls how the same email address is treated across sign-in providers:
137
-
138
- | Strategy | Behavior | Schema |
139
- |---|---|---|
140
- | `'per-provider'` (default) | `sam@x.com` via Google and via GitHub are **two separate `User` rows**. Lookups are scoped to `(email, provider)`. | `@@unique([email, provider])` recommended. |
141
- | `'unified'` | `sam@x.com` maps to **one `User` row**; signing in via a new provider links to the existing account (credentials login, OAuth find-or-create, and register dedupe all resolve by email alone). | `email` must be `@unique`. |
142
-
143
- ```ts
144
- registerProjectConfig({ auth: { providerAccountStrategy: 'unified' } });
145
- ```
146
-
147
- **Migrating an existing project to `'unified'`** (the strategy reads accounts by email irrespective of provider, so the DB must enforce one row per email):
148
-
149
- 1. **Dedupe existing rows.** If you previously ran `'per-provider'`, the same email may already exist under multiple providers. Merge or remove duplicates so each email appears once. (Pick the row to keep — usually the credentials account or the earliest — repoint related rows, delete the rest.)
150
- 2. **Make `email` unique** in `prisma/schema.prisma`:
151
- ```prisma
152
- model User {
153
- // ...
154
- email String @unique // was: email String (+ optional @@unique([email, provider]))
155
- provider String // now records the ORIGINAL signup provider only
156
- }
157
- ```
158
- Then `prisma migrate` (or `db push`). The DB constraint closes the registration race that the application-level check alone cannot.
159
- 3. **No code change is required** beyond the config flag — the default `UserAdapter` already implements `findByEmailAnyProvider`. A **custom** `UserAdapter` must add `findByEmailAnyProvider({ email })` (resolve by email, ignoring provider); if it doesn't, the framework logs a one-time warning and falls back to provider-scoped lookup so the misconfiguration is visible rather than silent.
160
-
161
- ## Post-login redirect
162
-
163
- Compute the OAuth callback destination dynamically (per-user, per-tenant, per-provider):
164
-
165
- ```ts
166
- import { registerPostLoginRedirect } from '@luckystack/login';
167
-
168
- registerPostLoginRedirect(async ({ user, provider }) => {
169
- if (user.organizationId) return `/org/${user.organizationId}`;
170
- return '/welcome';
171
- });
172
- ```
173
-
174
- ## Password reset primitives
175
-
176
- Used by the `forgotPassword: 'framework'` mode and exported for `'custom'` consumers who want to drive their own flow:
177
-
178
- ```ts
179
- import {
180
- createPasswordResetToken,
181
- consumePasswordResetToken,
182
- updatePasswordHash,
183
- verifyPassword,
184
- sendPasswordResetEmail, // requires @luckystack/email registered
185
- } from '@luckystack/login';
186
- ```
187
-
188
- `sendPasswordResetEmail` is a no-op when `@luckystack/email` has not been registered, so you can keep the import unconditionally.
189
-
190
- ## Public API
191
-
192
- | Export | Purpose |
193
- | --- | --- |
194
- | `loginWithCredentials(params)` | Combined login/register dispatcher. Routes to `register*` or `login*` based on the body shape (presence of `confirmPassword`). Used by the HTTP `/auth/api/credentials` route. |
195
- | `registerWithCredentials({ email, password, name, confirmPassword })` | Register-only entry point. Use this when you wire a custom auth surface that bypasses the dispatcher's body-shape branching. |
196
- | `loginWithCredentialsCore({ email, password })` | Login-only entry point. Same idea as `registerWithCredentials`. |
197
- | `loginCallback(pathname, req, res)` | OAuth state-exchange handler — wired to `/auth/callback/<provider>` by `@luckystack/server`. |
198
- | `createOAuthState(providerName)` | Issue a CSRF state token (Redis, NX, TTL from project config). |
199
- | `logout({ token, socket, userId })` | End a single socket's session. |
200
- | `saveSession(token, user, newUser?)` | Write through the active `SessionAdapter` + broadcast to existing connections. |
201
- | `getSession(token)` | Read + slide expiration through the active adapter. Dispatches `preSessionRefresh` / `postSessionRefresh` around the adapter TTL refresh. |
202
- | `deleteSession(token)` | Hard delete + clean up active-tokens set. |
203
- | `getAllSessions()` | Admin utility — scans all sessions. |
204
- | `revokeUserSessions(userId)` | Force-logout every active session for a user. |
205
- | `sessionKeyFor(token)` / `activeUsersKeyFor(userId)` | Centralized Redis-key builders (`{projectName}-session:{token}` / `{projectName}-activeUsers:{userId}`). Use these when you read or scan session data from outside `@luckystack/login` so the key shape stays in lockstep. |
206
- | `registerOAuthProviders(list)` / `getOAuthProviders()` / `isFullOAuthProvider(p)` | OAuth registry. |
207
- | `googleProvider`, `githubProvider`, `discordProvider`, `facebookProvider`, `microsoftProvider`, `credentialsProvider` | Built-in provider factories. |
208
- | `registerUserAdapter(adapter)` / `getUserAdapter()` / `isUserAdapterRegistered()` / `defaultPrismaUserAdapter` | Pluggable user store. |
209
- | `registerSessionAdapter(adapter)` / `getSessionAdapter()` / `redisSessionAdapter` | Pluggable session storage; Redis is the default. |
210
- | `registerPostLoginRedirect(resolver)` / `getPostLoginRedirect()` | Dynamic redirect resolution. |
211
- | `createPasswordResetToken`, `consumePasswordResetToken`, `updatePasswordHash`, `verifyPassword`, `sendPasswordResetEmail` | Password-reset primitives. |
212
-
213
- Types: `BaseSessionLayout`, `SessionLocation`, `AuthProps` (re-exported from `@luckystack/core`); `OAuthProvider`, `CredentialsProvider`, `FullOAuthProvider`, `UserAdapter`, `UserAdapterCreateInput`, `UserRecord`, `PostLoginRedirectResolver`, `PostLoginRedirectInput`, plus all `Pre*Payload` / `Post*Payload` types.
214
-
215
- ## Stored-XSS warning: OAuth `name` fields
216
-
217
- `loginCallback` reads the user's display name straight from the OAuth provider's profile response and stores it on the `User.name` column unsanitized. That is fine for plain text rendering and for the framework's avatar-fallback initials, but **becomes stored XSS the moment a consumer renders the name as raw HTML** (e.g. `dangerouslySetInnerHTML`, an HTML email body, a server-rendered widget). Two recommended mitigations on the consumer side:
218
-
219
- - Render names with React text nodes (the default; safe).
220
- - Strip or escape any `<` / `>` characters before injecting names into HTML emails or non-React surfaces.
221
-
222
- If your project has no such surfaces this is informational. The framework intentionally does not silently mutate the field because some apps need exact-match search across providers.
223
-
224
- ## Related architecture docs
225
-
226
- - [`docs/ARCHITECTURE_AUTH.md`](../../docs/ARCHITECTURE_AUTH.md) — OAuth + credentials lifecycle, allowed-origin checks, role guards.
227
- - [`docs/ARCHITECTURE_SESSION.md`](../../docs/ARCHITECTURE_SESSION.md) — session adapters, default Redis layout, sliding expiration, and single-session enforcement.
228
- - [`docs/ARCHITECTURE_EMAIL.md`](../../docs/ARCHITECTURE_EMAIL.md) — forgot-password modes (`framework` / `custom` / `disabled`).
229
-
230
- ## Dependencies
231
-
232
- - Runtime: `@luckystack/core`, `bcryptjs`, `validator`, `dotenv`
233
- - Required peer: `socket.io@^4.8.0`
234
- - Optional peers: `@prisma/client@^6.19.0` (default user adapter) and `@luckystack/email` (only when `forgotPassword: 'framework'`). The package lazy-imports optional peers; every other API works without them.
235
-
236
- When using the default Prisma `UserAdapter`, your schema must include a `User` model with at least: `id`, `email`, `provider` (enum `PROVIDERS`), `password` (nullable), `name`, `avatar`, `avatarFallback`, `admin`, `language`. See [`prisma/schema.prisma`](../../prisma/schema.prisma) for the canonical shape. A custom `UserAdapter` and `SessionAdapter` can bind auth to another data layer and session store.
237
-
238
- ## License
239
-
240
- MIT — see [LICENSE](../../LICENSE).
9
+ 1. Obtain feature approval before opting into `auth.emailCodeLogin: true` and/or `auth.twoFactor: 'optional'`. Register a real email sender for email-code login and any email fallback; console delivery is not sufficient.
10
+ 2. Before 2FA enrollment, add persistence for `twoFactorEnabled` (initially false), nullable `totpSecret` and nullable `recoveryCodes` to the user store. For the default Prisma adapter the fields are `Boolean @default(false)`, `String?` and `Json?` respectively. Review/apply the database change with authorization and regenerate the client; custom adapters must support these fields in `src/userAdapter.ts`.
11
+ 3. **Set a high-entropy secret `TOTP_ENCRYPTION_KEY` before enrollment. Without it new TOTP secrets are stored unencrypted.** Keep it outside Git/logs. During rotation retain previous values in `TOTP_ENCRYPTION_LEGACY_KEYS` (a JSON array of strings) until old records are migrated; deleting them early can lock users out. See `src/twoFactor.ts` for the installed keyring contract.
12
+ 4. Run `npx luckystack update --app`, review/merge UI `.new` sidecars without overwriting consumer edits, then verify enrollment, login, recovery and rotation in an authorized environment. Schema and real secrets remain manually owned.
@@ -0,0 +1,12 @@
1
+ import type { UserAdapter, UserRecord } from './userAdapter.js';
2
+ /**
3
+ * Resolve a user account honoring the configured `providerAccountStrategy`.
4
+ * Use this everywhere the login flow needs "does an account for this email
5
+ * exist?" — register dedupe, credentials login, and OAuth find-or-create.
6
+ */
7
+ export declare const resolveUserByEmail: (adapter: UserAdapter, { email, provider }: {
8
+ email: string;
9
+ provider: string;
10
+ }) => Promise<UserRecord | null>;
11
+ /** Test-only: reset the one-time missing-method warning latch. */
12
+ export declare const resetAccountStrategyWarningForTests: () => void;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Whether `accountKey` is currently locked out (failed-attempt counter already
3
+ * at/over the cap). Non-incrementing — safe to call on every login attempt.
4
+ * Always `false` when the feature is disabled.
5
+ * Pass `requesterIp` to check the IP+account composite bucket (DD-LOGIN-F5).
6
+ */
7
+ export declare const isAccountLocked: (accountKey: string, requesterIp?: string) => Promise<boolean>;
8
+ /**
9
+ * Increment the failed-attempt counter for `accountKey`. Call on every failed
10
+ * credentials login for a known/looked-up account. No-op when disabled.
11
+ * Pass `requesterIp` to increment the IP+account composite bucket (DD-LOGIN-F5).
12
+ */
13
+ export declare const recordAuthFailure: (accountKey: string, requesterIp?: string) => Promise<void>;
14
+ /**
15
+ * Reset the failed-attempt counter for `accountKey` (call after a SUCCESSFUL
16
+ * login so a user who eventually gets their password right isn't penalised by
17
+ * earlier typos). No-op when disabled.
18
+ * Pass `requesterIp` to clear the IP+account composite bucket (DD-LOGIN-F5).
19
+ */
20
+ export declare const clearAuthFailures: (accountKey: string, requesterIp?: string) => Promise<void>;
21
+ /**
22
+ * Wire the per-account lockout into the `loginFailed` hook (F7). Subscribes once
23
+ * (idempotent) and increments the failed-attempt counter only on a credentials
24
+ * LOGIN failure that carries an email AND is a genuine wrong-password attempt
25
+ * (see `COUNTING_REASONS`). Registration is unconditional (cheap); the recorder
26
+ * itself no-ops when `rateLimiting.auth.enabled` is false, so toggling the
27
+ * config at runtime is honoured without re-wiring.
28
+ */
29
+ export declare const registerAuthLockoutHook: () => void;
@@ -0,0 +1,2 @@
1
+ /** Frozen Set for O(1) `.has()` lookups; entries are pre-lowercased. */
2
+ export declare const COMMON_PASSWORDS: ReadonlySet<string>;
@@ -0,0 +1,21 @@
1
+ export interface EmailChangePayload {
2
+ userId: string;
3
+ newEmail: string;
4
+ }
5
+ /**
6
+ * Create a one-time email-change token bound to a user id + the new email
7
+ * address. Any previously issued (but not yet consumed) token for the same
8
+ * user is invalidated first (LOGIN-F16) so at most one confirmation link per
9
+ * user is redeemable. Stored in Redis with the configured TTL — only
10
+ * `sha256(token)` is persisted (hash-at-rest). Returns the raw token string —
11
+ * caller emails the token (embedded in a URL) to the NEW address so the
12
+ * confirmation click proves ownership of the new mailbox.
13
+ */
14
+ export declare const createEmailChangeToken: (userId: string, newEmail: string) => Promise<string>;
15
+ /**
16
+ * Validate and consume an email-change token. Returns the bound
17
+ * `{ userId, newEmail }` on success and removes the token (one-time use, atomic
18
+ * GET+DEL). Returns null when the token is missing, expired, malformed, or
19
+ * already consumed.
20
+ */
21
+ export declare const consumeEmailChangeToken: (token: string) => Promise<EmailChangePayload | null>;
@@ -0,0 +1,24 @@
1
+ interface SendEmailChangeArgs {
2
+ userId: string;
3
+ newEmail: string;
4
+ /** Optional display name used in the email greeting (falls back to "there"). */
5
+ userName?: string | null;
6
+ /**
7
+ * App-facing display name used in the email greeting + subject. Falls back
8
+ * to `projectConfig.auth.passwordResetBrand` (the shared brand label), then
9
+ * to `'LuckyStack'` as the absolute fallback.
10
+ */
11
+ brand?: string;
12
+ }
13
+ /**
14
+ * Mint a one-shot email-change token and send a confirmation email to the
15
+ * NEW address with the tokenized URL. Returns the send result for diagnostics
16
+ * + the token itself (callers can audit-log it through the
17
+ * `postEmailChangeRequested` hook).
18
+ */
19
+ export declare const sendEmailChangeConfirmation: ({ userId, newEmail, userName, brand }: SendEmailChangeArgs) => Promise<{
20
+ ok: boolean;
21
+ reason?: string;
22
+ token: string;
23
+ }>;
24
+ export {};
@@ -0,0 +1,19 @@
1
+ import { type CredentialsLoginResult } from './login.js';
2
+ export interface RequestEmailLoginCodeInput {
3
+ email: string;
4
+ requesterIp?: string;
5
+ }
6
+ export type RequestEmailLoginCodeResult = {
7
+ ok: true;
8
+ } | {
9
+ ok: false;
10
+ reason: string;
11
+ };
12
+ export declare const requestEmailLoginCode: ({ email, requesterIp }: RequestEmailLoginCodeInput) => Promise<RequestEmailLoginCodeResult>;
13
+ export interface VerifyEmailLoginCodeInput {
14
+ email: string;
15
+ code: string;
16
+ supersedeToken?: string;
17
+ requesterIp?: string;
18
+ }
19
+ export declare const verifyEmailLoginCode: (input: VerifyEmailLoginCodeInput) => Promise<CredentialsLoginResult>;
@@ -0,0 +1,13 @@
1
+ /** Minimal surface of `@luckystack/email` that the login orchestrators use. */
2
+ export interface EmailModule {
3
+ sendEmail: (input: Record<string, unknown>) => Promise<{
4
+ ok: boolean;
5
+ reason?: string;
6
+ }>;
7
+ }
8
+ /**
9
+ * Dynamically import the optional `@luckystack/email` peer. Resolves to the
10
+ * module, or REJECTS if the package is not installed (same as a bare
11
+ * `import('@luckystack/email')`). Callers decide whether to `.catch(...)`.
12
+ */
13
+ export declare const loadEmailModule: () => Promise<EmailModule>;
@@ -0,0 +1,33 @@
1
+ export type EmailOtpPurpose = 'login' | '2fa';
2
+ export declare const generateNumericCode: (digits: number) => string;
3
+ export interface IssueEmailCodeInput {
4
+ purpose: EmailOtpPurpose;
5
+ /** Email address (login) or userId (2fa). */
6
+ identity: string;
7
+ ttlSeconds: number;
8
+ digits: number;
9
+ }
10
+ /**
11
+ * Mint + store a fresh code for (purpose, identity). Any previously active
12
+ * code for the same slot is replaced, and its attempt counter reset. Returns
13
+ * the RAW code exactly once — the caller emails it; only sha256(code) is
14
+ * stored (hash-at-rest, ADR 0010 spirit).
15
+ */
16
+ export declare const issueEmailCode: ({ purpose, identity, ttlSeconds, digits }: IssueEmailCodeInput) => Promise<string>;
17
+ export type EmailCodeVerdict = 'valid' | 'invalid' | 'expired' | 'locked';
18
+ export interface VerifyEmailCodeInput {
19
+ purpose: EmailOtpPurpose;
20
+ identity: string;
21
+ code: string;
22
+ /** Wrong-code attempts before the code is burned (counter is server-side). */
23
+ maxAttempts: number;
24
+ }
25
+ /**
26
+ * Verify + consume. Verdicts: 'valid' (code matched, slot consumed),
27
+ * 'invalid' (wrong code, attempt recorded), 'locked' (attempt budget spent —
28
+ * the active code is burned so guessing can't continue), 'expired' (no active
29
+ * code: TTL passed, never issued, or already consumed).
30
+ */
31
+ export declare const verifyEmailCode: ({ purpose, identity, code, maxAttempts }: VerifyEmailCodeInput) => Promise<EmailCodeVerdict>;
32
+ /** Drop any active code + counter for the slot (e.g. after a completed login). */
33
+ export declare const clearEmailCode: (purpose: EmailOtpPurpose, identity: string) => Promise<void>;
@@ -0,0 +1,21 @@
1
+ interface SendResetEmailArgs {
2
+ email: string;
3
+ /**
4
+ * App-facing display name used in the email greeting + subject. If omitted,
5
+ * falls back to `projectConfig.auth.passwordResetBrand`, then to `'LuckyStack'`
6
+ * as the absolute fallback. Real consumers should set this on either the
7
+ * call site OR via `projectConfig.auth.passwordResetBrand`.
8
+ */
9
+ brand?: string;
10
+ }
11
+ /**
12
+ * Look up the user by credentials-provider email, generate a reset token,
13
+ * and email them a reset link. Always resolves successfully regardless of
14
+ * whether the email matched a user (anti-enumeration). Returns the result
15
+ * of the email send for diagnostics.
16
+ */
17
+ export declare const sendPasswordResetEmail: ({ email, brand }: SendResetEmailArgs) => Promise<{
18
+ ok: boolean;
19
+ reason?: string;
20
+ }>;
21
+ export {};
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Module augmentation: extends `@luckystack/core`'s `HookPayloads` interface
3
+ * with auth and session lifecycle hooks owned by this package.
4
+ *
5
+ * This file is picked up by the TypeScript compiler because it is in the
6
+ * tsconfig `include` path. A side-effect `import './hookPayloads';` in
7
+ * `index.ts` makes the dependency explicit for readers even though TS would
8
+ * merge the declaration regardless.
9
+ */
10
+ import type { BaseSessionLayout } from './sessionLayout.js';
11
+ export interface PreLoginPayload {
12
+ email: string;
13
+ provider: string;
14
+ }
15
+ export interface PostLoginPayload {
16
+ userId: string;
17
+ provider: string;
18
+ isNewUser: boolean;
19
+ token: string;
20
+ }
21
+ export interface PreRegisterPayload {
22
+ email: string;
23
+ provider: string;
24
+ name?: string;
25
+ }
26
+ export interface PostRegisterPayload {
27
+ userId: string;
28
+ provider: string;
29
+ }
30
+ export interface PreLogoutPayload {
31
+ userId: string | null;
32
+ token: string | null;
33
+ }
34
+ export interface PostLogoutPayload {
35
+ userId: string | null;
36
+ token: string | null;
37
+ }
38
+ export interface PreSessionCreatePayload {
39
+ token: string;
40
+ user: BaseSessionLayout;
41
+ persistent: boolean;
42
+ }
43
+ export interface PostSessionCreatePayload {
44
+ token: string;
45
+ user: BaseSessionLayout;
46
+ persistent: boolean;
47
+ }
48
+ export interface PreSessionDeletePayload {
49
+ token: string;
50
+ userId: string | null;
51
+ }
52
+ export interface PostSessionDeletePayload {
53
+ token: string;
54
+ userId: string | null;
55
+ }
56
+ export interface PasswordResetRequestedPayload {
57
+ /** Email the user typed in the reset form. May or may not match a real user. */
58
+ email: string;
59
+ /** True when a matching user was found and an email is being sent. */
60
+ matched: boolean;
61
+ /** The userId, when matched. */
62
+ userId?: string;
63
+ /** Token TTL in seconds. */
64
+ ttlSeconds?: number;
65
+ }
66
+ export interface PasswordResetCompletedPayload {
67
+ /** User whose password was reset via the forgot-password flow. */
68
+ userId: string;
69
+ /** Whether the framework auto-revoked all other active sessions. */
70
+ revokedOtherSessions: boolean;
71
+ }
72
+ export interface PasswordChangedPayload {
73
+ /** User whose password was changed via the in-session change flow. */
74
+ userId: string;
75
+ /** True if the user verified their current password successfully. */
76
+ verifiedCurrent: boolean;
77
+ /** Whether sessions other than the current one were revoked. */
78
+ revokedOtherSessions: boolean;
79
+ }
80
+ export interface PrePasswordResetCompletedPayload {
81
+ /** User whose password is about to be reset via the forgot-password flow. */
82
+ userId: string;
83
+ }
84
+ export interface PrePasswordChangedPayload {
85
+ /** User whose password is about to be changed via the in-session flow. */
86
+ userId: string;
87
+ /** True if the user verified their current password successfully. */
88
+ verifiedCurrent: boolean;
89
+ }
90
+ export interface PreEmailChangePayload {
91
+ userId: string;
92
+ currentEmail: string;
93
+ newEmail: string;
94
+ }
95
+ export interface PostEmailChangeRequestedPayload {
96
+ userId: string;
97
+ currentEmail: string;
98
+ newEmail: string;
99
+ sent?: boolean;
100
+ }
101
+ export interface PostEmailChangedPayload {
102
+ userId: string;
103
+ oldEmail: string;
104
+ newEmail: string;
105
+ }
106
+ export interface LoginFailedPayload {
107
+ /** Email the attempt was for, when available. */
108
+ email?: string;
109
+ /** Resolved userId, when the attempt matched a real account. */
110
+ userId?: string;
111
+ /** Auth provider (e.g. `credentials`, `google`). */
112
+ provider: string;
113
+ /** i18n reason key describing why the attempt failed. */
114
+ reason: string;
115
+ /** Which flow the failure occurred in. */
116
+ stage: 'login' | 'register' | 'oauth';
117
+ requesterIp?: string;
118
+ }
119
+ export interface PreAccountDeletePayload {
120
+ userId: string;
121
+ email?: string;
122
+ }
123
+ export interface PostAccountDeletePayload {
124
+ userId: string;
125
+ email?: string;
126
+ }
127
+ declare module '@luckystack/core' {
128
+ interface HookPayloads {
129
+ preLogin: PreLoginPayload;
130
+ postLogin: PostLoginPayload;
131
+ preRegister: PreRegisterPayload;
132
+ postRegister: PostRegisterPayload;
133
+ preLogout: PreLogoutPayload;
134
+ postLogout: PostLogoutPayload;
135
+ preSessionCreate: PreSessionCreatePayload;
136
+ postSessionCreate: PostSessionCreatePayload;
137
+ preSessionDelete: PreSessionDeletePayload;
138
+ postSessionDelete: PostSessionDeletePayload;
139
+ passwordResetRequested: PasswordResetRequestedPayload;
140
+ prePasswordResetCompleted: PrePasswordResetCompletedPayload;
141
+ passwordResetCompleted: PasswordResetCompletedPayload;
142
+ prePasswordChanged: PrePasswordChangedPayload;
143
+ passwordChanged: PasswordChangedPayload;
144
+ preEmailChange: PreEmailChangePayload;
145
+ postEmailChangeRequested: PostEmailChangeRequestedPayload;
146
+ postEmailChanged: PostEmailChangedPayload;
147
+ loginFailed: LoginFailedPayload;
148
+ preAccountDelete: PreAccountDeletePayload;
149
+ postAccountDelete: PostAccountDeletePayload;
150
+ }
151
+ }