@luckystack/login 0.4.1 → 0.5.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/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/authLockout.ts","../src/redirectResolver.ts","../src/login.ts","../src/accountStrategy.ts","../src/passwordPolicy.ts","../src/data/commonPasswords.ts","../src/passwordReset.ts","../src/forgotPassword.ts","../src/emailModuleLoader.ts","../src/emailChange.ts","../src/emailChangeNotification.ts"],"sourcesContent":["// Registers module augmentations on @luckystack/core for auth/session hooks.\nimport './hookPayloads';\n\n//? Decoupling seam (0.2.0): register this package's session implementation into\n//? @luckystack/core so api/sync/presence/server read sessions through core's\n//? null-safe accessors WITHOUT importing @luckystack/login directly. This is\n//? what makes login a genuine OPTIONAL package — when it isn't installed nothing\n//? registers and the accessors return null (unauthenticated). Side-effect at\n//? module load: any app that imports @luckystack/login (its overlay, LoginForm,\n//? config) wires this before the first request is served.\nimport { registerSessionProvider } from '@luckystack/core';\nimport { saveSession as _saveSession, getSession as _getSession, deleteSession as _deleteSession } from './session';\nimport { logout as _logout } from './logout';\nregisterSessionProvider({\n getSession: _getSession,\n saveSession: _saveSession,\n deleteSession: _deleteSession,\n logout: ({ token, socket, userId, skipSessionDelete }) =>\n _logout({ token, socket, userId: userId ?? null, skipSessionDelete }),\n});\n\n//? Subscribe the per-account brute-force lockout to the `loginFailed` hook at\n//? boot (F7). Idempotent + cheap; the recorder no-ops unless\n//? `rateLimiting.auth.enabled` is set, so this is safe to wire unconditionally.\nimport { registerAuthLockoutHook } from './authLockout';\nregisterAuthLockoutHook();\n\nexport type { BaseSessionLayout, SessionLocation, AuthProps } from './sessionLayout';\nexport type {\n PreLoginPayload,\n PostLoginPayload,\n PreRegisterPayload,\n PostRegisterPayload,\n PreLogoutPayload,\n PostLogoutPayload,\n PreSessionCreatePayload,\n PostSessionCreatePayload,\n PreSessionDeletePayload,\n PostSessionDeletePayload,\n PasswordResetRequestedPayload,\n PrePasswordResetCompletedPayload,\n PasswordResetCompletedPayload,\n PrePasswordChangedPayload,\n PasswordChangedPayload,\n PreEmailChangePayload,\n PostEmailChangeRequestedPayload,\n PostEmailChangedPayload,\n LoginFailedPayload,\n PreAccountDeletePayload,\n PostAccountDeletePayload,\n} from './hookPayloads';\nexport { saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor } from './session';\nexport { registerSessionAdapter, getSessionAdapter, redisSessionAdapter } from './sessionAdapter';\nexport type { SessionAdapter } from './sessionAdapter';\nexport { registerSessionSanitizer, getSessionSanitizer } from './sessionSanitizer';\nexport type { SessionSanitizer } from './sessionSanitizer';\nexport { loginWithCredentials, loginCallback, createOAuthState, registerWithCredentials, loginWithCredentialsCore } from './login';\nexport type {\n CredentialsLoginResult,\n CredentialsLoginSuccess,\n CredentialsLoginFailure,\n OAuthCallbackResult,\n} from './login';\nexport type { CreateOAuthStateResult } from './login';\nexport { OAUTH_STATE_COOKIE_NAME } from './login';\nexport { logout } from './logout';\n\n// Per-account brute-force lockout (F7). The hook is auto-registered at boot;\n// these are exported for consumers who want to query/clear a lock explicitly\n// (e.g. an admin \"unlock account\" action) or build their own surface on top.\nexport { isAccountLocked, recordAuthFailure, clearAuthFailures } from './authLockout';\n\n// Password-reset primitives. Used by the framework's `framework`-mode\n// forgot-password flow AND exported for consumers who picked `'custom'`.\nexport {\n createPasswordResetToken,\n consumePasswordResetToken,\n updatePasswordHash,\n verifyPassword,\n PasswordPolicyError,\n} from './passwordReset';\nexport { validatePassword } from './passwordPolicy';\nexport { sendPasswordResetEmail } from './forgotPassword';\n\n// Email-change primitives. Used by the framework's settings flow to confirm\n// a new email address before applying it (token mailed to NEW address proves\n// ownership of the new mailbox).\nexport {\n createEmailChangeToken,\n consumeEmailChangeToken,\n} from './emailChange';\nexport type { EmailChangePayload } from './emailChange';\nexport { sendEmailChangeConfirmation } from './emailChangeNotification';\n\n// OAuth provider registry + composable helpers.\nexport {\n registerOAuthProviders,\n registerOAuthProviderFactory,\n getOAuthProviders,\n isFullOAuthProvider,\n asOAuthUserData,\n credentialsProvider,\n googleProvider,\n githubProvider,\n discordProvider,\n facebookProvider,\n microsoftProvider,\n} from './oauthProviders';\nexport type {\n OAuthProvider,\n CredentialsProvider,\n FullOAuthProvider,\n OAuthUserData,\n} from './oauthProviders';\n\n// User adapter registry: lets consumers swap out the Prisma User model behind auth flows.\nexport {\n registerUserAdapter,\n getUserAdapter,\n isUserAdapterRegistered,\n defaultPrismaUserAdapter,\n} from './userAdapter';\nexport type {\n UserAdapter,\n UserAdapterCreateInput,\n UserRecord,\n} from './userAdapter';\n\n// Post-login redirect resolver — lets consumers compute the OAuth callback\n// destination dynamically (per-user, per-tenant, per-provider).\nexport {\n registerPostLoginRedirect,\n getPostLoginRedirect,\n} from './redirectResolver';\nexport type {\n PostLoginRedirectResolver,\n PostLoginRedirectInput,\n} from './redirectResolver';\n","//? Per-account brute-force lockout (F7 / MIS-017). The generic per-IP throttle\n//? on `/auth/api/credentials` (in @luckystack/server) does NOT stop distributed\n//? credential stuffing against a SINGLE account from many IPs. This module adds\n//? a per-account failed-attempt counter, keyed independently from the api/ip\n//? limits, that locks an account after `rateLimiting.auth.maxAttempts` failures\n//? within `rateLimiting.auth.windowMs`.\n//?\n//? It is OPT-IN: when `rateLimiting.auth.enabled === false` (the default) every\n//? function here is a no-op and behaviour is byte-identical to before — only\n//? the existing per-IP throttle applies.\n//?\n//? The counter rides on the framework rate limiter (`checkRateLimit`) so it\n//? inherits the configured store (memory/redis) and multi-instance behaviour.\n//? `recordAuthFailure` increments; `isAccountLocked` reads WITHOUT incrementing\n//? (so merely checking the lock can't itself trip it); `clearAuthFailures`\n//? resets the counter on a successful login.\n\nimport {\n getProjectConfig,\n checkRateLimit,\n getRateLimitStatus,\n clearRateLimit,\n getLogger,\n registerHook,\n tryCatch,\n} from '@luckystack/core';\n\n//? DD-LOGIN-F5: build the lockout bucket key. When `requesterIp` is supplied\n//? the key is an IP+account composite (`auth:<email>:<ip>`) so a remote attacker\n//? from one IP cannot lock out an account for legitimate users on different IPs.\n//? Without an IP the key is the bare account key (`auth:<email>`) — same\n//? behaviour as before. The composite bucket is cleared on success the same way\n//? (same key is used in `clearAuthFailures`). Lower-case + trim the account\n//? identifier so `Alice@x.com` and `alice@x.com` share one lockout bucket\n//? (matches the credentials email normalization).\nconst lockoutKey = (accountKey: string, requesterIp?: string): string => {\n const base = `auth:${accountKey.trim().toLowerCase()}`;\n return requesterIp ? `${base}:${requesterIp}` : base;\n};\n\n//? One-shot guard so the inert-config warning below fires once per process, not\n//? on every login attempt.\nlet warnedLockoutInert = false;\n\n//? One-shot guard for the M1 warning: surfaces the silent gap where credentials\n//? logins are FAILING but the per-account lockout is disabled entirely, so only\n//? the proxy-fragile per-IP cap stands between an attacker and distributed\n//? credential stuffing. Fires once, on the first real credentials-login failure.\nlet warnedLockoutDisabled = false;\n\n/** Read the active auth-lockout config (returns null when the feature is off). */\nconst getAuthLockoutConfig = (): { maxAttempts: number; maxAttemptsPerAccount: number; windowMs: number } | null => {\n const rateLimiting = getProjectConfig().rateLimiting;\n if (!rateLimiting.auth.enabled) return null;\n //? The counter rides the framework rate limiter, which short-circuits when the\n //? GLOBAL `rateLimiting.enabled` is false — so `auth.enabled:true` +\n //? `enabled:false` makes recordAuthFailure/isAccountLocked silent no-ops, i.e. a\n //? believed-on-but-INERT brute-force defense. Warn once so the conflict surfaces.\n if (!rateLimiting.enabled && !warnedLockoutInert) {\n warnedLockoutInert = true;\n getLogger().warn(\n '[LuckyStack] rateLimiting.auth.enabled is true but rateLimiting.enabled is false — the per-account brute-force lockout is INERT (it rides the global rate limiter, which is off). Set rateLimiting.enabled to true for the lockout to take effect.',\n );\n }\n return {\n maxAttempts: rateLimiting.auth.maxAttempts,\n maxAttemptsPerAccount: rateLimiting.auth.maxAttemptsPerAccount,\n windowMs: rateLimiting.auth.windowMs,\n };\n};\n\n/**\n * Whether `accountKey` is currently locked out (failed-attempt counter already\n * at/over the cap). Non-incrementing — safe to call on every login attempt.\n * Always `false` when the feature is disabled.\n * Pass `requesterIp` to check the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const isAccountLocked = async (accountKey: string, requesterIp?: string): Promise<boolean> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return false;\n //? DUAL counter (DD-LOGIN-F5 + the cross-IP fix). Lock if EITHER trips:\n //? - the bare-account bucket (`auth:<email>`) against the cross-IP cap\n //? (`maxAttemptsPerAccount`) — the distributed-credential-stuffing defense, and\n //? - the IP+account composite (`auth:<email>:<ip>`) against the per-IP cap\n //? (`maxAttempts`) — bounds one IP + shields other IPs from a victim-lock DoS.\n const accountStatus = await getRateLimitStatus(lockoutKey(accountKey), cfg.maxAttemptsPerAccount);\n if (accountStatus.remaining <= 0) return true;\n if (requesterIp) {\n const ipStatus = await getRateLimitStatus(lockoutKey(accountKey, requesterIp), cfg.maxAttempts);\n if (ipStatus.remaining <= 0) return true;\n }\n return false;\n};\n\n/**\n * Increment the failed-attempt counter for `accountKey`. Call on every failed\n * credentials login for a known/looked-up account. No-op when disabled.\n * Pass `requesterIp` to increment the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const recordAuthFailure = async (accountKey: string, requesterIp?: string): Promise<void> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return;\n //? Increment BOTH the cross-IP bare-account counter and (when an IP is known)\n //? the per-IP composite counter — mirrors the dual gate in isAccountLocked.\n const account = await checkRateLimit({\n key: lockoutKey(accountKey),\n limit: cfg.maxAttemptsPerAccount,\n windowMs: cfg.windowMs,\n });\n let ipBlocked = false;\n if (requesterIp) {\n const ip = await checkRateLimit({\n key: lockoutKey(accountKey, requesterIp),\n limit: cfg.maxAttempts,\n windowMs: cfg.windowMs,\n });\n ipBlocked = !ip.allowed;\n }\n if (!account.allowed || ipBlocked) {\n getLogger().warn('[authLockout] account temporarily locked after repeated failures', {\n maxAttempts: cfg.maxAttempts,\n maxAttemptsPerAccount: cfg.maxAttemptsPerAccount,\n crossIp: !account.allowed,\n });\n }\n};\n\n/**\n * Reset the failed-attempt counter for `accountKey` (call after a SUCCESSFUL\n * login so a user who eventually gets their password right isn't penalised by\n * earlier typos). No-op when disabled.\n * Pass `requesterIp` to clear the IP+account composite bucket (DD-LOGIN-F5).\n */\nexport const clearAuthFailures = async (accountKey: string, requesterIp?: string): Promise<void> => {\n const cfg = getAuthLockoutConfig();\n if (!cfg || !accountKey) return;\n //? Clear BOTH counters on a successful login so earlier typos don't penalise the\n //? user — the cross-IP bare-account bucket and the per-IP composite bucket.\n //? L6: callers invoke this fire-and-forget (`void clearAuthFailures(...)`) on the\n //? success path, so a transient Redis error must NOT become an unhandled\n //? rejection. Swallow + log; a stale lockout counter self-expires within the\n //? window, so a missed clear is non-fatal.\n const [error] = await tryCatch(async () => {\n await clearRateLimit(lockoutKey(accountKey));\n if (requesterIp) await clearRateLimit(lockoutKey(accountKey, requesterIp));\n });\n if (error) {\n getLogger().warn('[authLockout] clearAuthFailures failed (non-fatal — counter self-expires)', { error });\n }\n};\n\n//? ADR 0012 — the lockout counter must reflect GENUINE credential failures only.\n//? This is an ALLOW-LIST, not a deny-list: only a real wrong-password attempt\n//? (`login.wrongPassword`, covering both the no-account/no-hash dummy-compare\n//? path and a failed bcrypt compare) feeds the per-account counter. Everything\n//? else is excluded by construction — `preLogin` veto rejections (whose reason\n//? is a consumer-controlled `errorCode`), password-POLICY rejections (M-15),\n//? input-shape errors, the `login.accountLocked` self-trip, and infra failures\n//? (DB find / bcrypt throw, surfaced via `toReasonKey`). A deny-list let any of\n//? those — most dangerously a consumer `preLogin` veto an attacker can trigger\n//? with just a victim's email and no password — silently drive the lockout and\n//? DoS the account. Keep this allow-list narrow.\nconst COUNTING_REASONS = new Set([\n 'login.wrongPassword',\n]);\n\nlet lockoutHookRegistered = false;\n\n/**\n * Wire the per-account lockout into the `loginFailed` hook (F7). Subscribes once\n * (idempotent) and increments the failed-attempt counter only on a credentials\n * LOGIN failure that carries an email AND is a genuine wrong-password attempt\n * (see `COUNTING_REASONS`). Registration is unconditional (cheap); the recorder\n * itself no-ops when `rateLimiting.auth.enabled` is false, so toggling the\n * config at runtime is honoured without re-wiring.\n */\nexport const registerAuthLockoutHook = (): void => {\n if (lockoutHookRegistered) return;\n lockoutHookRegistered = true;\n registerHook('loginFailed', async ({ email, provider, reason, stage, requesterIp }) => {\n if (stage !== 'login' || provider !== 'credentials') return;\n //? M1: a credentials login is failing while the per-account lockout is OFF.\n //? Warn ONCE so operators of a credentials app don't ship with distributed\n //? credential stuffing unthrottled beyond the (proxy-fragile) per-IP cap. Kept\n //? as a loud default rather than force-enabling the lockout, because enabling\n //? the cross-IP counter also introduces a victim-account-lock DoS tradeoff the\n //? operator should opt into consciously (set rateLimiting.auth.enabled:true).\n if (!getProjectConfig().rateLimiting.auth.enabled && !warnedLockoutDisabled) {\n warnedLockoutDisabled = true;\n getLogger().warn(\n '[LuckyStack] a credentials login failed but the per-account brute-force lockout is DISABLED (rateLimiting.auth.enabled:false). Only the per-IP API cap protects credentials login, so distributed credential stuffing against one account (many IPs) is unthrottled. Set rateLimiting.auth.enabled:true to enable the per-account lockout (note: the cross-IP counter also enables a bounded victim-account-lock DoS — see docs/ARCHITECTURE_AUTH.md).',\n );\n }\n if (!email || !COUNTING_REASONS.has(reason)) return;\n //? DD-LOGIN-F5: use the IP+account composite key when an IP was threaded in.\n await recordAuthFailure(email, requesterIp);\n });\n};\n","//? Post-login redirect resolver. Lets consumers compute the OAuth callback\n//? redirect URL dynamically (per-user, per-tenant, per-provider).\n//?\n//? Why a dedicated registry instead of a hook:\n//? The framework's hooks are \"stop-or-continue\" — handlers return undefined\n//? or a stop signal. They don't carry a value. A redirect-resolution\n//? handler must RETURN a string, so it lives in its own registry.\n//?\n//? Validation: the resolved URL is checked against\n//? `ProjectConfig.http.cors.allowedOrigins` (plus the localhost convenience when\n//? `http.cors.allowLocalhost` is on) before being used. An invalid URL falls back\n//? to `ProjectConfig.loginRedirectUrl`.\n\nexport interface PostLoginRedirectInput {\n userId: string;\n provider: string;\n isNewUser: boolean;\n defaultUrl: string;\n}\n\nexport type PostLoginRedirectResolver = (\n input: PostLoginRedirectInput,\n) => string | Promise<string>;\n\nlet activeResolver: PostLoginRedirectResolver | null = null;\n\nexport const registerPostLoginRedirect = (\n resolver: PostLoginRedirectResolver,\n): PostLoginRedirectResolver => {\n activeResolver = resolver;\n return resolver;\n};\n\nexport const getPostLoginRedirect = (): PostLoginRedirectResolver | null => activeResolver;\n","import { getOAuthProviders, isFullOAuthProvider, type FullOAuthProvider, type OAuthUserData } from './oauthProviders';\nimport { getPostLoginRedirect } from './redirectResolver';\nimport { IncomingMessage, ServerResponse } from 'node:http';\nimport { URLSearchParams } from 'node:url';\nimport { tryCatch, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger, formatKey, getProjectConfig, getCookieValue } from '@luckystack/core';\nimport bcrypt from 'bcryptjs';\nimport { randomBytes, createHash, timingSafeEqual } from 'node:crypto';\nimport { saveSession } from \"./session\"\nimport validator from 'validator';\nimport type { BaseSessionLayout as SessionLayout } from './sessionLayout';\nimport { getUserAdapter } from './userAdapter';\nimport { resolveUserByEmail } from './accountStrategy';\nimport { validatePassword } from './passwordPolicy';\nimport { isAccountLocked, clearAuthFailures } from './authLockout';\nimport path from 'node:path';\nimport { promises as fsPromises } from 'node:fs';\n\n//? Combined login/register request body. When `name` + `confirmPassword` are\n//? both present the dispatcher registers; otherwise it logs in.\ninterface LoginOrRegisterParams {\n email?: string,\n password?: string,\n name?: string,\n confirmPassword?: string,\n}\n\n//? Discriminated result of the credentials login/register surface (QUA-080).\n//? Exporting this lets `@luckystack/server`'s auth route narrow on `status`\n//? instead of casting the result to an inline shape. On `status: true`,\n//? `newToken` + `session` are always present; on `status: false` only `reason`\n//? is meaningful (the failure branch never mints a token).\nexport interface CredentialsLoginSuccess {\n status: true;\n reason: string;\n newToken: string;\n session: SessionLayout;\n}\nexport interface CredentialsLoginFailure {\n status: false;\n reason: string;\n}\nexport type CredentialsLoginResult = CredentialsLoginSuccess | CredentialsLoginFailure;\n\n//? Resolved at call time via getUploadsDir() so consumer path overrides win.\nconst uploadsFolder = (): string => getUploadsDir();\n//? Resolve at call time so dotenv/test-setup timing can't capture a stale\n//? value at module load. Routes through `projectConfig.logging.devLogs`\n//? which is the standard predicate everywhere else in the codebase.\nconst isDevMode = (): boolean => getProjectConfig().logging.devLogs;\nconst { compare, genSalt, hash } = bcrypt;\n\n//? LOGIN-F22: the dummy hash used on the user-not-found path must be generated\n//? at the CONFIGURED bcrypt rounds, not hard-pinned to cost 10. If the consumer\n//? raises `auth.bcryptRounds` (e.g. to 12), a cost-10 dummy compare finishes in\n//? ~80 ms while a real compare takes ~400 ms — the 5× difference reopens the\n//? user-enumeration timing channel we closed with the dummy compare. We lazily\n//? generate one hash per unique rounds value (cached so the per-request compare\n//? stays O(1) after the first login at a given cost) and use that hash for all\n//? subsequent not-found compares at the same cost.\nconst dummyHashCache = new Map<number, string>();\nconst getDummyBcryptHash = async (): Promise<string> => {\n const rounds = getProjectConfig().auth.bcryptRounds;\n const cached = dummyHashCache.get(rounds);\n if (cached) return cached;\n const salt = await genSalt(rounds);\n const generated = await hash(randomBytes(16).toString('hex'), salt);\n dummyHashCache.set(rounds, generated);\n return generated;\n};\n//? `validator` is a CommonJS module whose helpers hang off the default export;\n//? destructuring the default is the documented usage. The named-export warning\n//? is a false positive for this CJS interop shape.\n// eslint-disable-next-line import-x/no-named-as-default-member\nconst { escape, isEmail } = validator;\n\n//? Use the shared `getProjectName()` helper so the OAuth state Redis key\n//? prefix matches the namespace used everywhere else (sessions, activeUsers,\n//? rate-limit, password-reset). Previously this used a separate\n//? `auth.oauthStateProjectNameFallback` config field which could silently\n//? drift from `session.projectName`.\nconst getOAuthStateKey = (providerName: string, state: string): string => {\n return formatKey('-oauth-state', `${providerName}:${state}`);\n};\n\n//? Cookie the server sets at the OAuth-authorize step (F1). It carries a random\n//? nonce bound to the Redis state entry; the callback requires the cookie nonce\n//? to hash to the value stored alongside the state before it accepts the\n//? callback. This proves the SAME browser that started the flow is completing it\n//? (closes the login-CSRF / session-fixation hole where an attacker delivers\n//? their own valid `code+state` to a victim). Server + login MUST agree on this\n//? name — it is exported so `@luckystack/server`'s authorize route reuses it.\nexport const OAUTH_STATE_COOKIE_NAME = 'ls-oauth-state';\n\n//? Hash the browser-binding nonce before persisting it in Redis: the Redis\n//? state value is server-side-readable, so storing only the SHA-256 means a\n//? leaked state entry can't be replayed without the matching cookie nonce.\nconst hashStateNonce = (nonce: string): string =>\n createHash('sha256').update(nonce).digest('hex');\n\n//? Generate a PKCE verifier + its S256 challenge (RFC 7636). Only used when a\n//? provider opts in via `usePkce` — default OAuth flows are byte-identical to\n//? before. The verifier is stored server-side (in the Redis state value) and\n//? replayed at token exchange; the challenge is handed to the authorize step.\nconst base64Url = (buf: Buffer): string =>\n buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');\nconst generatePkcePair = (): { verifier: string; challenge: string } => {\n const verifier = base64Url(randomBytes(32));\n const challenge = base64Url(createHash('sha256').update(verifier).digest());\n return { verifier, challenge };\n};\n\n//? Server-side payload persisted under the Redis state key. `nonceHash` binds\n//? the flow to the initiating browser's cookie; `codeVerifier` (PKCE) is the\n//? secret replayed at token exchange.\ninterface OAuthStateEntry {\n nonceHash: string;\n codeVerifier?: string;\n //? Frontend URL to redirect to after the OAuth callback completes. Stored\n //? server-side in Redis (never echoed from the client at callback time) so it\n //? is tamper-proof. Validated against allowedOrigins + allowLocalhost before\n //? use — see isAllowedRedirectUrl. Absent when the auth initiation did not\n //? carry a return_url param (falls back to the configured loginRedirectUrl).\n returnUrl?: string;\n}\n\nexport interface CreateOAuthStateResult {\n /** Opaque `state` param put on the authorize URL + echoed back on callback. */\n state: string;\n /**\n * Random nonce the server must set as the value of the {@link OAUTH_STATE_COOKIE_NAME}\n * cookie (HttpOnly, SameSite=Lax, short TTL) at the authorize redirect. The\n * callback requires this back to accept the flow (browser-binding, F1).\n */\n stateCookie: string;\n /**\n * PKCE `code_challenge` (S256) when the provider opted into PKCE — the server\n * appends `code_challenge=<value>&code_challenge_method=S256` to the authorize\n * URL. `undefined` for non-PKCE providers (default).\n */\n codeChallenge?: string;\n}\n\nexport const createOAuthState = async (\n providerName: string,\n options?: { usePkce?: boolean; returnUrl?: string },\n): Promise<CreateOAuthStateResult | null> => {\n const state = randomBytes(32).toString('hex');\n const nonce = randomBytes(32).toString('hex');\n const key = getOAuthStateKey(providerName, state);\n\n const pkce = options?.usePkce ? generatePkcePair() : null;\n const entry: OAuthStateEntry = {\n nonceHash: hashStateNonce(nonce),\n ...(pkce ? { codeVerifier: pkce.verifier } : {}),\n ...(options?.returnUrl ? { returnUrl: options.returnUrl } : {}),\n };\n\n //? State TTL is consumer-configurable via `auth.oauthStateTtlSeconds`\n //? (default 600s / 10 min). Too short and a legitimate, slow provider\n //? round-trip expires mid-flow and the callback fails state validation\n //? (UX/soft-DoS); too long and a stolen state stays replayable. The `NX`\n //? guard keeps each state single-use even before the TTL elapses, and\n //? `consumeOAuthState` deletes it on first redemption. The value is now a\n //? JSON envelope (nonce hash + optional PKCE verifier) instead of the literal\n //? `'1'` so the flow can be bound to the initiating browser (F1) and carry a\n //? PKCE secret (F11).\n const result = await redisClient.set(\n key,\n JSON.stringify(entry),\n 'EX',\n getProjectConfig().auth.oauthStateTtlSeconds,\n 'NX',\n );\n\n if (result !== 'OK') {\n return null;\n }\n\n return {\n state,\n stateCookie: nonce,\n ...(pkce ? { codeChallenge: pkce.challenge } : {}),\n };\n};\n\n//? Atomically read + delete the state entry (single-use), then verify the\n//? browser-binding nonce. Returns the parsed entry on success (so the caller can\n//? read `codeVerifier` for the PKCE token exchange), or `null` on any failure:\n//? missing state, missing/empty cookie nonce, Redis miss, malformed payload, or\n//? a nonce that doesn't match the stored hash. `cookieNonce` is the value the\n//? server read from the {@link OAUTH_STATE_COOKIE_NAME} cookie on the callback\n//? request.\nconst consumeOAuthState = async (\n providerName: string,\n state: string,\n cookieNonce: string,\n): Promise<OAuthStateEntry | null> => {\n if (!state || !cookieNonce) {\n return null;\n }\n\n const key = getOAuthStateKey(providerName, state);\n const txResult = await redisClient.multi().get(key).del(key).exec();\n if (!txResult || txResult.length < 2) {\n return null;\n }\n\n const getResult = txResult[0];\n if (!getResult || getResult[0]) {\n return null;\n }\n\n //? Verify the DEL also succeeded (txResult[1][0] is the per-command error slot).\n //? If DEL failed the state key was not consumed — fail closed so the OAuth state\n //? cannot be replayed with the same nonce before its TTL expires (mirrors the\n //? consumeOneTimeToken hardening in @luckystack/core).\n const delResult = txResult[1];\n if (!delResult || delResult[0]) {\n return null;\n }\n\n const rawValue = getResult[1];\n if (typeof rawValue !== 'string') {\n return null;\n }\n\n const [parseErr, entry] = tryCatchSync(() => JSON.parse(rawValue) as OAuthStateEntry);\n if (parseErr || !entry || typeof entry.nonceHash !== 'string') {\n return null;\n }\n\n //? Constant-time compare of the two fixed-length (SHA-256) hex digests via\n //? `timingSafeEqual` (QUA-21). Both operands are 64-char hex strings, so their\n //? byte buffers are equal-length; a length mismatch (malformed stored value)\n //? falls through to reject. A mismatch means the browser completing the flow is\n //? NOT the one that started it — reject (F1).\n const incomingHash = Buffer.from(hashStateNonce(cookieNonce));\n const storedHash = Buffer.from(entry.nonceHash);\n if (incomingHash.length !== storedHash.length || !timingSafeEqual(incomingHash, storedHash)) {\n return null;\n }\n\n return entry;\n};\n\nconst asRecord = (value: unknown): Record<string, unknown> => {\n if (value && typeof value === 'object') {\n return value as Record<string, unknown>;\n }\n return {};\n};\n\n//? Read a string-ish field from an untyped provider JSON record. Strings pass\n//? through; finite numbers / booleans are coerced (preserving the previous\n//? `String(value)` behaviour for primitive fields); objects, arrays, null and\n//? undefined collapse to `fallback` instead of stringifying to `[object Object]`.\nconst readStringField = (record: Record<string, unknown>, key: string, fallback = ''): string => {\n const value = record[key];\n if (typeof value === 'string') return value;\n if (typeof value === 'number' && Number.isFinite(value)) return String(value);\n if (typeof value === 'boolean') return String(value);\n return fallback;\n};\n\n//? Random 6-digit hex color used as the avatar placeholder for users without\n//? an uploaded image. Shared by the credentials-register and OAuth-create paths.\nconst generateAvatarFallbackColor = (): string =>\n `#${Math.floor(Math.random() * 0xFF_FF_FF).toString(16).padStart(6, \"0\")}`;\n\n//? LOGIN-F18: check whether a user has an uploaded webp avatar on disk and\n//? return the filename when found. Used by both the credentials and OAuth login\n//? paths to avoid duplicating the async stat pattern.\nconst resolveUploadedAvatar = async (userId: string): Promise<string | null> => {\n const filePath = path.join(uploadsFolder(), `${userId}.webp`);\n const [, avatarStat] = await tryCatch(() => fsPromises.stat(filePath));\n return avatarStat ? `${userId}.webp` : null;\n};\n\nconst sanitizeUserForSession = <T extends { password?: unknown }>(user: T): Omit<T, 'password'> => {\n const { password: _password, ...safeUser } = user;\n return safeUser;\n};\n\n//? Fire-and-forget observational failure signal. Dispatched on every failed\n//? login/register/OAuth attempt so consumers can audit, feed a SIEM, or build\n//? per-account lockout (HOK-10). Never awaited and never throws — a failing\n//? handler must not change the auth outcome the user already got.\nconst emitLoginFailed = (payload: {\n email?: string;\n userId?: string;\n provider: string;\n reason: string;\n stage: 'login' | 'register' | 'oauth';\n //? DD-LOGIN-F5: optional resolved client IP — threaded from the HTTP auth\n //? route so the per-account lockout can build an IP+account composite key.\n requesterIp?: string;\n}): void => {\n void dispatchHook('loginFailed', payload);\n};\n\nconst toReasonKey = (error: unknown, fallback = 'api.internalServerError'): string => {\n if (typeof error === 'string' && error.length > 0) {\n return error;\n }\n\n if (error instanceof Error && error.message) {\n const message = error.message.toLowerCase();\n if (message.includes('authentication failed') || message.includes('scram failure')) {\n return 'api.internalServerError';\n }\n }\n\n return fallback;\n};\n\ninterface NormalizedCredentials {\n email: string;\n password: string;\n name: string | undefined;\n confirmPassword: string | undefined;\n}\n\nconst normalizeCredentials = (params: LoginOrRegisterParams): NormalizedCredentials => ({\n //? Don't HTML-escape password OR confirmPassword before bcrypt — neither\n //? reaches HTML, and they're memory-compared for equality during register.\n //? Escaping confirmPassword while leaving password raw made any password\n //? containing `& < > \" '` fail the `password !== confirmPassword` check\n //? (regression introduced in pass-1 fix #9 which only un-escaped password).\n //? Email is normalized (lowercase + trim) and validated via `isEmail`;\n //? HTML-escaping it would make legacy raw rows unfindable.\n email: (params.email ?? '').trim().toLowerCase(),\n password: params.password ?? '',\n name: params.name ? escape(params.name) : undefined,\n //? Empty-string confirmPassword must collapse to `undefined` so the\n //? register-vs-login dispatcher's truthiness check treats a blank field as\n //? \"absent\". `??` would keep the empty string (only nullish collapses), so the\n //? falsy-coercing `||` is deliberate here.\n // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional empty-string → undefined collapse\n confirmPassword: params.confirmPassword || undefined,\n});\n\n//? Shape-validate normalized credentials. `mode` decides whether the\n//? password-POLICY check runs:\n//? - `'register'` enforces the full policy (length, complexity, common-list,\n//? customValidator) — a new account MUST meet the active policy.\n//? - `'login'` does NOT run the policy (M-15). A login must accept ANY password\n//? string and let the bcrypt compare decide pass/fail. Running the policy on\n//? login is both a lockout-DoS vector (an attacker POSTing policy-violating\n//? passwords for a victim's email could otherwise feed the per-account\n//? failed-attempt counter) AND a correctness bug (tightening the policy would\n//? lock out existing users whose stored password no longer meets it). Empty /\n//? email-shape checks still run in both modes.\nconst validateCredentialsShape = (\n creds: NormalizedCredentials,\n mode: 'login' | 'register',\n): { status: false; reason: string } | null => {\n const authLimits = getProjectConfig().auth;\n if (!creds.email || !creds.password) return { status: false, reason: 'login.empty' };\n if (creds.email.length > authLimits.emailMaxLength) return { status: false, reason: 'login.emailCharacterLimit' };\n if (creds.name && creds.name.length > authLimits.nameMaxLength) return { status: false, reason: 'login.nameCharacterLimit' };\n if (!isEmail(creds.email)) return { status: false, reason: 'login.invalidEmailFormat' };\n if (mode === 'register') {\n //? Full password-policy check (length, complexity, common-list, customValidator).\n //? Policy lives in `projectConfig.auth.passwordPolicy`; the deprecated\n //? `passwordMinLength`/`passwordMaxLength` top-level fields are still read\n //? from the policy's `minLength`/`maxLength` defaults. REGISTER-only — see the\n //? function doc for why the login branch skips it (M-15).\n const passwordReason = validatePassword(creds.password);\n if (passwordReason) return { status: false, reason: passwordReason };\n }\n return null;\n};\n\nconst registerWithCredentials = async (\n {\n email,\n password,\n name,\n confirmPassword,\n }: {\n email: string;\n password: string;\n name: string;\n confirmPassword: string;\n },\n options?: { supersedeToken?: string },\n): Promise<CredentialsLoginResult> => {\n if (password !== confirmPassword) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.passwordNotMatch', stage: 'register' });\n return { status: false, reason: 'login.passwordNotMatch' };\n }\n\n const preRegisterResult = await dispatchHook('preRegister', { email, provider: 'credentials', name });\n if (preRegisterResult.stopped) {\n emitLoginFailed({ email, provider: 'credentials', reason: preRegisterResult.signal.errorCode, stage: 'register' });\n return { status: false, reason: preRegisterResult.signal.errorCode };\n }\n\n //? LOGIN-F15: apply the per-account lockout check before the email-existence\n //? lookup. Without this an attacker can call the register endpoint repeatedly\n //? for a victim's email and perform unbounded `resolveUserByEmail` probes —\n //? effectively bypassing the lockout by choosing the register code path. A\n //? locked account means the credentials flow is temporarily suspended for that\n //? email; the same sentinel applies regardless of which surface (login vs\n //? register) is being used. No-op when `rateLimiting.auth.enabled` is false.\n if (await isAccountLocked(email)) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.accountLocked', stage: 'register' });\n return { status: false, reason: 'login.accountLocked' };\n }\n\n const userAdapter = getUserAdapter();\n const [checkEmailError, checkEmailResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: 'credentials' })\n );\n if (checkEmailError) {\n getLogger().error('login: findByEmail failed during register', checkEmailError);\n const reason = toReasonKey(checkEmailError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'register' });\n return { status: false, reason };\n }\n if (checkEmailResponse) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.emailExists', stage: 'register' });\n return { status: false, reason: 'login.emailExists' };\n }\n\n const createNewUser = async () => {\n const salt = await genSalt(getProjectConfig().auth.bcryptRounds);\n const hashedPassword = await hash(password, salt);\n return await userAdapter.create({\n email,\n provider: 'credentials',\n name,\n password: hashedPassword,\n avatar: '',\n avatarFallback: generateAvatarFallbackColor(),\n language: getProjectConfig().defaultLanguage,\n });\n };\n\n const [createNewUserError, createNewUserResponse] = await tryCatch(createNewUser);\n if (createNewUserError) {\n //? LOGIN-F7: treat a unique-constraint violation as a TOCTOU race — another\n //? concurrent request created this row between our findFirst and our create.\n //? Under the `'unified'` strategy (where `email @unique` is recommended) this\n //? is the expected concurrent-registration signal; return `login.emailExists`\n //? so the client can prompt the user to log in instead.\n const errorMessage =\n createNewUserError instanceof Error ? createNewUserError.message : String(createNewUserError);\n const isUniqueViolation =\n errorMessage.includes('Unique constraint') ||\n errorMessage.includes('unique constraint') ||\n // Prisma error code P2002 covers all DB engines\n (createNewUserError as { code?: unknown }).code === 'P2002';\n if (isUniqueViolation) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.emailExists', stage: 'register' });\n return { status: false, reason: 'login.emailExists' };\n }\n const reason = toReasonKey(createNewUserError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'register' });\n return { status: false, reason };\n }\n if (!createNewUserResponse) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.createUserFailed', stage: 'register' });\n return { status: false, reason: 'login.createUserFailed' };\n }\n\n await dispatchHook('postRegister', { userId: createNewUserResponse.id, provider: 'credentials' });\n\n //? Auto-login after register (ARCHITECTURE_AUTH.md: both flows return\n //? `{ status, session, newToken }`). Mirrors the login branch: mint a token,\n //? persist the session, and hand the token back so the route can set the\n //? cookie / the client can store it and land on `loginRedirectUrl`.\n const newToken = randomBytes(32).toString('hex');\n const nowLogin = new Date();\n const newUser: SessionLayout = {\n ...sanitizeUserForSession(createNewUserResponse),\n token: newToken,\n lastLogin: nowLogin,\n previousLogin: null,\n };\n\n const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });\n if (!saved.ok) {\n //? Account exists but the session never persisted — surface the failure so\n //? the route does not report an authenticated state it cannot back up. The\n //? user can recover by logging in normally.\n emitLoginFailed({ email, userId: newUser.id, provider: 'credentials', reason: saved.errorCode, stage: 'register' });\n return { status: false, reason: saved.errorCode };\n }\n await dispatchHook('postLogin', { userId: newUser.id, provider: 'credentials', isNewUser: true, token: newToken });\n\n return {\n status: true,\n reason: 'login.userCreated',\n newToken,\n session: newUser,\n };\n};\n\nconst loginWithCredentialsCore = async (\n { email, password }: { email: string; password: string },\n //? DD-LOGIN-F5: `requesterIp` is optional so all existing callers stay\n //? compatible. The HTTP auth route resolves and passes the IP; socket paths\n //? omit it and fall back to the pure-account lockout bucket.\n options?: { supersedeToken?: string; requesterIp?: string },\n): Promise<CredentialsLoginResult> => {\n const requesterIp = options?.requesterIp;\n const preLoginResult = await dispatchHook('preLogin', { email, provider: 'credentials' });\n if (preLoginResult.stopped) {\n emitLoginFailed({ email, provider: 'credentials', reason: preLoginResult.signal.errorCode, stage: 'login', requesterIp });\n return { status: false, reason: preLoginResult.signal.errorCode };\n }\n\n //? Per-account brute-force lockout (F7 / MIS-017). When the auth rate-limit\n //? slot is enabled and this account has exhausted its failed-attempt budget,\n //? refuse the attempt BEFORE any bcrypt work — distributed credential stuffing\n //? against one account (which the per-IP throttle can't see) is throttled\n //? per-account here. The counter is incremented by the registered `loginFailed`\n //? handler (see `registerAuthLockoutHook`); merely checking the lock does not\n //? increment it. No-op when `rateLimiting.auth.enabled` is false (default).\n if (await isAccountLocked(email, requesterIp)) {\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.accountLocked', stage: 'login', requesterIp });\n return { status: false, reason: 'login.accountLocked' };\n }\n\n const userAdapter = getUserAdapter();\n const [findUserError, findUserResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: 'credentials' })\n );\n if (findUserError) {\n getLogger().error('login: findByEmail failed', findUserError);\n const reason = toReasonKey(findUserError);\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'login', requesterIp });\n return { status: false, reason };\n }\n //? Anti-enumeration: a missing account and a missing/invalid password hash\n //? must be INDISTINGUISHABLE from a wrong password — same reason key AND\n //? similar timing. Returning a distinct `login.userNotFound` (and short-\n //? circuiting before any bcrypt work) let an attacker probe which addresses\n //? have credentials accounts via both the response key and a timing side\n //? channel. When there is no real hash to compare, run a bcrypt compare\n //? against a fixed dummy hash so the not-found path spends roughly the same\n //? CPU as a real wrong-password compare, then return the shared key.\n const passwordHash = findUserResponse?.password ?? null;\n if (!findUserResponse || !passwordHash) {\n const dummyHash = await getDummyBcryptHash();\n await tryCatch(() => compare(password, dummyHash));\n //? M3: AWAIT the failure dispatch on the counting (wrong-password) path so the\n //? per-account lockout increment (registered on `loginFailed`) lands BEFORE we\n //? respond — unlike the fire-and-forget `emitLoginFailed` used for observational\n //? failures. This closes the sequential check-then-increment TOCTOU (a retry now\n //? sees the updated counter). `dispatchHook` isolates handler errors, so this\n //? still can't change the auth outcome. A residual window remains for requests\n //? already in flight concurrently (bounded by the atomic per-IP cap) — inherent\n //? to any check-then-act gate.\n await dispatchHook('loginFailed', { email, userId: findUserResponse?.id, provider: 'credentials', reason: 'login.wrongPassword', stage: 'login', requesterIp });\n return { status: false, reason: 'login.wrongPassword' };\n }\n\n const [checkPasswordError, checkPasswordResponse] = await tryCatch(() =>\n compare(password, passwordHash)\n );\n if (checkPasswordError) {\n getLogger().error('login: bcrypt compare failed', checkPasswordError);\n const reason = toReasonKey(checkPasswordError);\n emitLoginFailed({ email, userId: findUserResponse.id, provider: 'credentials', reason, stage: 'login', requesterIp });\n return { status: false, reason };\n }\n if (!checkPasswordResponse) {\n //? M3: awaited on the counting path (see the no-account branch above) so the\n //? lockout increment lands before the response — closes the sequential TOCTOU.\n await dispatchHook('loginFailed', { email, userId: findUserResponse.id, provider: 'credentials', reason: 'login.wrongPassword', stage: 'login', requesterIp });\n return { status: false, reason: 'login.wrongPassword' };\n }\n\n const newToken = randomBytes(32).toString('hex');\n const previousLogin = findUserResponse.lastLogin ?? null;\n const nowLogin = new Date();\n\n // Best-effort lastLogin update — silently no-ops if the user adapter\n // (or User schema) doesn't accept the field.\n await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));\n\n const newUser: SessionLayout = {\n ...sanitizeUserForSession(findUserResponse),\n token: newToken,\n lastLogin: nowLogin,\n previousLogin,\n };\n\n const uploadedAvatar = await resolveUploadedAvatar(newUser.id);\n if (uploadedAvatar) {\n newUser.avatar = uploadedAvatar;\n }\n\n const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });\n if (!saved.ok) {\n //? Session never persisted (adapter blip / preSessionCreate veto). Fail the\n //? login so the route does NOT set a cookie or delete the prior session for\n //? a token that getSession() can't resolve.\n emitLoginFailed({ email, userId: newUser.id, provider: 'credentials', reason: saved.errorCode, stage: 'login', requesterIp });\n return { status: false, reason: saved.errorCode };\n }\n await dispatchHook('postLogin', { userId: newUser.id, provider: 'credentials', isNewUser: false, token: newToken });\n //? Reset the brute-force counter on success so earlier typos don't keep a\n //? legitimate user locked out (F7). No-op when the feature is disabled.\n //? Clear the IP+account composite key (DD-LOGIN-F5) AND the bare account key\n //? so that failures recorded without an IP (e.g. from a different surface) are\n //? also cleared after a confirmed legitimate login.\n void clearAuthFailures(email, requesterIp);\n if (requesterIp) void clearAuthFailures(email);\n if (isDevMode()) {\n getLogger().debug(`credentials login success for user ${newUser.id}`);\n }\n return { status: true, reason: 'login.loggedIn', newToken, session: newUser };\n};\n\n//? Thin dispatcher: keeps the existing single-entry HTTP surface but forwards\n//? to the dedicated register / login functions. `name && confirmPassword`\n//? present in the body means the client is registering; otherwise it's a\n//? login.\nconst loginWithCredentials = async (\n params: LoginOrRegisterParams,\n //? DD-LOGIN-F5: `requesterIp` forwarded to `loginWithCredentialsCore` to\n //? enable IP+account composite lockout keys on the HTTP auth path.\n options?: { supersedeToken?: string; requesterIp?: string },\n): Promise<CredentialsLoginResult> => {\n const creds = normalizeCredentials(params);\n\n if (isDevMode()) {\n getLogger().debug(`credentials auth attempt for ${creds.email || 'unknown-email'}`);\n }\n\n //? Decide the branch BEFORE shape-validation so the password-POLICY check only\n //? runs on register (M-15): `name && confirmPassword` present => register;\n //? otherwise login. A login must accept any password string (the bcrypt\n //? compare is the only authority), so an attacker can't trip a victim's\n //? lockout counter by POSTing policy-violating passwords for their email.\n //? Narrowing on the destructured locals keeps `name`/`confirmPassword` typed as\n //? `string` inside the register branch (no cast needed).\n const { name, confirmPassword } = creds;\n if (name && confirmPassword) {\n const validationError = validateCredentialsShape(creds, 'register');\n if (validationError) return validationError;\n //? Public-registration gate (F18). When `auth.allowRegistration === false`\n //? (invite-only / admin-provisioned apps) refuse the register branch with a\n //? dedicated reason key BEFORE any user lookup or hook. OAuth-driven\n //? first-login account creation is governed separately by the provider flow.\n //? Default `true` keeps today's open-registration behavior.\n if (!getProjectConfig().auth.allowRegistration) {\n emitLoginFailed({ email: creds.email, provider: 'credentials', reason: 'auth.registrationDisabled', stage: 'register' });\n return { status: false, reason: 'auth.registrationDisabled' };\n }\n return registerWithCredentials({\n email: creds.email,\n password: creds.password,\n name,\n confirmPassword,\n }, options);\n }\n\n const validationError = validateCredentialsShape(creds, 'login');\n if (validationError) return validationError;\n return loginWithCredentialsCore({ email: creds.email, password: creds.password }, options);\n};\n\nexport interface OAuthCallbackResult {\n token: string;\n redirectUrl: string;\n userId: string;\n provider: string;\n isNewUser: boolean;\n}\n\nconst isAllowedRedirectUrl = (url: string): boolean => {\n if (!URL.canParse(url, 'http://placeholder')) return false;\n //? Reject relative URLs whose path starts with a backslash or contains any\n //? backslash before the same-origin shortcut (SEC-12): `new URL('/\\\\evil.com',\n //? base)` keeps the placeholder origin and would pass the same-origin check,\n //? yet browsers normalise `/\\` → `//` (protocol-relative) → open-redirect to\n //? `evil.com`. A legitimate same-origin path never needs a backslash.\n if (url.includes('\\\\')) return false;\n const parsed = new URL(url, 'http://placeholder');\n if (parsed.origin === 'http://placeholder') {\n // relative URL — same-origin, always safe\n return true;\n }\n //? `allowedOrigins` is `string[] | (origin: string) => boolean` since we\n //? added the function-resolver variant. Branch on the shape.\n //? Defensive default at a security boundary (redirect-origin validation):\n //? the type is non-nullable, but a malformed consumer config could still\n //? hand us `undefined` at runtime, so the `?? []` fail-closed guard stays.\n //? `allowLocalhost` is the dev convenience that accepts any http://localhost:*\n //? origin without needing an explicit allowedOrigins entry. Safe in production\n //? because allowLocalhost defaults to false there.\n const cfg = getProjectConfig();\n if (cfg.http.cors.allowLocalhost && parsed.hostname === 'localhost') return true;\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime-defensive guard at a security boundary\n const allowed = cfg.http.cors.allowedOrigins ?? [];\n if (typeof allowed === 'function') {\n return allowed(parsed.origin);\n }\n return allowed.some((origin) => {\n if (!URL.canParse(origin)) return false;\n return new URL(origin).origin === parsed.origin;\n });\n};\n\nconst exchangeOAuthToken = async (\n provider: FullOAuthProvider,\n code: string,\n codeVerifier?: string,\n): Promise<string | null> => {\n //? `redirect_uri` is pinned to the registered, immutable `provider.callbackURL`\n //? — the SAME static config value the authorization request is built from.\n //? It is never derived from the incoming request, so there is no per-request\n //? drift to re-validate against (the authorization-time value and the\n //? exchange-time value are guaranteed identical by construction). Persisting\n //? it into the Redis state would be a behaviour-preserving no-op here; if the\n //? callback URL ever becomes request-/tenant-derived, store it alongside the\n //? state in `createOAuthState` and compare before exchange.\n //? `code_verifier` (PKCE, F11) is included ONLY when the provider opted in and\n //? a verifier was stored with the state — for every existing flow it is\n //? `undefined` and the exchange body is unchanged.\n const values = {\n code,\n client_id: provider.clientID,\n client_secret: provider.clientSecret,\n redirect_uri: provider.callbackURL,\n grant_type: 'authorization_code',\n ...(codeVerifier ? { code_verifier: codeVerifier } : {}),\n };\n\n //? `Promise<unknown>` return annotation: `response.json()` is `any`, and an\n //? explicit `unknown` boundary type forces every caller to narrow via\n //? `asRecord(...)` instead of leaking `any` through the token-exchange seam.\n const getToken = async (): Promise<unknown> => {\n if (provider.tokenExchangeMethod === 'json') {\n const response = await fetch(provider.tokenExchangeURL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\n body: JSON.stringify(values),\n });\n if (!response.ok) {\n getLogger().warn('oauth: token exchange returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n }\n //? `tokenExchangeMethod` is the closed union `'json' | 'form'`; the `json`\n //? branch returned above, so this is the exhaustive `form` case.\n const formParams = new URLSearchParams();\n formParams.append('client_id', provider.clientID);\n formParams.append('client_secret', provider.clientSecret);\n formParams.append('code', values.code);\n formParams.append('grant_type', 'authorization_code');\n formParams.append('redirect_uri', provider.callbackURL);\n if (codeVerifier) {\n formParams.append('code_verifier', codeVerifier);\n }\n\n if (isDevMode()) {\n //? NEVER stringify the raw form — it carries `client_secret` (a\n //? long-lived credential) and the single-use authorization `code`. Log\n //? only the param NAMES so the dev sees the shape without leaking secrets\n //? into the log sink.\n getLogger().debug('oauth: token-exchange form params', { paramNames: [...formParams.keys()] });\n }\n const response = await fetch(provider.tokenExchangeURL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },\n body: formParams.toString(),\n });\n if (!response.ok) {\n getLogger().warn('oauth: token exchange returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n };\n\n const [error, response] = await tryCatch(getToken);\n if (error) {\n getLogger().error('oauth: token exchange failed', error);\n return null;\n }\n const tokenData = asRecord(response);\n const accessToken = typeof tokenData.access_token === 'string' ? tokenData.access_token : '';\n if (!accessToken) {\n getLogger().warn('oauth: no access_token in token response', { provider: provider.name });\n return null;\n }\n return accessToken;\n};\n\ninterface OAuthProfile {\n email: string | undefined;\n name: string;\n avatar: string;\n /**\n * `true` only when the provider POSITIVELY reported the email as verified\n * (via `emailVerifiedKey`) or supplied it through a `getEmail` accessor that\n * itself filters on a verified flag (GitHub). `false` when the provider\n * exposes NO verified-email signal at all — used to fail-closed on\n * cross-provider account LINKING under the `'unified'` strategy (SEC-21/SEC-40),\n * where linking an unverified address to a victim's existing account is an\n * account-takeover vector. First-time account CREATION is unaffected.\n */\n emailVerified: boolean;\n /**\n * The RAW, un-stripped provider userInfo record. Threaded through so the\n * `extraSessionFields` hook receives every provider-specific claim (`sub`,\n * tenant id, custom claims) instead of the trimmed `{email,name,avatar}`\n * projection — the documented contract (SEC-3 / oauthProviders.ts hook docs).\n */\n rawUserData: OAuthUserData;\n}\n\nconst fetchOAuthProfile = async (\n provider: FullOAuthProvider,\n accessToken: string,\n): Promise<OAuthProfile | null> => {\n const getUserData = async (): Promise<unknown> => {\n const response = await fetch(provider.userInfoURL, {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'Authorization': `Bearer ${accessToken}`,\n },\n });\n if (!response.ok) {\n getLogger().warn('oauth: userInfo fetch returned non-OK status', { provider: provider.name, status: response.status });\n return null;\n }\n return await response.json();\n };\n\n const [error, response] = await tryCatch(getUserData);\n if (error) {\n getLogger().error('oauth: userInfo fetch failed', error);\n return null;\n }\n\n const userData = asRecord(response);\n //? Provider responses are untyped JSON; read each field through `readString`\n //? so a non-string (object/number/missing) value falls back instead of\n //? stringifying to `[object Object]`.\n //? A present-but-empty name string falls back too (mirrors the old\n //? `String(value || 'didnt find a name')` truthy-coercion).\n const resolvedName = readStringField(userData, provider.nameKey);\n const name = resolvedName === '' ? 'didnt find a name' : resolvedName;\n const emailValue = userData[provider.emailKey];\n let email: string | undefined = typeof emailValue === 'string' ? emailValue : undefined;\n\n //? Track whether the provider POSITIVELY confirmed this email as verified.\n //? `emailVerifiedKey === true` (or any non-false value when the key is set and\n //? present) counts as verified; a provider with NO `emailVerifiedKey` exposes\n //? no signal, so the email stays `emailVerified: false` and only first-time\n //? account creation (not cross-provider linking) is allowed downstream.\n let emailVerified = false;\n\n //? Reject a provider-supplied email that the provider explicitly marks\n //? unverified (e.g. Discord's `verified: false`). A missing flag is treated\n //? as \"no signal\" (not verified, but not rejected outright — see the\n //? cross-provider link guard in `findOrCreateOAuthUser`). Defense-in-depth\n //? against account-linking takeover under `'unified'` (SEC-21).\n if (email && provider.emailVerifiedKey) {\n const verifiedFlag = userData[provider.emailVerifiedKey];\n //? Only a STRICT boolean `true` counts as verified. A present-but-not-true\n //? value (`false`, the strings \"false\"/\"0\", `0`, `null`) is an explicit\n //? not-verified signal → reject. This hardens against a CUSTOM provider that\n //? encodes the flag as a string/number (a truthy `\"false\"` previously slipped\n //? through as verified, re-opening cross-provider account-linking takeover).\n //? A MISSING flag (undefined) is \"no signal\" — not verified, not rejected here;\n //? the cross-provider link guard in findOrCreateOAuthUser handles it (SEC-21).\n if (verifiedFlag !== undefined && verifiedFlag !== true) {\n getLogger().warn('oauth: provider email is not verified — rejecting', { provider: provider.name });\n return null;\n }\n if (verifiedFlag === true) emailVerified = true;\n }\n\n //? Providers whose userinfo only returns an email once it is CONFIRMED (e.g.\n //? Facebook Graph /me) expose no separate verified flag — the presence of the\n //? email IS the verified signal. `emailImpliesVerified` opts such a provider in\n //? so cross-provider LINK + first-login CREATION both treat it as verified.\n if (email && provider.emailImpliesVerified) {\n emailVerified = true;\n }\n\n const avatarId = provider.avatarCodeKey ? userData[provider.avatarCodeKey] : undefined;\n const avatarValue = provider.avatarKey\n ? readStringField(userData, provider.avatarKey)\n : (provider.getAvatar\n ? await provider.getAvatar({ userData, avatarId: typeof avatarId === 'string' ? avatarId : '', accessToken })\n : '');\n const avatar = typeof avatarValue === 'string' ? avatarValue : '';\n\n //? Some providers (GitHub) don't return email in /userinfo — fall back to the\n //? provider's `getEmail` accessor. GitHub's accessor only ever selects a\n //? `verified === true` address (see `githubProvider.getEmail`), so an email\n //? obtained this way is treated as positively verified for the cross-provider\n //? link guard (SEC-21).\n if (!email && provider.getEmail) {\n const selectedEmail = await provider.getEmail(accessToken);\n if (!selectedEmail) {\n getLogger().warn('oauth: no email found via provider.getEmail', { provider: provider.name });\n return null;\n }\n email = selectedEmail;\n emailVerified = true;\n }\n\n //? Normalize OAuth emails through the SAME gate as credentials (trim +\n //? lowercase + `isEmail`) before they reach the adapter. Credentials emails\n //? are normalized at `normalizeCredentials`; taking the provider value\n //? verbatim meant `Victim@x.com` (OAuth) and `victim@x.com` (credentials)\n //? resolved to different rows — which under the `'unified'` account strategy\n //? (case-sensitive lookup) silently DEFEATS cross-provider linking. A\n //? present-but-malformed provider email is treated as \"no usable email\".\n if (email !== undefined) {\n const normalizedEmail = email.trim().toLowerCase();\n if (!isEmail(normalizedEmail)) {\n getLogger().warn('oauth: provider returned a malformed email', { provider: provider.name });\n return null;\n }\n email = normalizedEmail;\n }\n\n return { email, name, avatar, emailVerified, rawUserData: userData };\n};\n\ninterface ResolvedOAuthUser {\n user: SessionLayout;\n isNewUser: boolean;\n}\n\nconst findOrCreateOAuthUser = async (\n provider: FullOAuthProvider,\n profile: OAuthProfile,\n): Promise<ResolvedOAuthUser | null> => {\n const { email, name, avatar, emailVerified } = profile;\n if (!email) return null;\n\n const preLoginResult = await dispatchHook('preLogin', { email, provider: provider.name });\n if (preLoginResult.stopped) {\n getLogger().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });\n return null;\n }\n\n const userAdapter = getUserAdapter();\n //? Under `'unified'` this links to an existing account with the same email\n //? created via ANY provider (incl. credentials); under `'per-provider'` it\n //? stays scoped to this provider (existing behavior).\n const [findError, findResponse] = await tryCatch(() =>\n resolveUserByEmail(userAdapter, { email, provider: provider.name })\n );\n if (findError) {\n getLogger().error('oauth: findByEmail failed', findError);\n return null;\n }\n\n if (findResponse?.id) {\n //? Fail-closed cross-provider account-LINK guard (SEC-21/SEC-40). Under the\n //? `'unified'` strategy `resolveUserByEmail` matches by email across every\n //? provider, so an OAuth sign-in can LINK into an account originally created\n //? by a DIFFERENT provider (incl. credentials). If the current provider did\n //? NOT positively verify the email (no `emailVerifiedKey` / `getEmail`\n //? verified-filter), an attacker who controls an unverified address equal to\n //? a victim's could take over that account. Refuse the link in that case —\n //? same-provider re-login and first-time account CREATION are unaffected, so\n //? existing flows for built-ins that DO verify (Google/GitHub/Discord) keep\n //? working. `'per-provider'` always matches within the same provider, so the\n //? cross-provider condition is never met there (no behaviour change).\n const existingProvider = findResponse.provider ?? null;\n //? Fail CLOSED on a missing provider field: treat unknown (null/undefined) as a\n //? cross-provider link so an unverified provider email can't link into an\n //? existing account whose record omits `provider` (a custom UserAdapter may not\n //? populate it). Same-provider re-login still matches (=== provider.name → not\n //? cross), and first-time creation is unaffected (this branch needs an existing id).\n const isCrossProviderLink = existingProvider !== provider.name;\n if (isCrossProviderLink && !emailVerified) {\n getLogger().warn(\n 'oauth: refusing to link an unverified provider email to an existing account created by a different provider (account-takeover guard)',\n { provider: provider.name, existingProvider },\n );\n return null;\n }\n\n //? LOGIN-F18 (OAuth path): same async stat lookup via the shared helper.\n const uploadedAvatar = await resolveUploadedAvatar(findResponse.id);\n if (uploadedAvatar) {\n findResponse.avatar = uploadedAvatar;\n }\n\n const previousLogin = findResponse.lastLogin ?? null;\n const nowLogin = new Date();\n await tryCatch(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));\n\n return {\n user: {\n ...sanitizeUserForSession(findResponse),\n token: '',\n lastLogin: nowLogin,\n previousLogin,\n },\n isNewUser: false,\n };\n }\n\n //? Fail-closed account-CREATION guard (M2), symmetric with the cross-provider\n //? LINK guard above. When NO account exists, a provider that did not positively\n //? verify the email (no `emailVerifiedKey === true` / `getEmail` /\n //? `emailImpliesVerified`) must not be able to seed a brand-new account bound to\n //? an address the signer-in may not own — a custom, non-verifying provider would\n //? otherwise let an attacker SQUAT a victim's email (blocking the victim's later\n //? signup with `login.emailExists` and, under `'unified'`, absorbing the victim's\n //? later verified sign-in into the attacker-seeded row). All built-in providers\n //? set `emailVerified` (Google/GitHub/Microsoft/Discord/Facebook), so this only\n //? refuses genuinely-unverified CUSTOM providers; such a provider must verify the\n //? email (set `emailVerifiedKey` / `getEmail` / `emailImpliesVerified`) to enable\n //? OAuth signup, rather than the framework trusting it by default.\n if (!emailVerified) {\n getLogger().warn(\n 'oauth: refusing first-login account creation for an unverified provider email (account-squatting guard)',\n { provider: provider.name },\n );\n return null;\n }\n\n //? Gate OAuth first-login account CREATION on the same `auth.allowRegistration`\n //? flag the credentials register branch enforces. Without this, invite-only /\n //? admin-provisioned deployments (allowRegistration:false) are silently\n //? bypassable: anyone with an enabled provider's account completes the OAuth\n //? flow and gets a fresh account. Existing users hit the find-branch above and\n //? are unaffected; only NEW-account creation is refused. A consumer preRegister\n //? hook can still veto, but the documented gate must hold by construction.\n if (!getProjectConfig().auth.allowRegistration) {\n getLogger().warn('oauth: registration disabled — refusing first-login account creation');\n return null;\n }\n\n const preRegisterResult = await dispatchHook('preRegister', { email, provider: provider.name, name });\n if (preRegisterResult.stopped) {\n getLogger().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });\n return null;\n }\n\n const createNewUser = async () => userAdapter.create({\n email,\n provider: provider.name,\n name,\n avatar,\n avatarFallback: generateAvatarFallbackColor(),\n language: getProjectConfig().defaultLanguage,\n });\n const [createError, createResponse] = await tryCatch(createNewUser);\n if (createError) {\n getLogger().error('oauth: user create failed', createError);\n return null;\n }\n if (!createResponse) return null;\n\n return {\n user: { ...sanitizeUserForSession(createResponse), token: '' },\n isNewUser: true,\n };\n};\n\nconst resolvePostLoginRedirect = async ({\n fallbackUrl,\n userId,\n providerName,\n isNewUser,\n}: {\n fallbackUrl: string;\n userId: string;\n providerName: string;\n isNewUser: boolean;\n}): Promise<string> => {\n const resolver = getPostLoginRedirect();\n if (!resolver) return fallbackUrl;\n\n const [resolverError, resolved] = await tryCatch(() => resolver({\n userId,\n provider: providerName,\n isNewUser,\n defaultUrl: fallbackUrl,\n }));\n if (resolverError) {\n getLogger().warn(`[oauth] postLoginRedirect resolver threw`, { message: resolverError.message });\n return fallbackUrl;\n }\n if (resolved && isAllowedRedirectUrl(resolved)) return resolved;\n if (resolved) {\n getLogger().warn(\n `[oauth] postLoginRedirect returned a URL not in allowed origins — falling back`,\n { resolved, fallbackUrl },\n );\n }\n return fallbackUrl;\n};\n\n//? Whitelist the URL path segment against registered providers and verify the\n//? request carries a URL. Returns the matched full provider + its canonical name,\n//? or null when the segment is absent, unregistered, or an incomplete provider\n//? shape. Separating this keeps the callback routing concern out of the main flow.\nconst resolveProviderFromCallback = (\n pathname: string,\n url: string | undefined,\n): { provider: FullOAuthProvider; providerName: string } | null => {\n const rawProviderSegment = pathname.split('/')[3]; // google/github/etc.\n if (!rawProviderSegment || !url) return null;\n const found = getOAuthProviders().find(p => p.name === rawProviderSegment);\n if (!found || !isFullOAuthProvider(found)) return null;\n return { provider: found, providerName: found.name };\n};\n\n//? Merge per-provider runtime extras (calendar tokens, tenant ids, etc.) into\n//? the session object IN PLACE before it is persisted. Errors are non-fatal —\n//? a failing or absent hook must not block the user from signing in (SEC-10).\nconst applyExtraSessionFields = async (\n user: SessionLayout,\n provider: FullOAuthProvider,\n profile: OAuthProfile,\n accessToken: string,\n): Promise<void> => {\n const { extraSessionFields } = provider;\n if (!extraSessionFields) return;\n\n //? Pass the RAW provider userData (SEC-3) so the hook sees every provider\n //? claim (`sub`, tenant id, custom claims) rather than the trimmed projection.\n const [extraErr, extra] = await tryCatch(async () =>\n extraSessionFields({ userData: profile.rawUserData, accessToken }),\n );\n if (extraErr) {\n getLogger().warn(`[oauth:${provider.name}] extraSessionFields hook threw — continuing without extras`, { err: extraErr });\n return;\n }\n if (!extra) return;\n\n //? Strip framework-owned session keys before merging (SEC-10).\n const { id, token, csrfToken, password, ...safeExtra } = extra;\n if (id !== undefined || token !== undefined || csrfToken !== undefined || password !== undefined) {\n getLogger().warn(\n `[oauth:${provider.name}] extraSessionFields returned framework-owned key(s) (id/token/csrfToken/password) — dropped`,\n );\n }\n //? SEC: warn when extra fields appear to contain bearer credentials. Access\n //? tokens stored in the Redis session are broadcast to connected sockets via\n //? updateSession — they should not be in session fields unless intentional.\n //? The stripped set above already removes \"token\"; this covers other key names.\n const CREDENTIAL_PATTERN = /token|secret|key|auth/i;\n const suspiciousKeys = Object.keys(safeExtra).filter(k => CREDENTIAL_PATTERN.test(k));\n if (suspiciousKeys.length > 0) {\n getLogger().warn(\n `[oauth:${provider.name}] extraSessionFields contains field(s) that look like credentials (${suspiciousKeys.join(', ')}) — these will be stored in the Redis session and broadcast to connected sockets. Consider keeping them server-side only.`,\n );\n }\n Object.assign(user, safeExtra);\n};\n\n//? Assemble the fallback redirect URL for the OAuth callback by preferring the\n//? tamper-proof return URL stored server-side with the OAuth state, then the\n//? caller-supplied default, then the configured `loginRedirectUrl`, then '/'.\nconst resolveOAuthFallbackUrl = (\n stateEntry: OAuthStateEntry,\n options: { defaultRedirectUrl?: string },\n): string => {\n const stateReturnUrl =\n stateEntry.returnUrl && isAllowedRedirectUrl(stateEntry.returnUrl)\n ? stateEntry.returnUrl\n : undefined;\n\n return (\n stateReturnUrl\n ?? options.defaultRedirectUrl\n ?? getProjectConfig().loginRedirectUrl\n ?? '/'\n );\n};\n\n// Route that handles the callback from the OAuth provider\nconst loginCallback = async (\n pathname: string,\n req: IncomingMessage,\n _res: ServerResponse,\n options: { defaultRedirectUrl?: string; supersedeToken?: string } = {},\n): Promise<OAuthCallbackResult | false> => {\n const providerResolution = resolveProviderFromCallback(pathname, req.url);\n if (!providerResolution) return false;\n const { provider, providerName } = providerResolution;\n\n // req.url is guaranteed non-null here: resolveProviderFromCallback returns null when url is absent\n const queryString = (req.url ?? '').split('?')[1] ?? '';\n const params = new URLSearchParams(queryString);\n const code = params.get('code');\n const state = params.get('state');\n\n //? Browser-binding (F1): the nonce the server set as the `ls-oauth-state`\n //? cookie at the authorize step must round-trip back and match the value\n //? stored alongside the Redis state entry. A missing/mismatched cookie means\n //? this browser did NOT start the flow — reject before any token exchange.\n const cookieNonce = getCookieValue(req.headers.cookie, OAUTH_STATE_COOKIE_NAME) ?? '';\n const stateEntry = await consumeOAuthState(provider.name, state ?? '', cookieNonce);\n if (!stateEntry) {\n getLogger().warn('oauth: invalid, missing, or unbound state', { provider: provider.name });\n emitLoginFailed({ provider: providerName, reason: 'oauth.invalidState', stage: 'oauth' });\n return false;\n }\n\n if (!code) {\n getLogger().warn('oauth: no code provided in callback url', { provider: provider.name });\n emitLoginFailed({ provider: providerName, reason: 'oauth.noCode', stage: 'oauth' });\n return false;\n }\n\n //? PKCE (F11): replay the verifier stored with the state entry at token\n //? exchange when the provider opted in. `undefined` for non-PKCE providers,\n //? in which case the exchange is byte-identical to before.\n const accessToken = await exchangeOAuthToken(provider, code, stateEntry.codeVerifier);\n if (!accessToken) {\n emitLoginFailed({ provider: providerName, reason: 'oauth.tokenExchangeFailed', stage: 'oauth' });\n return false;\n }\n\n const profile = await fetchOAuthProfile(provider, accessToken);\n if (!profile) {\n emitLoginFailed({ provider: providerName, reason: 'oauth.profileFetchFailed', stage: 'oauth' });\n return false;\n }\n\n const resolved = await findOrCreateOAuthUser(provider, profile);\n if (!resolved) {\n emitLoginFailed({ provider: providerName, email: profile.email, reason: 'oauth.userResolveFailed', stage: 'oauth' });\n return false;\n }\n\n const newToken = randomBytes(32).toString('hex');\n resolved.user.token = newToken;\n\n //? Per-provider runtime extras (calendar tokens, tenant ids, etc.) are merged\n //? into the session BEFORE save. Errors are non-fatal — see applyExtraSessionFields.\n await applyExtraSessionFields(resolved.user, provider, profile, accessToken);\n\n const saved = await saveSession(newToken, resolved.user, true, { supersedeToken: options.supersedeToken });\n if (!saved.ok) {\n //? Session never persisted — abort the OAuth callback so authCallbackRoute\n //? returns \"Login failed\" instead of redirecting with a dead token.\n getLogger().error(`[oauth:${providerName}] saveSession failed — aborting callback`, { errorCode: saved.errorCode });\n emitLoginFailed({ provider: providerName, userId: resolved.user.id, reason: saved.errorCode, stage: 'oauth' });\n return false;\n }\n\n if (resolved.isNewUser) {\n await dispatchHook('postRegister', { userId: resolved.user.id, provider: providerName });\n }\n await dispatchHook('postLogin', {\n userId: resolved.user.id,\n provider: providerName,\n isNewUser: resolved.isNewUser,\n token: newToken,\n });\n\n if (isDevMode()) {\n getLogger().debug(`oauth login success for user ${resolved.user.id}`);\n }\n\n const fallbackUrl = resolveOAuthFallbackUrl(stateEntry, options);\n const redirectUrl = await resolvePostLoginRedirect({\n fallbackUrl,\n userId: resolved.user.id,\n providerName,\n isNewUser: resolved.isNewUser,\n });\n\n return {\n token: newToken,\n redirectUrl,\n userId: resolved.user.id,\n provider: providerName,\n isNewUser: resolved.isNewUser,\n };\n};\n\nexport { loginWithCredentials, loginCallback, registerWithCredentials, loginWithCredentialsCore }\n","//? @adr 0019 — Email uniqueness is opt-in and governed by this knob: the default\n//? `'per-provider'` strategy INTENTIONALLY does not require `email @unique`\n//? (same address across providers = separate rows); only `'unified'` needs the\n//? consumer to add the constraint. A missing `@unique` on `User.email` is\n//? by-design, not a bug — see ADR 0019 before flagging it.\n//?\n//? Implements `auth.providerAccountStrategy` (CFG-04). The knob was declared\n//? and documented but no code read it — every lookup was provider-scoped, so\n//? `'unified'` silently behaved like `'per-provider'` and produced duplicate\n//? accounts for the same email across providers.\n//?\n//? - `'per-provider'` (default): resolve a user by (email, provider). The\n//? same address via Google and GitHub is two separate User rows. No schema\n//? change required.\n//? - `'unified'`: resolve a user by email alone (across providers) so the\n//? address maps to a single User; a sign-in via a new provider LINKS to the\n//? existing account instead of creating a duplicate. Requires the User\n//? schema's `email` to be unique (see @luckystack/login README — \"Unified\n//? account strategy\").\n//?\n//? Custom `UserAdapter`s that predate this feature may not implement\n//? `findByEmailAnyProvider`. When `'unified'` is configured but the adapter\n//? lacks it, we log ONCE and fall back to provider-scoped lookup so the project\n//? degrades loudly rather than silently mis-resolving.\n\nimport { getProjectConfig, getLogger } from '@luckystack/core';\n\nimport type { UserAdapter, UserRecord } from './userAdapter';\n\nlet warnedMissingAnyProvider = false;\n\n/**\n * Resolve a user account honoring the configured `providerAccountStrategy`.\n * Use this everywhere the login flow needs \"does an account for this email\n * exist?\" — register dedupe, credentials login, and OAuth find-or-create.\n */\nexport const resolveUserByEmail = async (\n adapter: UserAdapter,\n { email, provider }: { email: string; provider: string },\n): Promise<UserRecord | null> => {\n const strategy = getProjectConfig().auth.providerAccountStrategy;\n\n if (strategy === 'unified') {\n if (adapter.findByEmailAnyProvider) {\n return adapter.findByEmailAnyProvider({ email });\n }\n if (!warnedMissingAnyProvider) {\n warnedMissingAnyProvider = true;\n getLogger().warn(\n '[login] auth.providerAccountStrategy is \"unified\" but the registered UserAdapter does not implement findByEmailAnyProvider — falling back to provider-scoped lookup (duplicate accounts per provider are still possible). Implement findByEmailAnyProvider on your adapter to enable unified accounts.',\n );\n }\n }\n\n return adapter.findByEmail({ email, provider });\n};\n\n/** Test-only: reset the one-time missing-method warning latch. */\nexport const resetAccountStrategyWarningForTests = (): void => {\n warnedMissingAnyProvider = false;\n};\n","//? Password-policy enforcement for credentials auth. Read by\n//? `registerWithCredentials` and `updatePasswordHash` to validate plaintext\n//? passwords against the consumer-tunable `projectConfig.auth.passwordPolicy`.\n//?\n//? Returns null when the password passes every check, or a reason-key\n//? (i18n-style errorCode) describing the first failure. Callers surface\n//? the reason to the client; the framework's i18n layer handles\n//? translation.\n\nimport { getProjectConfig } from '@luckystack/core';\n\nimport { COMMON_PASSWORDS } from './data/commonPasswords';\n\nconst UPPERCASE_REGEX = /[A-Z]/;\nconst LOWERCASE_REGEX = /[a-z]/;\nconst NUMBER_REGEX = /\\d/;\nconst SPECIAL_REGEX = /[^A-Za-z0-9]/;\n\n//? bcrypt silently truncates at 72 utf8-encoded bytes. Anything over 72 bytes\n//? hashes identically to its 72-byte prefix, so a longer password gives no\n//? extra security and actively misleads the user. Cap at 72 bytes so the\n//? stored hash always reflects the full secret the user typed.\nconst BCRYPT_MAX_BYTES = 72;\n\n/**\n * Validate `password` against the active project's password policy.\n * Returns null on success, a string reason-key on the first failure.\n *\n * The policy lives in `projectConfig.auth.passwordPolicy` and is read at\n * call time, so consumers can override defaults via `registerProjectConfig`.\n */\nexport const validatePassword = (password: string): string | null => {\n const policy = getProjectConfig().auth.passwordPolicy;\n\n if (password.length < policy.minLength) return 'login.passwordCharacterMinimum';\n //? Byte-length cap takes precedence over the policy maxLength when the policy\n //? exceeds BCRYPT_MAX_BYTES — reject before bcrypt silently truncates.\n const byteLength = Buffer.byteLength(password, 'utf8');\n if (byteLength > BCRYPT_MAX_BYTES) return 'login.passwordCharacterLimit';\n if (password.length > policy.maxLength) return 'login.passwordCharacterLimit';\n if (policy.requireUppercase && !UPPERCASE_REGEX.test(password)) return 'login.passwordRequiresUppercase';\n if (policy.requireLowercase && !LOWERCASE_REGEX.test(password)) return 'login.passwordRequiresLowercase';\n if (policy.requireNumber && !NUMBER_REGEX.test(password)) return 'login.passwordRequiresNumber';\n if (policy.requireSpecial && !SPECIAL_REGEX.test(password)) return 'login.passwordRequiresSpecial';\n if (policy.forbidCommon && COMMON_PASSWORDS.has(password.toLowerCase())) return 'login.passwordTooCommon';\n if (policy.customValidator) {\n const customReason = policy.customValidator(password);\n if (customReason) return customReason;\n }\n\n return null;\n};\n","//? Built-in common-password blocklist. Sourced from the public-domain\n//? SecLists Top-passwords list (Daniel Miessler), trimmed to the 200 most\n//? common entries to keep the package size small. Consumers needing a\n//? larger list (10k+ entries) can supply one through\n//? `projectConfig.auth.passwordPolicy.customValidator` and check their\n//? own data source there.\n//?\n//? Lookups are case-insensitive (the validator lower-cases before\n//? checking). Adding additional entries here is a non-breaking change.\n\nconst COMMON_PASSWORD_LIST: readonly string[] = [\n '123456', 'password', '12345678', 'qwerty', '123456789', '12345', '1234', '111111',\n '1234567', 'dragon', '123123', 'baseball', 'abc123', 'football', 'monkey', 'letmein',\n '696969', 'shadow', 'master', '666666', 'qwertyuiop', '123321', 'mustang', '1234567890',\n 'michael', '654321', 'pussy', 'superman', '1qaz2wsx', '7777777', 'fuckyou', '121212',\n '000000', 'qazwsx', '123qwe', 'killer', 'trustno1', 'jordan', 'jennifer', 'zxcvbnm',\n 'asdfgh', 'hunter', 'buster', 'soccer', 'harley', 'batman', 'andrew', 'tigger',\n 'sunshine', 'iloveyou', 'fuckme', '2000', 'charlie', 'robert', 'thomas', 'hockey',\n 'ranger', 'daniel', 'starwars', 'klaster', '112233', 'george', 'asshole', 'computer',\n 'michelle', 'jessica', 'pepper', '1111', 'zxcvbn', '555555', '11111111', '131313',\n 'freedom', '777777', 'pass', 'fuck', 'maggie', '159753', 'aaaaaa', 'ginger',\n 'princess', 'joshua', 'cheese', 'amanda', 'summer', 'love', 'ashley', '6969',\n 'nicole', 'chelsea', 'biteme', 'matthew', 'access', 'yankees', '987654321', 'dallas',\n 'austin', 'thunder', 'taylor', 'matrix', 'william', 'corvette', 'hello', 'martin',\n 'heather', 'secret', 'fucker', 'merlin', 'diamond', '1234qwer', 'gfhjkm', 'hammer',\n 'silver', 'richard', 'samantha', 'iceman', 'tigers', 'purple', 'andrea', 'horney',\n 'dakota', 'aaaaa', 'player', 'sucker', 'mercedes', 'whatever', 'orange', 'qweqwe',\n 'mickey', 'panties', 'hannah', 'fucking', 'paul', 'pakistan', 'patrick', 'martha',\n 'morgan', 'iwantu', 'lakers', 'rachel', 'slayer', 'scott', '2112', 'fish',\n 'porn', 'matt', 'qwert', 'cookie', 'eagle1', 'london', 'phoenix', 'chicago',\n 'badboy', 'braves', 'yankee', 'lover', 'barney', 'edward', 'raiders', 'green',\n 'maverick', 'rangers', 'joseph', 'maddog', 'mike', 'jasmine', 'arsenal', 'beach',\n 'tiger', 'snoopy', 'natasha', 'matthews', 'jackson', 'cumshot', 'chevy', 'gateway',\n 'gators', 'angel', 'junior', 'samson', '6666', 'amateur', 'gemini', 'apples',\n 'august', 'biscuit', '5150', 'apollo', 'parker', 'qwerty123', 'rabbit', 'angela',\n 'love123', 'rocket', 'scooby', 'beaver', 'oliver', 'walter', 'blowme', 'spider',\n '4321', 'cumshot1', 'lucky', 'helpme', 'jackie', 'monica',\n];\n\n/** Frozen Set for O(1) `.has()` lookups; entries are pre-lowercased. */\nexport const COMMON_PASSWORDS: ReadonlySet<string> = new Set(\n COMMON_PASSWORD_LIST.map((p) => p.toLowerCase()),\n);\n","//? Password-reset primitives. Used by the framework's own forgot-password\n//? flow when `ProjectConfig.auth.forgotPassword === 'framework'`, AND\n//? exported as building blocks for consumers who picked `'custom'` and want\n//? to wire their own UI / email layer without re-implementing crypto + Redis.\n//?\n//? Token format: 32 random bytes hex-encoded → 64-char URL-safe string.\n//? Storage: `<projectName>-pwreset:<sha256(token)> → userId` with a configurable\n//? TTL (`auth.passwordResetTtlSeconds`, default 3600 = 1 hour).\n//?\n//? HASHED AT REST (0.2.0): the raw token is NEVER stored — we persist only\n//? `sha256(token)` (the Redis key) via the `@luckystack/core` one-time-token\n//? primitive, so a leaked Redis keyspace can't be replayed to mint a reset.\n//? The raw token is returned to the caller exactly once for the emailed URL.\n\n// eslint-disable-next-line import-x/no-named-as-default -- bcryptjs ships both a default and namespace export; using the default mirrors its README\nimport bcrypt from 'bcryptjs';\n/* eslint-disable import-x/no-named-as-default-member -- accessing genSalt/hash/compare via the default import is the documented pattern */\n\nimport { getProjectConfig, issueOneTimeToken, consumeOneTimeToken, formatKey, redis, oneTimeTokenKey } from '@luckystack/core';\n\nimport { validatePassword } from './passwordPolicy';\nimport { getUserAdapter } from './userAdapter';\n\n//? Shared key namespace for the one-time-token primitive. `formatKey('-pwreset',\n//? sha256(token))` is applied inside the primitive, so the project-name prefix /\n//? multi-tenant formatter still apply — only the SUFFIX is now the hash, not the\n//? raw token (the historical `<projectName>-pwreset:<token>` shape becomes\n//? `<projectName>-pwreset:<sha256(token)>`).\nconst PWRESET_NAMESPACE = '-pwreset';\n\n//? Per-user pointer key that records the hash-key of the currently-active reset\n//? token (LOGIN-F16). On each new issue we delete the prior hash key (if any) so\n//? at most one reset link per user is redeemable at a time — multiple live links\n//? until TTL is a session-hijack / takeover risk if the first link is intercepted.\nconst pwresetUserKey = (userId: string): string => formatKey('-pwreset-user', userId);\n\n/**\n * Create a one-time password-reset token bound to a user id. Any previously\n * issued (but not yet consumed) token for the same user is invalidated first\n * (LOGIN-F16). Stored in Redis with the configured TTL — only `sha256(token)`\n * is persisted (hash-at-rest). Returns the raw token string — caller emails it\n * to the user (typically embedded in a URL).\n */\nexport const createPasswordResetToken = async (userId: string): Promise<string> => {\n const ttl = getProjectConfig().auth.passwordResetTtlSeconds;\n\n //? Invalidate the prior active token before issuing the new one (LOGIN-F16).\n //? The pointer key holds the exact Redis key of the prior token hash, so we\n //? can delete it without possessing the raw token.\n const priorKey = await redis.get(pwresetUserKey(userId));\n if (priorKey) {\n await redis.del(priorKey);\n }\n\n const handle = issueOneTimeToken(PWRESET_NAMESPACE, ttl, userId);\n await handle.store();\n\n //? Record the new hash-key so the NEXT issue can invalidate this one.\n //? TTL matches the token so the pointer never outlives what it points to.\n //? `oneTimeTokenKey(namespace, rawToken)` derives the same Redis key the\n //? primitive stored — we never persist the raw token itself.\n const newHashKey = oneTimeTokenKey(PWRESET_NAMESPACE, handle.token);\n await redis.set(pwresetUserKey(userId), newHashKey, 'EX', ttl);\n\n return handle.token;\n};\n\n/**\n * Validate and consume a password-reset token. Returns the bound userId on\n * success and removes the token (one-time use, atomic GET+DEL). Returns null\n * when the token is missing, expired, malformed, or already consumed.\n */\nexport const consumePasswordResetToken = async (token: string): Promise<string | null> => {\n return consumeOneTimeToken(PWRESET_NAMESPACE, token);\n};\n\n/**\n * Bcrypt-hash a plaintext password and write it to the user record via the\n * registered user adapter. Used by the reset flow and by the settings\n * password-change flow. Validates against the active password policy\n * (`projectConfig.auth.passwordPolicy`) before hashing — throws a `PasswordPolicyError`\n * when the plaintext fails policy. Catch and surface the error code to the\n * client; framework-mode reset/change flows already do this.\n *\n * ADR — DD-LOGIN-F17: this primitive intentionally does NOT revoke sessions.\n * Session revocation is the responsibility of each calling route because the\n * right behaviour differs per use-case:\n * - `reset-password/confirmReset_v1` revokes ALL sessions (`revokeUserSessions(userId, null)`)\n * because a forgot-password reset means the old credential is compromised.\n * - `settings/changePassword_v1` revokes every OTHER session but keeps the\n * current one active (`revokeUserSessions(userId, user.token)`) so the\n * user is not kicked from the device they changed the password on.\n * - Custom consumers may want a third behaviour (e.g. re-use the existing\n * token after an admin password-force) — keeping the primitive pure lets\n * them compose it freely.\n * If you call this function directly, YOU must call `revokeUserSessions`\n * afterwards unless you have a deliberate reason not to.\n */\nexport const updatePasswordHash = async (userId: string, plaintext: string): Promise<void> => {\n const reason = validatePassword(plaintext);\n if (reason) {\n throw new PasswordPolicyError(reason);\n }\n const salt = await bcrypt.genSalt(getProjectConfig().auth.bcryptRounds);\n const hashedPassword = await bcrypt.hash(plaintext, salt);\n const patch: Record<string, unknown> = { password: hashedPassword };\n await getUserAdapter().update(userId, patch);\n};\n\n/**\n * Thrown by `updatePasswordHash` when the supplied plaintext violates the\n * active password policy. `errorCode` matches the i18n reason key the rest\n * of the login flow uses (e.g. `login.passwordRequiresUppercase`).\n */\nexport class PasswordPolicyError extends Error {\n readonly errorCode: string;\n constructor(errorCode: string) {\n super(`Password policy violation: ${errorCode}`);\n this.name = 'PasswordPolicyError';\n this.errorCode = errorCode;\n }\n}\n\n/** Verify a plaintext password against a stored bcrypt hash. */\nexport const verifyPassword = async (plaintext: string, hash: string): Promise<boolean> => {\n return bcrypt.compare(plaintext, hash);\n};\n","//? Framework-mode forgot-password orchestration. Used only when\n//? `ProjectConfig.auth.forgotPassword === 'framework'`. Pulls in\n//? `@luckystack/email` lazily so the login package doesn't list it as a\n//? hard dep — `'custom'` and `'disabled'` modes never reach this code.\n\nimport { getProjectConfig, dispatchHook, getLogger } from '@luckystack/core';\n\nimport { loadEmailModule } from './emailModuleLoader';\nimport { createPasswordResetToken } from './passwordReset';\nimport { getUserAdapter } from './userAdapter';\n\ninterface SendResetEmailArgs {\n email: string;\n /**\n * App-facing display name used in the email greeting + subject. If omitted,\n * falls back to `projectConfig.auth.passwordResetBrand`, then to `'LuckyStack'`\n * as the absolute fallback. Real consumers should set this on either the\n * call site OR via `projectConfig.auth.passwordResetBrand`.\n */\n brand?: string;\n}\n\n/**\n * Look up the user by credentials-provider email, generate a reset token,\n * and email them a reset link. Always resolves successfully regardless of\n * whether the email matched a user (anti-enumeration). Returns the result\n * of the email send for diagnostics.\n */\nexport const sendPasswordResetEmail = async ({ email, brand }: SendResetEmailArgs): Promise<{ ok: boolean; reason?: string }> => {\n const config = getProjectConfig();\n //? Log mode but NOT the raw email at start — the email is a PII field.\n getLogger().info('[forgotPassword] start', { forgotPasswordMode: config.auth.forgotPassword });\n if (config.auth.forgotPassword !== 'framework') {\n getLogger().warn('[forgotPassword] auth.forgotPassword is not \"framework\" — nothing sent', { mode: config.auth.forgotPassword });\n return { ok: false, reason: 'forgotPassword-not-framework' };\n }\n\n const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';\n\n // Lazy import — the email package is an optional peer dep so its types\n // may not be resolvable at the framework's compile time.\n //? Mirror testEmail's robustness: catch a failed dynamic import instead of\n //? throwing (an uncaught throw here would bubble out of the anti-enumeration\n //? wrapper as a generic 500). Surfaces the real reason in the server log.\n const emailModule = await loadEmailModule().catch((error: unknown) => {\n getLogger().warn('[forgotPassword] failed to load @luckystack/email — is it installed?', { error: String(error) });\n return null;\n });\n if (!emailModule) {\n return { ok: false, reason: 'email-module-load-failed' };\n }\n const { sendEmail } = emailModule;\n\n const userAdapter = getUserAdapter();\n const user = await userAdapter.findByEmail({ email, provider: 'credentials' });\n //? Log found/userId without the raw email — logging email+found together is\n //? an enumeration oracle (any reader of the log can probe the user table).\n getLogger().info('[forgotPassword] credentials user lookup', { found: Boolean(user), userId: user?.id ?? null });\n\n // Anti-enumeration: always pretend it succeeded if the email isn't found.\n if (!user) {\n //? The client always gets \"success\" (anti-enumeration). This server-side\n //? line is the ONLY signal that the address simply isn't a credentials\n //? (email+password) account — by far the most common reason a reset email\n //? never arrives (OAuth-only signups and unknown/typo'd addresses have no\n //? credentials row, so there is nothing to reset).\n //? Do NOT include the email here — this line together with the email would\n //? let a log reader determine which addresses have credentials accounts.\n getLogger().warn('[forgotPassword] no credentials account for this email — nothing sent. Use the email+password account you registered with.');\n void dispatchHook('passwordResetRequested', {\n email,\n matched: false,\n });\n return { ok: true };\n }\n\n const token = await createPasswordResetToken(user.id);\n //? SEC: do NOT emit the raw token on the hook bus — hook subscribers are\n //? untrusted code and the token is a single-use credential. Consumers who need\n //? to deliver the email themselves should call `createPasswordResetToken`\n //? directly and build the URL without the framework exposing the secret here.\n void dispatchHook('passwordResetRequested', {\n email,\n matched: true,\n userId: user.id,\n ttlSeconds: config.auth.passwordResetTtlSeconds,\n });\n const baseUrl = (config.app.publicUrl || '').replace(/\\/+$/, '');\n //? ADR — DD-ROOTSRC-O8: the reset token is in the URL query string\n //? (`?token=<hex>`), not in the URL fragment (`#token=<hex>`).\n //? Tradeoff: query strings appear in server access logs and Referer headers\n //? if the user's mail client renders the link via a tracking proxy. Fragment\n //? identifiers (`#`) are never sent to the server and are therefore\n //? invisible to any log / proxy in the chain — they are more private.\n //? Decision: query-string is the standard email-link pattern understood by\n //? every SPA router, and tokens here are one-time + hashed at rest (Redis\n //? holds only `sha256(token)`), so the exposure window is the TTL of a\n //? single-use credential, not a replayable secret. Logging is mitigated\n //? by not logging the full URL at the point of issue (we log `userId`, not\n //? `resetUrl`). A future `auth.tokenLinkMode: 'fragment'` option can be\n //? introduced without touching this file — callers receive the raw token\n //? and can build their own URL.\n const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(token)}`;\n\n const ttlMinutes = Math.round(config.auth.passwordResetTtlSeconds / 60);\n\n //? Dispatch via the `'password-reset'` template rather than building the\n //? email inline. `@luckystack/email` ships a built-in for this name, so the\n //? out-of-the-box copy is unchanged — but a consumer can now\n //? `registerEmailTemplate('password-reset', …)` to translate or rebrand it\n //? without forking this flow (CFG-05). `adapterHint: 'transactional'` routes\n //? through a dedicated transactional sender when one is registered, else the\n //? default sender.\n const result = await sendEmail({\n to: user.email,\n template: 'password-reset',\n data: { resetUrl, userName: user.name, brand: resolvedBrand, ttlMinutes },\n adapterHint: 'transactional',\n });\n\n if (result.ok) {\n //? SEC: log only userId for correlation — not the email address (PII).\n getLogger().info('[forgotPassword] reset email dispatched', { userId: user.id });\n } else {\n getLogger().warn('[forgotPassword] reset email send FAILED', { userId: user.id, reason: result.reason });\n }\n\n return result.ok ? { ok: true } : { ok: false, reason: result.reason };\n};\n","//? Shared lazy-loader for the optional `@luckystack/email` peer dep. Both\n//? `forgotPassword.ts` and `emailChangeNotification.ts` need the SAME dynamic\n//? import + the SAME minimal `EmailModule` shape, so the interface + the\n//? `@ts-expect-error` import + the cast live here once (finding 37).\n//?\n//? IMPORTANT — this helper does NOT swallow a failed import. It resolves to the\n//? module or REJECTS exactly like the raw `import(...)` would. That preserves\n//? the two callers' DIFFERENT failure behavior verbatim:\n//? - `emailChangeNotification` awaits it directly (a load failure throws).\n//? - `forgotPassword` wraps it in `.catch(...)` (a load failure → null + warn).\n//? Centralizing the catch here would change one of those — do NOT.\n\n/** Minimal surface of `@luckystack/email` that the login orchestrators use. */\nexport interface EmailModule {\n sendEmail: (input: Record<string, unknown>) => Promise<{ ok: boolean; reason?: string }>;\n}\n\n/**\n * Dynamically import the optional `@luckystack/email` peer. Resolves to the\n * module, or REJECTS if the package is not installed (same as a bare\n * `import('@luckystack/email')`). Callers decide whether to `.catch(...)`.\n */\nexport const loadEmailModule = (): Promise<EmailModule> =>\n // @ts-expect-error optional peer dep — installed only when the consumer wires email sending\n import('@luckystack/email') as Promise<EmailModule>;\n","//? Email-change token primitives. Mirrors `passwordReset.ts` for the\n//? confirm-via-email-link flow: user submits a new address, framework mails\n//? a tokenized URL to the NEW address, click-through confirms and updates\n//? `User.email` + revokes all sessions.\n//?\n//? Token format: 32 random bytes hex-encoded -> 64-char URL-safe string.\n//? Storage: `<projectName>-email-change:<sha256(token)>` -> JSON payload\n//? `{ userId: string; newEmail: string }`\n//? TTL: `auth.emailChangeTtlSeconds` (default 3600 = 1 hour).\n//?\n//? HASHED AT REST (0.2.0): only `sha256(token)` is persisted (the Redis key)\n//? via the `@luckystack/core` one-time-token primitive, so a leaked Redis\n//? keyspace can't be replayed to confirm an email change. The raw token is\n//? returned to the caller exactly once for the emailed URL.\n\nimport { getProjectConfig, issueOneTimeToken, consumeOneTimeTokenJson, formatKey, redis, oneTimeTokenKey } from '@luckystack/core';\n\nconst EMAIL_CHANGE_NAMESPACE = '-email-change';\n\n//? Per-user pointer key for prior-token invalidation (LOGIN-F16), mirroring the\n//? same pattern used for password-reset tokens. Stored as a formatted key so the\n//? multi-tenant key formatter applies uniformly.\nconst emailChangeUserKey = (userId: string): string => formatKey('-email-change-user', userId);\n\nexport interface EmailChangePayload {\n userId: string;\n newEmail: string;\n}\n\n/**\n * Create a one-time email-change token bound to a user id + the new email\n * address. Any previously issued (but not yet consumed) token for the same\n * user is invalidated first (LOGIN-F16) so at most one confirmation link per\n * user is redeemable. Stored in Redis with the configured TTL — only\n * `sha256(token)` is persisted (hash-at-rest). Returns the raw token string —\n * caller emails the token (embedded in a URL) to the NEW address so the\n * confirmation click proves ownership of the new mailbox.\n */\nexport const createEmailChangeToken = async (userId: string, newEmail: string): Promise<string> => {\n const ttl = getProjectConfig().auth.emailChangeTtlSeconds;\n\n //? Invalidate the prior active email-change token (LOGIN-F16). Without this,\n //? multiple links could be outstanding concurrently — a link sent to an\n //? attacker-controlled address (via a race or a compromised session) would\n //? remain redeemable even after the user re-requests the flow.\n const priorKey = await redis.get(emailChangeUserKey(userId));\n if (priorKey) {\n await redis.del(priorKey);\n }\n\n const handle = issueOneTimeToken(\n EMAIL_CHANGE_NAMESPACE,\n ttl,\n { userId, newEmail } satisfies EmailChangePayload,\n );\n await handle.store();\n\n //? Record the new hash-key so the NEXT issue can invalidate this one.\n const newHashKey = oneTimeTokenKey(EMAIL_CHANGE_NAMESPACE, handle.token);\n await redis.set(emailChangeUserKey(userId), newHashKey, 'EX', ttl);\n\n return handle.token;\n};\n\n/**\n * Validate and consume an email-change token. Returns the bound\n * `{ userId, newEmail }` on success and removes the token (one-time use, atomic\n * GET+DEL). Returns null when the token is missing, expired, malformed, or\n * already consumed.\n */\nexport const consumeEmailChangeToken = async (token: string): Promise<EmailChangePayload | null> => {\n const parsed = await consumeOneTimeTokenJson<EmailChangePayload>(EMAIL_CHANGE_NAMESPACE, token);\n if (\n !parsed\n || typeof parsed !== 'object'\n || typeof parsed.userId !== 'string'\n || typeof parsed.newEmail !== 'string'\n ) {\n return null;\n }\n return parsed;\n};\n","//? Framework-mode \"email change confirmation\" orchestration. Renders the\n//? confirmation email + sends it to the NEW address (proves the user can\n//? receive at the new mailbox). Mirrors the lazy-import pattern from\n//? `forgotPassword.ts` so `@luckystack/email` stays an optional peer.\n\nimport { getProjectConfig } from '@luckystack/core';\n\nimport { createEmailChangeToken } from './emailChange';\nimport { loadEmailModule } from './emailModuleLoader';\n\ninterface SendEmailChangeArgs {\n userId: string;\n newEmail: string;\n /** Optional display name used in the email greeting (falls back to \"there\"). */\n userName?: string | null;\n /**\n * App-facing display name used in the email greeting + subject. Falls back\n * to `projectConfig.auth.passwordResetBrand` (the shared brand label), then\n * to `'LuckyStack'` as the absolute fallback.\n */\n brand?: string;\n}\n\n/**\n * Mint a one-shot email-change token and send a confirmation email to the\n * NEW address with the tokenized URL. Returns the send result for diagnostics\n * + the token itself (callers can audit-log it through the\n * `postEmailChangeRequested` hook).\n */\nexport const sendEmailChangeConfirmation = async (\n { userId, newEmail, userName, brand }: SendEmailChangeArgs,\n): Promise<{ ok: boolean; reason?: string; token: string }> => {\n const config = getProjectConfig();\n const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';\n\n // Lazy import — `@luckystack/email` is an optional peer dep. A failed load\n //? rejects here (unlike forgotPassword's catch); this caller's behavior is\n //? unchanged — the rejection bubbles out exactly as the inline import did.\n const { sendEmail } = await loadEmailModule();\n\n const token = await createEmailChangeToken(userId, newEmail);\n const baseUrl = (config.app.publicUrl || '').replace(/\\/+$/, '');\n //? ADR — DD-ROOTSRC-O8: same query-string tradeoff as the password-reset\n //? flow (see `forgotPassword.ts`). Tokens are one-time + hashed at rest;\n //? the raw token is never persisted. The confirm URL is only sent to the\n //? NEW address (verifying mailbox ownership), so it never appears in an\n //? access log that an attacker of the OLD address could read.\n //? #64: honor the configurable `auth.emailChangeConfirmPath` (where the\n //? consumer's confirm-email page lives) instead of a hardcoded path; the\n //? prior hardcoded value is the fallback when the key is empty.\n const confirmPath = config.auth.emailChangeConfirmPath || '/settings/confirm-email';\n const confirmUrl = `${baseUrl}${confirmPath}?token=${encodeURIComponent(token)}`;\n const ttlMinutes = Math.round(config.auth.emailChangeTtlSeconds / 60);\n\n //? Dispatch via the built-in `'email-change'` template so consumers can\n //? override the copy with `registerEmailTemplate('email-change', …)` without\n //? forking (CFG-05). Default copy is unchanged.\n const result = await sendEmail({\n to: newEmail,\n template: 'email-change',\n data: { confirmUrl, userName, brand: resolvedBrand, ttlMinutes },\n adapterHint: 'transactional',\n });\n\n return result.ok ? { ok: true, token } : { ok: false, reason: result.reason, token };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAUA,SAAS,+BAA+B;;;ACOxC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUP,IAAM,aAAa,CAAC,YAAoB,gBAAiC;AACvE,QAAM,OAAO,QAAQ,WAAW,KAAK,EAAE,YAAY,CAAC;AACpD,SAAO,cAAc,GAAG,IAAI,IAAI,WAAW,KAAK;AAClD;AAIA,IAAI,qBAAqB;AAMzB,IAAI,wBAAwB;AAG5B,IAAM,uBAAuB,MAAuF;AAClH,QAAM,eAAe,iBAAiB,EAAE;AACxC,MAAI,CAAC,aAAa,KAAK,QAAS,QAAO;AAKvC,MAAI,CAAC,aAAa,WAAW,CAAC,oBAAoB;AAChD,yBAAqB;AACrB,cAAU,EAAE;AAAA,MACV;AAAA,IACF;AAAA,EACF;AACA,SAAO;AAAA,IACL,aAAa,aAAa,KAAK;AAAA,IAC/B,uBAAuB,aAAa,KAAK;AAAA,IACzC,UAAU,aAAa,KAAK;AAAA,EAC9B;AACF;AAQO,IAAM,kBAAkB,OAAO,YAAoB,gBAA2C;AACnG,QAAM,MAAM,qBAAqB;AACjC,MAAI,CAAC,OAAO,CAAC,WAAY,QAAO;AAMhC,QAAM,gBAAgB,MAAM,mBAAmB,WAAW,UAAU,GAAG,IAAI,qBAAqB;AAChG,MAAI,cAAc,aAAa,EAAG,QAAO;AACzC,MAAI,aAAa;AACf,UAAM,WAAW,MAAM,mBAAmB,WAAW,YAAY,WAAW,GAAG,IAAI,WAAW;AAC9F,QAAI,SAAS,aAAa,EAAG,QAAO;AAAA,EACtC;AACA,SAAO;AACT;AAOO,IAAM,oBAAoB,OAAO,YAAoB,gBAAwC;AAClG,QAAM,MAAM,qBAAqB;AACjC,MAAI,CAAC,OAAO,CAAC,WAAY;AAGzB,QAAM,UAAU,MAAM,eAAe;AAAA,IACnC,KAAK,WAAW,UAAU;AAAA,IAC1B,OAAO,IAAI;AAAA,IACX,UAAU,IAAI;AAAA,EAChB,CAAC;AACD,MAAI,YAAY;AAChB,MAAI,aAAa;AACf,UAAM,KAAK,MAAM,eAAe;AAAA,MAC9B,KAAK,WAAW,YAAY,WAAW;AAAA,MACvC,OAAO,IAAI;AAAA,MACX,UAAU,IAAI;AAAA,IAChB,CAAC;AACD,gBAAY,CAAC,GAAG;AAAA,EAClB;AACA,MAAI,CAAC,QAAQ,WAAW,WAAW;AACjC,cAAU,EAAE,KAAK,oEAAoE;AAAA,MACnF,aAAa,IAAI;AAAA,MACjB,uBAAuB,IAAI;AAAA,MAC3B,SAAS,CAAC,QAAQ;AAAA,IACpB,CAAC;AAAA,EACH;AACF;AAQO,IAAM,oBAAoB,OAAO,YAAoB,gBAAwC;AAClG,QAAM,MAAM,qBAAqB;AACjC,MAAI,CAAC,OAAO,CAAC,WAAY;AAOzB,QAAM,CAAC,KAAK,IAAI,MAAM,SAAS,YAAY;AACzC,UAAM,eAAe,WAAW,UAAU,CAAC;AAC3C,QAAI,YAAa,OAAM,eAAe,WAAW,YAAY,WAAW,CAAC;AAAA,EAC3E,CAAC;AACD,MAAI,OAAO;AACT,cAAU,EAAE,KAAK,kFAA6E,EAAE,MAAM,CAAC;AAAA,EACzG;AACF;AAaA,IAAM,mBAAmB,oBAAI,IAAI;AAAA,EAC/B;AACF,CAAC;AAED,IAAI,wBAAwB;AAUrB,IAAM,0BAA0B,MAAY;AACjD,MAAI,sBAAuB;AAC3B,0BAAwB;AACxB,eAAa,eAAe,OAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,YAAY,MAAM;AACrF,QAAI,UAAU,WAAW,aAAa,cAAe;AAOrD,QAAI,CAAC,iBAAiB,EAAE,aAAa,KAAK,WAAW,CAAC,uBAAuB;AAC3E,8BAAwB;AACxB,gBAAU,EAAE;AAAA,QACV;AAAA,MACF;AAAA,IACF;AACA,QAAI,CAAC,SAAS,CAAC,iBAAiB,IAAI,MAAM,EAAG;AAE7C,UAAM,kBAAkB,OAAO,WAAW;AAAA,EAC5C,CAAC;AACH;;;AC7KA,IAAI,iBAAmD;AAEhD,IAAM,4BAA4B,CACvC,aAC8B;AAC9B,mBAAiB;AACjB,SAAO;AACT;AAEO,IAAM,uBAAuB,MAAwC;;;AC9B5E,SAAS,uBAAuB;AAChC,SAAS,YAAAA,WAAU,cAAc,SAAS,aAAa,eAAe,cAAc,aAAAC,YAAW,WAAW,oBAAAC,mBAAkB,sBAAsB;AAClJ,OAAO,YAAY;AACnB,SAAS,aAAa,YAAY,uBAAuB;AAEzD,OAAO,eAAe;;;ACiBtB,SAAS,oBAAAC,mBAAkB,aAAAC,kBAAiB;AAI5C,IAAI,2BAA2B;AAOxB,IAAM,qBAAqB,OAChC,SACA,EAAE,OAAO,SAAS,MACa;AAC/B,QAAM,WAAWD,kBAAiB,EAAE,KAAK;AAEzC,MAAI,aAAa,WAAW;AAC1B,QAAI,QAAQ,wBAAwB;AAClC,aAAO,QAAQ,uBAAuB,EAAE,MAAM,CAAC;AAAA,IACjD;AACA,QAAI,CAAC,0BAA0B;AAC7B,iCAA2B;AAC3B,MAAAC,WAAU,EAAE;AAAA,QACV;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO,QAAQ,YAAY,EAAE,OAAO,SAAS,CAAC;AAChD;;;AC9CA,SAAS,oBAAAC,yBAAwB;;;ACCjC,IAAM,uBAA0C;AAAA,EAC9C;AAAA,EAAU;AAAA,EAAY;AAAA,EAAY;AAAA,EAAU;AAAA,EAAa;AAAA,EAAS;AAAA,EAAQ;AAAA,EAC1E;AAAA,EAAW;AAAA,EAAU;AAAA,EAAU;AAAA,EAAY;AAAA,EAAU;AAAA,EAAY;AAAA,EAAU;AAAA,EAC3E;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAc;AAAA,EAAU;AAAA,EAAW;AAAA,EAC3E;AAAA,EAAW;AAAA,EAAU;AAAA,EAAS;AAAA,EAAY;AAAA,EAAY;AAAA,EAAW;AAAA,EAAW;AAAA,EAC5E;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAY;AAAA,EAAU;AAAA,EAAY;AAAA,EAC1E;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EACtE;AAAA,EAAY;AAAA,EAAY;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAW;AAAA,EAAU;AAAA,EAAU;AAAA,EACzE;AAAA,EAAU;AAAA,EAAU;AAAA,EAAY;AAAA,EAAW;AAAA,EAAU;AAAA,EAAU;AAAA,EAAW;AAAA,EAC1E;AAAA,EAAY;AAAA,EAAW;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAU;AAAA,EAAU;AAAA,EAAY;AAAA,EACzE;AAAA,EAAW;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EACnE;AAAA,EAAY;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAU;AAAA,EACtE;AAAA,EAAU;AAAA,EAAW;AAAA,EAAU;AAAA,EAAW;AAAA,EAAU;AAAA,EAAW;AAAA,EAAa;AAAA,EAC5E;AAAA,EAAU;AAAA,EAAW;AAAA,EAAU;AAAA,EAAU;AAAA,EAAW;AAAA,EAAY;AAAA,EAAS;AAAA,EACzE;AAAA,EAAW;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAW;AAAA,EAAY;AAAA,EAAU;AAAA,EAC1E;AAAA,EAAU;AAAA,EAAW;AAAA,EAAY;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EACzE;AAAA,EAAU;AAAA,EAAS;AAAA,EAAU;AAAA,EAAU;AAAA,EAAY;AAAA,EAAY;AAAA,EAAU;AAAA,EACzE;AAAA,EAAU;AAAA,EAAW;AAAA,EAAU;AAAA,EAAW;AAAA,EAAQ;AAAA,EAAY;AAAA,EAAW;AAAA,EACzE;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAS;AAAA,EAAQ;AAAA,EACnE;AAAA,EAAQ;AAAA,EAAQ;AAAA,EAAS;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAW;AAAA,EAClE;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAS;AAAA,EAAU;AAAA,EAAU;AAAA,EAAW;AAAA,EACtE;AAAA,EAAY;AAAA,EAAW;AAAA,EAAU;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAW;AAAA,EAAW;AAAA,EACzE;AAAA,EAAS;AAAA,EAAU;AAAA,EAAW;AAAA,EAAY;AAAA,EAAW;AAAA,EAAW;AAAA,EAAS;AAAA,EACzE;AAAA,EAAU;AAAA,EAAS;AAAA,EAAU;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAW;AAAA,EAAU;AAAA,EACpE;AAAA,EAAU;AAAA,EAAW;AAAA,EAAQ;AAAA,EAAU;AAAA,EAAU;AAAA,EAAa;AAAA,EAAU;AAAA,EACxE;AAAA,EAAW;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EAAU;AAAA,EACvE;AAAA,EAAQ;AAAA,EAAY;AAAA,EAAS;AAAA,EAAU;AAAA,EAAU;AACnD;AAGO,IAAM,mBAAwC,IAAI;AAAA,EACvD,qBAAqB,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC;AACjD;;;AD7BA,IAAM,kBAAkB;AACxB,IAAM,kBAAkB;AACxB,IAAM,eAAe;AACrB,IAAM,gBAAgB;AAMtB,IAAM,mBAAmB;AASlB,IAAM,mBAAmB,CAAC,aAAoC;AACnE,QAAM,SAASC,kBAAiB,EAAE,KAAK;AAEvC,MAAI,SAAS,SAAS,OAAO,UAAW,QAAO;AAG/C,QAAM,aAAa,OAAO,WAAW,UAAU,MAAM;AACrD,MAAI,aAAa,iBAAkB,QAAO;AAC1C,MAAI,SAAS,SAAS,OAAO,UAAW,QAAO;AAC/C,MAAI,OAAO,oBAAoB,CAAC,gBAAgB,KAAK,QAAQ,EAAG,QAAO;AACvE,MAAI,OAAO,oBAAoB,CAAC,gBAAgB,KAAK,QAAQ,EAAG,QAAO;AACvE,MAAI,OAAO,iBAAiB,CAAC,aAAa,KAAK,QAAQ,EAAG,QAAO;AACjE,MAAI,OAAO,kBAAkB,CAAC,cAAc,KAAK,QAAQ,EAAG,QAAO;AACnE,MAAI,OAAO,gBAAgB,iBAAiB,IAAI,SAAS,YAAY,CAAC,EAAG,QAAO;AAChF,MAAI,OAAO,iBAAiB;AAC1B,UAAM,eAAe,OAAO,gBAAgB,QAAQ;AACpD,QAAI,aAAc,QAAO;AAAA,EAC3B;AAEA,SAAO;AACT;;;AFrCA,OAAO,UAAU;AACjB,SAAS,YAAY,kBAAkB;AA6BvC,IAAM,gBAAgB,MAAc,cAAc;AAIlD,IAAM,YAAY,MAAeC,kBAAiB,EAAE,QAAQ;AAC5D,IAAM,EAAE,SAAS,SAAS,KAAK,IAAI;AAUnC,IAAM,iBAAiB,oBAAI,IAAoB;AAC/C,IAAM,qBAAqB,YAA6B;AACtD,QAAM,SAASA,kBAAiB,EAAE,KAAK;AACvC,QAAM,SAAS,eAAe,IAAI,MAAM;AACxC,MAAI,OAAQ,QAAO;AACnB,QAAM,OAAO,MAAM,QAAQ,MAAM;AACjC,QAAM,YAAY,MAAM,KAAK,YAAY,EAAE,EAAE,SAAS,KAAK,GAAG,IAAI;AAClE,iBAAe,IAAI,QAAQ,SAAS;AACpC,SAAO;AACT;AAKA,IAAM,EAAE,QAAQ,QAAQ,IAAI;AAO5B,IAAM,mBAAmB,CAAC,cAAsB,UAA0B;AACxE,SAAO,UAAU,gBAAgB,GAAG,YAAY,IAAI,KAAK,EAAE;AAC7D;AASO,IAAM,0BAA0B;AAKvC,IAAM,iBAAiB,CAAC,UACtB,WAAW,QAAQ,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AAMjD,IAAM,YAAY,CAAC,QACjB,IAAI,SAAS,QAAQ,EAAE,WAAW,KAAK,GAAG,EAAE,WAAW,KAAK,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpF,IAAM,mBAAmB,MAA+C;AACtE,QAAM,WAAW,UAAU,YAAY,EAAE,CAAC;AAC1C,QAAM,YAAY,UAAU,WAAW,QAAQ,EAAE,OAAO,QAAQ,EAAE,OAAO,CAAC;AAC1E,SAAO,EAAE,UAAU,UAAU;AAC/B;AAiCO,IAAM,mBAAmB,OAC9B,cACA,YAC2C;AAC3C,QAAM,QAAQ,YAAY,EAAE,EAAE,SAAS,KAAK;AAC5C,QAAM,QAAQ,YAAY,EAAE,EAAE,SAAS,KAAK;AAC5C,QAAM,MAAM,iBAAiB,cAAc,KAAK;AAEhD,QAAM,OAAO,SAAS,UAAU,iBAAiB,IAAI;AACrD,QAAM,QAAyB;AAAA,IAC7B,WAAW,eAAe,KAAK;AAAA,IAC/B,GAAI,OAAO,EAAE,cAAc,KAAK,SAAS,IAAI,CAAC;AAAA,IAC9C,GAAI,SAAS,YAAY,EAAE,WAAW,QAAQ,UAAU,IAAI,CAAC;AAAA,EAC/D;AAWA,QAAM,SAAS,MAAM,YAAY;AAAA,IAC/B;AAAA,IACA,KAAK,UAAU,KAAK;AAAA,IACpB;AAAA,IACAA,kBAAiB,EAAE,KAAK;AAAA,IACxB;AAAA,EACF;AAEA,MAAI,WAAW,MAAM;AACnB,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL;AAAA,IACA,aAAa;AAAA,IACb,GAAI,OAAO,EAAE,eAAe,KAAK,UAAU,IAAI,CAAC;AAAA,EAClD;AACF;AASA,IAAM,oBAAoB,OACxB,cACA,OACA,gBACoC;AACpC,MAAI,CAAC,SAAS,CAAC,aAAa;AAC1B,WAAO;AAAA,EACT;AAEA,QAAM,MAAM,iBAAiB,cAAc,KAAK;AAChD,QAAM,WAAW,MAAM,YAAY,MAAM,EAAE,IAAI,GAAG,EAAE,IAAI,GAAG,EAAE,KAAK;AAClE,MAAI,CAAC,YAAY,SAAS,SAAS,GAAG;AACpC,WAAO;AAAA,EACT;AAEA,QAAM,YAAY,SAAS,CAAC;AAC5B,MAAI,CAAC,aAAa,UAAU,CAAC,GAAG;AAC9B,WAAO;AAAA,EACT;AAMA,QAAM,YAAY,SAAS,CAAC;AAC5B,MAAI,CAAC,aAAa,UAAU,CAAC,GAAG;AAC9B,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,UAAU,CAAC;AAC5B,MAAI,OAAO,aAAa,UAAU;AAChC,WAAO;AAAA,EACT;AAEA,QAAM,CAAC,UAAU,KAAK,IAAI,aAAa,MAAM,KAAK,MAAM,QAAQ,CAAoB;AACpF,MAAI,YAAY,CAAC,SAAS,OAAO,MAAM,cAAc,UAAU;AAC7D,WAAO;AAAA,EACT;AAOA,QAAM,eAAe,OAAO,KAAK,eAAe,WAAW,CAAC;AAC5D,QAAM,aAAa,OAAO,KAAK,MAAM,SAAS;AAC9C,MAAI,aAAa,WAAW,WAAW,UAAU,CAAC,gBAAgB,cAAc,UAAU,GAAG;AAC3F,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEA,IAAM,WAAW,CAAC,UAA4C;AAC5D,MAAI,SAAS,OAAO,UAAU,UAAU;AACtC,WAAO;AAAA,EACT;AACA,SAAO,CAAC;AACV;AAMA,IAAM,kBAAkB,CAAC,QAAiC,KAAa,WAAW,OAAe;AAC/F,QAAM,QAAQ,OAAO,GAAG;AACxB,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO,OAAO,KAAK;AAC5E,MAAI,OAAO,UAAU,UAAW,QAAO,OAAO,KAAK;AACnD,SAAO;AACT;AAIA,IAAM,8BAA8B,MAClC,IAAI,KAAK,MAAM,KAAK,OAAO,IAAI,QAAU,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC;AAK1E,IAAM,wBAAwB,OAAO,WAA2C;AAC9E,QAAM,WAAW,KAAK,KAAK,cAAc,GAAG,GAAG,MAAM,OAAO;AAC5D,QAAM,CAAC,EAAE,UAAU,IAAI,MAAMC,UAAS,MAAM,WAAW,KAAK,QAAQ,CAAC;AACrE,SAAO,aAAa,GAAG,MAAM,UAAU;AACzC;AAEA,IAAM,yBAAyB,CAAmC,SAAiC;AACjG,QAAM,EAAE,UAAU,WAAW,GAAG,SAAS,IAAI;AAC7C,SAAO;AACT;AAMA,IAAM,kBAAkB,CAAC,YASb;AACV,OAAK,aAAa,eAAe,OAAO;AAC1C;AAEA,IAAM,cAAc,CAAC,OAAgB,WAAW,8BAAsC;AACpF,MAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAAG;AACjD,WAAO;AAAA,EACT;AAEA,MAAI,iBAAiB,SAAS,MAAM,SAAS;AAC3C,UAAM,UAAU,MAAM,QAAQ,YAAY;AAC1C,QAAI,QAAQ,SAAS,uBAAuB,KAAK,QAAQ,SAAS,eAAe,GAAG;AAClF,aAAO;AAAA,IACT;AAAA,EACF;AAEA,SAAO;AACT;AASA,IAAM,uBAAuB,CAAC,YAA0D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQtF,QAAQ,OAAO,SAAS,IAAI,KAAK,EAAE,YAAY;AAAA,EAC/C,UAAU,OAAO,YAAY;AAAA,EAC7B,MAAM,OAAO,OAAO,OAAO,OAAO,IAAI,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAM1C,iBAAiB,OAAO,mBAAmB;AAC7C;AAaA,IAAM,2BAA2B,CAC/B,OACA,SAC6C;AAC7C,QAAM,aAAaD,kBAAiB,EAAE;AACtC,MAAI,CAAC,MAAM,SAAS,CAAC,MAAM,SAAU,QAAO,EAAE,QAAQ,OAAO,QAAQ,cAAc;AACnF,MAAI,MAAM,MAAM,SAAS,WAAW,eAAgB,QAAO,EAAE,QAAQ,OAAO,QAAQ,4BAA4B;AAChH,MAAI,MAAM,QAAQ,MAAM,KAAK,SAAS,WAAW,cAAe,QAAO,EAAE,QAAQ,OAAO,QAAQ,2BAA2B;AAC3H,MAAI,CAAC,QAAQ,MAAM,KAAK,EAAG,QAAO,EAAE,QAAQ,OAAO,QAAQ,2BAA2B;AACtF,MAAI,SAAS,YAAY;AAMvB,UAAM,iBAAiB,iBAAiB,MAAM,QAAQ;AACtD,QAAI,eAAgB,QAAO,EAAE,QAAQ,OAAO,QAAQ,eAAe;AAAA,EACrE;AACA,SAAO;AACT;AAEA,IAAM,0BAA0B,OAC9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAMA,YACoC;AACpC,MAAI,aAAa,iBAAiB;AAChC,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,0BAA0B,OAAO,WAAW,CAAC;AACvG,WAAO,EAAE,QAAQ,OAAO,QAAQ,yBAAyB;AAAA,EAC3D;AAEA,QAAM,oBAAoB,MAAM,aAAa,eAAe,EAAE,OAAO,UAAU,eAAe,KAAK,CAAC;AACpG,MAAI,kBAAkB,SAAS;AAC7B,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,kBAAkB,OAAO,WAAW,OAAO,WAAW,CAAC;AACjH,WAAO,EAAE,QAAQ,OAAO,QAAQ,kBAAkB,OAAO,UAAU;AAAA,EACrE;AASA,MAAI,MAAM,gBAAgB,KAAK,GAAG;AAChC,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,uBAAuB,OAAO,WAAW,CAAC;AACpG,WAAO,EAAE,QAAQ,OAAO,QAAQ,sBAAsB;AAAA,EACxD;AAEA,QAAM,cAAc,eAAe;AACnC,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,MAAMC;AAAA,IAAS,MAC3D,mBAAmB,aAAa,EAAE,OAAO,UAAU,cAAc,CAAC;AAAA,EACpE;AACA,MAAI,iBAAiB;AACnB,IAAAC,WAAU,EAAE,MAAM,6CAA6C,eAAe;AAC9E,UAAM,SAAS,YAAY,eAAe;AAC1C,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,OAAO,WAAW,CAAC;AAC7E,WAAO,EAAE,QAAQ,OAAO,OAAO;AAAA,EACjC;AACA,MAAI,oBAAoB;AACtB,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,qBAAqB,OAAO,WAAW,CAAC;AAClG,WAAO,EAAE,QAAQ,OAAO,QAAQ,oBAAoB;AAAA,EACtD;AAEA,QAAM,gBAAgB,YAAY;AAChC,UAAM,OAAO,MAAM,QAAQF,kBAAiB,EAAE,KAAK,YAAY;AAC/D,UAAM,iBAAiB,MAAM,KAAK,UAAU,IAAI;AAChD,WAAO,MAAM,YAAY,OAAO;AAAA,MAC9B;AAAA,MACA,UAAU;AAAA,MACV;AAAA,MACA,UAAU;AAAA,MACV,QAAQ;AAAA,MACR,gBAAgB,4BAA4B;AAAA,MAC5C,UAAUA,kBAAiB,EAAE;AAAA,IAC/B,CAAC;AAAA,EACH;AAEA,QAAM,CAAC,oBAAoB,qBAAqB,IAAI,MAAMC,UAAS,aAAa;AAChF,MAAI,oBAAoB;AAMtB,UAAM,eACJ,8BAA8B,QAAQ,mBAAmB,UAAU,OAAO,kBAAkB;AAC9F,UAAM,oBACJ,aAAa,SAAS,mBAAmB,KACzC,aAAa,SAAS,mBAAmB;AAAA,IAExC,mBAA0C,SAAS;AACtD,QAAI,mBAAmB;AACrB,sBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,qBAAqB,OAAO,WAAW,CAAC;AAClG,aAAO,EAAE,QAAQ,OAAO,QAAQ,oBAAoB;AAAA,IACtD;AACA,UAAM,SAAS,YAAY,kBAAkB;AAC7C,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,OAAO,WAAW,CAAC;AAC7E,WAAO,EAAE,QAAQ,OAAO,OAAO;AAAA,EACjC;AACA,MAAI,CAAC,uBAAuB;AAC1B,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,0BAA0B,OAAO,WAAW,CAAC;AACvG,WAAO,EAAE,QAAQ,OAAO,QAAQ,yBAAyB;AAAA,EAC3D;AAEA,QAAM,aAAa,gBAAgB,EAAE,QAAQ,sBAAsB,IAAI,UAAU,cAAc,CAAC;AAMhG,QAAM,WAAW,YAAY,EAAE,EAAE,SAAS,KAAK;AAC/C,QAAM,WAAW,oBAAI,KAAK;AAC1B,QAAM,UAAyB;AAAA,IAC7B,GAAG,uBAAuB,qBAAqB;AAAA,IAC/C,OAAO;AAAA,IACP,WAAW;AAAA,IACX,eAAe;AAAA,EACjB;AAEA,QAAM,QAAQ,MAAM,YAAY,UAAU,SAAS,MAAM,EAAE,gBAAgB,SAAS,eAAe,CAAC;AACpG,MAAI,CAAC,MAAM,IAAI;AAIb,oBAAgB,EAAE,OAAO,QAAQ,QAAQ,IAAI,UAAU,eAAe,QAAQ,MAAM,WAAW,OAAO,WAAW,CAAC;AAClH,WAAO,EAAE,QAAQ,OAAO,QAAQ,MAAM,UAAU;AAAA,EAClD;AACA,QAAM,aAAa,aAAa,EAAE,QAAQ,QAAQ,IAAI,UAAU,eAAe,WAAW,MAAM,OAAO,SAAS,CAAC;AAEjH,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR;AAAA,IACA,SAAS;AAAA,EACX;AACF;AAEA,IAAM,2BAA2B,OAC/B,EAAE,OAAO,SAAS,GAIlB,YACoC;AACpC,QAAM,cAAc,SAAS;AAC7B,QAAM,iBAAiB,MAAM,aAAa,YAAY,EAAE,OAAO,UAAU,cAAc,CAAC;AACxF,MAAI,eAAe,SAAS;AAC1B,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,eAAe,OAAO,WAAW,OAAO,SAAS,YAAY,CAAC;AACxH,WAAO,EAAE,QAAQ,OAAO,QAAQ,eAAe,OAAO,UAAU;AAAA,EAClE;AASA,MAAI,MAAM,gBAAgB,OAAO,WAAW,GAAG;AAC7C,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,uBAAuB,OAAO,SAAS,YAAY,CAAC;AAC9G,WAAO,EAAE,QAAQ,OAAO,QAAQ,sBAAsB;AAAA,EACxD;AAEA,QAAM,cAAc,eAAe;AACnC,QAAM,CAAC,eAAe,gBAAgB,IAAI,MAAMA;AAAA,IAAS,MACvD,mBAAmB,aAAa,EAAE,OAAO,UAAU,cAAc,CAAC;AAAA,EACpE;AACA,MAAI,eAAe;AACjB,IAAAC,WAAU,EAAE,MAAM,6BAA6B,aAAa;AAC5D,UAAM,SAAS,YAAY,aAAa;AACxC,oBAAgB,EAAE,OAAO,UAAU,eAAe,QAAQ,OAAO,SAAS,YAAY,CAAC;AACvF,WAAO,EAAE,QAAQ,OAAO,OAAO;AAAA,EACjC;AASA,QAAM,eAAe,kBAAkB,YAAY;AACnD,MAAI,CAAC,oBAAoB,CAAC,cAAc;AACtC,UAAM,YAAY,MAAM,mBAAmB;AAC3C,UAAMD,UAAS,MAAM,QAAQ,UAAU,SAAS,CAAC;AASjD,UAAM,aAAa,eAAe,EAAE,OAAO,QAAQ,kBAAkB,IAAI,UAAU,eAAe,QAAQ,uBAAuB,OAAO,SAAS,YAAY,CAAC;AAC9J,WAAO,EAAE,QAAQ,OAAO,QAAQ,sBAAsB;AAAA,EACxD;AAEA,QAAM,CAAC,oBAAoB,qBAAqB,IAAI,MAAMA;AAAA,IAAS,MACjE,QAAQ,UAAU,YAAY;AAAA,EAChC;AACA,MAAI,oBAAoB;AACtB,IAAAC,WAAU,EAAE,MAAM,gCAAgC,kBAAkB;AACpE,UAAM,SAAS,YAAY,kBAAkB;AAC7C,oBAAgB,EAAE,OAAO,QAAQ,iBAAiB,IAAI,UAAU,eAAe,QAAQ,OAAO,SAAS,YAAY,CAAC;AACpH,WAAO,EAAE,QAAQ,OAAO,OAAO;AAAA,EACjC;AACA,MAAI,CAAC,uBAAuB;AAG1B,UAAM,aAAa,eAAe,EAAE,OAAO,QAAQ,iBAAiB,IAAI,UAAU,eAAe,QAAQ,uBAAuB,OAAO,SAAS,YAAY,CAAC;AAC7J,WAAO,EAAE,QAAQ,OAAO,QAAQ,sBAAsB;AAAA,EACxD;AAEA,QAAM,WAAW,YAAY,EAAE,EAAE,SAAS,KAAK;AAC/C,QAAM,gBAAgB,iBAAiB,aAAa;AACpD,QAAM,WAAW,oBAAI,KAAK;AAI1B,QAAMD,UAAS,MAAM,YAAY,OAAO,iBAAiB,IAAI,EAAE,WAAW,SAAS,CAAC,CAAC;AAErF,QAAM,UAAyB;AAAA,IAC7B,GAAG,uBAAuB,gBAAgB;AAAA,IAC1C,OAAO;AAAA,IACP,WAAW;AAAA,IACX;AAAA,EACF;AAEA,QAAM,iBAAiB,MAAM,sBAAsB,QAAQ,EAAE;AAC7D,MAAI,gBAAgB;AAClB,YAAQ,SAAS;AAAA,EACnB;AAEA,QAAM,QAAQ,MAAM,YAAY,UAAU,SAAS,MAAM,EAAE,gBAAgB,SAAS,eAAe,CAAC;AACpG,MAAI,CAAC,MAAM,IAAI;AAIb,oBAAgB,EAAE,OAAO,QAAQ,QAAQ,IAAI,UAAU,eAAe,QAAQ,MAAM,WAAW,OAAO,SAAS,YAAY,CAAC;AAC5H,WAAO,EAAE,QAAQ,OAAO,QAAQ,MAAM,UAAU;AAAA,EAClD;AACA,QAAM,aAAa,aAAa,EAAE,QAAQ,QAAQ,IAAI,UAAU,eAAe,WAAW,OAAO,OAAO,SAAS,CAAC;AAMlH,OAAK,kBAAkB,OAAO,WAAW;AACzC,MAAI,YAAa,MAAK,kBAAkB,KAAK;AAC7C,MAAI,UAAU,GAAG;AACf,IAAAC,WAAU,EAAE,MAAM,sCAAsC,QAAQ,EAAE,EAAE;AAAA,EACtE;AACA,SAAO,EAAE,QAAQ,MAAM,QAAQ,kBAAkB,UAAU,SAAS,QAAQ;AAC9E;AAMA,IAAM,uBAAuB,OAC3B,QAGA,YACoC;AACpC,QAAM,QAAQ,qBAAqB,MAAM;AAEzC,MAAI,UAAU,GAAG;AACf,IAAAA,WAAU,EAAE,MAAM,gCAAgC,MAAM,SAAS,eAAe,EAAE;AAAA,EACpF;AASA,QAAM,EAAE,MAAM,gBAAgB,IAAI;AAClC,MAAI,QAAQ,iBAAiB;AAC3B,UAAMC,mBAAkB,yBAAyB,OAAO,UAAU;AAClE,QAAIA,iBAAiB,QAAOA;AAM5B,QAAI,CAACH,kBAAiB,EAAE,KAAK,mBAAmB;AAC9C,sBAAgB,EAAE,OAAO,MAAM,OAAO,UAAU,eAAe,QAAQ,6BAA6B,OAAO,WAAW,CAAC;AACvH,aAAO,EAAE,QAAQ,OAAO,QAAQ,4BAA4B;AAAA,IAC9D;AACA,WAAO,wBAAwB;AAAA,MAC7B,OAAO,MAAM;AAAA,MACb,UAAU,MAAM;AAAA,MAChB;AAAA,MACA;AAAA,IACF,GAAG,OAAO;AAAA,EACZ;AAEA,QAAM,kBAAkB,yBAAyB,OAAO,OAAO;AAC/D,MAAI,gBAAiB,QAAO;AAC5B,SAAO,yBAAyB,EAAE,OAAO,MAAM,OAAO,UAAU,MAAM,SAAS,GAAG,OAAO;AAC3F;AAUA,IAAM,uBAAuB,CAAC,QAAyB;AACrD,MAAI,CAAC,IAAI,SAAS,KAAK,oBAAoB,EAAG,QAAO;AAMrD,MAAI,IAAI,SAAS,IAAI,EAAG,QAAO;AAC/B,QAAM,SAAS,IAAI,IAAI,KAAK,oBAAoB;AAChD,MAAI,OAAO,WAAW,sBAAsB;AAE1C,WAAO;AAAA,EACT;AASA,QAAM,MAAMA,kBAAiB;AAC7B,MAAI,IAAI,KAAK,KAAK,kBAAkB,OAAO,aAAa,YAAa,QAAO;AAE5E,QAAM,UAAU,IAAI,KAAK,KAAK,kBAAkB,CAAC;AACjD,MAAI,OAAO,YAAY,YAAY;AACjC,WAAO,QAAQ,OAAO,MAAM;AAAA,EAC9B;AACA,SAAO,QAAQ,KAAK,CAAC,WAAW;AAC9B,QAAI,CAAC,IAAI,SAAS,MAAM,EAAG,QAAO;AAClC,WAAO,IAAI,IAAI,MAAM,EAAE,WAAW,OAAO;AAAA,EAC3C,CAAC;AACH;AAEA,IAAM,qBAAqB,OACzB,UACA,MACA,iBAC2B;AAY3B,QAAM,SAAS;AAAA,IACb;AAAA,IACA,WAAW,SAAS;AAAA,IACpB,eAAe,SAAS;AAAA,IACxB,cAAc,SAAS;AAAA,IACvB,YAAY;AAAA,IACZ,GAAI,eAAe,EAAE,eAAe,aAAa,IAAI,CAAC;AAAA,EACxD;AAKA,QAAM,WAAW,YAA8B;AAC7C,QAAI,SAAS,wBAAwB,QAAQ;AAC3C,YAAMI,YAAW,MAAM,MAAM,SAAS,kBAAkB;AAAA,QACtD,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,oBAAoB,UAAU,mBAAmB;AAAA,QAC5E,MAAM,KAAK,UAAU,MAAM;AAAA,MAC7B,CAAC;AACD,UAAI,CAACA,UAAS,IAAI;AAChB,QAAAF,WAAU,EAAE,KAAK,gDAAgD,EAAE,UAAU,SAAS,MAAM,QAAQE,UAAS,OAAO,CAAC;AACrH,eAAO;AAAA,MACT;AACA,aAAO,MAAMA,UAAS,KAAK;AAAA,IAC7B;AAGA,UAAM,aAAa,IAAI,gBAAgB;AACvC,eAAW,OAAO,aAAa,SAAS,QAAQ;AAChD,eAAW,OAAO,iBAAiB,SAAS,YAAY;AACxD,eAAW,OAAO,QAAQ,OAAO,IAAI;AACrC,eAAW,OAAO,cAAc,oBAAoB;AACpD,eAAW,OAAO,gBAAgB,SAAS,WAAW;AACtD,QAAI,cAAc;AAChB,iBAAW,OAAO,iBAAiB,YAAY;AAAA,IACjD;AAEA,QAAI,UAAU,GAAG;AAKf,MAAAF,WAAU,EAAE,MAAM,qCAAqC,EAAE,YAAY,CAAC,GAAG,WAAW,KAAK,CAAC,EAAE,CAAC;AAAA,IAC/F;AACA,UAAME,YAAW,MAAM,MAAM,SAAS,kBAAkB;AAAA,MACtD,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,qCAAqC,UAAU,mBAAmB;AAAA,MAC7F,MAAM,WAAW,SAAS;AAAA,IAC5B,CAAC;AACD,QAAI,CAACA,UAAS,IAAI;AAChB,MAAAF,WAAU,EAAE,KAAK,gDAAgD,EAAE,UAAU,SAAS,MAAM,QAAQE,UAAS,OAAO,CAAC;AACrH,aAAO;AAAA,IACT;AACA,WAAO,MAAMA,UAAS,KAAK;AAAA,EAC7B;AAEA,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAMH,UAAS,QAAQ;AACjD,MAAI,OAAO;AACT,IAAAC,WAAU,EAAE,MAAM,gCAAgC,KAAK;AACvD,WAAO;AAAA,EACT;AACA,QAAM,YAAY,SAAS,QAAQ;AACnC,QAAM,cAAc,OAAO,UAAU,iBAAiB,WAAW,UAAU,eAAe;AAC1F,MAAI,CAAC,aAAa;AAChB,IAAAA,WAAU,EAAE,KAAK,4CAA4C,EAAE,UAAU,SAAS,KAAK,CAAC;AACxF,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAyBA,IAAM,oBAAoB,OACxB,UACA,gBACiC;AACjC,QAAM,cAAc,YAA8B;AAChD,UAAME,YAAW,MAAM,MAAM,SAAS,aAAa;AAAA,MACjD,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,UAAU;AAAA,QACV,iBAAiB,UAAU,WAAW;AAAA,MACxC;AAAA,IACF,CAAC;AACD,QAAI,CAACA,UAAS,IAAI;AAChB,MAAAF,WAAU,EAAE,KAAK,gDAAgD,EAAE,UAAU,SAAS,MAAM,QAAQE,UAAS,OAAO,CAAC;AACrH,aAAO;AAAA,IACT;AACA,WAAO,MAAMA,UAAS,KAAK;AAAA,EAC7B;AAEA,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAMH,UAAS,WAAW;AACpD,MAAI,OAAO;AACT,IAAAC,WAAU,EAAE,MAAM,gCAAgC,KAAK;AACvD,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,SAAS,QAAQ;AAMlC,QAAM,eAAe,gBAAgB,UAAU,SAAS,OAAO;AAC/D,QAAM,OAAO,iBAAiB,KAAK,sBAAsB;AACzD,QAAM,aAAa,SAAS,SAAS,QAAQ;AAC7C,MAAI,QAA4B,OAAO,eAAe,WAAW,aAAa;AAO9E,MAAI,gBAAgB;AAOpB,MAAI,SAAS,SAAS,kBAAkB;AACtC,UAAM,eAAe,SAAS,SAAS,gBAAgB;AAQvD,QAAI,iBAAiB,UAAa,iBAAiB,MAAM;AACvD,MAAAA,WAAU,EAAE,KAAK,0DAAqD,EAAE,UAAU,SAAS,KAAK,CAAC;AACjG,aAAO;AAAA,IACT;AACA,QAAI,iBAAiB,KAAM,iBAAgB;AAAA,EAC7C;AAMA,MAAI,SAAS,SAAS,sBAAsB;AAC1C,oBAAgB;AAAA,EAClB;AAEA,QAAM,WAAW,SAAS,gBAAgB,SAAS,SAAS,aAAa,IAAI;AAC7E,QAAM,cAAc,SAAS,YACzB,gBAAgB,UAAU,SAAS,SAAS,IAC3C,SAAS,YACR,MAAM,SAAS,UAAU,EAAE,UAAU,UAAU,OAAO,aAAa,WAAW,WAAW,IAAI,YAAY,CAAC,IAC1G;AACN,QAAM,SAAS,OAAO,gBAAgB,WAAW,cAAc;AAO/D,MAAI,CAAC,SAAS,SAAS,UAAU;AAC/B,UAAM,gBAAgB,MAAM,SAAS,SAAS,WAAW;AACzD,QAAI,CAAC,eAAe;AAClB,MAAAA,WAAU,EAAE,KAAK,+CAA+C,EAAE,UAAU,SAAS,KAAK,CAAC;AAC3F,aAAO;AAAA,IACT;AACA,YAAQ;AACR,oBAAgB;AAAA,EAClB;AASA,MAAI,UAAU,QAAW;AACvB,UAAM,kBAAkB,MAAM,KAAK,EAAE,YAAY;AACjD,QAAI,CAAC,QAAQ,eAAe,GAAG;AAC7B,MAAAA,WAAU,EAAE,KAAK,8CAA8C,EAAE,UAAU,SAAS,KAAK,CAAC;AAC1F,aAAO;AAAA,IACT;AACA,YAAQ;AAAA,EACV;AAEA,SAAO,EAAE,OAAO,MAAM,QAAQ,eAAe,aAAa,SAAS;AACrE;AAOA,IAAM,wBAAwB,OAC5B,UACA,YACsC;AACtC,QAAM,EAAE,OAAO,MAAM,QAAQ,cAAc,IAAI;AAC/C,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,iBAAiB,MAAM,aAAa,YAAY,EAAE,OAAO,UAAU,SAAS,KAAK,CAAC;AACxF,MAAI,eAAe,SAAS;AAC1B,IAAAA,WAAU,EAAE,KAAK,wCAAwC,EAAE,WAAW,eAAe,OAAO,UAAU,CAAC;AACvG,WAAO;AAAA,EACT;AAEA,QAAM,cAAc,eAAe;AAInC,QAAM,CAAC,WAAW,YAAY,IAAI,MAAMD;AAAA,IAAS,MAC/C,mBAAmB,aAAa,EAAE,OAAO,UAAU,SAAS,KAAK,CAAC;AAAA,EACpE;AACA,MAAI,WAAW;AACb,IAAAC,WAAU,EAAE,MAAM,6BAA6B,SAAS;AACxD,WAAO;AAAA,EACT;AAEA,MAAI,cAAc,IAAI;AAYpB,UAAM,mBAAmB,aAAa,YAAY;AAMlD,UAAM,sBAAsB,qBAAqB,SAAS;AAC1D,QAAI,uBAAuB,CAAC,eAAe;AACzC,MAAAA,WAAU,EAAE;AAAA,QACV;AAAA,QACA,EAAE,UAAU,SAAS,MAAM,iBAAiB;AAAA,MAC9C;AACA,aAAO;AAAA,IACT;AAGA,UAAM,iBAAiB,MAAM,sBAAsB,aAAa,EAAE;AAClE,QAAI,gBAAgB;AAClB,mBAAa,SAAS;AAAA,IACxB;AAEA,UAAM,gBAAgB,aAAa,aAAa;AAChD,UAAM,WAAW,oBAAI,KAAK;AAC1B,UAAMD,UAAS,MAAM,YAAY,OAAO,aAAa,IAAI,EAAE,WAAW,SAAS,CAAC,CAAC;AAEjF,WAAO;AAAA,MACL,MAAM;AAAA,QACJ,GAAG,uBAAuB,YAAY;AAAA,QACtC,OAAO;AAAA,QACP,WAAW;AAAA,QACX;AAAA,MACF;AAAA,MACA,WAAW;AAAA,IACb;AAAA,EACF;AAcA,MAAI,CAAC,eAAe;AAClB,IAAAC,WAAU,EAAE;AAAA,MACV;AAAA,MACA,EAAE,UAAU,SAAS,KAAK;AAAA,IAC5B;AACA,WAAO;AAAA,EACT;AASA,MAAI,CAACF,kBAAiB,EAAE,KAAK,mBAAmB;AAC9C,IAAAE,WAAU,EAAE,KAAK,2EAAsE;AACvF,WAAO;AAAA,EACT;AAEA,QAAM,oBAAoB,MAAM,aAAa,eAAe,EAAE,OAAO,UAAU,SAAS,MAAM,KAAK,CAAC;AACpG,MAAI,kBAAkB,SAAS;AAC7B,IAAAA,WAAU,EAAE,KAAK,8CAA8C,EAAE,WAAW,kBAAkB,OAAO,UAAU,CAAC;AAChH,WAAO;AAAA,EACT;AAEA,QAAM,gBAAgB,YAAY,YAAY,OAAO;AAAA,IACnD;AAAA,IACA,UAAU,SAAS;AAAA,IACnB;AAAA,IACA;AAAA,IACA,gBAAgB,4BAA4B;AAAA,IAC5C,UAAUF,kBAAiB,EAAE;AAAA,EAC/B,CAAC;AACD,QAAM,CAAC,aAAa,cAAc,IAAI,MAAMC,UAAS,aAAa;AAClE,MAAI,aAAa;AACf,IAAAC,WAAU,EAAE,MAAM,6BAA6B,WAAW;AAC1D,WAAO;AAAA,EACT;AACA,MAAI,CAAC,eAAgB,QAAO;AAE5B,SAAO;AAAA,IACL,MAAM,EAAE,GAAG,uBAAuB,cAAc,GAAG,OAAO,GAAG;AAAA,IAC7D,WAAW;AAAA,EACb;AACF;AAEA,IAAM,2BAA2B,OAAO;AAAA,EACtC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAKuB;AACrB,QAAM,WAAW,qBAAqB;AACtC,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,CAAC,eAAe,QAAQ,IAAI,MAAMD,UAAS,MAAM,SAAS;AAAA,IAC9D;AAAA,IACA,UAAU;AAAA,IACV;AAAA,IACA,YAAY;AAAA,EACd,CAAC,CAAC;AACF,MAAI,eAAe;AACjB,IAAAC,WAAU,EAAE,KAAK,4CAA4C,EAAE,SAAS,cAAc,QAAQ,CAAC;AAC/F,WAAO;AAAA,EACT;AACA,MAAI,YAAY,qBAAqB,QAAQ,EAAG,QAAO;AACvD,MAAI,UAAU;AACZ,IAAAA,WAAU,EAAE;AAAA,MACV;AAAA,MACA,EAAE,UAAU,YAAY;AAAA,IAC1B;AAAA,EACF;AACA,SAAO;AACT;AAMA,IAAM,8BAA8B,CAClC,UACA,QACiE;AACjE,QAAM,qBAAqB,SAAS,MAAM,GAAG,EAAE,CAAC;AAChD,MAAI,CAAC,sBAAsB,CAAC,IAAK,QAAO;AACxC,QAAM,QAAQ,kBAAkB,EAAE,KAAK,OAAK,EAAE,SAAS,kBAAkB;AACzE,MAAI,CAAC,SAAS,CAAC,oBAAoB,KAAK,EAAG,QAAO;AAClD,SAAO,EAAE,UAAU,OAAO,cAAc,MAAM,KAAK;AACrD;AAKA,IAAM,0BAA0B,OAC9B,MACA,UACA,SACA,gBACkB;AAClB,QAAM,EAAE,mBAAmB,IAAI;AAC/B,MAAI,CAAC,mBAAoB;AAIzB,QAAM,CAAC,UAAU,KAAK,IAAI,MAAMD;AAAA,IAAS,YACvC,mBAAmB,EAAE,UAAU,QAAQ,aAAa,YAAY,CAAC;AAAA,EACnE;AACA,MAAI,UAAU;AACZ,IAAAC,WAAU,EAAE,KAAK,UAAU,SAAS,IAAI,oEAA+D,EAAE,KAAK,SAAS,CAAC;AACxH;AAAA,EACF;AACA,MAAI,CAAC,MAAO;AAGZ,QAAM,EAAE,IAAI,OAAO,WAAW,UAAU,GAAG,UAAU,IAAI;AACzD,MAAI,OAAO,UAAa,UAAU,UAAa,cAAc,UAAa,aAAa,QAAW;AAChG,IAAAA,WAAU,EAAE;AAAA,MACV,UAAU,SAAS,IAAI;AAAA,IACzB;AAAA,EACF;AAKA,QAAM,qBAAqB;AAC3B,QAAM,iBAAiB,OAAO,KAAK,SAAS,EAAE,OAAO,OAAK,mBAAmB,KAAK,CAAC,CAAC;AACpF,MAAI,eAAe,SAAS,GAAG;AAC7B,IAAAA,WAAU,EAAE;AAAA,MACV,UAAU,SAAS,IAAI,sEAAsE,eAAe,KAAK,IAAI,CAAC;AAAA,IACxH;AAAA,EACF;AACA,SAAO,OAAO,MAAM,SAAS;AAC/B;AAKA,IAAM,0BAA0B,CAC9B,YACA,YACW;AACX,QAAM,iBACJ,WAAW,aAAa,qBAAqB,WAAW,SAAS,IAC7D,WAAW,YACX;AAEN,SACE,kBACG,QAAQ,sBACRF,kBAAiB,EAAE,oBACnB;AAEP;AAGA,IAAM,gBAAgB,OACpB,UACA,KACA,MACA,UAAoE,CAAC,MAC5B;AACzC,QAAM,qBAAqB,4BAA4B,UAAU,IAAI,GAAG;AACxE,MAAI,CAAC,mBAAoB,QAAO;AAChC,QAAM,EAAE,UAAU,aAAa,IAAI;AAGnC,QAAM,eAAe,IAAI,OAAO,IAAI,MAAM,GAAG,EAAE,CAAC,KAAK;AACrD,QAAM,SAAS,IAAI,gBAAgB,WAAW;AAC9C,QAAM,OAAO,OAAO,IAAI,MAAM;AAC9B,QAAM,QAAQ,OAAO,IAAI,OAAO;AAMhC,QAAM,cAAc,eAAe,IAAI,QAAQ,QAAQ,uBAAuB,KAAK;AACnF,QAAM,aAAa,MAAM,kBAAkB,SAAS,MAAM,SAAS,IAAI,WAAW;AAClF,MAAI,CAAC,YAAY;AACf,IAAAE,WAAU,EAAE,KAAK,6CAA6C,EAAE,UAAU,SAAS,KAAK,CAAC;AACzF,oBAAgB,EAAE,UAAU,cAAc,QAAQ,sBAAsB,OAAO,QAAQ,CAAC;AACxF,WAAO;AAAA,EACT;AAEA,MAAI,CAAC,MAAM;AACT,IAAAA,WAAU,EAAE,KAAK,2CAA2C,EAAE,UAAU,SAAS,KAAK,CAAC;AACvF,oBAAgB,EAAE,UAAU,cAAc,QAAQ,gBAAgB,OAAO,QAAQ,CAAC;AAClF,WAAO;AAAA,EACT;AAKA,QAAM,cAAc,MAAM,mBAAmB,UAAU,MAAM,WAAW,YAAY;AACpF,MAAI,CAAC,aAAa;AAChB,oBAAgB,EAAE,UAAU,cAAc,QAAQ,6BAA6B,OAAO,QAAQ,CAAC;AAC/F,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,MAAM,kBAAkB,UAAU,WAAW;AAC7D,MAAI,CAAC,SAAS;AACZ,oBAAgB,EAAE,UAAU,cAAc,QAAQ,4BAA4B,OAAO,QAAQ,CAAC;AAC9F,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,MAAM,sBAAsB,UAAU,OAAO;AAC9D,MAAI,CAAC,UAAU;AACb,oBAAgB,EAAE,UAAU,cAAc,OAAO,QAAQ,OAAO,QAAQ,2BAA2B,OAAO,QAAQ,CAAC;AACnH,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,YAAY,EAAE,EAAE,SAAS,KAAK;AAC/C,WAAS,KAAK,QAAQ;AAItB,QAAM,wBAAwB,SAAS,MAAM,UAAU,SAAS,WAAW;AAE3E,QAAM,QAAQ,MAAM,YAAY,UAAU,SAAS,MAAM,MAAM,EAAE,gBAAgB,QAAQ,eAAe,CAAC;AACzG,MAAI,CAAC,MAAM,IAAI;AAGb,IAAAA,WAAU,EAAE,MAAM,UAAU,YAAY,iDAA4C,EAAE,WAAW,MAAM,UAAU,CAAC;AAClH,oBAAgB,EAAE,UAAU,cAAc,QAAQ,SAAS,KAAK,IAAI,QAAQ,MAAM,WAAW,OAAO,QAAQ,CAAC;AAC7G,WAAO;AAAA,EACT;AAEA,MAAI,SAAS,WAAW;AACtB,UAAM,aAAa,gBAAgB,EAAE,QAAQ,SAAS,KAAK,IAAI,UAAU,aAAa,CAAC;AAAA,EACzF;AACA,QAAM,aAAa,aAAa;AAAA,IAC9B,QAAQ,SAAS,KAAK;AAAA,IACtB,UAAU;AAAA,IACV,WAAW,SAAS;AAAA,IACpB,OAAO;AAAA,EACT,CAAC;AAED,MAAI,UAAU,GAAG;AACf,IAAAA,WAAU,EAAE,MAAM,gCAAgC,SAAS,KAAK,EAAE,EAAE;AAAA,EACtE;AAEA,QAAM,cAAc,wBAAwB,YAAY,OAAO;AAC/D,QAAM,cAAc,MAAM,yBAAyB;AAAA,IACjD;AAAA,IACA,QAAQ,SAAS,KAAK;AAAA,IACtB;AAAA,IACA,WAAW,SAAS;AAAA,EACtB,CAAC;AAED,SAAO;AAAA,IACL,OAAO;AAAA,IACP;AAAA,IACA,QAAQ,SAAS,KAAK;AAAA,IACtB,UAAU;AAAA,IACV,WAAW,SAAS;AAAA,EACtB;AACF;;;AIpvCA,OAAOG,aAAY;AAGnB,SAAS,oBAAAC,mBAAkB,mBAAmB,qBAAqB,aAAAC,YAAW,OAAO,uBAAuB;AAU5G,IAAM,oBAAoB;AAM1B,IAAM,iBAAiB,CAAC,WAA2BC,WAAU,iBAAiB,MAAM;AAS7E,IAAM,2BAA2B,OAAO,WAAoC;AACjF,QAAM,MAAMC,kBAAiB,EAAE,KAAK;AAKpC,QAAM,WAAW,MAAM,MAAM,IAAI,eAAe,MAAM,CAAC;AACvD,MAAI,UAAU;AACZ,UAAM,MAAM,IAAI,QAAQ;AAAA,EAC1B;AAEA,QAAM,SAAS,kBAAkB,mBAAmB,KAAK,MAAM;AAC/D,QAAM,OAAO,MAAM;AAMnB,QAAM,aAAa,gBAAgB,mBAAmB,OAAO,KAAK;AAClE,QAAM,MAAM,IAAI,eAAe,MAAM,GAAG,YAAY,MAAM,GAAG;AAE7D,SAAO,OAAO;AAChB;AAOO,IAAM,4BAA4B,OAAO,UAA0C;AACxF,SAAO,oBAAoB,mBAAmB,KAAK;AACrD;AAwBO,IAAM,qBAAqB,OAAO,QAAgB,cAAqC;AAC5F,QAAM,SAAS,iBAAiB,SAAS;AACzC,MAAI,QAAQ;AACV,UAAM,IAAI,oBAAoB,MAAM;AAAA,EACtC;AACA,QAAM,OAAO,MAAMC,QAAO,QAAQD,kBAAiB,EAAE,KAAK,YAAY;AACtE,QAAM,iBAAiB,MAAMC,QAAO,KAAK,WAAW,IAAI;AACxD,QAAM,QAAiC,EAAE,UAAU,eAAe;AAClE,QAAM,eAAe,EAAE,OAAO,QAAQ,KAAK;AAC7C;AAOO,IAAM,sBAAN,cAAkC,MAAM;AAAA,EACpC;AAAA,EACT,YAAY,WAAmB;AAC7B,UAAM,8BAA8B,SAAS,EAAE;AAC/C,SAAK,OAAO;AACZ,SAAK,YAAY;AAAA,EACnB;AACF;AAGO,IAAM,iBAAiB,OAAO,WAAmBC,UAAmC;AACzF,SAAOD,QAAO,QAAQ,WAAWC,KAAI;AACvC;;;ACzHA,SAAS,oBAAAC,mBAAkB,gBAAAC,eAAc,aAAAC,kBAAiB;;;ACiBnD,IAAM,kBAAkB;AAAA;AAAA,EAE7B,OAAO,mBAAmB;AAAA;;;ADIrB,IAAM,yBAAyB,OAAO,EAAE,OAAO,MAAM,MAAqE;AAC/H,QAAM,SAASC,kBAAiB;AAEhC,EAAAC,WAAU,EAAE,KAAK,0BAA0B,EAAE,oBAAoB,OAAO,KAAK,eAAe,CAAC;AAC7F,MAAI,OAAO,KAAK,mBAAmB,aAAa;AAC9C,IAAAA,WAAU,EAAE,KAAK,+EAA0E,EAAE,MAAM,OAAO,KAAK,eAAe,CAAC;AAC/H,WAAO,EAAE,IAAI,OAAO,QAAQ,+BAA+B;AAAA,EAC7D;AAEA,QAAM,gBAAgB,SAAS,OAAO,KAAK,sBAAsB;AAOjE,QAAM,cAAc,MAAM,gBAAgB,EAAE,MAAM,CAAC,UAAmB;AACpE,IAAAA,WAAU,EAAE,KAAK,6EAAwE,EAAE,OAAO,OAAO,KAAK,EAAE,CAAC;AACjH,WAAO;AAAA,EACT,CAAC;AACD,MAAI,CAAC,aAAa;AAChB,WAAO,EAAE,IAAI,OAAO,QAAQ,2BAA2B;AAAA,EACzD;AACA,QAAM,EAAE,UAAU,IAAI;AAEtB,QAAM,cAAc,eAAe;AACnC,QAAM,OAAO,MAAM,YAAY,YAAY,EAAE,OAAO,UAAU,cAAc,CAAC;AAG7E,EAAAA,WAAU,EAAE,KAAK,4CAA4C,EAAE,OAAO,QAAQ,IAAI,GAAG,QAAQ,MAAM,MAAM,KAAK,CAAC;AAG/G,MAAI,CAAC,MAAM;AAQT,IAAAA,WAAU,EAAE,KAAK,iIAA4H;AAC7I,SAAKC,cAAa,0BAA0B;AAAA,MAC1C;AAAA,MACA,SAAS;AAAA,IACX,CAAC;AACD,WAAO,EAAE,IAAI,KAAK;AAAA,EACpB;AAEA,QAAM,QAAQ,MAAM,yBAAyB,KAAK,EAAE;AAKpD,OAAKA,cAAa,0BAA0B;AAAA,IAC1C;AAAA,IACA,SAAS;AAAA,IACT,QAAQ,KAAK;AAAA,IACb,YAAY,OAAO,KAAK;AAAA,EAC1B,CAAC;AACD,QAAM,WAAW,OAAO,IAAI,aAAa,IAAI,QAAQ,QAAQ,EAAE;AAe/D,QAAM,WAAW,GAAG,OAAO,yBAAyB,mBAAmB,KAAK,CAAC;AAE7E,QAAM,aAAa,KAAK,MAAM,OAAO,KAAK,0BAA0B,EAAE;AAStE,QAAM,SAAS,MAAM,UAAU;AAAA,IAC7B,IAAI,KAAK;AAAA,IACT,UAAU;AAAA,IACV,MAAM,EAAE,UAAU,UAAU,KAAK,MAAM,OAAO,eAAe,WAAW;AAAA,IACxE,aAAa;AAAA,EACf,CAAC;AAED,MAAI,OAAO,IAAI;AAEb,IAAAD,WAAU,EAAE,KAAK,2CAA2C,EAAE,QAAQ,KAAK,GAAG,CAAC;AAAA,EACjF,OAAO;AACL,IAAAA,WAAU,EAAE,KAAK,4CAA4C,EAAE,QAAQ,KAAK,IAAI,QAAQ,OAAO,OAAO,CAAC;AAAA,EACzG;AAEA,SAAO,OAAO,KAAK,EAAE,IAAI,KAAK,IAAI,EAAE,IAAI,OAAO,QAAQ,OAAO,OAAO;AACvE;;;AEjHA,SAAS,oBAAAE,mBAAkB,qBAAAC,oBAAmB,yBAAyB,aAAAC,YAAW,SAAAC,QAAO,mBAAAC,wBAAuB;AAEhH,IAAM,yBAAyB;AAK/B,IAAM,qBAAqB,CAAC,WAA2BF,WAAU,sBAAsB,MAAM;AAgBtF,IAAM,yBAAyB,OAAO,QAAgB,aAAsC;AACjG,QAAM,MAAMF,kBAAiB,EAAE,KAAK;AAMpC,QAAM,WAAW,MAAMG,OAAM,IAAI,mBAAmB,MAAM,CAAC;AAC3D,MAAI,UAAU;AACZ,UAAMA,OAAM,IAAI,QAAQ;AAAA,EAC1B;AAEA,QAAM,SAASF;AAAA,IACb;AAAA,IACA;AAAA,IACA,EAAE,QAAQ,SAAS;AAAA,EACrB;AACA,QAAM,OAAO,MAAM;AAGnB,QAAM,aAAaG,iBAAgB,wBAAwB,OAAO,KAAK;AACvE,QAAMD,OAAM,IAAI,mBAAmB,MAAM,GAAG,YAAY,MAAM,GAAG;AAEjE,SAAO,OAAO;AAChB;AAQO,IAAM,0BAA0B,OAAO,UAAsD;AAClG,QAAM,SAAS,MAAM,wBAA4C,wBAAwB,KAAK;AAC9F,MACE,CAAC,UACE,OAAO,WAAW,YAClB,OAAO,OAAO,WAAW,YACzB,OAAO,OAAO,aAAa,UAC9B;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;;;AC5EA,SAAS,oBAAAE,yBAAwB;AAwB1B,IAAM,8BAA8B,OACzC,EAAE,QAAQ,UAAU,UAAU,MAAM,MACyB;AAC7D,QAAM,SAASC,kBAAiB;AAChC,QAAM,gBAAgB,SAAS,OAAO,KAAK,sBAAsB;AAKjE,QAAM,EAAE,UAAU,IAAI,MAAM,gBAAgB;AAE5C,QAAM,QAAQ,MAAM,uBAAuB,QAAQ,QAAQ;AAC3D,QAAM,WAAW,OAAO,IAAI,aAAa,IAAI,QAAQ,QAAQ,EAAE;AAS/D,QAAM,cAAc,OAAO,KAAK,0BAA0B;AAC1D,QAAM,aAAa,GAAG,OAAO,GAAG,WAAW,UAAU,mBAAmB,KAAK,CAAC;AAC9E,QAAM,aAAa,KAAK,MAAM,OAAO,KAAK,wBAAwB,EAAE;AAKpE,QAAM,SAAS,MAAM,UAAU;AAAA,IAC7B,IAAI;AAAA,IACJ,UAAU;AAAA,IACV,MAAM,EAAE,YAAY,UAAU,OAAO,eAAe,WAAW;AAAA,IAC/D,aAAa;AAAA,EACf,CAAC;AAED,SAAO,OAAO,KAAK,EAAE,IAAI,MAAM,MAAM,IAAI,EAAE,IAAI,OAAO,QAAQ,OAAO,QAAQ,MAAM;AACrF;;;AXpDA,wBAAwB;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ,CAAC,EAAE,OAAO,QAAQ,QAAQ,kBAAkB,MAClD,OAAQ,EAAE,OAAO,QAAQ,QAAQ,UAAU,MAAM,kBAAkB,CAAC;AACxE,CAAC;AAMD,wBAAwB;","names":["tryCatch","getLogger","getProjectConfig","getProjectConfig","getLogger","getProjectConfig","getProjectConfig","getProjectConfig","tryCatch","getLogger","validationError","response","bcrypt","getProjectConfig","formatKey","formatKey","getProjectConfig","bcrypt","hash","getProjectConfig","dispatchHook","getLogger","getProjectConfig","getLogger","dispatchHook","getProjectConfig","issueOneTimeToken","formatKey","redis","oneTimeTokenKey","getProjectConfig","getProjectConfig"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/authLockout.ts","../src/redirectResolver.ts","../src/login.ts","../src/accountStrategy.ts","../src/passwordPolicy.ts","../src/data/commonPasswords.ts","../src/passwordReset.ts","../src/forgotPassword.ts","../src/emailModuleLoader.ts","../src/emailChange.ts","../src/emailChangeNotification.ts"],"sourcesContent":["// Registers module augmentations on @luckystack/core for auth/session hooks.\r\nimport './hookPayloads';\r\n\r\n//? Decoupling seam (0.2.0): register this package's session implementation into\r\n//? @luckystack/core so api/sync/presence/server read sessions through core's\r\n//? null-safe accessors WITHOUT importing @luckystack/login directly. This is\r\n//? what makes login a genuine OPTIONAL package — when it isn't installed nothing\r\n//? registers and the accessors return null (unauthenticated). Side-effect at\r\n//? module load: any app that imports @luckystack/login (its overlay, LoginForm,\r\n//? config) wires this before the first request is served.\r\nimport { registerSessionProvider } from '@luckystack/core';\r\nimport { saveSession as _saveSession, getSession as _getSession, deleteSession as _deleteSession } from './session';\r\nimport { logout as _logout } from './logout';\r\nregisterSessionProvider({\r\n getSession: _getSession,\r\n saveSession: _saveSession,\r\n deleteSession: _deleteSession,\r\n logout: ({ token, socket, userId, skipSessionDelete }) =>\r\n _logout({ token, socket, userId: userId ?? null, skipSessionDelete }),\r\n});\r\n\r\n//? Subscribe the per-account brute-force lockout to the `loginFailed` hook at\r\n//? boot (F7). Idempotent + cheap; the recorder no-ops unless\r\n//? `rateLimiting.auth.enabled` is set, so this is safe to wire unconditionally.\r\nimport { registerAuthLockoutHook } from './authLockout';\r\nregisterAuthLockoutHook();\r\n\r\nexport type { BaseSessionLayout, SessionLocation, AuthProps } from './sessionLayout';\r\nexport type {\r\n PreLoginPayload,\r\n PostLoginPayload,\r\n PreRegisterPayload,\r\n PostRegisterPayload,\r\n PreLogoutPayload,\r\n PostLogoutPayload,\r\n PreSessionCreatePayload,\r\n PostSessionCreatePayload,\r\n PreSessionDeletePayload,\r\n PostSessionDeletePayload,\r\n PasswordResetRequestedPayload,\r\n PrePasswordResetCompletedPayload,\r\n PasswordResetCompletedPayload,\r\n PrePasswordChangedPayload,\r\n PasswordChangedPayload,\r\n PreEmailChangePayload,\r\n PostEmailChangeRequestedPayload,\r\n PostEmailChangedPayload,\r\n LoginFailedPayload,\r\n PreAccountDeletePayload,\r\n PostAccountDeletePayload,\r\n} from './hookPayloads';\r\nexport { saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor } from './session';\r\nexport { registerSessionAdapter, getSessionAdapter, redisSessionAdapter } from './sessionAdapter';\r\nexport type { SessionAdapter } from './sessionAdapter';\r\nexport { registerSessionSanitizer, getSessionSanitizer } from './sessionSanitizer';\r\nexport type { SessionSanitizer } from './sessionSanitizer';\r\nexport { loginWithCredentials, loginCallback, createOAuthState, registerWithCredentials, loginWithCredentialsCore } from './login';\r\nexport type {\r\n CredentialsLoginResult,\r\n CredentialsLoginSuccess,\r\n CredentialsLoginFailure,\r\n OAuthCallbackResult,\r\n} from './login';\r\nexport type { CreateOAuthStateResult } from './login';\r\nexport { OAUTH_STATE_COOKIE_NAME } from './login';\r\nexport { logout } from './logout';\r\n\r\n// Per-account brute-force lockout (F7). The hook is auto-registered at boot;\r\n// these are exported for consumers who want to query/clear a lock explicitly\r\n// (e.g. an admin \"unlock account\" action) or build their own surface on top.\r\nexport { isAccountLocked, recordAuthFailure, clearAuthFailures } from './authLockout';\r\n\r\n// Password-reset primitives. Used by the framework's `framework`-mode\r\n// forgot-password flow AND exported for consumers who picked `'custom'`.\r\nexport {\r\n createPasswordResetToken,\r\n consumePasswordResetToken,\r\n updatePasswordHash,\r\n verifyPassword,\r\n PasswordPolicyError,\r\n} from './passwordReset';\r\nexport { validatePassword } from './passwordPolicy';\r\nexport { sendPasswordResetEmail } from './forgotPassword';\r\n\r\n// Email-change primitives. Used by the framework's settings flow to confirm\r\n// a new email address before applying it (token mailed to NEW address proves\r\n// ownership of the new mailbox).\r\nexport {\r\n createEmailChangeToken,\r\n consumeEmailChangeToken,\r\n} from './emailChange';\r\nexport type { EmailChangePayload } from './emailChange';\r\nexport { sendEmailChangeConfirmation } from './emailChangeNotification';\r\n\r\n// OAuth provider registry + composable helpers.\r\nexport {\r\n registerOAuthProviders,\r\n registerOAuthProviderFactory,\r\n getOAuthProviders,\r\n isFullOAuthProvider,\r\n asOAuthUserData,\r\n credentialsProvider,\r\n googleProvider,\r\n githubProvider,\r\n discordProvider,\r\n facebookProvider,\r\n microsoftProvider,\r\n} from './oauthProviders';\r\nexport type {\r\n OAuthProvider,\r\n CredentialsProvider,\r\n FullOAuthProvider,\r\n OAuthUserData,\r\n} from './oauthProviders';\r\n\r\n// User adapter registry: lets consumers swap out the Prisma User model behind auth flows.\r\nexport {\r\n registerUserAdapter,\r\n getUserAdapter,\r\n isUserAdapterRegistered,\r\n defaultPrismaUserAdapter,\r\n} from './userAdapter';\r\nexport type {\r\n UserAdapter,\r\n UserAdapterCreateInput,\r\n UserRecord,\r\n} from './userAdapter';\r\n\r\n// Post-login redirect resolver — lets consumers compute the OAuth callback\r\n// destination dynamically (per-user, per-tenant, per-provider).\r\nexport {\r\n registerPostLoginRedirect,\r\n getPostLoginRedirect,\r\n} from './redirectResolver';\r\nexport type {\r\n PostLoginRedirectResolver,\r\n PostLoginRedirectInput,\r\n} from './redirectResolver';\r\n","//? Per-account brute-force lockout (F7 / MIS-017). The generic per-IP throttle\r\n//? on `/auth/api/credentials` (in @luckystack/server) does NOT stop distributed\r\n//? credential stuffing against a SINGLE account from many IPs. This module adds\r\n//? a per-account failed-attempt counter, keyed independently from the api/ip\r\n//? limits, that locks an account after `rateLimiting.auth.maxAttempts` failures\r\n//? within `rateLimiting.auth.windowMs`.\r\n//?\r\n//? It is OPT-IN: when `rateLimiting.auth.enabled === false` (the default) every\r\n//? function here is a no-op and behaviour is byte-identical to before — only\r\n//? the existing per-IP throttle applies.\r\n//?\r\n//? The counter rides on the framework rate limiter (`checkRateLimit`) so it\r\n//? inherits the configured store (memory/redis) and multi-instance behaviour.\r\n//? `recordAuthFailure` increments; `isAccountLocked` reads WITHOUT incrementing\r\n//? (so merely checking the lock can't itself trip it); `clearAuthFailures`\r\n//? resets the counter on a successful login.\r\n\r\nimport {\r\n getProjectConfig,\r\n checkRateLimit,\r\n getRateLimitStatus,\r\n clearRateLimit,\r\n getLogger,\r\n registerHook,\r\n tryCatch,\r\n} from '@luckystack/core';\r\n\r\n//? DD-LOGIN-F5: build the lockout bucket key. When `requesterIp` is supplied\r\n//? the key is an IP+account composite (`auth:<email>:<ip>`) so a remote attacker\r\n//? from one IP cannot lock out an account for legitimate users on different IPs.\r\n//? Without an IP the key is the bare account key (`auth:<email>`) — same\r\n//? behaviour as before. The composite bucket is cleared on success the same way\r\n//? (same key is used in `clearAuthFailures`). Lower-case + trim the account\r\n//? identifier so `Alice@x.com` and `alice@x.com` share one lockout bucket\r\n//? (matches the credentials email normalization).\r\nconst lockoutKey = (accountKey: string, requesterIp?: string): string => {\r\n const base = `auth:${accountKey.trim().toLowerCase()}`;\r\n return requesterIp ? `${base}:${requesterIp}` : base;\r\n};\r\n\r\n//? One-shot guard so the inert-config warning below fires once per process, not\r\n//? on every login attempt.\r\nlet warnedLockoutInert = false;\r\n\r\n//? One-shot guard for the M1 warning: surfaces the silent gap where credentials\r\n//? logins are FAILING but the per-account lockout is disabled entirely, so only\r\n//? the proxy-fragile per-IP cap stands between an attacker and distributed\r\n//? credential stuffing. Fires once, on the first real credentials-login failure.\r\nlet warnedLockoutDisabled = false;\r\n\r\n/** Read the active auth-lockout config (returns null when the feature is off). */\r\nconst getAuthLockoutConfig = (): { maxAttempts: number; maxAttemptsPerAccount: number; windowMs: number } | null => {\r\n const rateLimiting = getProjectConfig().rateLimiting;\r\n if (!rateLimiting.auth.enabled) return null;\r\n //? The counter rides the framework rate limiter, which short-circuits when the\r\n //? GLOBAL `rateLimiting.enabled` is false — so `auth.enabled:true` +\r\n //? `enabled:false` makes recordAuthFailure/isAccountLocked silent no-ops, i.e. a\r\n //? believed-on-but-INERT brute-force defense. Warn once so the conflict surfaces.\r\n if (!rateLimiting.enabled && !warnedLockoutInert) {\r\n warnedLockoutInert = true;\r\n getLogger().warn(\r\n '[LuckyStack] rateLimiting.auth.enabled is true but rateLimiting.enabled is false — the per-account brute-force lockout is INERT (it rides the global rate limiter, which is off). Set rateLimiting.enabled to true for the lockout to take effect.',\r\n );\r\n }\r\n return {\r\n maxAttempts: rateLimiting.auth.maxAttempts,\r\n maxAttemptsPerAccount: rateLimiting.auth.maxAttemptsPerAccount,\r\n windowMs: rateLimiting.auth.windowMs,\r\n };\r\n};\r\n\r\n/**\r\n * Whether `accountKey` is currently locked out (failed-attempt counter already\r\n * at/over the cap). Non-incrementing — safe to call on every login attempt.\r\n * Always `false` when the feature is disabled.\r\n * Pass `requesterIp` to check the IP+account composite bucket (DD-LOGIN-F5).\r\n */\r\nexport const isAccountLocked = async (accountKey: string, requesterIp?: string): Promise<boolean> => {\r\n const cfg = getAuthLockoutConfig();\r\n if (!cfg || !accountKey) return false;\r\n //? DUAL counter (DD-LOGIN-F5 + the cross-IP fix). Lock if EITHER trips:\r\n //? - the bare-account bucket (`auth:<email>`) against the cross-IP cap\r\n //? (`maxAttemptsPerAccount`) — the distributed-credential-stuffing defense, and\r\n //? - the IP+account composite (`auth:<email>:<ip>`) against the per-IP cap\r\n //? (`maxAttempts`) — bounds one IP + shields other IPs from a victim-lock DoS.\r\n const accountStatus = await getRateLimitStatus(lockoutKey(accountKey), cfg.maxAttemptsPerAccount);\r\n if (accountStatus.remaining <= 0) return true;\r\n if (requesterIp) {\r\n const ipStatus = await getRateLimitStatus(lockoutKey(accountKey, requesterIp), cfg.maxAttempts);\r\n if (ipStatus.remaining <= 0) return true;\r\n }\r\n return false;\r\n};\r\n\r\n/**\r\n * Increment the failed-attempt counter for `accountKey`. Call on every failed\r\n * credentials login for a known/looked-up account. No-op when disabled.\r\n * Pass `requesterIp` to increment the IP+account composite bucket (DD-LOGIN-F5).\r\n */\r\nexport const recordAuthFailure = async (accountKey: string, requesterIp?: string): Promise<void> => {\r\n const cfg = getAuthLockoutConfig();\r\n if (!cfg || !accountKey) return;\r\n //? Increment BOTH the cross-IP bare-account counter and (when an IP is known)\r\n //? the per-IP composite counter — mirrors the dual gate in isAccountLocked.\r\n const account = await checkRateLimit({\r\n key: lockoutKey(accountKey),\r\n limit: cfg.maxAttemptsPerAccount,\r\n windowMs: cfg.windowMs,\r\n });\r\n let ipBlocked = false;\r\n if (requesterIp) {\r\n const ip = await checkRateLimit({\r\n key: lockoutKey(accountKey, requesterIp),\r\n limit: cfg.maxAttempts,\r\n windowMs: cfg.windowMs,\r\n });\r\n ipBlocked = !ip.allowed;\r\n }\r\n if (!account.allowed || ipBlocked) {\r\n getLogger().warn('[authLockout] account temporarily locked after repeated failures', {\r\n maxAttempts: cfg.maxAttempts,\r\n maxAttemptsPerAccount: cfg.maxAttemptsPerAccount,\r\n crossIp: !account.allowed,\r\n });\r\n }\r\n};\r\n\r\n/**\r\n * Reset the failed-attempt counter for `accountKey` (call after a SUCCESSFUL\r\n * login so a user who eventually gets their password right isn't penalised by\r\n * earlier typos). No-op when disabled.\r\n * Pass `requesterIp` to clear the IP+account composite bucket (DD-LOGIN-F5).\r\n */\r\nexport const clearAuthFailures = async (accountKey: string, requesterIp?: string): Promise<void> => {\r\n const cfg = getAuthLockoutConfig();\r\n if (!cfg || !accountKey) return;\r\n //? Clear BOTH counters on a successful login so earlier typos don't penalise the\r\n //? user — the cross-IP bare-account bucket and the per-IP composite bucket.\r\n //? L6: callers invoke this fire-and-forget (`void clearAuthFailures(...)`) on the\r\n //? success path, so a transient Redis error must NOT become an unhandled\r\n //? rejection. Swallow + log; a stale lockout counter self-expires within the\r\n //? window, so a missed clear is non-fatal.\r\n const [error] = await tryCatch(async () => {\r\n await clearRateLimit(lockoutKey(accountKey));\r\n if (requesterIp) await clearRateLimit(lockoutKey(accountKey, requesterIp));\r\n });\r\n if (error) {\r\n getLogger().warn('[authLockout] clearAuthFailures failed (non-fatal — counter self-expires)', { error });\r\n }\r\n};\r\n\r\n//? ADR 0012 — the lockout counter must reflect GENUINE credential failures only.\r\n//? This is an ALLOW-LIST, not a deny-list: only a real wrong-password attempt\r\n//? (`login.wrongPassword`, covering both the no-account/no-hash dummy-compare\r\n//? path and a failed bcrypt compare) feeds the per-account counter. Everything\r\n//? else is excluded by construction — `preLogin` veto rejections (whose reason\r\n//? is a consumer-controlled `errorCode`), password-POLICY rejections (M-15),\r\n//? input-shape errors, the `login.accountLocked` self-trip, and infra failures\r\n//? (DB find / bcrypt throw, surfaced via `toReasonKey`). A deny-list let any of\r\n//? those — most dangerously a consumer `preLogin` veto an attacker can trigger\r\n//? with just a victim's email and no password — silently drive the lockout and\r\n//? DoS the account. Keep this allow-list narrow.\r\nconst COUNTING_REASONS = new Set([\r\n 'login.wrongPassword',\r\n]);\r\n\r\nlet lockoutHookRegistered = false;\r\n\r\n/**\r\n * Wire the per-account lockout into the `loginFailed` hook (F7). Subscribes once\r\n * (idempotent) and increments the failed-attempt counter only on a credentials\r\n * LOGIN failure that carries an email AND is a genuine wrong-password attempt\r\n * (see `COUNTING_REASONS`). Registration is unconditional (cheap); the recorder\r\n * itself no-ops when `rateLimiting.auth.enabled` is false, so toggling the\r\n * config at runtime is honoured without re-wiring.\r\n */\r\nexport const registerAuthLockoutHook = (): void => {\r\n if (lockoutHookRegistered) return;\r\n lockoutHookRegistered = true;\r\n registerHook('loginFailed', async ({ email, provider, reason, stage, requesterIp }) => {\r\n if (stage !== 'login' || provider !== 'credentials') return;\r\n //? M1: a credentials login is failing while the per-account lockout is OFF.\r\n //? Warn ONCE so operators of a credentials app don't ship with distributed\r\n //? credential stuffing unthrottled beyond the (proxy-fragile) per-IP cap. Kept\r\n //? as a loud default rather than force-enabling the lockout, because enabling\r\n //? the cross-IP counter also introduces a victim-account-lock DoS tradeoff the\r\n //? operator should opt into consciously (set rateLimiting.auth.enabled:true).\r\n if (!getProjectConfig().rateLimiting.auth.enabled && !warnedLockoutDisabled) {\r\n warnedLockoutDisabled = true;\r\n getLogger().warn(\r\n '[LuckyStack] a credentials login failed but the per-account brute-force lockout is DISABLED (rateLimiting.auth.enabled:false). Only the per-IP API cap protects credentials login, so distributed credential stuffing against one account (many IPs) is unthrottled. Set rateLimiting.auth.enabled:true to enable the per-account lockout (note: the cross-IP counter also enables a bounded victim-account-lock DoS — see docs/ARCHITECTURE_AUTH.md).',\r\n );\r\n }\r\n if (!email || !COUNTING_REASONS.has(reason)) return;\r\n //? DD-LOGIN-F5: use the IP+account composite key when an IP was threaded in.\r\n await recordAuthFailure(email, requesterIp);\r\n });\r\n};\r\n","//? Post-login redirect resolver. Lets consumers compute the OAuth callback\r\n//? redirect URL dynamically (per-user, per-tenant, per-provider).\r\n//?\r\n//? Why a dedicated registry instead of a hook:\r\n//? The framework's hooks are \"stop-or-continue\" — handlers return undefined\r\n//? or a stop signal. They don't carry a value. A redirect-resolution\r\n//? handler must RETURN a string, so it lives in its own registry.\r\n//?\r\n//? Validation: the resolved URL is checked against\r\n//? `ProjectConfig.http.cors.allowedOrigins` (plus the localhost convenience when\r\n//? `http.cors.allowLocalhost` is on) before being used. An invalid URL falls back\r\n//? to `ProjectConfig.loginRedirectUrl`.\r\n\r\nexport interface PostLoginRedirectInput {\r\n userId: string;\r\n provider: string;\r\n isNewUser: boolean;\r\n defaultUrl: string;\r\n}\r\n\r\nexport type PostLoginRedirectResolver = (\r\n input: PostLoginRedirectInput,\r\n) => string | Promise<string>;\r\n\r\nlet activeResolver: PostLoginRedirectResolver | null = null;\r\n\r\nexport const registerPostLoginRedirect = (\r\n resolver: PostLoginRedirectResolver,\r\n): PostLoginRedirectResolver => {\r\n activeResolver = resolver;\r\n return resolver;\r\n};\r\n\r\nexport const getPostLoginRedirect = (): PostLoginRedirectResolver | null => activeResolver;\r\n","import { getOAuthProviders, isFullOAuthProvider, type FullOAuthProvider, type OAuthUserData } from './oauthProviders';\r\nimport { getPostLoginRedirect } from './redirectResolver';\r\nimport { IncomingMessage, ServerResponse } from 'node:http';\r\nimport { URLSearchParams } from 'node:url';\r\nimport { tryCatch, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger, formatKey, getProjectConfig, getCookieValue } from '@luckystack/core';\r\nimport bcrypt from 'bcryptjs';\r\nimport { randomBytes, createHash, timingSafeEqual } from 'node:crypto';\r\nimport { saveSession } from \"./session\"\r\nimport validator from 'validator';\r\nimport type { BaseSessionLayout as SessionLayout } from './sessionLayout';\r\nimport { getUserAdapter } from './userAdapter';\r\nimport { resolveUserByEmail } from './accountStrategy';\r\nimport { validatePassword } from './passwordPolicy';\r\nimport { isAccountLocked, clearAuthFailures } from './authLockout';\r\nimport path from 'node:path';\r\nimport { promises as fsPromises } from 'node:fs';\r\n\r\n//? Combined login/register request body. When `name` + `confirmPassword` are\r\n//? both present the dispatcher registers; otherwise it logs in.\r\ninterface LoginOrRegisterParams {\r\n email?: string,\r\n password?: string,\r\n name?: string,\r\n confirmPassword?: string,\r\n}\r\n\r\n//? Discriminated result of the credentials login/register surface (QUA-080).\r\n//? Exporting this lets `@luckystack/server`'s auth route narrow on `status`\r\n//? instead of casting the result to an inline shape. On `status: true`,\r\n//? `newToken` + `session` are always present; on `status: false` only `reason`\r\n//? is meaningful (the failure branch never mints a token).\r\nexport interface CredentialsLoginSuccess {\r\n status: true;\r\n reason: string;\r\n newToken: string;\r\n session: SessionLayout;\r\n}\r\nexport interface CredentialsLoginFailure {\r\n status: false;\r\n reason: string;\r\n}\r\nexport type CredentialsLoginResult = CredentialsLoginSuccess | CredentialsLoginFailure;\r\n\r\n//? Resolved at call time via getUploadsDir() so consumer path overrides win.\r\nconst uploadsFolder = (): string => getUploadsDir();\r\n//? Resolve at call time so dotenv/test-setup timing can't capture a stale\r\n//? value at module load. Routes through `projectConfig.logging.devLogs`\r\n//? which is the standard predicate everywhere else in the codebase.\r\nconst isDevMode = (): boolean => getProjectConfig().logging.devLogs;\r\nconst { compare, genSalt, hash } = bcrypt;\r\n\r\n//? LOGIN-F22: the dummy hash used on the user-not-found path must be generated\r\n//? at the CONFIGURED bcrypt rounds, not hard-pinned to cost 10. If the consumer\r\n//? raises `auth.bcryptRounds` (e.g. to 12), a cost-10 dummy compare finishes in\r\n//? ~80 ms while a real compare takes ~400 ms — the 5× difference reopens the\r\n//? user-enumeration timing channel we closed with the dummy compare. We lazily\r\n//? generate one hash per unique rounds value (cached so the per-request compare\r\n//? stays O(1) after the first login at a given cost) and use that hash for all\r\n//? subsequent not-found compares at the same cost.\r\nconst dummyHashCache = new Map<number, string>();\r\nconst getDummyBcryptHash = async (): Promise<string> => {\r\n const rounds = getProjectConfig().auth.bcryptRounds;\r\n const cached = dummyHashCache.get(rounds);\r\n if (cached) return cached;\r\n const salt = await genSalt(rounds);\r\n const generated = await hash(randomBytes(16).toString('hex'), salt);\r\n dummyHashCache.set(rounds, generated);\r\n return generated;\r\n};\r\n//? `validator` is a CommonJS module whose helpers hang off the default export;\r\n//? destructuring the default is the documented usage. The named-export warning\r\n//? is a false positive for this CJS interop shape.\r\n// eslint-disable-next-line import-x/no-named-as-default-member\r\nconst { escape, isEmail } = validator;\r\n\r\n//? Use the shared `getProjectName()` helper so the OAuth state Redis key\r\n//? prefix matches the namespace used everywhere else (sessions, activeUsers,\r\n//? rate-limit, password-reset). Previously this used a separate\r\n//? `auth.oauthStateProjectNameFallback` config field which could silently\r\n//? drift from `session.projectName`.\r\nconst getOAuthStateKey = (providerName: string, state: string): string => {\r\n return formatKey('-oauth-state', `${providerName}:${state}`);\r\n};\r\n\r\n//? Cookie the server sets at the OAuth-authorize step (F1). It carries a random\r\n//? nonce bound to the Redis state entry; the callback requires the cookie nonce\r\n//? to hash to the value stored alongside the state before it accepts the\r\n//? callback. This proves the SAME browser that started the flow is completing it\r\n//? (closes the login-CSRF / session-fixation hole where an attacker delivers\r\n//? their own valid `code+state` to a victim). Server + login MUST agree on this\r\n//? name — it is exported so `@luckystack/server`'s authorize route reuses it.\r\nexport const OAUTH_STATE_COOKIE_NAME = 'ls-oauth-state';\r\n\r\n//? Hash the browser-binding nonce before persisting it in Redis: the Redis\r\n//? state value is server-side-readable, so storing only the SHA-256 means a\r\n//? leaked state entry can't be replayed without the matching cookie nonce.\r\nconst hashStateNonce = (nonce: string): string =>\r\n createHash('sha256').update(nonce).digest('hex');\r\n\r\n//? Generate a PKCE verifier + its S256 challenge (RFC 7636). Only used when a\r\n//? provider opts in via `usePkce` — default OAuth flows are byte-identical to\r\n//? before. The verifier is stored server-side (in the Redis state value) and\r\n//? replayed at token exchange; the challenge is handed to the authorize step.\r\nconst base64Url = (buf: Buffer): string =>\r\n buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');\r\nconst generatePkcePair = (): { verifier: string; challenge: string } => {\r\n const verifier = base64Url(randomBytes(32));\r\n const challenge = base64Url(createHash('sha256').update(verifier).digest());\r\n return { verifier, challenge };\r\n};\r\n\r\n//? Server-side payload persisted under the Redis state key. `nonceHash` binds\r\n//? the flow to the initiating browser's cookie; `codeVerifier` (PKCE) is the\r\n//? secret replayed at token exchange.\r\ninterface OAuthStateEntry {\r\n nonceHash: string;\r\n codeVerifier?: string;\r\n //? Frontend URL to redirect to after the OAuth callback completes. Stored\r\n //? server-side in Redis (never echoed from the client at callback time) so it\r\n //? is tamper-proof. Validated against allowedOrigins + allowLocalhost before\r\n //? use — see isAllowedRedirectUrl. Absent when the auth initiation did not\r\n //? carry a return_url param (falls back to the configured loginRedirectUrl).\r\n returnUrl?: string;\r\n}\r\n\r\nexport interface CreateOAuthStateResult {\r\n /** Opaque `state` param put on the authorize URL + echoed back on callback. */\r\n state: string;\r\n /**\r\n * Random nonce the server must set as the value of the {@link OAUTH_STATE_COOKIE_NAME}\r\n * cookie (HttpOnly, SameSite=Lax, short TTL) at the authorize redirect. The\r\n * callback requires this back to accept the flow (browser-binding, F1).\r\n */\r\n stateCookie: string;\r\n /**\r\n * PKCE `code_challenge` (S256) when the provider opted into PKCE — the server\r\n * appends `code_challenge=<value>&code_challenge_method=S256` to the authorize\r\n * URL. `undefined` for non-PKCE providers (default).\r\n */\r\n codeChallenge?: string;\r\n}\r\n\r\nexport const createOAuthState = async (\r\n providerName: string,\r\n options?: { usePkce?: boolean; returnUrl?: string },\r\n): Promise<CreateOAuthStateResult | null> => {\r\n const state = randomBytes(32).toString('hex');\r\n const nonce = randomBytes(32).toString('hex');\r\n const key = getOAuthStateKey(providerName, state);\r\n\r\n const pkce = options?.usePkce ? generatePkcePair() : null;\r\n const entry: OAuthStateEntry = {\r\n nonceHash: hashStateNonce(nonce),\r\n ...(pkce ? { codeVerifier: pkce.verifier } : {}),\r\n ...(options?.returnUrl ? { returnUrl: options.returnUrl } : {}),\r\n };\r\n\r\n //? State TTL is consumer-configurable via `auth.oauthStateTtlSeconds`\r\n //? (default 600s / 10 min). Too short and a legitimate, slow provider\r\n //? round-trip expires mid-flow and the callback fails state validation\r\n //? (UX/soft-DoS); too long and a stolen state stays replayable. The `NX`\r\n //? guard keeps each state single-use even before the TTL elapses, and\r\n //? `consumeOAuthState` deletes it on first redemption. The value is now a\r\n //? JSON envelope (nonce hash + optional PKCE verifier) instead of the literal\r\n //? `'1'` so the flow can be bound to the initiating browser (F1) and carry a\r\n //? PKCE secret (F11).\r\n const result = await redisClient.set(\r\n key,\r\n JSON.stringify(entry),\r\n 'EX',\r\n getProjectConfig().auth.oauthStateTtlSeconds,\r\n 'NX',\r\n );\r\n\r\n if (result !== 'OK') {\r\n return null;\r\n }\r\n\r\n return {\r\n state,\r\n stateCookie: nonce,\r\n ...(pkce ? { codeChallenge: pkce.challenge } : {}),\r\n };\r\n};\r\n\r\n//? Atomically read + delete the state entry (single-use), then verify the\r\n//? browser-binding nonce. Returns the parsed entry on success (so the caller can\r\n//? read `codeVerifier` for the PKCE token exchange), or `null` on any failure:\r\n//? missing state, missing/empty cookie nonce, Redis miss, malformed payload, or\r\n//? a nonce that doesn't match the stored hash. `cookieNonce` is the value the\r\n//? server read from the {@link OAUTH_STATE_COOKIE_NAME} cookie on the callback\r\n//? request.\r\nconst consumeOAuthState = async (\r\n providerName: string,\r\n state: string,\r\n cookieNonce: string,\r\n): Promise<OAuthStateEntry | null> => {\r\n if (!state || !cookieNonce) {\r\n return null;\r\n }\r\n\r\n const key = getOAuthStateKey(providerName, state);\r\n const txResult = await redisClient.multi().get(key).del(key).exec();\r\n if (!txResult || txResult.length < 2) {\r\n return null;\r\n }\r\n\r\n const getResult = txResult[0];\r\n if (!getResult || getResult[0]) {\r\n return null;\r\n }\r\n\r\n //? Verify the DEL also succeeded (txResult[1][0] is the per-command error slot).\r\n //? If DEL failed the state key was not consumed — fail closed so the OAuth state\r\n //? cannot be replayed with the same nonce before its TTL expires (mirrors the\r\n //? consumeOneTimeToken hardening in @luckystack/core).\r\n const delResult = txResult[1];\r\n if (!delResult || delResult[0]) {\r\n return null;\r\n }\r\n\r\n const rawValue = getResult[1];\r\n if (typeof rawValue !== 'string') {\r\n return null;\r\n }\r\n\r\n const [parseErr, entry] = tryCatchSync(() => JSON.parse(rawValue) as OAuthStateEntry);\r\n if (parseErr || !entry || typeof entry.nonceHash !== 'string') {\r\n return null;\r\n }\r\n\r\n //? Constant-time compare of the two fixed-length (SHA-256) hex digests via\r\n //? `timingSafeEqual` (QUA-21). Both operands are 64-char hex strings, so their\r\n //? byte buffers are equal-length; a length mismatch (malformed stored value)\r\n //? falls through to reject. A mismatch means the browser completing the flow is\r\n //? NOT the one that started it — reject (F1).\r\n const incomingHash = Buffer.from(hashStateNonce(cookieNonce));\r\n const storedHash = Buffer.from(entry.nonceHash);\r\n if (incomingHash.length !== storedHash.length || !timingSafeEqual(incomingHash, storedHash)) {\r\n return null;\r\n }\r\n\r\n return entry;\r\n};\r\n\r\nconst asRecord = (value: unknown): Record<string, unknown> => {\r\n if (value && typeof value === 'object') {\r\n return value as Record<string, unknown>;\r\n }\r\n return {};\r\n};\r\n\r\n//? Read a string-ish field from an untyped provider JSON record. Strings pass\r\n//? through; finite numbers / booleans are coerced (preserving the previous\r\n//? `String(value)` behaviour for primitive fields); objects, arrays, null and\r\n//? undefined collapse to `fallback` instead of stringifying to `[object Object]`.\r\nconst readStringField = (record: Record<string, unknown>, key: string, fallback = ''): string => {\r\n const value = record[key];\r\n if (typeof value === 'string') return value;\r\n if (typeof value === 'number' && Number.isFinite(value)) return String(value);\r\n if (typeof value === 'boolean') return String(value);\r\n return fallback;\r\n};\r\n\r\n//? Random 6-digit hex color used as the avatar placeholder for users without\r\n//? an uploaded image. Shared by the credentials-register and OAuth-create paths.\r\nconst generateAvatarFallbackColor = (): string =>\r\n `#${Math.floor(Math.random() * 0xFF_FF_FF).toString(16).padStart(6, \"0\")}`;\r\n\r\n//? LOGIN-F18: check whether a user has an uploaded webp avatar on disk and\r\n//? return the filename when found. Used by both the credentials and OAuth login\r\n//? paths to avoid duplicating the async stat pattern.\r\nconst resolveUploadedAvatar = async (userId: string): Promise<string | null> => {\r\n const filePath = path.join(uploadsFolder(), `${userId}.webp`);\r\n const [, avatarStat] = await tryCatch(() => fsPromises.stat(filePath));\r\n return avatarStat ? `${userId}.webp` : null;\r\n};\r\n\r\nconst sanitizeUserForSession = <T extends { password?: unknown }>(user: T): Omit<T, 'password'> => {\r\n const { password: _password, ...safeUser } = user;\r\n return safeUser;\r\n};\r\n\r\n//? Fire-and-forget observational failure signal. Dispatched on every failed\r\n//? login/register/OAuth attempt so consumers can audit, feed a SIEM, or build\r\n//? per-account lockout (HOK-10). Never awaited and never throws — a failing\r\n//? handler must not change the auth outcome the user already got.\r\nconst emitLoginFailed = (payload: {\r\n email?: string;\r\n userId?: string;\r\n provider: string;\r\n reason: string;\r\n stage: 'login' | 'register' | 'oauth';\r\n //? DD-LOGIN-F5: optional resolved client IP — threaded from the HTTP auth\r\n //? route so the per-account lockout can build an IP+account composite key.\r\n requesterIp?: string;\r\n}): void => {\r\n void dispatchHook('loginFailed', payload);\r\n};\r\n\r\nconst toReasonKey = (error: unknown, fallback = 'api.internalServerError'): string => {\r\n if (typeof error === 'string' && error.length > 0) {\r\n return error;\r\n }\r\n\r\n if (error instanceof Error && error.message) {\r\n const message = error.message.toLowerCase();\r\n if (message.includes('authentication failed') || message.includes('scram failure')) {\r\n return 'api.internalServerError';\r\n }\r\n }\r\n\r\n return fallback;\r\n};\r\n\r\ninterface NormalizedCredentials {\r\n email: string;\r\n password: string;\r\n name: string | undefined;\r\n confirmPassword: string | undefined;\r\n}\r\n\r\nconst normalizeCredentials = (params: LoginOrRegisterParams): NormalizedCredentials => ({\r\n //? Don't HTML-escape password OR confirmPassword before bcrypt — neither\r\n //? reaches HTML, and they're memory-compared for equality during register.\r\n //? Escaping confirmPassword while leaving password raw made any password\r\n //? containing `& < > \" '` fail the `password !== confirmPassword` check\r\n //? (regression introduced in pass-1 fix #9 which only un-escaped password).\r\n //? Email is normalized (lowercase + trim) and validated via `isEmail`;\r\n //? HTML-escaping it would make legacy raw rows unfindable.\r\n email: (params.email ?? '').trim().toLowerCase(),\r\n password: params.password ?? '',\r\n name: params.name ? escape(params.name) : undefined,\r\n //? Empty-string confirmPassword must collapse to `undefined` so the\r\n //? register-vs-login dispatcher's truthiness check treats a blank field as\r\n //? \"absent\". `??` would keep the empty string (only nullish collapses), so the\r\n //? falsy-coercing `||` is deliberate here.\r\n // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional empty-string → undefined collapse\r\n confirmPassword: params.confirmPassword || undefined,\r\n});\r\n\r\n//? Shape-validate normalized credentials. `mode` decides whether the\r\n//? password-POLICY check runs:\r\n//? - `'register'` enforces the full policy (length, complexity, common-list,\r\n//? customValidator) — a new account MUST meet the active policy.\r\n//? - `'login'` does NOT run the policy (M-15). A login must accept ANY password\r\n//? string and let the bcrypt compare decide pass/fail. Running the policy on\r\n//? login is both a lockout-DoS vector (an attacker POSTing policy-violating\r\n//? passwords for a victim's email could otherwise feed the per-account\r\n//? failed-attempt counter) AND a correctness bug (tightening the policy would\r\n//? lock out existing users whose stored password no longer meets it). Empty /\r\n//? email-shape checks still run in both modes.\r\nconst validateCredentialsShape = (\r\n creds: NormalizedCredentials,\r\n mode: 'login' | 'register',\r\n): { status: false; reason: string } | null => {\r\n const authLimits = getProjectConfig().auth;\r\n if (!creds.email || !creds.password) return { status: false, reason: 'login.empty' };\r\n if (creds.email.length > authLimits.emailMaxLength) return { status: false, reason: 'login.emailCharacterLimit' };\r\n if (creds.name && creds.name.length > authLimits.nameMaxLength) return { status: false, reason: 'login.nameCharacterLimit' };\r\n if (!isEmail(creds.email)) return { status: false, reason: 'login.invalidEmailFormat' };\r\n if (mode === 'register') {\r\n //? Full password-policy check (length, complexity, common-list, customValidator).\r\n //? Policy lives in `projectConfig.auth.passwordPolicy`; the deprecated\r\n //? `passwordMinLength`/`passwordMaxLength` top-level fields are still read\r\n //? from the policy's `minLength`/`maxLength` defaults. REGISTER-only — see the\r\n //? function doc for why the login branch skips it (M-15).\r\n const passwordReason = validatePassword(creds.password);\r\n if (passwordReason) return { status: false, reason: passwordReason };\r\n }\r\n return null;\r\n};\r\n\r\nconst registerWithCredentials = async (\r\n {\r\n email,\r\n password,\r\n name,\r\n confirmPassword,\r\n }: {\r\n email: string;\r\n password: string;\r\n name: string;\r\n confirmPassword: string;\r\n },\r\n options?: { supersedeToken?: string },\r\n): Promise<CredentialsLoginResult> => {\r\n if (password !== confirmPassword) {\r\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.passwordNotMatch', stage: 'register' });\r\n return { status: false, reason: 'login.passwordNotMatch' };\r\n }\r\n\r\n const preRegisterResult = await dispatchHook('preRegister', { email, provider: 'credentials', name });\r\n if (preRegisterResult.stopped) {\r\n emitLoginFailed({ email, provider: 'credentials', reason: preRegisterResult.signal.errorCode, stage: 'register' });\r\n return { status: false, reason: preRegisterResult.signal.errorCode };\r\n }\r\n\r\n //? LOGIN-F15: apply the per-account lockout check before the email-existence\r\n //? lookup. Without this an attacker can call the register endpoint repeatedly\r\n //? for a victim's email and perform unbounded `resolveUserByEmail` probes —\r\n //? effectively bypassing the lockout by choosing the register code path. A\r\n //? locked account means the credentials flow is temporarily suspended for that\r\n //? email; the same sentinel applies regardless of which surface (login vs\r\n //? register) is being used. No-op when `rateLimiting.auth.enabled` is false.\r\n if (await isAccountLocked(email)) {\r\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.accountLocked', stage: 'register' });\r\n return { status: false, reason: 'login.accountLocked' };\r\n }\r\n\r\n const userAdapter = getUserAdapter();\r\n const [checkEmailError, checkEmailResponse] = await tryCatch(() =>\r\n resolveUserByEmail(userAdapter, { email, provider: 'credentials' })\r\n );\r\n if (checkEmailError) {\r\n getLogger().error('login: findByEmail failed during register', checkEmailError);\r\n const reason = toReasonKey(checkEmailError);\r\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'register' });\r\n return { status: false, reason };\r\n }\r\n if (checkEmailResponse) {\r\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.emailExists', stage: 'register' });\r\n return { status: false, reason: 'login.emailExists' };\r\n }\r\n\r\n const createNewUser = async () => {\r\n const salt = await genSalt(getProjectConfig().auth.bcryptRounds);\r\n const hashedPassword = await hash(password, salt);\r\n return await userAdapter.create({\r\n email,\r\n provider: 'credentials',\r\n name,\r\n password: hashedPassword,\r\n avatar: '',\r\n avatarFallback: generateAvatarFallbackColor(),\r\n language: getProjectConfig().defaultLanguage,\r\n });\r\n };\r\n\r\n const [createNewUserError, createNewUserResponse] = await tryCatch(createNewUser);\r\n if (createNewUserError) {\r\n //? LOGIN-F7: treat a unique-constraint violation as a TOCTOU race — another\r\n //? concurrent request created this row between our findFirst and our create.\r\n //? Under the `'unified'` strategy (where `email @unique` is recommended) this\r\n //? is the expected concurrent-registration signal; return `login.emailExists`\r\n //? so the client can prompt the user to log in instead.\r\n const errorMessage =\r\n createNewUserError instanceof Error ? createNewUserError.message : String(createNewUserError);\r\n const isUniqueViolation =\r\n errorMessage.includes('Unique constraint') ||\r\n errorMessage.includes('unique constraint') ||\r\n // Prisma error code P2002 covers all DB engines\r\n (createNewUserError as { code?: unknown }).code === 'P2002';\r\n if (isUniqueViolation) {\r\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.emailExists', stage: 'register' });\r\n return { status: false, reason: 'login.emailExists' };\r\n }\r\n const reason = toReasonKey(createNewUserError);\r\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'register' });\r\n return { status: false, reason };\r\n }\r\n if (!createNewUserResponse) {\r\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.createUserFailed', stage: 'register' });\r\n return { status: false, reason: 'login.createUserFailed' };\r\n }\r\n\r\n await dispatchHook('postRegister', { userId: createNewUserResponse.id, provider: 'credentials' });\r\n\r\n //? Auto-login after register (ARCHITECTURE_AUTH.md: both flows return\r\n //? `{ status, session, newToken }`). Mirrors the login branch: mint a token,\r\n //? persist the session, and hand the token back so the route can set the\r\n //? cookie / the client can store it and land on `loginRedirectUrl`.\r\n const newToken = randomBytes(32).toString('hex');\r\n const nowLogin = new Date();\r\n const newUser: SessionLayout = {\r\n ...sanitizeUserForSession(createNewUserResponse),\r\n token: newToken,\r\n lastLogin: nowLogin,\r\n previousLogin: null,\r\n };\r\n\r\n const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });\r\n if (!saved.ok) {\r\n //? Account exists but the session never persisted — surface the failure so\r\n //? the route does not report an authenticated state it cannot back up. The\r\n //? user can recover by logging in normally.\r\n emitLoginFailed({ email, userId: newUser.id, provider: 'credentials', reason: saved.errorCode, stage: 'register' });\r\n return { status: false, reason: saved.errorCode };\r\n }\r\n await dispatchHook('postLogin', { userId: newUser.id, provider: 'credentials', isNewUser: true, token: newToken });\r\n\r\n return {\r\n status: true,\r\n reason: 'login.userCreated',\r\n newToken,\r\n session: newUser,\r\n };\r\n};\r\n\r\nconst loginWithCredentialsCore = async (\r\n { email, password }: { email: string; password: string },\r\n //? DD-LOGIN-F5: `requesterIp` is optional so all existing callers stay\r\n //? compatible. The HTTP auth route resolves and passes the IP; socket paths\r\n //? omit it and fall back to the pure-account lockout bucket.\r\n options?: { supersedeToken?: string; requesterIp?: string },\r\n): Promise<CredentialsLoginResult> => {\r\n const requesterIp = options?.requesterIp;\r\n const preLoginResult = await dispatchHook('preLogin', { email, provider: 'credentials' });\r\n if (preLoginResult.stopped) {\r\n emitLoginFailed({ email, provider: 'credentials', reason: preLoginResult.signal.errorCode, stage: 'login', requesterIp });\r\n return { status: false, reason: preLoginResult.signal.errorCode };\r\n }\r\n\r\n //? Per-account brute-force lockout (F7 / MIS-017). When the auth rate-limit\r\n //? slot is enabled and this account has exhausted its failed-attempt budget,\r\n //? refuse the attempt BEFORE any bcrypt work — distributed credential stuffing\r\n //? against one account (which the per-IP throttle can't see) is throttled\r\n //? per-account here. The counter is incremented by the registered `loginFailed`\r\n //? handler (see `registerAuthLockoutHook`); merely checking the lock does not\r\n //? increment it. No-op when `rateLimiting.auth.enabled` is false (default).\r\n if (await isAccountLocked(email, requesterIp)) {\r\n emitLoginFailed({ email, provider: 'credentials', reason: 'login.accountLocked', stage: 'login', requesterIp });\r\n return { status: false, reason: 'login.accountLocked' };\r\n }\r\n\r\n const userAdapter = getUserAdapter();\r\n const [findUserError, findUserResponse] = await tryCatch(() =>\r\n resolveUserByEmail(userAdapter, { email, provider: 'credentials' })\r\n );\r\n if (findUserError) {\r\n getLogger().error('login: findByEmail failed', findUserError);\r\n const reason = toReasonKey(findUserError);\r\n emitLoginFailed({ email, provider: 'credentials', reason, stage: 'login', requesterIp });\r\n return { status: false, reason };\r\n }\r\n //? Anti-enumeration: a missing account and a missing/invalid password hash\r\n //? must be INDISTINGUISHABLE from a wrong password — same reason key AND\r\n //? similar timing. Returning a distinct `login.userNotFound` (and short-\r\n //? circuiting before any bcrypt work) let an attacker probe which addresses\r\n //? have credentials accounts via both the response key and a timing side\r\n //? channel. When there is no real hash to compare, run a bcrypt compare\r\n //? against a fixed dummy hash so the not-found path spends roughly the same\r\n //? CPU as a real wrong-password compare, then return the shared key.\r\n const passwordHash = findUserResponse?.password ?? null;\r\n if (!findUserResponse || !passwordHash) {\r\n const dummyHash = await getDummyBcryptHash();\r\n await tryCatch(() => compare(password, dummyHash));\r\n //? M3: AWAIT the failure dispatch on the counting (wrong-password) path so the\r\n //? per-account lockout increment (registered on `loginFailed`) lands BEFORE we\r\n //? respond — unlike the fire-and-forget `emitLoginFailed` used for observational\r\n //? failures. This closes the sequential check-then-increment TOCTOU (a retry now\r\n //? sees the updated counter). `dispatchHook` isolates handler errors, so this\r\n //? still can't change the auth outcome. A residual window remains for requests\r\n //? already in flight concurrently (bounded by the atomic per-IP cap) — inherent\r\n //? to any check-then-act gate.\r\n await dispatchHook('loginFailed', { email, userId: findUserResponse?.id, provider: 'credentials', reason: 'login.wrongPassword', stage: 'login', requesterIp });\r\n return { status: false, reason: 'login.wrongPassword' };\r\n }\r\n\r\n const [checkPasswordError, checkPasswordResponse] = await tryCatch(() =>\r\n compare(password, passwordHash)\r\n );\r\n if (checkPasswordError) {\r\n getLogger().error('login: bcrypt compare failed', checkPasswordError);\r\n const reason = toReasonKey(checkPasswordError);\r\n emitLoginFailed({ email, userId: findUserResponse.id, provider: 'credentials', reason, stage: 'login', requesterIp });\r\n return { status: false, reason };\r\n }\r\n if (!checkPasswordResponse) {\r\n //? M3: awaited on the counting path (see the no-account branch above) so the\r\n //? lockout increment lands before the response — closes the sequential TOCTOU.\r\n await dispatchHook('loginFailed', { email, userId: findUserResponse.id, provider: 'credentials', reason: 'login.wrongPassword', stage: 'login', requesterIp });\r\n return { status: false, reason: 'login.wrongPassword' };\r\n }\r\n\r\n const newToken = randomBytes(32).toString('hex');\r\n const previousLogin = findUserResponse.lastLogin ?? null;\r\n const nowLogin = new Date();\r\n\r\n // Best-effort lastLogin update — silently no-ops if the user adapter\r\n // (or User schema) doesn't accept the field.\r\n await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));\r\n\r\n const newUser: SessionLayout = {\r\n ...sanitizeUserForSession(findUserResponse),\r\n token: newToken,\r\n lastLogin: nowLogin,\r\n previousLogin,\r\n };\r\n\r\n const uploadedAvatar = await resolveUploadedAvatar(newUser.id);\r\n if (uploadedAvatar) {\r\n newUser.avatar = uploadedAvatar;\r\n }\r\n\r\n const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });\r\n if (!saved.ok) {\r\n //? Session never persisted (adapter blip / preSessionCreate veto). Fail the\r\n //? login so the route does NOT set a cookie or delete the prior session for\r\n //? a token that getSession() can't resolve.\r\n emitLoginFailed({ email, userId: newUser.id, provider: 'credentials', reason: saved.errorCode, stage: 'login', requesterIp });\r\n return { status: false, reason: saved.errorCode };\r\n }\r\n await dispatchHook('postLogin', { userId: newUser.id, provider: 'credentials', isNewUser: false, token: newToken });\r\n //? Reset the brute-force counter on success so earlier typos don't keep a\r\n //? legitimate user locked out (F7). No-op when the feature is disabled.\r\n //? Clear the IP+account composite key (DD-LOGIN-F5) AND the bare account key\r\n //? so that failures recorded without an IP (e.g. from a different surface) are\r\n //? also cleared after a confirmed legitimate login.\r\n void clearAuthFailures(email, requesterIp);\r\n if (requesterIp) void clearAuthFailures(email);\r\n if (isDevMode()) {\r\n getLogger().debug(`credentials login success for user ${newUser.id}`);\r\n }\r\n return { status: true, reason: 'login.loggedIn', newToken, session: newUser };\r\n};\r\n\r\n//? Thin dispatcher: keeps the existing single-entry HTTP surface but forwards\r\n//? to the dedicated register / login functions. `name && confirmPassword`\r\n//? present in the body means the client is registering; otherwise it's a\r\n//? login.\r\nconst loginWithCredentials = async (\r\n params: LoginOrRegisterParams,\r\n //? DD-LOGIN-F5: `requesterIp` forwarded to `loginWithCredentialsCore` to\r\n //? enable IP+account composite lockout keys on the HTTP auth path.\r\n options?: { supersedeToken?: string; requesterIp?: string },\r\n): Promise<CredentialsLoginResult> => {\r\n const creds = normalizeCredentials(params);\r\n\r\n if (isDevMode()) {\r\n getLogger().debug(`credentials auth attempt for ${creds.email || 'unknown-email'}`);\r\n }\r\n\r\n //? Decide the branch BEFORE shape-validation so the password-POLICY check only\r\n //? runs on register (M-15): `name && confirmPassword` present => register;\r\n //? otherwise login. A login must accept any password string (the bcrypt\r\n //? compare is the only authority), so an attacker can't trip a victim's\r\n //? lockout counter by POSTing policy-violating passwords for their email.\r\n //? Narrowing on the destructured locals keeps `name`/`confirmPassword` typed as\r\n //? `string` inside the register branch (no cast needed).\r\n const { name, confirmPassword } = creds;\r\n if (name && confirmPassword) {\r\n const validationError = validateCredentialsShape(creds, 'register');\r\n if (validationError) return validationError;\r\n //? Public-registration gate (F18). When `auth.allowRegistration === false`\r\n //? (invite-only / admin-provisioned apps) refuse the register branch with a\r\n //? dedicated reason key BEFORE any user lookup or hook. OAuth-driven\r\n //? first-login account creation is governed separately by the provider flow.\r\n //? Default `true` keeps today's open-registration behavior.\r\n if (!getProjectConfig().auth.allowRegistration) {\r\n emitLoginFailed({ email: creds.email, provider: 'credentials', reason: 'auth.registrationDisabled', stage: 'register' });\r\n return { status: false, reason: 'auth.registrationDisabled' };\r\n }\r\n return registerWithCredentials({\r\n email: creds.email,\r\n password: creds.password,\r\n name,\r\n confirmPassword,\r\n }, options);\r\n }\r\n\r\n const validationError = validateCredentialsShape(creds, 'login');\r\n if (validationError) return validationError;\r\n return loginWithCredentialsCore({ email: creds.email, password: creds.password }, options);\r\n};\r\n\r\nexport interface OAuthCallbackResult {\r\n token: string;\r\n redirectUrl: string;\r\n userId: string;\r\n provider: string;\r\n isNewUser: boolean;\r\n}\r\n\r\nconst isAllowedRedirectUrl = (url: string): boolean => {\r\n if (!URL.canParse(url, 'http://placeholder')) return false;\r\n //? Reject relative URLs whose path starts with a backslash or contains any\r\n //? backslash before the same-origin shortcut (SEC-12): `new URL('/\\\\evil.com',\r\n //? base)` keeps the placeholder origin and would pass the same-origin check,\r\n //? yet browsers normalise `/\\` → `//` (protocol-relative) → open-redirect to\r\n //? `evil.com`. A legitimate same-origin path never needs a backslash.\r\n if (url.includes('\\\\')) return false;\r\n const parsed = new URL(url, 'http://placeholder');\r\n if (parsed.origin === 'http://placeholder') {\r\n // relative URL — same-origin, always safe\r\n return true;\r\n }\r\n //? `allowedOrigins` is `string[] | (origin: string) => boolean` since we\r\n //? added the function-resolver variant. Branch on the shape.\r\n //? Defensive default at a security boundary (redirect-origin validation):\r\n //? the type is non-nullable, but a malformed consumer config could still\r\n //? hand us `undefined` at runtime, so the `?? []` fail-closed guard stays.\r\n //? `allowLocalhost` is the dev convenience that accepts any http://localhost:*\r\n //? origin without needing an explicit allowedOrigins entry. Safe in production\r\n //? because allowLocalhost defaults to false there.\r\n const cfg = getProjectConfig();\r\n if (cfg.http.cors.allowLocalhost && parsed.hostname === 'localhost') return true;\r\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime-defensive guard at a security boundary\r\n const allowed = cfg.http.cors.allowedOrigins ?? [];\r\n if (typeof allowed === 'function') {\r\n return allowed(parsed.origin);\r\n }\r\n return allowed.some((origin) => {\r\n if (!URL.canParse(origin)) return false;\r\n return new URL(origin).origin === parsed.origin;\r\n });\r\n};\r\n\r\nconst exchangeOAuthToken = async (\r\n provider: FullOAuthProvider,\r\n code: string,\r\n codeVerifier?: string,\r\n): Promise<string | null> => {\r\n //? `redirect_uri` is pinned to the registered, immutable `provider.callbackURL`\r\n //? — the SAME static config value the authorization request is built from.\r\n //? It is never derived from the incoming request, so there is no per-request\r\n //? drift to re-validate against (the authorization-time value and the\r\n //? exchange-time value are guaranteed identical by construction). Persisting\r\n //? it into the Redis state would be a behaviour-preserving no-op here; if the\r\n //? callback URL ever becomes request-/tenant-derived, store it alongside the\r\n //? state in `createOAuthState` and compare before exchange.\r\n //? `code_verifier` (PKCE, F11) is included ONLY when the provider opted in and\r\n //? a verifier was stored with the state — for every existing flow it is\r\n //? `undefined` and the exchange body is unchanged.\r\n const values = {\r\n code,\r\n client_id: provider.clientID,\r\n client_secret: provider.clientSecret,\r\n redirect_uri: provider.callbackURL,\r\n grant_type: 'authorization_code',\r\n ...(codeVerifier ? { code_verifier: codeVerifier } : {}),\r\n };\r\n\r\n //? `Promise<unknown>` return annotation: `response.json()` is `any`, and an\r\n //? explicit `unknown` boundary type forces every caller to narrow via\r\n //? `asRecord(...)` instead of leaking `any` through the token-exchange seam.\r\n const getToken = async (): Promise<unknown> => {\r\n if (provider.tokenExchangeMethod === 'json') {\r\n const response = await fetch(provider.tokenExchangeURL, {\r\n method: 'POST',\r\n headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\r\n body: JSON.stringify(values),\r\n });\r\n if (!response.ok) {\r\n getLogger().warn('oauth: token exchange returned non-OK status', { provider: provider.name, status: response.status });\r\n return null;\r\n }\r\n return await response.json();\r\n }\r\n //? `tokenExchangeMethod` is the closed union `'json' | 'form'`; the `json`\r\n //? branch returned above, so this is the exhaustive `form` case.\r\n const formParams = new URLSearchParams();\r\n formParams.append('client_id', provider.clientID);\r\n formParams.append('client_secret', provider.clientSecret);\r\n formParams.append('code', values.code);\r\n formParams.append('grant_type', 'authorization_code');\r\n formParams.append('redirect_uri', provider.callbackURL);\r\n if (codeVerifier) {\r\n formParams.append('code_verifier', codeVerifier);\r\n }\r\n\r\n if (isDevMode()) {\r\n //? NEVER stringify the raw form — it carries `client_secret` (a\r\n //? long-lived credential) and the single-use authorization `code`. Log\r\n //? only the param NAMES so the dev sees the shape without leaking secrets\r\n //? into the log sink.\r\n getLogger().debug('oauth: token-exchange form params', { paramNames: [...formParams.keys()] });\r\n }\r\n const response = await fetch(provider.tokenExchangeURL, {\r\n method: 'POST',\r\n headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },\r\n body: formParams.toString(),\r\n });\r\n if (!response.ok) {\r\n getLogger().warn('oauth: token exchange returned non-OK status', { provider: provider.name, status: response.status });\r\n return null;\r\n }\r\n return await response.json();\r\n };\r\n\r\n const [error, response] = await tryCatch(getToken);\r\n if (error) {\r\n getLogger().error('oauth: token exchange failed', error);\r\n return null;\r\n }\r\n const tokenData = asRecord(response);\r\n const accessToken = typeof tokenData.access_token === 'string' ? tokenData.access_token : '';\r\n if (!accessToken) {\r\n getLogger().warn('oauth: no access_token in token response', { provider: provider.name });\r\n return null;\r\n }\r\n return accessToken;\r\n};\r\n\r\ninterface OAuthProfile {\r\n email: string | undefined;\r\n name: string;\r\n avatar: string;\r\n /**\r\n * `true` only when the provider POSITIVELY reported the email as verified\r\n * (via `emailVerifiedKey`) or supplied it through a `getEmail` accessor that\r\n * itself filters on a verified flag (GitHub). `false` when the provider\r\n * exposes NO verified-email signal at all — used to fail-closed on\r\n * cross-provider account LINKING under the `'unified'` strategy (SEC-21/SEC-40),\r\n * where linking an unverified address to a victim's existing account is an\r\n * account-takeover vector. First-time account CREATION is unaffected.\r\n */\r\n emailVerified: boolean;\r\n /**\r\n * The RAW, un-stripped provider userInfo record. Threaded through so the\r\n * `extraSessionFields` hook receives every provider-specific claim (`sub`,\r\n * tenant id, custom claims) instead of the trimmed `{email,name,avatar}`\r\n * projection — the documented contract (SEC-3 / oauthProviders.ts hook docs).\r\n */\r\n rawUserData: OAuthUserData;\r\n}\r\n\r\nconst fetchOAuthProfile = async (\r\n provider: FullOAuthProvider,\r\n accessToken: string,\r\n): Promise<OAuthProfile | null> => {\r\n const getUserData = async (): Promise<unknown> => {\r\n const response = await fetch(provider.userInfoURL, {\r\n method: 'GET',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n 'Accept': 'application/json',\r\n 'Authorization': `Bearer ${accessToken}`,\r\n },\r\n });\r\n if (!response.ok) {\r\n getLogger().warn('oauth: userInfo fetch returned non-OK status', { provider: provider.name, status: response.status });\r\n return null;\r\n }\r\n return await response.json();\r\n };\r\n\r\n const [error, response] = await tryCatch(getUserData);\r\n if (error) {\r\n getLogger().error('oauth: userInfo fetch failed', error);\r\n return null;\r\n }\r\n\r\n const userData = asRecord(response);\r\n //? Provider responses are untyped JSON; read each field through `readString`\r\n //? so a non-string (object/number/missing) value falls back instead of\r\n //? stringifying to `[object Object]`.\r\n //? A present-but-empty name string falls back too (mirrors the old\r\n //? `String(value || 'didnt find a name')` truthy-coercion).\r\n const resolvedName = readStringField(userData, provider.nameKey);\r\n const name = resolvedName === '' ? 'didnt find a name' : resolvedName;\r\n const emailValue = userData[provider.emailKey];\r\n let email: string | undefined = typeof emailValue === 'string' ? emailValue : undefined;\r\n\r\n //? Track whether the provider POSITIVELY confirmed this email as verified.\r\n //? `emailVerifiedKey === true` (or any non-false value when the key is set and\r\n //? present) counts as verified; a provider with NO `emailVerifiedKey` exposes\r\n //? no signal, so the email stays `emailVerified: false` and only first-time\r\n //? account creation (not cross-provider linking) is allowed downstream.\r\n let emailVerified = false;\r\n\r\n //? Reject a provider-supplied email that the provider explicitly marks\r\n //? unverified (e.g. Discord's `verified: false`). A missing flag is treated\r\n //? as \"no signal\" (not verified, but not rejected outright — see the\r\n //? cross-provider link guard in `findOrCreateOAuthUser`). Defense-in-depth\r\n //? against account-linking takeover under `'unified'` (SEC-21).\r\n if (email && provider.emailVerifiedKey) {\r\n const verifiedFlag = userData[provider.emailVerifiedKey];\r\n //? Only a STRICT boolean `true` counts as verified. A present-but-not-true\r\n //? value (`false`, the strings \"false\"/\"0\", `0`, `null`) is an explicit\r\n //? not-verified signal → reject. This hardens against a CUSTOM provider that\r\n //? encodes the flag as a string/number (a truthy `\"false\"` previously slipped\r\n //? through as verified, re-opening cross-provider account-linking takeover).\r\n //? A MISSING flag (undefined) is \"no signal\" — not verified, not rejected here;\r\n //? the cross-provider link guard in findOrCreateOAuthUser handles it (SEC-21).\r\n if (verifiedFlag !== undefined && verifiedFlag !== true) {\r\n getLogger().warn('oauth: provider email is not verified — rejecting', { provider: provider.name });\r\n return null;\r\n }\r\n if (verifiedFlag === true) emailVerified = true;\r\n }\r\n\r\n //? Providers whose userinfo only returns an email once it is CONFIRMED (e.g.\r\n //? Facebook Graph /me) expose no separate verified flag — the presence of the\r\n //? email IS the verified signal. `emailImpliesVerified` opts such a provider in\r\n //? so cross-provider LINK + first-login CREATION both treat it as verified.\r\n if (email && provider.emailImpliesVerified) {\r\n emailVerified = true;\r\n }\r\n\r\n const avatarId = provider.avatarCodeKey ? userData[provider.avatarCodeKey] : undefined;\r\n const avatarValue = provider.avatarKey\r\n ? readStringField(userData, provider.avatarKey)\r\n : (provider.getAvatar\r\n ? await provider.getAvatar({ userData, avatarId: typeof avatarId === 'string' ? avatarId : '', accessToken })\r\n : '');\r\n const avatar = typeof avatarValue === 'string' ? avatarValue : '';\r\n\r\n //? Some providers (GitHub) don't return email in /userinfo — fall back to the\r\n //? provider's `getEmail` accessor. GitHub's accessor only ever selects a\r\n //? `verified === true` address (see `githubProvider.getEmail`), so an email\r\n //? obtained this way is treated as positively verified for the cross-provider\r\n //? link guard (SEC-21).\r\n if (!email && provider.getEmail) {\r\n const selectedEmail = await provider.getEmail(accessToken);\r\n if (!selectedEmail) {\r\n getLogger().warn('oauth: no email found via provider.getEmail', { provider: provider.name });\r\n return null;\r\n }\r\n email = selectedEmail;\r\n emailVerified = true;\r\n }\r\n\r\n //? Normalize OAuth emails through the SAME gate as credentials (trim +\r\n //? lowercase + `isEmail`) before they reach the adapter. Credentials emails\r\n //? are normalized at `normalizeCredentials`; taking the provider value\r\n //? verbatim meant `Victim@x.com` (OAuth) and `victim@x.com` (credentials)\r\n //? resolved to different rows — which under the `'unified'` account strategy\r\n //? (case-sensitive lookup) silently DEFEATS cross-provider linking. A\r\n //? present-but-malformed provider email is treated as \"no usable email\".\r\n if (email !== undefined) {\r\n const normalizedEmail = email.trim().toLowerCase();\r\n if (!isEmail(normalizedEmail)) {\r\n getLogger().warn('oauth: provider returned a malformed email', { provider: provider.name });\r\n return null;\r\n }\r\n email = normalizedEmail;\r\n }\r\n\r\n return { email, name, avatar, emailVerified, rawUserData: userData };\r\n};\r\n\r\ninterface ResolvedOAuthUser {\r\n user: SessionLayout;\r\n isNewUser: boolean;\r\n}\r\n\r\nconst findOrCreateOAuthUser = async (\r\n provider: FullOAuthProvider,\r\n profile: OAuthProfile,\r\n): Promise<ResolvedOAuthUser | null> => {\r\n const { email, name, avatar, emailVerified } = profile;\r\n if (!email) return null;\r\n\r\n const preLoginResult = await dispatchHook('preLogin', { email, provider: provider.name });\r\n if (preLoginResult.stopped) {\r\n getLogger().warn(`oauth login aborted by preLogin hook`, { errorCode: preLoginResult.signal.errorCode });\r\n return null;\r\n }\r\n\r\n const userAdapter = getUserAdapter();\r\n //? Under `'unified'` this links to an existing account with the same email\r\n //? created via ANY provider (incl. credentials); under `'per-provider'` it\r\n //? stays scoped to this provider (existing behavior).\r\n const [findError, findResponse] = await tryCatch(() =>\r\n resolveUserByEmail(userAdapter, { email, provider: provider.name })\r\n );\r\n if (findError) {\r\n getLogger().error('oauth: findByEmail failed', findError);\r\n return null;\r\n }\r\n\r\n if (findResponse?.id) {\r\n //? Fail-closed cross-provider account-LINK guard (SEC-21/SEC-40). Under the\r\n //? `'unified'` strategy `resolveUserByEmail` matches by email across every\r\n //? provider, so an OAuth sign-in can LINK into an account originally created\r\n //? by a DIFFERENT provider (incl. credentials). If the current provider did\r\n //? NOT positively verify the email (no `emailVerifiedKey` / `getEmail`\r\n //? verified-filter), an attacker who controls an unverified address equal to\r\n //? a victim's could take over that account. Refuse the link in that case —\r\n //? same-provider re-login and first-time account CREATION are unaffected, so\r\n //? existing flows for built-ins that DO verify (Google/GitHub/Discord) keep\r\n //? working. `'per-provider'` always matches within the same provider, so the\r\n //? cross-provider condition is never met there (no behaviour change).\r\n const existingProvider = findResponse.provider ?? null;\r\n //? Fail CLOSED on a missing provider field: treat unknown (null/undefined) as a\r\n //? cross-provider link so an unverified provider email can't link into an\r\n //? existing account whose record omits `provider` (a custom UserAdapter may not\r\n //? populate it). Same-provider re-login still matches (=== provider.name → not\r\n //? cross), and first-time creation is unaffected (this branch needs an existing id).\r\n const isCrossProviderLink = existingProvider !== provider.name;\r\n if (isCrossProviderLink && !emailVerified) {\r\n getLogger().warn(\r\n 'oauth: refusing to link an unverified provider email to an existing account created by a different provider (account-takeover guard)',\r\n { provider: provider.name, existingProvider },\r\n );\r\n return null;\r\n }\r\n\r\n //? LOGIN-F18 (OAuth path): same async stat lookup via the shared helper.\r\n const uploadedAvatar = await resolveUploadedAvatar(findResponse.id);\r\n if (uploadedAvatar) {\r\n findResponse.avatar = uploadedAvatar;\r\n }\r\n\r\n const previousLogin = findResponse.lastLogin ?? null;\r\n const nowLogin = new Date();\r\n await tryCatch(() => userAdapter.update(findResponse.id, { lastLogin: nowLogin }));\r\n\r\n return {\r\n user: {\r\n ...sanitizeUserForSession(findResponse),\r\n token: '',\r\n lastLogin: nowLogin,\r\n previousLogin,\r\n },\r\n isNewUser: false,\r\n };\r\n }\r\n\r\n //? Fail-closed account-CREATION guard (M2), symmetric with the cross-provider\r\n //? LINK guard above. When NO account exists, a provider that did not positively\r\n //? verify the email (no `emailVerifiedKey === true` / `getEmail` /\r\n //? `emailImpliesVerified`) must not be able to seed a brand-new account bound to\r\n //? an address the signer-in may not own — a custom, non-verifying provider would\r\n //? otherwise let an attacker SQUAT a victim's email (blocking the victim's later\r\n //? signup with `login.emailExists` and, under `'unified'`, absorbing the victim's\r\n //? later verified sign-in into the attacker-seeded row). All built-in providers\r\n //? set `emailVerified` (Google/GitHub/Microsoft/Discord/Facebook), so this only\r\n //? refuses genuinely-unverified CUSTOM providers; such a provider must verify the\r\n //? email (set `emailVerifiedKey` / `getEmail` / `emailImpliesVerified`) to enable\r\n //? OAuth signup, rather than the framework trusting it by default.\r\n if (!emailVerified) {\r\n getLogger().warn(\r\n 'oauth: refusing first-login account creation for an unverified provider email (account-squatting guard)',\r\n { provider: provider.name },\r\n );\r\n return null;\r\n }\r\n\r\n //? Gate OAuth first-login account CREATION on the same `auth.allowRegistration`\r\n //? flag the credentials register branch enforces. Without this, invite-only /\r\n //? admin-provisioned deployments (allowRegistration:false) are silently\r\n //? bypassable: anyone with an enabled provider's account completes the OAuth\r\n //? flow and gets a fresh account. Existing users hit the find-branch above and\r\n //? are unaffected; only NEW-account creation is refused. A consumer preRegister\r\n //? hook can still veto, but the documented gate must hold by construction.\r\n if (!getProjectConfig().auth.allowRegistration) {\r\n getLogger().warn('oauth: registration disabled — refusing first-login account creation');\r\n return null;\r\n }\r\n\r\n const preRegisterResult = await dispatchHook('preRegister', { email, provider: provider.name, name });\r\n if (preRegisterResult.stopped) {\r\n getLogger().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });\r\n return null;\r\n }\r\n\r\n const createNewUser = async () => userAdapter.create({\r\n email,\r\n provider: provider.name,\r\n name,\r\n avatar,\r\n avatarFallback: generateAvatarFallbackColor(),\r\n language: getProjectConfig().defaultLanguage,\r\n });\r\n const [createError, createResponse] = await tryCatch(createNewUser);\r\n if (createError) {\r\n getLogger().error('oauth: user create failed', createError);\r\n return null;\r\n }\r\n if (!createResponse) return null;\r\n\r\n return {\r\n user: { ...sanitizeUserForSession(createResponse), token: '' },\r\n isNewUser: true,\r\n };\r\n};\r\n\r\nconst resolvePostLoginRedirect = async ({\r\n fallbackUrl,\r\n userId,\r\n providerName,\r\n isNewUser,\r\n}: {\r\n fallbackUrl: string;\r\n userId: string;\r\n providerName: string;\r\n isNewUser: boolean;\r\n}): Promise<string> => {\r\n const resolver = getPostLoginRedirect();\r\n if (!resolver) return fallbackUrl;\r\n\r\n const [resolverError, resolved] = await tryCatch(() => resolver({\r\n userId,\r\n provider: providerName,\r\n isNewUser,\r\n defaultUrl: fallbackUrl,\r\n }));\r\n if (resolverError) {\r\n getLogger().warn(`[oauth] postLoginRedirect resolver threw`, { message: resolverError.message });\r\n return fallbackUrl;\r\n }\r\n if (resolved && isAllowedRedirectUrl(resolved)) return resolved;\r\n if (resolved) {\r\n getLogger().warn(\r\n `[oauth] postLoginRedirect returned a URL not in allowed origins — falling back`,\r\n { resolved, fallbackUrl },\r\n );\r\n }\r\n return fallbackUrl;\r\n};\r\n\r\n//? Whitelist the URL path segment against registered providers and verify the\r\n//? request carries a URL. Returns the matched full provider + its canonical name,\r\n//? or null when the segment is absent, unregistered, or an incomplete provider\r\n//? shape. Separating this keeps the callback routing concern out of the main flow.\r\nconst resolveProviderFromCallback = (\r\n pathname: string,\r\n url: string | undefined,\r\n): { provider: FullOAuthProvider; providerName: string } | null => {\r\n const rawProviderSegment = pathname.split('/')[3]; // google/github/etc.\r\n if (!rawProviderSegment || !url) return null;\r\n const found = getOAuthProviders().find(p => p.name === rawProviderSegment);\r\n if (!found || !isFullOAuthProvider(found)) return null;\r\n return { provider: found, providerName: found.name };\r\n};\r\n\r\n//? Merge per-provider runtime extras (calendar tokens, tenant ids, etc.) into\r\n//? the session object IN PLACE before it is persisted. Errors are non-fatal —\r\n//? a failing or absent hook must not block the user from signing in (SEC-10).\r\nconst applyExtraSessionFields = async (\r\n user: SessionLayout,\r\n provider: FullOAuthProvider,\r\n profile: OAuthProfile,\r\n accessToken: string,\r\n): Promise<void> => {\r\n const { extraSessionFields } = provider;\r\n if (!extraSessionFields) return;\r\n\r\n //? Pass the RAW provider userData (SEC-3) so the hook sees every provider\r\n //? claim (`sub`, tenant id, custom claims) rather than the trimmed projection.\r\n const [extraErr, extra] = await tryCatch(async () =>\r\n extraSessionFields({ userData: profile.rawUserData, accessToken }),\r\n );\r\n if (extraErr) {\r\n getLogger().warn(`[oauth:${provider.name}] extraSessionFields hook threw — continuing without extras`, { err: extraErr });\r\n return;\r\n }\r\n if (!extra) return;\r\n\r\n //? Strip framework-owned session keys before merging (SEC-10).\r\n const { id, token, csrfToken, password, ...safeExtra } = extra;\r\n if (id !== undefined || token !== undefined || csrfToken !== undefined || password !== undefined) {\r\n getLogger().warn(\r\n `[oauth:${provider.name}] extraSessionFields returned framework-owned key(s) (id/token/csrfToken/password) — dropped`,\r\n );\r\n }\r\n //? SEC: warn when extra fields appear to contain bearer credentials. Access\r\n //? tokens stored in the Redis session are broadcast to connected sockets via\r\n //? updateSession — they should not be in session fields unless intentional.\r\n //? The stripped set above already removes \"token\"; this covers other key names.\r\n const CREDENTIAL_PATTERN = /token|secret|key|auth/i;\r\n const suspiciousKeys = Object.keys(safeExtra).filter(k => CREDENTIAL_PATTERN.test(k));\r\n if (suspiciousKeys.length > 0) {\r\n getLogger().warn(\r\n `[oauth:${provider.name}] extraSessionFields contains field(s) that look like credentials (${suspiciousKeys.join(', ')}) — these will be stored in the Redis session and broadcast to connected sockets. Consider keeping them server-side only.`,\r\n );\r\n }\r\n Object.assign(user, safeExtra);\r\n};\r\n\r\n//? Assemble the fallback redirect URL for the OAuth callback by preferring the\r\n//? tamper-proof return URL stored server-side with the OAuth state, then the\r\n//? caller-supplied default, then the configured `loginRedirectUrl`, then '/'.\r\nconst resolveOAuthFallbackUrl = (\r\n stateEntry: OAuthStateEntry,\r\n options: { defaultRedirectUrl?: string },\r\n): string => {\r\n const stateReturnUrl =\r\n stateEntry.returnUrl && isAllowedRedirectUrl(stateEntry.returnUrl)\r\n ? stateEntry.returnUrl\r\n : undefined;\r\n\r\n return (\r\n stateReturnUrl\r\n ?? options.defaultRedirectUrl\r\n ?? getProjectConfig().loginRedirectUrl\r\n ?? '/'\r\n );\r\n};\r\n\r\n// Route that handles the callback from the OAuth provider\r\nconst loginCallback = async (\r\n pathname: string,\r\n req: IncomingMessage,\r\n _res: ServerResponse,\r\n options: { defaultRedirectUrl?: string; supersedeToken?: string } = {},\r\n): Promise<OAuthCallbackResult | false> => {\r\n const providerResolution = resolveProviderFromCallback(pathname, req.url);\r\n if (!providerResolution) return false;\r\n const { provider, providerName } = providerResolution;\r\n\r\n // req.url is guaranteed non-null here: resolveProviderFromCallback returns null when url is absent\r\n const queryString = (req.url ?? '').split('?')[1] ?? '';\r\n const params = new URLSearchParams(queryString);\r\n const code = params.get('code');\r\n const state = params.get('state');\r\n\r\n //? Browser-binding (F1): the nonce the server set as the `ls-oauth-state`\r\n //? cookie at the authorize step must round-trip back and match the value\r\n //? stored alongside the Redis state entry. A missing/mismatched cookie means\r\n //? this browser did NOT start the flow — reject before any token exchange.\r\n const cookieNonce = getCookieValue(req.headers.cookie, OAUTH_STATE_COOKIE_NAME) ?? '';\r\n const stateEntry = await consumeOAuthState(provider.name, state ?? '', cookieNonce);\r\n if (!stateEntry) {\r\n getLogger().warn('oauth: invalid, missing, or unbound state', { provider: provider.name });\r\n emitLoginFailed({ provider: providerName, reason: 'oauth.invalidState', stage: 'oauth' });\r\n return false;\r\n }\r\n\r\n if (!code) {\r\n getLogger().warn('oauth: no code provided in callback url', { provider: provider.name });\r\n emitLoginFailed({ provider: providerName, reason: 'oauth.noCode', stage: 'oauth' });\r\n return false;\r\n }\r\n\r\n //? PKCE (F11): replay the verifier stored with the state entry at token\r\n //? exchange when the provider opted in. `undefined` for non-PKCE providers,\r\n //? in which case the exchange is byte-identical to before.\r\n const accessToken = await exchangeOAuthToken(provider, code, stateEntry.codeVerifier);\r\n if (!accessToken) {\r\n emitLoginFailed({ provider: providerName, reason: 'oauth.tokenExchangeFailed', stage: 'oauth' });\r\n return false;\r\n }\r\n\r\n const profile = await fetchOAuthProfile(provider, accessToken);\r\n if (!profile) {\r\n emitLoginFailed({ provider: providerName, reason: 'oauth.profileFetchFailed', stage: 'oauth' });\r\n return false;\r\n }\r\n\r\n const resolved = await findOrCreateOAuthUser(provider, profile);\r\n if (!resolved) {\r\n emitLoginFailed({ provider: providerName, email: profile.email, reason: 'oauth.userResolveFailed', stage: 'oauth' });\r\n return false;\r\n }\r\n\r\n const newToken = randomBytes(32).toString('hex');\r\n resolved.user.token = newToken;\r\n\r\n //? Per-provider runtime extras (calendar tokens, tenant ids, etc.) are merged\r\n //? into the session BEFORE save. Errors are non-fatal — see applyExtraSessionFields.\r\n await applyExtraSessionFields(resolved.user, provider, profile, accessToken);\r\n\r\n const saved = await saveSession(newToken, resolved.user, true, { supersedeToken: options.supersedeToken });\r\n if (!saved.ok) {\r\n //? Session never persisted — abort the OAuth callback so authCallbackRoute\r\n //? returns \"Login failed\" instead of redirecting with a dead token.\r\n getLogger().error(`[oauth:${providerName}] saveSession failed — aborting callback`, { errorCode: saved.errorCode });\r\n emitLoginFailed({ provider: providerName, userId: resolved.user.id, reason: saved.errorCode, stage: 'oauth' });\r\n return false;\r\n }\r\n\r\n if (resolved.isNewUser) {\r\n await dispatchHook('postRegister', { userId: resolved.user.id, provider: providerName });\r\n }\r\n await dispatchHook('postLogin', {\r\n userId: resolved.user.id,\r\n provider: providerName,\r\n isNewUser: resolved.isNewUser,\r\n token: newToken,\r\n });\r\n\r\n if (isDevMode()) {\r\n getLogger().debug(`oauth login success for user ${resolved.user.id}`);\r\n }\r\n\r\n const fallbackUrl = resolveOAuthFallbackUrl(stateEntry, options);\r\n const redirectUrl = await resolvePostLoginRedirect({\r\n fallbackUrl,\r\n userId: resolved.user.id,\r\n providerName,\r\n isNewUser: resolved.isNewUser,\r\n });\r\n\r\n return {\r\n token: newToken,\r\n redirectUrl,\r\n userId: resolved.user.id,\r\n provider: providerName,\r\n isNewUser: resolved.isNewUser,\r\n };\r\n};\r\n\r\nexport { loginWithCredentials, loginCallback, registerWithCredentials, loginWithCredentialsCore }\r\n","//? @adr 0019 — Email uniqueness is opt-in and governed by this knob: the default\r\n//? `'per-provider'` strategy INTENTIONALLY does not require `email @unique`\r\n//? (same address across providers = separate rows); only `'unified'` needs the\r\n//? consumer to add the constraint. A missing `@unique` on `User.email` is\r\n//? by-design, not a bug — see ADR 0019 before flagging it.\r\n//?\r\n//? Implements `auth.providerAccountStrategy` (CFG-04). The knob was declared\r\n//? and documented but no code read it — every lookup was provider-scoped, so\r\n//? `'unified'` silently behaved like `'per-provider'` and produced duplicate\r\n//? accounts for the same email across providers.\r\n//?\r\n//? - `'per-provider'` (default): resolve a user by (email, provider). The\r\n//? same address via Google and GitHub is two separate User rows. No schema\r\n//? change required.\r\n//? - `'unified'`: resolve a user by email alone (across providers) so the\r\n//? address maps to a single User; a sign-in via a new provider LINKS to the\r\n//? existing account instead of creating a duplicate. Requires the User\r\n//? schema's `email` to be unique (see @luckystack/login README — \"Unified\r\n//? account strategy\").\r\n//?\r\n//? Custom `UserAdapter`s that predate this feature may not implement\r\n//? `findByEmailAnyProvider`. When `'unified'` is configured but the adapter\r\n//? lacks it, we log ONCE and fall back to provider-scoped lookup so the project\r\n//? degrades loudly rather than silently mis-resolving.\r\n\r\nimport { getProjectConfig, getLogger } from '@luckystack/core';\r\n\r\nimport type { UserAdapter, UserRecord } from './userAdapter';\r\n\r\nlet warnedMissingAnyProvider = false;\r\n\r\n/**\r\n * Resolve a user account honoring the configured `providerAccountStrategy`.\r\n * Use this everywhere the login flow needs \"does an account for this email\r\n * exist?\" — register dedupe, credentials login, and OAuth find-or-create.\r\n */\r\nexport const resolveUserByEmail = async (\r\n adapter: UserAdapter,\r\n { email, provider }: { email: string; provider: string },\r\n): Promise<UserRecord | null> => {\r\n const strategy = getProjectConfig().auth.providerAccountStrategy;\r\n\r\n if (strategy === 'unified') {\r\n if (adapter.findByEmailAnyProvider) {\r\n return adapter.findByEmailAnyProvider({ email });\r\n }\r\n if (!warnedMissingAnyProvider) {\r\n warnedMissingAnyProvider = true;\r\n getLogger().warn(\r\n '[login] auth.providerAccountStrategy is \"unified\" but the registered UserAdapter does not implement findByEmailAnyProvider — falling back to provider-scoped lookup (duplicate accounts per provider are still possible). Implement findByEmailAnyProvider on your adapter to enable unified accounts.',\r\n );\r\n }\r\n }\r\n\r\n return adapter.findByEmail({ email, provider });\r\n};\r\n\r\n/** Test-only: reset the one-time missing-method warning latch. */\r\nexport const resetAccountStrategyWarningForTests = (): void => {\r\n warnedMissingAnyProvider = false;\r\n};\r\n","//? Password-policy enforcement for credentials auth. Read by\r\n//? `registerWithCredentials` and `updatePasswordHash` to validate plaintext\r\n//? passwords against the consumer-tunable `projectConfig.auth.passwordPolicy`.\r\n//?\r\n//? Returns null when the password passes every check, or a reason-key\r\n//? (i18n-style errorCode) describing the first failure. Callers surface\r\n//? the reason to the client; the framework's i18n layer handles\r\n//? translation.\r\n\r\nimport { getProjectConfig } from '@luckystack/core';\r\n\r\nimport { COMMON_PASSWORDS } from './data/commonPasswords';\r\n\r\nconst UPPERCASE_REGEX = /[A-Z]/;\r\nconst LOWERCASE_REGEX = /[a-z]/;\r\nconst NUMBER_REGEX = /\\d/;\r\nconst SPECIAL_REGEX = /[^A-Za-z0-9]/;\r\n\r\n//? bcrypt silently truncates at 72 utf8-encoded bytes. Anything over 72 bytes\r\n//? hashes identically to its 72-byte prefix, so a longer password gives no\r\n//? extra security and actively misleads the user. Cap at 72 bytes so the\r\n//? stored hash always reflects the full secret the user typed.\r\nconst BCRYPT_MAX_BYTES = 72;\r\n\r\n/**\r\n * Validate `password` against the active project's password policy.\r\n * Returns null on success, a string reason-key on the first failure.\r\n *\r\n * The policy lives in `projectConfig.auth.passwordPolicy` and is read at\r\n * call time, so consumers can override defaults via `registerProjectConfig`.\r\n */\r\nexport const validatePassword = (password: string): string | null => {\r\n const policy = getProjectConfig().auth.passwordPolicy;\r\n\r\n if (password.length < policy.minLength) return 'login.passwordCharacterMinimum';\r\n //? Byte-length cap takes precedence over the policy maxLength when the policy\r\n //? exceeds BCRYPT_MAX_BYTES — reject before bcrypt silently truncates.\r\n const byteLength = Buffer.byteLength(password, 'utf8');\r\n if (byteLength > BCRYPT_MAX_BYTES) return 'login.passwordCharacterLimit';\r\n if (password.length > policy.maxLength) return 'login.passwordCharacterLimit';\r\n if (policy.requireUppercase && !UPPERCASE_REGEX.test(password)) return 'login.passwordRequiresUppercase';\r\n if (policy.requireLowercase && !LOWERCASE_REGEX.test(password)) return 'login.passwordRequiresLowercase';\r\n if (policy.requireNumber && !NUMBER_REGEX.test(password)) return 'login.passwordRequiresNumber';\r\n if (policy.requireSpecial && !SPECIAL_REGEX.test(password)) return 'login.passwordRequiresSpecial';\r\n if (policy.forbidCommon && COMMON_PASSWORDS.has(password.toLowerCase())) return 'login.passwordTooCommon';\r\n if (policy.customValidator) {\r\n const customReason = policy.customValidator(password);\r\n if (customReason) return customReason;\r\n }\r\n\r\n return null;\r\n};\r\n","//? Built-in common-password blocklist. Sourced from the public-domain\r\n//? SecLists Top-passwords list (Daniel Miessler), trimmed to the 200 most\r\n//? common entries to keep the package size small. Consumers needing a\r\n//? larger list (10k+ entries) can supply one through\r\n//? `projectConfig.auth.passwordPolicy.customValidator` and check their\r\n//? own data source there.\r\n//?\r\n//? Lookups are case-insensitive (the validator lower-cases before\r\n//? checking). Adding additional entries here is a non-breaking change.\r\n\r\nconst COMMON_PASSWORD_LIST: readonly string[] = [\r\n '123456', 'password', '12345678', 'qwerty', '123456789', '12345', '1234', '111111',\r\n '1234567', 'dragon', '123123', 'baseball', 'abc123', 'football', 'monkey', 'letmein',\r\n '696969', 'shadow', 'master', '666666', 'qwertyuiop', '123321', 'mustang', '1234567890',\r\n 'michael', '654321', 'pussy', 'superman', '1qaz2wsx', '7777777', 'fuckyou', '121212',\r\n '000000', 'qazwsx', '123qwe', 'killer', 'trustno1', 'jordan', 'jennifer', 'zxcvbnm',\r\n 'asdfgh', 'hunter', 'buster', 'soccer', 'harley', 'batman', 'andrew', 'tigger',\r\n 'sunshine', 'iloveyou', 'fuckme', '2000', 'charlie', 'robert', 'thomas', 'hockey',\r\n 'ranger', 'daniel', 'starwars', 'klaster', '112233', 'george', 'asshole', 'computer',\r\n 'michelle', 'jessica', 'pepper', '1111', 'zxcvbn', '555555', '11111111', '131313',\r\n 'freedom', '777777', 'pass', 'fuck', 'maggie', '159753', 'aaaaaa', 'ginger',\r\n 'princess', 'joshua', 'cheese', 'amanda', 'summer', 'love', 'ashley', '6969',\r\n 'nicole', 'chelsea', 'biteme', 'matthew', 'access', 'yankees', '987654321', 'dallas',\r\n 'austin', 'thunder', 'taylor', 'matrix', 'william', 'corvette', 'hello', 'martin',\r\n 'heather', 'secret', 'fucker', 'merlin', 'diamond', '1234qwer', 'gfhjkm', 'hammer',\r\n 'silver', 'richard', 'samantha', 'iceman', 'tigers', 'purple', 'andrea', 'horney',\r\n 'dakota', 'aaaaa', 'player', 'sucker', 'mercedes', 'whatever', 'orange', 'qweqwe',\r\n 'mickey', 'panties', 'hannah', 'fucking', 'paul', 'pakistan', 'patrick', 'martha',\r\n 'morgan', 'iwantu', 'lakers', 'rachel', 'slayer', 'scott', '2112', 'fish',\r\n 'porn', 'matt', 'qwert', 'cookie', 'eagle1', 'london', 'phoenix', 'chicago',\r\n 'badboy', 'braves', 'yankee', 'lover', 'barney', 'edward', 'raiders', 'green',\r\n 'maverick', 'rangers', 'joseph', 'maddog', 'mike', 'jasmine', 'arsenal', 'beach',\r\n 'tiger', 'snoopy', 'natasha', 'matthews', 'jackson', 'cumshot', 'chevy', 'gateway',\r\n 'gators', 'angel', 'junior', 'samson', '6666', 'amateur', 'gemini', 'apples',\r\n 'august', 'biscuit', '5150', 'apollo', 'parker', 'qwerty123', 'rabbit', 'angela',\r\n 'love123', 'rocket', 'scooby', 'beaver', 'oliver', 'walter', 'blowme', 'spider',\r\n '4321', 'cumshot1', 'lucky', 'helpme', 'jackie', 'monica',\r\n];\r\n\r\n/** Frozen Set for O(1) `.has()` lookups; entries are pre-lowercased. */\r\nexport const COMMON_PASSWORDS: ReadonlySet<string> = new Set(\r\n COMMON_PASSWORD_LIST.map((p) => p.toLowerCase()),\r\n);\r\n","//? Password-reset primitives. Used by the framework's own forgot-password\r\n//? flow when `ProjectConfig.auth.forgotPassword === 'framework'`, AND\r\n//? exported as building blocks for consumers who picked `'custom'` and want\r\n//? to wire their own UI / email layer without re-implementing crypto + Redis.\r\n//?\r\n//? Token format: 32 random bytes hex-encoded → 64-char URL-safe string.\r\n//? Storage: `<projectName>-pwreset:<sha256(token)> → userId` with a configurable\r\n//? TTL (`auth.passwordResetTtlSeconds`, default 3600 = 1 hour).\r\n//?\r\n//? HASHED AT REST (0.2.0): the raw token is NEVER stored — we persist only\r\n//? `sha256(token)` (the Redis key) via the `@luckystack/core` one-time-token\r\n//? primitive, so a leaked Redis keyspace can't be replayed to mint a reset.\r\n//? The raw token is returned to the caller exactly once for the emailed URL.\r\n\r\n// eslint-disable-next-line import-x/no-named-as-default -- bcryptjs ships both a default and namespace export; using the default mirrors its README\r\nimport bcrypt from 'bcryptjs';\r\n/* eslint-disable import-x/no-named-as-default-member -- accessing genSalt/hash/compare via the default import is the documented pattern */\r\n\r\nimport { getProjectConfig, issueOneTimeToken, consumeOneTimeToken, formatKey, redis, oneTimeTokenKey } from '@luckystack/core';\r\n\r\nimport { validatePassword } from './passwordPolicy';\r\nimport { getUserAdapter } from './userAdapter';\r\n\r\n//? Shared key namespace for the one-time-token primitive. `formatKey('-pwreset',\r\n//? sha256(token))` is applied inside the primitive, so the project-name prefix /\r\n//? multi-tenant formatter still apply — only the SUFFIX is now the hash, not the\r\n//? raw token (the historical `<projectName>-pwreset:<token>` shape becomes\r\n//? `<projectName>-pwreset:<sha256(token)>`).\r\nconst PWRESET_NAMESPACE = '-pwreset';\r\n\r\n//? Per-user pointer key that records the hash-key of the currently-active reset\r\n//? token (LOGIN-F16). On each new issue we delete the prior hash key (if any) so\r\n//? at most one reset link per user is redeemable at a time — multiple live links\r\n//? until TTL is a session-hijack / takeover risk if the first link is intercepted.\r\nconst pwresetUserKey = (userId: string): string => formatKey('-pwreset-user', userId);\r\n\r\n/**\r\n * Create a one-time password-reset token bound to a user id. Any previously\r\n * issued (but not yet consumed) token for the same user is invalidated first\r\n * (LOGIN-F16). Stored in Redis with the configured TTL — only `sha256(token)`\r\n * is persisted (hash-at-rest). Returns the raw token string — caller emails it\r\n * to the user (typically embedded in a URL).\r\n */\r\nexport const createPasswordResetToken = async (userId: string): Promise<string> => {\r\n const ttl = getProjectConfig().auth.passwordResetTtlSeconds;\r\n\r\n //? Invalidate the prior active token before issuing the new one (LOGIN-F16).\r\n //? The pointer key holds the exact Redis key of the prior token hash, so we\r\n //? can delete it without possessing the raw token.\r\n const priorKey = await redis.get(pwresetUserKey(userId));\r\n if (priorKey) {\r\n await redis.del(priorKey);\r\n }\r\n\r\n const handle = issueOneTimeToken(PWRESET_NAMESPACE, ttl, userId);\r\n await handle.store();\r\n\r\n //? Record the new hash-key so the NEXT issue can invalidate this one.\r\n //? TTL matches the token so the pointer never outlives what it points to.\r\n //? `oneTimeTokenKey(namespace, rawToken)` derives the same Redis key the\r\n //? primitive stored — we never persist the raw token itself.\r\n const newHashKey = oneTimeTokenKey(PWRESET_NAMESPACE, handle.token);\r\n await redis.set(pwresetUserKey(userId), newHashKey, 'EX', ttl);\r\n\r\n return handle.token;\r\n};\r\n\r\n/**\r\n * Validate and consume a password-reset token. Returns the bound userId on\r\n * success and removes the token (one-time use, atomic GET+DEL). Returns null\r\n * when the token is missing, expired, malformed, or already consumed.\r\n */\r\nexport const consumePasswordResetToken = async (token: string): Promise<string | null> => {\r\n return consumeOneTimeToken(PWRESET_NAMESPACE, token);\r\n};\r\n\r\n/**\r\n * Bcrypt-hash a plaintext password and write it to the user record via the\r\n * registered user adapter. Used by the reset flow and by the settings\r\n * password-change flow. Validates against the active password policy\r\n * (`projectConfig.auth.passwordPolicy`) before hashing — throws a `PasswordPolicyError`\r\n * when the plaintext fails policy. Catch and surface the error code to the\r\n * client; framework-mode reset/change flows already do this.\r\n *\r\n * ADR — DD-LOGIN-F17: this primitive intentionally does NOT revoke sessions.\r\n * Session revocation is the responsibility of each calling route because the\r\n * right behaviour differs per use-case:\r\n * - `reset-password/confirmReset_v1` revokes ALL sessions (`revokeUserSessions(userId, null)`)\r\n * because a forgot-password reset means the old credential is compromised.\r\n * - `settings/changePassword_v1` revokes every OTHER session but keeps the\r\n * current one active (`revokeUserSessions(userId, user.token)`) so the\r\n * user is not kicked from the device they changed the password on.\r\n * - Custom consumers may want a third behaviour (e.g. re-use the existing\r\n * token after an admin password-force) — keeping the primitive pure lets\r\n * them compose it freely.\r\n * If you call this function directly, YOU must call `revokeUserSessions`\r\n * afterwards unless you have a deliberate reason not to.\r\n */\r\nexport const updatePasswordHash = async (userId: string, plaintext: string): Promise<void> => {\r\n const reason = validatePassword(plaintext);\r\n if (reason) {\r\n throw new PasswordPolicyError(reason);\r\n }\r\n const salt = await bcrypt.genSalt(getProjectConfig().auth.bcryptRounds);\r\n const hashedPassword = await bcrypt.hash(plaintext, salt);\r\n const patch: Record<string, unknown> = { password: hashedPassword };\r\n await getUserAdapter().update(userId, patch);\r\n};\r\n\r\n/**\r\n * Thrown by `updatePasswordHash` when the supplied plaintext violates the\r\n * active password policy. `errorCode` matches the i18n reason key the rest\r\n * of the login flow uses (e.g. `login.passwordRequiresUppercase`).\r\n */\r\nexport class PasswordPolicyError extends Error {\r\n readonly errorCode: string;\r\n constructor(errorCode: string) {\r\n super(`Password policy violation: ${errorCode}`);\r\n this.name = 'PasswordPolicyError';\r\n this.errorCode = errorCode;\r\n }\r\n}\r\n\r\n/** Verify a plaintext password against a stored bcrypt hash. */\r\nexport const verifyPassword = async (plaintext: string, hash: string): Promise<boolean> => {\r\n return bcrypt.compare(plaintext, hash);\r\n};\r\n","//? Framework-mode forgot-password orchestration. Used only when\r\n//? `ProjectConfig.auth.forgotPassword === 'framework'`. Pulls in\r\n//? `@luckystack/email` lazily so the login package doesn't list it as a\r\n//? hard dep — `'custom'` and `'disabled'` modes never reach this code.\r\n\r\nimport { getProjectConfig, dispatchHook, getLogger } from '@luckystack/core';\r\n\r\nimport { loadEmailModule } from './emailModuleLoader';\r\nimport { createPasswordResetToken } from './passwordReset';\r\nimport { getUserAdapter } from './userAdapter';\r\n\r\ninterface SendResetEmailArgs {\r\n email: string;\r\n /**\r\n * App-facing display name used in the email greeting + subject. If omitted,\r\n * falls back to `projectConfig.auth.passwordResetBrand`, then to `'LuckyStack'`\r\n * as the absolute fallback. Real consumers should set this on either the\r\n * call site OR via `projectConfig.auth.passwordResetBrand`.\r\n */\r\n brand?: string;\r\n}\r\n\r\n/**\r\n * Look up the user by credentials-provider email, generate a reset token,\r\n * and email them a reset link. Always resolves successfully regardless of\r\n * whether the email matched a user (anti-enumeration). Returns the result\r\n * of the email send for diagnostics.\r\n */\r\nexport const sendPasswordResetEmail = async ({ email, brand }: SendResetEmailArgs): Promise<{ ok: boolean; reason?: string }> => {\r\n const config = getProjectConfig();\r\n //? Log mode but NOT the raw email at start — the email is a PII field.\r\n getLogger().info('[forgotPassword] start', { forgotPasswordMode: config.auth.forgotPassword });\r\n if (config.auth.forgotPassword !== 'framework') {\r\n getLogger().warn('[forgotPassword] auth.forgotPassword is not \"framework\" — nothing sent', { mode: config.auth.forgotPassword });\r\n return { ok: false, reason: 'forgotPassword-not-framework' };\r\n }\r\n\r\n const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';\r\n\r\n // Lazy import — the email package is an optional peer dep so its types\r\n // may not be resolvable at the framework's compile time.\r\n //? Mirror testEmail's robustness: catch a failed dynamic import instead of\r\n //? throwing (an uncaught throw here would bubble out of the anti-enumeration\r\n //? wrapper as a generic 500). Surfaces the real reason in the server log.\r\n const emailModule = await loadEmailModule().catch((error: unknown) => {\r\n getLogger().warn('[forgotPassword] failed to load @luckystack/email — is it installed?', { error: String(error) });\r\n return null;\r\n });\r\n if (!emailModule) {\r\n return { ok: false, reason: 'email-module-load-failed' };\r\n }\r\n const { sendEmail } = emailModule;\r\n\r\n const userAdapter = getUserAdapter();\r\n const user = await userAdapter.findByEmail({ email, provider: 'credentials' });\r\n //? Log found/userId without the raw email — logging email+found together is\r\n //? an enumeration oracle (any reader of the log can probe the user table).\r\n getLogger().info('[forgotPassword] credentials user lookup', { found: Boolean(user), userId: user?.id ?? null });\r\n\r\n // Anti-enumeration: always pretend it succeeded if the email isn't found.\r\n if (!user) {\r\n //? The client always gets \"success\" (anti-enumeration). This server-side\r\n //? line is the ONLY signal that the address simply isn't a credentials\r\n //? (email+password) account — by far the most common reason a reset email\r\n //? never arrives (OAuth-only signups and unknown/typo'd addresses have no\r\n //? credentials row, so there is nothing to reset).\r\n //? Do NOT include the email here — this line together with the email would\r\n //? let a log reader determine which addresses have credentials accounts.\r\n getLogger().warn('[forgotPassword] no credentials account for this email — nothing sent. Use the email+password account you registered with.');\r\n void dispatchHook('passwordResetRequested', {\r\n email,\r\n matched: false,\r\n });\r\n return { ok: true };\r\n }\r\n\r\n const token = await createPasswordResetToken(user.id);\r\n //? SEC: do NOT emit the raw token on the hook bus — hook subscribers are\r\n //? untrusted code and the token is a single-use credential. Consumers who need\r\n //? to deliver the email themselves should call `createPasswordResetToken`\r\n //? directly and build the URL without the framework exposing the secret here.\r\n void dispatchHook('passwordResetRequested', {\r\n email,\r\n matched: true,\r\n userId: user.id,\r\n ttlSeconds: config.auth.passwordResetTtlSeconds,\r\n });\r\n const baseUrl = (config.app.publicUrl || '').replace(/\\/+$/, '');\r\n //? ADR — DD-ROOTSRC-O8: the reset token is in the URL query string\r\n //? (`?token=<hex>`), not in the URL fragment (`#token=<hex>`).\r\n //? Tradeoff: query strings appear in server access logs and Referer headers\r\n //? if the user's mail client renders the link via a tracking proxy. Fragment\r\n //? identifiers (`#`) are never sent to the server and are therefore\r\n //? invisible to any log / proxy in the chain — they are more private.\r\n //? Decision: query-string is the standard email-link pattern understood by\r\n //? every SPA router, and tokens here are one-time + hashed at rest (Redis\r\n //? holds only `sha256(token)`), so the exposure window is the TTL of a\r\n //? single-use credential, not a replayable secret. Logging is mitigated\r\n //? by not logging the full URL at the point of issue (we log `userId`, not\r\n //? `resetUrl`). A future `auth.tokenLinkMode: 'fragment'` option can be\r\n //? introduced without touching this file — callers receive the raw token\r\n //? and can build their own URL.\r\n const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(token)}`;\r\n\r\n const ttlMinutes = Math.round(config.auth.passwordResetTtlSeconds / 60);\r\n\r\n //? Dispatch via the `'password-reset'` template rather than building the\r\n //? email inline. `@luckystack/email` ships a built-in for this name, so the\r\n //? out-of-the-box copy is unchanged — but a consumer can now\r\n //? `registerEmailTemplate('password-reset', …)` to translate or rebrand it\r\n //? without forking this flow (CFG-05). `adapterHint: 'transactional'` routes\r\n //? through a dedicated transactional sender when one is registered, else the\r\n //? default sender.\r\n const result = await sendEmail({\r\n to: user.email,\r\n template: 'password-reset',\r\n data: { resetUrl, userName: user.name, brand: resolvedBrand, ttlMinutes },\r\n adapterHint: 'transactional',\r\n });\r\n\r\n if (result.ok) {\r\n //? SEC: log only userId for correlation — not the email address (PII).\r\n getLogger().info('[forgotPassword] reset email dispatched', { userId: user.id });\r\n } else {\r\n getLogger().warn('[forgotPassword] reset email send FAILED', { userId: user.id, reason: result.reason });\r\n }\r\n\r\n return result.ok ? { ok: true } : { ok: false, reason: result.reason };\r\n};\r\n","//? Shared lazy-loader for the optional `@luckystack/email` peer dep. Both\n//? `forgotPassword.ts` and `emailChangeNotification.ts` need the SAME dynamic\n//? import + the SAME minimal `EmailModule` shape, so the interface + the\n//? `@ts-expect-error` import + the cast live here once (finding 37).\n//?\n//? IMPORTANT — this helper does NOT swallow a failed import. It resolves to the\n//? module or REJECTS exactly like the raw `import(...)` would. That preserves\n//? the two callers' DIFFERENT failure behavior verbatim:\n//? - `emailChangeNotification` awaits it directly (a load failure throws).\n//? - `forgotPassword` wraps it in `.catch(...)` (a load failure → null + warn).\n//? Centralizing the catch here would change one of those — do NOT.\n\n/** Minimal surface of `@luckystack/email` that the login orchestrators use. */\nexport interface EmailModule {\n sendEmail: (input: Record<string, unknown>) => Promise<{ ok: boolean; reason?: string }>;\n}\n\n/**\n * Dynamically import the optional `@luckystack/email` peer. Resolves to the\n * module, or REJECTS if the package is not installed (same as a bare\n * `import('@luckystack/email')`). Callers decide whether to `.catch(...)`.\n */\nexport const loadEmailModule = (): Promise<EmailModule> =>\n // @ts-expect-error optional peer dep — installed only when the consumer wires email sending\n import('@luckystack/email') as Promise<EmailModule>;\n","//? Email-change token primitives. Mirrors `passwordReset.ts` for the\r\n//? confirm-via-email-link flow: user submits a new address, framework mails\r\n//? a tokenized URL to the NEW address, click-through confirms and updates\r\n//? `User.email` + revokes all sessions.\r\n//?\r\n//? Token format: 32 random bytes hex-encoded -> 64-char URL-safe string.\r\n//? Storage: `<projectName>-email-change:<sha256(token)>` -> JSON payload\r\n//? `{ userId: string; newEmail: string }`\r\n//? TTL: `auth.emailChangeTtlSeconds` (default 3600 = 1 hour).\r\n//?\r\n//? HASHED AT REST (0.2.0): only `sha256(token)` is persisted (the Redis key)\r\n//? via the `@luckystack/core` one-time-token primitive, so a leaked Redis\r\n//? keyspace can't be replayed to confirm an email change. The raw token is\r\n//? returned to the caller exactly once for the emailed URL.\r\n\r\nimport { getProjectConfig, issueOneTimeToken, consumeOneTimeTokenJson, formatKey, redis, oneTimeTokenKey } from '@luckystack/core';\r\n\r\nconst EMAIL_CHANGE_NAMESPACE = '-email-change';\r\n\r\n//? Per-user pointer key for prior-token invalidation (LOGIN-F16), mirroring the\r\n//? same pattern used for password-reset tokens. Stored as a formatted key so the\r\n//? multi-tenant key formatter applies uniformly.\r\nconst emailChangeUserKey = (userId: string): string => formatKey('-email-change-user', userId);\r\n\r\nexport interface EmailChangePayload {\r\n userId: string;\r\n newEmail: string;\r\n}\r\n\r\n/**\r\n * Create a one-time email-change token bound to a user id + the new email\r\n * address. Any previously issued (but not yet consumed) token for the same\r\n * user is invalidated first (LOGIN-F16) so at most one confirmation link per\r\n * user is redeemable. Stored in Redis with the configured TTL — only\r\n * `sha256(token)` is persisted (hash-at-rest). Returns the raw token string —\r\n * caller emails the token (embedded in a URL) to the NEW address so the\r\n * confirmation click proves ownership of the new mailbox.\r\n */\r\nexport const createEmailChangeToken = async (userId: string, newEmail: string): Promise<string> => {\r\n const ttl = getProjectConfig().auth.emailChangeTtlSeconds;\r\n\r\n //? Invalidate the prior active email-change token (LOGIN-F16). Without this,\r\n //? multiple links could be outstanding concurrently — a link sent to an\r\n //? attacker-controlled address (via a race or a compromised session) would\r\n //? remain redeemable even after the user re-requests the flow.\r\n const priorKey = await redis.get(emailChangeUserKey(userId));\r\n if (priorKey) {\r\n await redis.del(priorKey);\r\n }\r\n\r\n const handle = issueOneTimeToken(\r\n EMAIL_CHANGE_NAMESPACE,\r\n ttl,\r\n { userId, newEmail } satisfies EmailChangePayload,\r\n );\r\n await handle.store();\r\n\r\n //? Record the new hash-key so the NEXT issue can invalidate this one.\r\n const newHashKey = oneTimeTokenKey(EMAIL_CHANGE_NAMESPACE, handle.token);\r\n await redis.set(emailChangeUserKey(userId), newHashKey, 'EX', ttl);\r\n\r\n return handle.token;\r\n};\r\n\r\n/**\r\n * Validate and consume an email-change token. Returns the bound\r\n * `{ userId, newEmail }` on success and removes the token (one-time use, atomic\r\n * GET+DEL). Returns null when the token is missing, expired, malformed, or\r\n * already consumed.\r\n */\r\nexport const consumeEmailChangeToken = async (token: string): Promise<EmailChangePayload | null> => {\r\n const parsed = await consumeOneTimeTokenJson<EmailChangePayload>(EMAIL_CHANGE_NAMESPACE, token);\r\n if (\r\n !parsed\r\n || typeof parsed !== 'object'\r\n || typeof parsed.userId !== 'string'\r\n || typeof parsed.newEmail !== 'string'\r\n ) {\r\n return null;\r\n }\r\n return parsed;\r\n};\r\n","//? Framework-mode \"email change confirmation\" orchestration. Renders the\r\n//? confirmation email + sends it to the NEW address (proves the user can\r\n//? receive at the new mailbox). Mirrors the lazy-import pattern from\r\n//? `forgotPassword.ts` so `@luckystack/email` stays an optional peer.\r\n\r\nimport { getProjectConfig } from '@luckystack/core';\r\n\r\nimport { createEmailChangeToken } from './emailChange';\r\nimport { loadEmailModule } from './emailModuleLoader';\r\n\r\ninterface SendEmailChangeArgs {\r\n userId: string;\r\n newEmail: string;\r\n /** Optional display name used in the email greeting (falls back to \"there\"). */\r\n userName?: string | null;\r\n /**\r\n * App-facing display name used in the email greeting + subject. Falls back\r\n * to `projectConfig.auth.passwordResetBrand` (the shared brand label), then\r\n * to `'LuckyStack'` as the absolute fallback.\r\n */\r\n brand?: string;\r\n}\r\n\r\n/**\r\n * Mint a one-shot email-change token and send a confirmation email to the\r\n * NEW address with the tokenized URL. Returns the send result for diagnostics\r\n * + the token itself (callers can audit-log it through the\r\n * `postEmailChangeRequested` hook).\r\n */\r\nexport const sendEmailChangeConfirmation = async (\r\n { userId, newEmail, userName, brand }: SendEmailChangeArgs,\r\n): Promise<{ ok: boolean; reason?: string; token: string }> => {\r\n const config = getProjectConfig();\r\n const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';\r\n\r\n // Lazy import — `@luckystack/email` is an optional peer dep. A failed load\r\n //? rejects here (unlike forgotPassword's catch); this caller's behavior is\r\n //? unchanged — the rejection bubbles out exactly as the inline import did.\r\n const { sendEmail } = await loadEmailModule();\r\n\r\n const token = await createEmailChangeToken(userId, newEmail);\r\n const baseUrl = (config.app.publicUrl || '').replace(/\\/+$/, '');\r\n //? ADR — DD-ROOTSRC-O8: same query-string tradeoff as the password-reset\r\n //? flow (see `forgotPassword.ts`). Tokens are one-time + hashed at rest;\r\n //? the raw token is never persisted. The confirm URL is only sent to the\r\n //? NEW address (verifying mailbox ownership), so it never appears in an\r\n //? access log that an attacker of the OLD address could read.\r\n //? #64: honor the configurable `auth.emailChangeConfirmPath` (where the\r\n //? consumer's confirm-email page lives) instead of a hardcoded path; the\r\n //? prior hardcoded value is the fallback when the key is empty.\r\n const confirmPath = config.auth.emailChangeConfirmPath || '/settings/confirm-email';\r\n const confirmUrl = `${baseUrl}${confirmPath}?token=${encodeURIComponent(token)}`;\r\n const ttlMinutes = Math.round(config.auth.emailChangeTtlSeconds / 60);\r\n\r\n //? Dispatch via the built-in `'email-change'` template so consumers can\r\n //? override the copy with `registerEmailTemplate('email-change', …)` without\r\n //? forking (CFG-05). Default copy is unchanged.\r\n const result = await sendEmail({\r\n to: newEmail,\r\n template: 'email-change',\r\n data: { confirmUrl, userName, brand: resolvedBrand, ttlMinutes },\r\n adapterHint: 'transactional',\r\n });\r\n\r\n return result.ok ? { ok: true, token } : { ok: false, reason: result.reason, token };\r\n};\r\n"],"mappings":"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","names":["tryCatch","getLogger","getProjectConfig","getProjectConfig","getLogger","getProjectConfig","getProjectConfig","getProjectConfig","tryCatch","getLogger","validationError","response","bcrypt","getProjectConfig","formatKey","formatKey","getProjectConfig","bcrypt","hash","getProjectConfig","dispatchHook","getLogger","getProjectConfig","getLogger","dispatchHook","getProjectConfig","issueOneTimeToken","formatKey","redis","oneTimeTokenKey","getProjectConfig","getProjectConfig"]}