@luckystack/login 0.2.7 → 0.3.1

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/CLAUDE.md CHANGED
@@ -119,7 +119,7 @@ Type-only module that augments `@luckystack/core`'s `HookPayloads` interface. Ex
119
119
  Email-change hook payloads (new):
120
120
 
121
121
  - `PreEmailChangePayload` — `{ userId, currentEmail, newEmail }`. Vetoable; returning a stop-signal from a handler aborts the change before any token is minted.
122
- - `PostEmailChangeRequestedPayload` — `{ userId, newEmail }`. Observational. Fires after the confirmation token is minted and the email has been queued to the new address.
122
+ - `PostEmailChangeRequestedPayload` — `{ userId, currentEmail, newEmail, sent? }`. Observational. Fires after the confirmation token is minted and the email has been queued to the NEW address. The framework does NOT alert the old address by default — subscribe to this hook (it carries `currentEmail`) to send your own "change requested" notice to the old owner. `sent: false` marks an anti-enumeration silent drop (new address already taken).
123
123
  - `PostEmailChangedPayload` — `{ userId, oldEmail, newEmail }`. Observational. Fires after the user's address is persisted via the `UserAdapter` AND all of the user's sessions have been revoked (forced re-login on the next request).
124
124
 
125
125
  ---
@@ -179,7 +179,7 @@ var saveSession = async (token, data, newUser, options) => {
179
179
  tokensToKick = previousTokens.slice(0, excess);
180
180
  }
181
181
  if (tokensToKick.length > 0) {
182
- const { logout: logout2 } = await import("./logout-E3FUC4Q6.js");
182
+ const { logout: logout2 } = await import("./logout-65O7OTJW.js");
183
183
  await Promise.all(tokensToKick.map(async (previousToken) => {
184
184
  const sockets = io.sockets.adapter.rooms.get(previousToken);
185
185
  if (sockets) {
@@ -277,11 +277,12 @@ var deleteSession = async (token, options) => {
277
277
  getLogger().warn(`session delete aborted by preSessionDelete hook`, { errorCode: preSessionDeleteResult.signal.errorCode });
278
278
  return false;
279
279
  }
280
+ await adapter.delete(token);
280
281
  if (raw && resolvedUserId) {
281
282
  const { getIoInstance } = await import("@luckystack/core");
282
283
  const ioInstance = getIoInstance();
283
284
  if (ioInstance && !options?.skipSocketLogout) {
284
- const { logout: logout2 } = await import("./logout-E3FUC4Q6.js");
285
+ const { logout: logout2 } = await import("./logout-65O7OTJW.js");
285
286
  const sockets = ioInstance.sockets.adapter.rooms.get(token);
286
287
  if (sockets) {
287
288
  await Promise.all([...sockets].map(async (socketId) => {
@@ -298,7 +299,6 @@ var deleteSession = async (token, options) => {
298
299
  }
299
300
  await adapter.untrackActive(resolvedUserId, token);
300
301
  }
301
- await adapter.delete(token);
302
302
  await dispatchHook("postSessionDelete", { token, userId: resolvedUserId });
303
303
  void dispatchHook("sessionRevoked", { token, userId: resolvedUserId });
304
304
  return true;
@@ -398,4 +398,4 @@ export {
398
398
  sessionKeyFor,
399
399
  activeUsersKeyFor
400
400
  };
401
- //# sourceMappingURL=chunk-Q2PZ3VZU.js.map
401
+ //# sourceMappingURL=chunk-6H42NTVX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/logout.ts","../src/session.ts","../src/sessionAdapter.ts","../src/sessionSanitizer.ts"],"sourcesContent":["import { Socket } from \"socket.io\";\nimport { tryCatch, socketEventNames, dispatchHook, getLogger } from '@luckystack/core';\nimport { deleteSession } from \"./session\";\n\nexport const logout = async ({ token, socket, userId, skipSessionDelete }: {\n token: string | null,\n socket: Socket | undefined,\n userId: string | null,\n skipSessionDelete?: boolean,\n}) => {\n const [error, result] = await tryCatch(async () => {\n if (!socket) {\n getLogger().warn('logout: invalid socket');\n return;\n }\n if (!token) {\n getLogger().warn('logout: no token provided');\n return;\n }\n\n const preLogoutResult = await dispatchHook('preLogout', { userId, token });\n if (preLogoutResult.stopped) {\n getLogger().warn('logout: aborted by preLogout hook', { errorCode: preLogoutResult.signal.errorCode });\n socket.emit(socketEventNames.logout, \"error\");\n return;\n }\n\n getLogger().debug(`logout: user ${userId ?? '?'}`, { token });\n\n if (!skipSessionDelete) {\n //? `deleteSession` handles both the key delete AND `adapter.untrackActive`\n //? through the registered SessionAdapter (LOGIN-02). A separate raw\n //? `redis.srem(activeUsersKeyFor(userId), token)` call here was a direct\n //? bypass that broke custom adapters and duplicated work the adapter already\n //? performed; removed.\n await deleteSession(token);\n }\n //? `socket.leave` returns a Promise in socket.io's adapter contract but the\n //? room-leave is fire-and-forget here (the session is already being torn\n //? down) — `void` marks it intentionally un-awaited without changing timing.\n void socket.leave(token);\n\n // Presence state (disconnectTimers, tempDisconnectedSockets) is cleaned up\n // by `@luckystack/presence`'s `postLogout` hook handler, registered at\n // server startup via `registerPresenceHooks()`. Keeps login → presence\n // a one-way dep: presence knows about login (uses getSession/deleteSession),\n // login does not know about presence.\n await dispatchHook('postLogout', { userId, token });\n return true;\n });\n if (error) {\n if (socket) {\n socket.emit(socketEventNames.logout, \"error\");\n }\n } else if (result && socket) {\n //? Opt-in logout tracing. This fires on EVERY successful logout, so an\n //? unconditional warn+stacktrace spams the server log. Set\n //? LUCKYSTACK_TRACE_SESSION_DELETES=1 when hunting a \"why was I logged out?\"\n //? bug to print the originating caller's stack; silent otherwise. The\n //? client still receives the success-emit and clears its session regardless.\n if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === '1') {\n getLogger().warn('[session] logout success — emitting logout to socket', {\n tokenPrefix: token ? token.slice(0, 8) : null,\n userId,\n socketId: socket.id,\n stack: new Error('logout invoked').stack,\n });\n }\n socket.emit(socketEventNames.logout, \"success\");\n }\n}\n","import type { BaseSessionLayout as SessionLayout } from \"./sessionLayout\";\nimport { randomBytes } from 'node:crypto';\nimport {\n socketEventNames,\n dispatchHook,\n getCsrfConfig,\n getLogger,\n getProjectConfig,\n formatKey,\n tryCatch,\n tryCatchSync,\n} from '@luckystack/core';\n\nimport { getSessionAdapter } from './sessionAdapter';\nimport { applySessionSanitizer } from './sessionSanitizer';\n\n//? Resolved at call time so project registration order doesn't matter.\n//? Session TTL comes from projectConfig and is honoured by every adapter\n//? (Redis default, or any custom adapter the consumer registers).\nconst getSessionTtl = (): number => 60 * 60 * 24 * getProjectConfig().session.expiryDays;\n\nconst isSessionLayout = (value: unknown): value is SessionLayout => {\n return value !== null && typeof value === 'object' && 'id' in value;\n};\n\nconst parseSessionLayout = (value: string): SessionLayout | null => {\n const parsed = JSON.parse(value) as unknown;\n return isSessionLayout(parsed) ? parsed : null;\n};\n\n/**\n * Save or update a user session through the active session adapter.\n *\n * @param token - The session token (unique identifier)\n * @param data - The session data to store\n * @param newUser - If true, this is a new login (triggers single-session enforcement)\n * @param options.supersedeToken - A token belonging to the SAME browser that is\n * being replaced by this login (e.g. re-login while already signed in). It is\n * excluded from single-session enforcement so the user's current device is not\n * kicked by its own new session. The caller is responsible for deleting it\n * afterwards (use `deleteSession(supersedeToken, { skipSocketLogout: true })`).\n */\nconst saveSession = async (\n token: string,\n data: SessionLayout,\n newUser?: boolean,\n options?: { supersedeToken?: string },\n): Promise<{ ok: true } | { ok: false; errorCode: string }> => {\n //? Reject a session with an empty `id` (B30). `isSessionLayout` only checks\n //? the `id` KEY is present, so a custom adapter / caller handing us an\n //? empty-string id would mint a session that is never tracked in the\n //? activeUsers set keyed by id — making it invisible to `revokeUserSessions`\n //? / \"sign out everywhere\". A real authenticated user always has a non-empty\n //? id, so this can only reject a malformed/untrackable session.\n if (!data.id) {\n //? Log only the first 8 chars of the token (LOGIN-F2) — enough to correlate\n //? with a specific request in logs/traces without exposing a live credential.\n getLogger().error('saveSession: refusing to persist a session with an empty user id', { tokenPrefix: token.slice(0, 8) });\n return { ok: false, errorCode: 'api.internalServerError' };\n }\n\n const [error, outcome] = await tryCatch(async () => {\n const adapter = getSessionAdapter();\n\n if (newUser) {\n const preSessionCreateResult = await dispatchHook('preSessionCreate', {\n token,\n user: data,\n persistent: !getProjectConfig().session.basedToken,\n });\n if (preSessionCreateResult.stopped) {\n getLogger().warn(`session create aborted by preSessionCreate hook`, { errorCode: preSessionCreateResult.signal.errorCode });\n return { ok: false, errorCode: preSessionCreateResult.signal.errorCode || 'api.internalServerError' } as const;\n }\n\n //? `onConflict: 'rejectNew'` enforcement. Previously this was documented\n //? as \"enforced at the API layer\" but NO code refused the login — the new\n //? session was persisted regardless and sessions accumulated unbounded,\n //? making the documented cap a silent no-op. Enforce it HERE (before any\n //? persist) so every login surface — credentials AND OAuth — is covered by\n //? construction: when `perUser: 'multiple'` with a `maxConcurrentPerUser`\n //? cap already reached and `onConflict === 'rejectNew'`, refuse the new\n //? login with a dedicated reason key instead of kicking an existing\n //? session. `'single'` and `'revokeOld'` keep kicking (handled below).\n const cfg = getProjectConfig().session;\n const perUser = cfg.perUser;\n if (\n perUser === 'multiple' &&\n cfg.onConflict === 'rejectNew' &&\n cfg.maxConcurrentPerUser !== null &&\n data.id\n ) {\n //? SOFT-CAP (LOGIN-F4): this is a read-then-decide, not an atomic\n //? reserve, so two concurrent logins for the same user can both pass the\n //? count check and temporarily exceed `maxConcurrentPerUser` by one.\n //? A Lua SETNX atomic counter would close the gap, but at the cost of a\n //? separate primitive and significant complexity. The window is narrowly\n //? time-bounded (< round-trip latency of two simultaneous logins), the\n //? worst case is one extra session that cleans up at normal TTL, and\n //? consumers operating strict caps should keep `maxConcurrentPerUser` at\n //? a margin that absorbs the race. Documented here so no future change\n //? re-tightens without understanding the trade-off.\n const existing = await getSessionAdapter().listActive(data.id);\n const others = existing.filter((t) => t !== token && t !== options?.supersedeToken);\n if (others.length + 1 > cfg.maxConcurrentPerUser) {\n getLogger().warn('session create rejected — concurrent-session cap reached (onConflict: rejectNew)', {\n userId: data.id,\n cap: cfg.maxConcurrentPerUser,\n active: others.length,\n });\n return { ok: false, errorCode: 'login.sessionLimitReached' } as const;\n }\n }\n }\n\n //? Mint a CSRF token on first session write. Subsequent (non-new) writes\n //? preserve the existing token so the client doesn't have to re-fetch on\n //? every session update. The token is rotated on logout via `deleteSession`\n //? AND whenever a genuinely new session is established (`newUser`) — a fresh\n //? login / OAuth re-login must not inherit a CSRF token that may have been\n //? carried into `data` from a prior session, so we always mint a new one.\n //? Token length is consumer-configurable via `registerCsrfConfig({ tokenLength })`.\n if (newUser || !data.csrfToken) {\n data.csrfToken = randomBytes(getCsrfConfig().tokenLength).toString('hex');\n }\n\n //? Apply the optional session sanitizer (M7) before BOTH persist and the\n //? client broadcast, so a consumer-registered redactor strips sensitive\n //? non-password columns (2FA secrets, billing ids, internal flags) that the\n //? default `sanitizeUserForSession` (password-only) leaves on the record. A\n //? throwing sanitizer must not break login — fall back to the raw record and\n //? log. `data.id` (used below for tracking/enforcement) is read from the\n //? original record, which is unaffected.\n //? Fail-CLOSED on a throwing sanitizer (M6): the previous fallback persisted\n //? AND broadcast the RAW record, leaking exactly the sensitive columns\n //? (2FA secrets, billing ids, internal flags) the sanitizer exists to strip.\n //? On error, fall back to a minimal known-safe projection — drop `password`\n //? (always) and any field a registered sanitizer was meant to redact is at\n //? least not leaked verbatim; the user can re-login if a field they needed is\n //? missing, but we never broadcast secrets.\n let persisted = data;\n const [sanitizeErr, sanitized] = tryCatchSync(() => applySessionSanitizer(data));\n if (sanitizeErr) {\n getLogger().error('saveSession: session sanitizer threw — falling back to password-stripped projection', sanitizeErr, { tokenPrefix: token.slice(0, 8) });\n const { password: _password, ...safe } = data as SessionLayout & { password?: unknown };\n persisted = safe;\n } else if (sanitized) {\n persisted = sanitized;\n }\n\n const ttl = getSessionTtl();\n //? Strip `token` from the stored value (LOGIN-M9). The token is the Redis key\n //? itself, so embedding it in the value is redundant AND means a Redis keyspace\n //? dump / `getAllSessions` / any admin log that prints a session record exposes\n //? a live replayable credential. `getSession` re-attaches it from the lookup\n //? key (`{ ...parsed, token }`) so callers receive the expected shape.\n const { token: _stripToken, ...persistedWithoutToken } = persisted as SessionLayout & { token?: unknown };\n await adapter.setRaw(token, JSON.stringify(persistedWithoutToken), ttl);\n\n const userId = data.id;\n\n //? Always track active tokens BEFORE the io check — `trackActive` is a\n //? Redis-only write, independent of any live socket. Gating it behind a\n //? live io meant processes without a Socket.io instance (the test harness,\n //? background workers, CLI tasks) persisted the session but never populated\n //? the active-tokens set, so `listSessions` / `revokeUserSessions` saw\n //? nothing for that user.\n if (userId) {\n await adapter.trackActive(userId, token, ttl);\n }\n\n const { getIoInstance } = await import('@luckystack/core');\n const io = getIoInstance();\n //? Socket fanout + single-session enforcement below need a live io; skip\n //? them when there isn't one. The session IS already persisted + tracked, so\n //? this is a success (background workers / CLI tasks have no io).\n if (!io) return { ok: true } as const;\n\n // Handle session-limit enforcement on new login. Logic:\n // 1. perUser = 'single' (default) →\n // kick every prior session for this user.\n // 2. perUser = 'multiple' with `maxConcurrentPerUser` cap reached:\n // `onConflict === 'revokeOld'` kicks oldest until under cap,\n // `onConflict === 'rejectNew'` already refused the login above (before\n // persisting), so by here the cap has room.\n // 3. perUser = 'multiple' with no cap → no kick.\n if (newUser && userId) {\n const sessionCfg = getProjectConfig().session;\n const effectivePerUser = sessionCfg.perUser;\n const cap = sessionCfg.maxConcurrentPerUser;\n\n const allTokens = await adapter.listActive(userId);\n //? Exclude the current token — it was just added and has no socket room yet.\n //? Also exclude `supersedeToken`: when the same browser re-logs-in, its old\n //? token is being replaced (not a separate device), so kicking it would log\n //? the user out of the very browser performing the login.\n const previousTokens = allTokens.filter(\n (t) => t !== token && t !== options?.supersedeToken,\n );\n\n let tokensToKick: string[] = [];\n if (effectivePerUser === 'single') {\n tokensToKick = previousTokens;\n } else if (cap !== null && previousTokens.length + 1 > cap && sessionCfg.onConflict === 'revokeOld') {\n // Kick oldest until under cap. Without ordering metadata in the adapter,\n // we drop the head of the list — most adapters return insertion-order\n // for set-like structures, but consumers needing strict LRU should\n // implement a custom adapter that orders deterministically.\n const excess = previousTokens.length + 1 - cap;\n tokensToKick = previousTokens.slice(0, excess);\n }\n\n if (tokensToKick.length > 0) {\n const { logout } = await import('./logout');\n\n await Promise.all(tokensToKick.map(async (previousToken) => {\n const sockets = io.sockets.adapter.rooms.get(previousToken);\n if (sockets) {\n getLogger().debug(`Kicking previous session for user ${userId}`);\n // Optional UI notification before the disconnect lands.\n if (sessionCfg.notifyOldDeviceOnRevoke) {\n io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({\n reason: 'session-replaced',\n userId,\n }));\n }\n for (const socketId of sockets) {\n const socket = io.sockets.sockets.get(socketId);\n if (socket) {\n await logout({ token: previousToken, socket, userId });\n }\n }\n } else {\n // No active sockets, just clean up the adapter state\n await adapter.delete(previousToken);\n await adapter.untrackActive(userId, previousToken);\n }\n }));\n }\n }\n\n // Broadcast session updates to connected clients (sanitized copy).\n if (io.sockets.adapter.rooms.has(token)) {\n io.to(token).emit(socketEventNames.updateSession, JSON.stringify(persisted));\n }\n\n if (newUser) {\n await dispatchHook('postSessionCreate', { token, user: data, persistent: !getProjectConfig().session.basedToken });\n //? Core-level observational session-lifecycle hook (CORE-40). Dispatched\n //? alongside the login-owned `postSessionCreate` so consumers (audit,\n //? presence, error-tracking) can react to a minted session via core's\n //? `registerHook('sessionCreated', …)` WITHOUT importing @luckystack/login.\n //? Fire-and-forget — the session already exists, so a handler error must\n //? not change the login outcome.\n if (userId) {\n void dispatchHook('sessionCreated', { token, userId });\n }\n }\n\n return { ok: true } as const;\n }, undefined, { fn: 'saveSession', token });\n\n if (error) {\n getLogger().error('saveSession failed', error, { tokenPrefix: token.slice(0, 8) });\n //? Surface the failure instead of swallowing it. Callers (credentials/OAuth\n //? login) MUST NOT mint a token + delete the prior session when the new\n //? session never persisted — a transient adapter blip would otherwise log\n //? the user out with no usable replacement.\n return { ok: false, errorCode: 'api.internalServerError' };\n }\n return outcome ?? { ok: false, errorCode: 'api.internalServerError' };\n};\n\n/**\n * Retrieve a user session through the active session adapter.\n *\n * @param token - The session token\n * @param options.refresh - When false, skip the sliding-expiration TTL\n * extension. Defaults to true. Pass false for read-only probes (e.g. a\n * background audit, an admin lookup) where a stolen token should NOT earn\n * extra lifetime just because the server touched it (LOGIN-F9).\n * @returns The session data or null if not found\n */\nconst getSession = async (\n token: string | null,\n options?: { refresh?: boolean },\n): Promise<SessionLayout | null> => {\n if (!token) return null;\n\n const [error, value] = await tryCatch(async () => {\n const adapter = getSessionAdapter();\n\n const raw = await adapter.getRaw(token);\n if (!raw) return null;\n\n const parsed = parseSessionLayout(raw);\n const userId = parsed?.id ?? null;\n\n //? Sliding expiration: each successful authenticated access extends session\n //? lifetime. `options.refresh` defaults to true to preserve the existing\n //? behaviour on the hot request path. Pass `{ refresh: false }` from\n //? background / admin probes that must not extend a potentially stolen token.\n const shouldRefresh = options?.refresh !== false;\n\n // Sliding expiration: each successful authenticated access extends session lifetime.\n const newTtl = getSessionTtl();\n const oldTtl = await adapter.ttl(token);\n const preRefreshResult = shouldRefresh\n ? await dispatchHook('preSessionRefresh', { token, userId, oldTtl, newTtl })\n : { stopped: true };\n\n //? A `preSessionRefresh` handler can stop the TTL extension (e.g. admin\n //? freezing a session pending review). When stopped, we skip the\n //? `adapter.expire` call and surface `applied: false` on the post payload\n //? so audit handlers see the same signal.\n let applied = false;\n if (!preRefreshResult.stopped) {\n applied = await adapter.expire(token, newTtl);\n //? Also refresh the activeUsers-set TTL on a sliding read. `trackActive`\n //? (which sets that TTL) runs only on saveSession, so a session kept\n //? alive purely by reads would outlive its activeUsers entry — after\n //? which `revokeUserSessions` / single-session enforcement enumerate\n //? `listActive` -> [] and silently miss the live token (a stolen token\n //? would then survive a password reset / sign-out-everywhere). Refresh\n //? the set TTL in lock-step with the session-key TTL to close that drift.\n if (applied && userId && adapter.touchActive) {\n await adapter.touchActive(userId, newTtl);\n }\n }\n if (shouldRefresh) {\n await dispatchHook('postSessionRefresh', {\n token,\n userId,\n oldTtl,\n newTtl,\n applied,\n });\n }\n\n if (!parsed) return null;\n const merged: SessionLayout = { ...parsed, token };\n return merged;\n }, undefined, { fn: 'getSession', token });\n\n if (error) {\n getLogger().error('getSession failed', error, { tokenPrefix: token.slice(0, 8) });\n return null;\n }\n return value;\n};\n\n/**\n * Delete a user session through the active adapter and notify connected\n * clients.\n *\n * @param token - The session token to delete\n * @param options.skipSocketLogout - When true, the session data + active-token\n * tracking are cleaned up and the pre/post delete hooks still fire, but NO\n * `logout` is emitted to sockets in the token's room. Use this when the SAME\n * browser is replacing its session (re-login): emitting a logout would bounce\n * that browser to the login page and null its brand-new session.\n * @returns true if successful\n */\nconst deleteSession = async (\n token: string,\n options?: { skipSocketLogout?: boolean },\n): Promise<boolean> => {\n //? Opt-in spurious-delete tracing. `deleteSession` runs on EVERY legitimate\n //? logout / revokeSession / signOutEverywhere / deleteAccount, so an\n //? unconditional warn+stacktrace drowns the signal it's meant to surface and\n //? spams dev/test output with stacktrace-shaped lines that read like errors.\n //? Set LUCKYSTACK_TRACE_SESSION_DELETES=1 when hunting a \"why was I logged\n //? out?\" bug to print the originating caller's stack; silent otherwise.\n if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === '1') {\n getLogger().warn('[session] deleteSession invoked', {\n tokenPrefix: token.slice(0, 8),\n stack: new Error('deleteSession invoked').stack,\n });\n }\n\n const [error, ok] = await tryCatch(async () => {\n const adapter = getSessionAdapter();\n\n const raw = await adapter.getRaw(token);\n let resolvedUserId: string | null = null;\n\n if (raw) {\n resolvedUserId = parseSessionLayout(raw)?.id ?? null;\n }\n\n const preSessionDeleteResult = await dispatchHook('preSessionDelete', { token, userId: resolvedUserId });\n if (preSessionDeleteResult.stopped) {\n getLogger().warn(`session delete aborted by preSessionDelete hook`, { errorCode: preSessionDeleteResult.signal.errorCode });\n return false;\n }\n\n //? Delete the session KEY before untracking it from the activeUsers set. If\n //? `delete` threw transiently AFTER untrackActive, the token would stay\n //? readable (still in Redis) yet enumerable-as-gone — a stolen token could\n //? ride past a sign-out-everywhere. Deleting first means a transient throw\n //? leaves the token tracked + retried by the next `revokeUserSessions` sweep\n //? instead of orphaning a still-readable session. `delete` was already\n //? unconditional (ran even when `raw` was missing), so it just moves earlier.\n await adapter.delete(token);\n\n if (raw && resolvedUserId) {\n const { getIoInstance } = await import('@luckystack/core');\n const ioInstance = getIoInstance();\n\n // Reuse the same logout flow as single-session enforcement.\n if (ioInstance && !options?.skipSocketLogout) {\n const { logout } = await import('./logout');\n const sockets = ioInstance.sockets.adapter.rooms.get(token);\n\n if (sockets) {\n await Promise.all([...sockets].map(async (socketId) => {\n const socket = ioInstance.sockets.sockets.get(socketId);\n if (!socket) return;\n\n await logout({\n token,\n socket,\n userId: resolvedUserId,\n skipSessionDelete: true,\n });\n }));\n }\n }\n\n await adapter.untrackActive(resolvedUserId, token);\n }\n await dispatchHook('postSessionDelete', { token, userId: resolvedUserId });\n //? Core-level observational session-lifecycle hook (CORE-40), the revoke\n //? counterpart to `sessionCreated`. Lets consumers react to a revoked\n //? session via core's `registerHook('sessionRevoked', …)` without depending\n //? on login internals. Fire-and-forget.\n void dispatchHook('sessionRevoked', { token, userId: resolvedUserId });\n return true;\n }, undefined, { fn: 'deleteSession', token });\n\n if (error) {\n getLogger().error('deleteSession failed', error, { tokenPrefix: token.slice(0, 8) });\n return false;\n }\n return ok ?? false;\n};\n\n/**\n * Get all active sessions (admin utility). Returns empty when the active\n * adapter doesn't implement enumeration (signed-JWT-stateless, log-only).\n *\n * @returns Array of all session data\n */\nconst getAllSessions = async (): Promise<SessionLayout[]> => {\n const [error, sessions] = await tryCatch(async () => {\n const adapter = getSessionAdapter();\n if (!adapter.listAll) {\n getLogger().warn(`[session] getAllSessions: adapter '${adapter.name}' does not implement listAll — returning empty`);\n return [];\n }\n\n const rows = await adapter.listAll();\n const parsedSessions: SessionLayout[] = [];\n for (const { raw } of rows) {\n const parsed = parseSessionLayout(raw);\n if (parsed) parsedSessions.push(parsed);\n }\n return parsedSessions;\n }, undefined, { fn: 'getAllSessions' });\n\n if (error) {\n getLogger().error('getAllSessions failed', error);\n return [];\n }\n return sessions ?? [];\n};\n\n/**\n * Revoke every active session for a user, optionally keeping one alive.\n *\n * @param userId - The user whose sessions to revoke\n * @param exceptToken - If provided, this session is left untouched. Pass the\n * caller's own token when revoking after a password change so the user\n * isn't logged out of the device they just changed it from. Pass null/omit\n * to revoke everything (account deletion, \"sign out everywhere\", etc.).\n *\n * Each revocation goes through `deleteSession`, so connected sockets are\n * told to log out via the same flow as a normal logout. Returns the number\n * of sessions actually revoked.\n */\nconst revokeUserSessions = async (userId: string, exceptToken?: string | null): Promise<number> => {\n if (!userId) return 0;\n\n const adapter = getSessionAdapter();\n const tokens = await adapter.listActive(userId);\n const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;\n\n //? Count only the sessions that were ACTUALLY revoked (M13). `deleteSession`\n //? can return `false` (preSessionDelete veto, adapter error) yet was\n //? previously counted as revoked — so \"sign out everywhere\" / post-password-\n //? reset over-reported and a token a delete failed for could survive a reset.\n //? Summing truthy results means the count reflects reality; a failed token\n //? stays live and is logged by `deleteSession` for retry.\n const results = await Promise.all(targets.map((token) => deleteSession(token)));\n return results.filter(Boolean).length;\n};\n\n//? Legacy key-builders preserved as exports for downstream code that\n//? assumed a stable Redis layout (admin tooling, dev REPL scripts). New\n//? callers should use the active SessionAdapter instead.\nconst sessionKeyFor = (token: string): string => formatKey('-session', token);\nconst activeUsersKeyFor = (userId: string): string => formatKey('-activeUsers', userId);\n\nexport { saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor };\n","//? Session storage DI surface. The default adapter persists session\n//? records in Redis (same behaviour as before extraction). Consumers can\n//? plug in DynamoDB / Memcached / Postgres-table / signed-JWT-stateless\n//? adapters by implementing `SessionAdapter` and calling\n//? `registerSessionAdapter(...)` at boot.\n//?\n//? The adapter owns ONLY storage primitives. Framework-level concerns\n//? (CSRF minting, hook dispatch, socket emission, single-session\n//? enforcement) stay in session.ts and call the adapter to read/write.\n\nimport { redis, tryCatch, formatKey } from '@luckystack/core';\n\nexport interface SessionAdapter {\n /** Human-readable identifier (used in logs/diagnostics). */\n name: string;\n\n /** Read the raw stored session string for `token`. Null when missing. */\n getRaw(token: string): Promise<string | null>;\n\n /**\n * Write the raw session string for `token` with a TTL. Implementations\n * MUST honour the TTL — sessions without one would never expire.\n */\n setRaw(token: string, value: string, ttlSeconds: number): Promise<void>;\n\n /** Delete the session record for `token`. Idempotent. */\n delete(token: string): Promise<void>;\n\n /**\n * Refresh the TTL on an existing session record (sliding-window\n * expiration). Returns true when the underlying record existed and the\n * TTL was applied, false otherwise. Implementations must not create a\n * record on miss.\n */\n expire(token: string, ttlSeconds: number): Promise<boolean>;\n\n /** Remaining TTL in seconds (or null when the record is missing). */\n ttl(token: string): Promise<number | null>;\n\n // ---- Active-tokens-per-user (for single-session enforcement and\n // revokeUserSessions). Implementations need a set-like structure keyed\n // by userId. Memcached/edge-KV adapters can serialise as a comma-list\n // if no native set type is available. ----\n\n trackActive(userId: string, token: string, ttlSeconds: number): Promise<void>;\n untrackActive(userId: string, token: string): Promise<void>;\n listActive(userId: string): Promise<string[]>;\n\n /**\n * Refresh the TTL on the active-tokens-per-user record WITHOUT adding a\n * token (sliding-window expiration parity with the session key). Called on\n * an authenticated read so a session kept alive purely by reads does not\n * outlive its activeUsers entry — which would otherwise let a stolen token\n * survive a sign-out-everywhere / password reset. Optional: backends whose\n * active-set entries do not expire independently (or that have no TTL\n * concept) can omit it. Must not create a record on miss.\n */\n touchActive?(userId: string, ttlSeconds: number): Promise<void>;\n\n /**\n * Admin walk: yield every active session record. Optional because\n * non-scannable backends (signed-JWT-stateless, log-only) cannot\n * enumerate. Callers fall back to a per-user listActive when omitted.\n *\n * **Dev/admin-only (LOGIN-F14).** Implementations scan the full keyspace and\n * load every session into memory in one call — O(all sessions) in both Redis\n * round-trips and memory. Do NOT call on the hot request path or from any\n * user-facing API. Use `listActive(userId)` for per-user lookups.\n */\n listAll?(): Promise<{ token: string; raw: string }[]>;\n}\n\n/**\n * Default adapter: Redis-backed. Key layout matches what the framework\n * has used since the first release, so registering this explicitly is\n * equivalent to not registering anything.\n *\n * Imports `redis` and `tryCatch` from `@luckystack/core`. Project name\n * + prefix are resolved at call time so projectConfig changes take\n * effect without reloading this module.\n */\n\nconst sessionKey = (token: string): string => formatKey('-session', token);\nconst activeUsersKey = (userId: string): string => formatKey('-activeUsers', userId);\n\nexport const redisSessionAdapter: SessionAdapter = {\n name: 'redis',\n\n async getRaw(token) {\n const [error, value] = await tryCatch(async () => await redis.get(sessionKey(token)));\n if (error) return null;\n return value ?? null;\n },\n\n async setRaw(token, value, ttlSeconds) {\n //? Atomic SET+EX (LOGIN-M7): a two-step SET then EXPIRE leaves a window\n //? where a crash or EXPIRE failure produces an immortal (TTL-less) key that\n //? a stolen token could use indefinitely. A single command eliminates that\n //? gap — the TTL is committed in the same atomic operation as the write.\n await redis.set(sessionKey(token), value, 'EX', ttlSeconds);\n },\n\n async delete(token) {\n await redis.del(sessionKey(token));\n },\n\n async expire(token, ttlSeconds) {\n const result = await redis.expire(sessionKey(token), ttlSeconds);\n return result === 1;\n },\n\n async ttl(token) {\n const [error, value] = await tryCatch(async () => await redis.ttl(sessionKey(token)));\n if (error) return null;\n return typeof value === 'number' ? value : null;\n },\n\n async trackActive(userId, token, ttlSeconds) {\n const key = activeUsersKey(userId);\n await redis.sadd(key, token);\n await redis.expire(key, ttlSeconds);\n },\n\n async untrackActive(userId, token) {\n await redis.srem(activeUsersKey(userId), token);\n },\n\n async touchActive(userId, ttlSeconds) {\n //? `expire` only sets a TTL on an EXISTING key (returns 0 on miss), so this\n //? never resurrects an emptied active-set — it just keeps a live one's TTL\n //? in lock-step with the session key it is read alongside.\n await redis.expire(activeUsersKey(userId), ttlSeconds);\n },\n\n async listActive(userId) {\n const [error, value] = await tryCatch(async () => await redis.smembers(activeUsersKey(userId)));\n if (error || !Array.isArray(value)) return [];\n return value;\n },\n\n async listAll() {\n const pattern = `${formatKey('-session', '')}:*`;\n const collected: { token: string; raw: string }[] = [];\n let cursor = '0';\n\n do {\n const [scanError, scanResponse] = await tryCatch(\n async () => await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100),\n );\n if (scanError || !scanResponse) break;\n\n cursor = scanResponse[0];\n const keys = scanResponse[1];\n\n if (Array.isArray(keys) && keys.length > 0) {\n const values = await Promise.all(keys.map((k) => redis.get(k)));\n for (const [idx, k] of keys.entries()) {\n const raw = values[idx];\n if (raw) {\n collected.push({\n token: k.replace(`${formatKey('-session', '')}:`, ''),\n raw,\n });\n }\n }\n }\n } while (cursor !== '0');\n\n return collected;\n },\n};\n\nlet activeAdapter: SessionAdapter = redisSessionAdapter;\n\n/**\n * Replace the active session storage backend. Call once at boot from\n * `luckystack/server/index.ts`, BEFORE the first login request. Common\n * use cases:\n *\n * - DynamoDB / Cosmos DB for serverless / edge deployments\n * - Postgres `sessions` table when you already operate Postgres\n * - signed-JWT-stateless (the adapter writes nothing, reads from the\n * JWT, and stores activeUsers per token in a small Redis hash)\n * - in-memory mock for integration tests\n *\n * Last-write-wins: subsequent calls overwrite the active adapter.\n */\nexport const registerSessionAdapter = (adapter: SessionAdapter): void => {\n activeAdapter = adapter;\n};\n\n/** Read the currently-active session adapter (defaults to Redis). */\nexport const getSessionAdapter = (): SessionAdapter => activeAdapter;\n","//? Session-field redaction seam (M7). The default session record persisted to\n//? the store AND broadcast to the browser is the full user row minus only\n//? `password` (see `sanitizeUserForSession` in login.ts). Any other sensitive\n//? column (2FA secrets, billing ids, internal flags) would otherwise leak to\n//? the client. `preSessionCreate` is veto-only (it cannot mutate the payload),\n//? so there was no supported way to redact fields. This registry provides one:\n//? register a sanitizer that returns the session shape to persist/broadcast.\n//?\n//? The sanitizer runs on EVERY session write (saveSession), so it covers the\n//? credentials, register, and OAuth paths uniformly without each having to\n//? remember to redact. It must be PURE and SYNCHRONOUS (hot path) and must\n//? return a session object — returning the input unchanged is the no-op default.\n\nimport type { BaseSessionLayout } from './sessionLayout';\n\nexport type SessionSanitizer = (session: BaseSessionLayout) => BaseSessionLayout;\n\nlet registeredSanitizer: SessionSanitizer | null = null;\n\n/**\n * Register a redactor applied to the session record before it is persisted to\n * the store and broadcast to connected clients. Call once at boot. Last write\n * wins. Pass a function that returns a shallow copy with sensitive fields\n * stripped, e.g. `({ totpSecret, stripeCustomerId, ...safe }) => safe`.\n */\nexport const registerSessionSanitizer = (sanitizer: SessionSanitizer): void => {\n registeredSanitizer = sanitizer;\n};\n\n/** Read the active sanitizer (or `null` when none is registered). */\nexport const getSessionSanitizer = (): SessionSanitizer | null => registeredSanitizer;\n\n/**\n * Apply the registered sanitizer (no-op identity when none registered). Used by\n * `saveSession`. Defensive: a throwing sanitizer must not break login, so the\n * caller falls back to the un-sanitized record on error (logged by the caller).\n */\nexport const applySessionSanitizer = (session: BaseSessionLayout): BaseSessionLayout => {\n if (!registeredSanitizer) return session;\n return registeredSanitizer(session);\n};\n\n/** Test-only: clear the registered sanitizer between scenarios. */\nexport const resetSessionSanitizerForTests = (): void => {\n registeredSanitizer = null;\n};\n"],"mappings":"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","names":["tryCatch","socketEventNames","dispatchHook","getLogger","formatKey","tryCatch","tryCatch","logout","formatKey","tryCatch","getLogger","dispatchHook","socketEventNames"]}
package/dist/index.d.ts CHANGED
@@ -99,6 +99,7 @@ interface PreEmailChangePayload {
99
99
  }
100
100
  interface PostEmailChangeRequestedPayload {
101
101
  userId: string;
102
+ currentEmail: string;
102
103
  newEmail: string;
103
104
  sent?: boolean;
104
105
  }
@@ -751,4 +752,4 @@ type PostLoginRedirectResolver = (input: PostLoginRedirectInput) => string | Pro
751
752
  declare const registerPostLoginRedirect: (resolver: PostLoginRedirectResolver) => PostLoginRedirectResolver;
752
753
  declare const getPostLoginRedirect: () => PostLoginRedirectResolver | null;
753
754
 
754
- export { type CreateOAuthStateResult, type CredentialsLoginFailure, type CredentialsLoginResult, type CredentialsLoginSuccess, type CredentialsProvider, type EmailChangePayload, type FullOAuthProvider, type LoginFailedPayload, OAUTH_STATE_COOKIE_NAME, type OAuthCallbackResult, type OAuthProvider, type OAuthUserData, PasswordPolicyError, type PostAccountDeletePayload, type PostEmailChangeRequestedPayload, type PostEmailChangedPayload, type PostLoginPayload, type PostLoginRedirectInput, type PostLoginRedirectResolver, type PostLogoutPayload, type PostRegisterPayload, type PostSessionCreatePayload, type PostSessionDeletePayload, type PreAccountDeletePayload, type PreEmailChangePayload, type PreLoginPayload, type PreLogoutPayload, type PreRegisterPayload, type PreSessionCreatePayload, type PreSessionDeletePayload, type SessionAdapter, type SessionSanitizer, type UserAdapter, type UserAdapterCreateInput, type UserRecord, activeUsersKeyFor, asOAuthUserData, clearAuthFailures, consumeEmailChangeToken, consumePasswordResetToken, createEmailChangeToken, createOAuthState, createPasswordResetToken, credentialsProvider, defaultPrismaUserAdapter, deleteSession, discordProvider, facebookProvider, getAllSessions, getOAuthProviders, getPostLoginRedirect, getSession, getSessionAdapter, getSessionSanitizer, getUserAdapter, githubProvider, googleProvider, isAccountLocked, isFullOAuthProvider, isUserAdapterRegistered, loginCallback, loginWithCredentials, loginWithCredentialsCore, logout, microsoftProvider, recordAuthFailure, redisSessionAdapter, registerOAuthProviderFactory, registerOAuthProviders, registerPostLoginRedirect, registerSessionAdapter, registerSessionSanitizer, registerUserAdapter, registerWithCredentials, revokeUserSessions, saveSession, sendEmailChangeConfirmation, sendPasswordResetEmail, sessionKeyFor, updatePasswordHash, validatePassword, verifyPassword };
755
+ export { type CreateOAuthStateResult, type CredentialsLoginFailure, type CredentialsLoginResult, type CredentialsLoginSuccess, type CredentialsProvider, type EmailChangePayload, type FullOAuthProvider, type LoginFailedPayload, OAUTH_STATE_COOKIE_NAME, type OAuthCallbackResult, type OAuthProvider, type OAuthUserData, type PasswordChangedPayload, PasswordPolicyError, type PasswordResetCompletedPayload, type PasswordResetRequestedPayload, type PostAccountDeletePayload, type PostEmailChangeRequestedPayload, type PostEmailChangedPayload, type PostLoginPayload, type PostLoginRedirectInput, type PostLoginRedirectResolver, type PostLogoutPayload, type PostRegisterPayload, type PostSessionCreatePayload, type PostSessionDeletePayload, type PreAccountDeletePayload, type PreEmailChangePayload, type PreLoginPayload, type PreLogoutPayload, type PrePasswordChangedPayload, type PrePasswordResetCompletedPayload, type PreRegisterPayload, type PreSessionCreatePayload, type PreSessionDeletePayload, type SessionAdapter, type SessionSanitizer, type UserAdapter, type UserAdapterCreateInput, type UserRecord, activeUsersKeyFor, asOAuthUserData, clearAuthFailures, consumeEmailChangeToken, consumePasswordResetToken, createEmailChangeToken, createOAuthState, createPasswordResetToken, credentialsProvider, defaultPrismaUserAdapter, deleteSession, discordProvider, facebookProvider, getAllSessions, getOAuthProviders, getPostLoginRedirect, getSession, getSessionAdapter, getSessionSanitizer, getUserAdapter, githubProvider, googleProvider, isAccountLocked, isFullOAuthProvider, isUserAdapterRegistered, loginCallback, loginWithCredentials, loginWithCredentialsCore, logout, microsoftProvider, recordAuthFailure, redisSessionAdapter, registerOAuthProviderFactory, registerOAuthProviders, registerPostLoginRedirect, registerSessionAdapter, registerSessionSanitizer, registerUserAdapter, registerWithCredentials, revokeUserSessions, saveSession, sendEmailChangeConfirmation, sendPasswordResetEmail, sessionKeyFor, updatePasswordHash, validatePassword, verifyPassword };
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  revokeUserSessions,
30
30
  saveSession,
31
31
  sessionKeyFor
32
- } from "./chunk-Q2PZ3VZU.js";
32
+ } from "./chunk-6H42NTVX.js";
33
33
 
34
34
  // src/index.ts
35
35
  import { registerSessionProvider } from "@luckystack/core";
@@ -47,36 +47,63 @@ var lockoutKey = (accountKey, requesterIp) => {
47
47
  const base = `auth:${accountKey.trim().toLowerCase()}`;
48
48
  return requesterIp ? `${base}:${requesterIp}` : base;
49
49
  };
50
+ var warnedLockoutInert = false;
50
51
  var getAuthLockoutConfig = () => {
51
- const auth = getProjectConfig().rateLimiting.auth;
52
- if (!auth.enabled) return null;
53
- return { maxAttempts: auth.maxAttempts, windowMs: auth.windowMs };
52
+ const rateLimiting = getProjectConfig().rateLimiting;
53
+ if (!rateLimiting.auth.enabled) return null;
54
+ if (!rateLimiting.enabled && !warnedLockoutInert) {
55
+ warnedLockoutInert = true;
56
+ getLogger().warn(
57
+ "[LuckyStack] rateLimiting.auth.enabled is true but rateLimiting.enabled is false \u2014 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."
58
+ );
59
+ }
60
+ return {
61
+ maxAttempts: rateLimiting.auth.maxAttempts,
62
+ maxAttemptsPerAccount: rateLimiting.auth.maxAttemptsPerAccount,
63
+ windowMs: rateLimiting.auth.windowMs
64
+ };
54
65
  };
55
66
  var isAccountLocked = async (accountKey, requesterIp) => {
56
67
  const cfg = getAuthLockoutConfig();
57
68
  if (!cfg || !accountKey) return false;
58
- const { remaining } = await getRateLimitStatus(lockoutKey(accountKey, requesterIp), cfg.maxAttempts);
59
- return remaining <= 0;
69
+ const accountStatus = await getRateLimitStatus(lockoutKey(accountKey), cfg.maxAttemptsPerAccount);
70
+ if (accountStatus.remaining <= 0) return true;
71
+ if (requesterIp) {
72
+ const ipStatus = await getRateLimitStatus(lockoutKey(accountKey, requesterIp), cfg.maxAttempts);
73
+ if (ipStatus.remaining <= 0) return true;
74
+ }
75
+ return false;
60
76
  };
61
77
  var recordAuthFailure = async (accountKey, requesterIp) => {
62
78
  const cfg = getAuthLockoutConfig();
63
79
  if (!cfg || !accountKey) return;
64
- const { allowed, remaining } = await checkRateLimit({
65
- key: lockoutKey(accountKey, requesterIp),
66
- limit: cfg.maxAttempts,
80
+ const account = await checkRateLimit({
81
+ key: lockoutKey(accountKey),
82
+ limit: cfg.maxAttemptsPerAccount,
67
83
  windowMs: cfg.windowMs
68
84
  });
69
- if (!allowed) {
85
+ let ipBlocked = false;
86
+ if (requesterIp) {
87
+ const ip = await checkRateLimit({
88
+ key: lockoutKey(accountKey, requesterIp),
89
+ limit: cfg.maxAttempts,
90
+ windowMs: cfg.windowMs
91
+ });
92
+ ipBlocked = !ip.allowed;
93
+ }
94
+ if (!account.allowed || ipBlocked) {
70
95
  getLogger().warn("[authLockout] account temporarily locked after repeated failures", {
71
- remaining,
72
- maxAttempts: cfg.maxAttempts
96
+ maxAttempts: cfg.maxAttempts,
97
+ maxAttemptsPerAccount: cfg.maxAttemptsPerAccount,
98
+ crossIp: !account.allowed
73
99
  });
74
100
  }
75
101
  };
76
102
  var clearAuthFailures = async (accountKey, requesterIp) => {
77
103
  const cfg = getAuthLockoutConfig();
78
104
  if (!cfg || !accountKey) return;
79
- await clearRateLimit(lockoutKey(accountKey, requesterIp));
105
+ await clearRateLimit(lockoutKey(accountKey));
106
+ if (requesterIp) await clearRateLimit(lockoutKey(accountKey, requesterIp));
80
107
  };
81
108
  var COUNTING_REASONS = /* @__PURE__ */ new Set([
82
109
  "login.wrongPassword"
@@ -802,11 +829,11 @@ var fetchOAuthProfile = async (provider, accessToken) => {
802
829
  let emailVerified = false;
803
830
  if (email && provider.emailVerifiedKey) {
804
831
  const verifiedFlag = userData[provider.emailVerifiedKey];
805
- if (verifiedFlag === false) {
832
+ if (verifiedFlag !== void 0 && verifiedFlag !== true) {
806
833
  getLogger3().warn("oauth: provider email is not verified \u2014 rejecting", { provider: provider.name });
807
834
  return null;
808
835
  }
809
- if (verifiedFlag !== void 0) emailVerified = true;
836
+ if (verifiedFlag === true) emailVerified = true;
810
837
  }
811
838
  const avatarId = provider.avatarCodeKey ? userData[provider.avatarCodeKey] : void 0;
812
839
  const avatarValue = provider.avatarKey ? readStringField(userData, provider.avatarKey) : provider.getAvatar ? await provider.getAvatar({ userData, avatarId: typeof avatarId === "string" ? avatarId : "", accessToken }) : "";
@@ -848,7 +875,7 @@ var findOrCreateOAuthUser = async (provider, profile) => {
848
875
  }
849
876
  if (findResponse?.id) {
850
877
  const existingProvider = findResponse.provider ?? null;
851
- const isCrossProviderLink = existingProvider !== null && existingProvider !== provider.name;
878
+ const isCrossProviderLink = existingProvider !== provider.name;
852
879
  if (isCrossProviderLink && !emailVerified) {
853
880
  getLogger3().warn(
854
881
  "oauth: refusing to link an unverified provider email to an existing account created by a different provider (account-takeover guard)",
@@ -873,6 +900,10 @@ var findOrCreateOAuthUser = async (provider, profile) => {
873
900
  isNewUser: false
874
901
  };
875
902
  }
903
+ if (!getProjectConfig4().auth.allowRegistration) {
904
+ getLogger3().warn("oauth: registration disabled \u2014 refusing first-login account creation");
905
+ return null;
906
+ }
876
907
  const preRegisterResult = await dispatchHook("preRegister", { email, provider: provider.name, name });
877
908
  if (preRegisterResult.stopped) {
878
909
  getLogger3().warn(`oauth register aborted by preRegister hook`, { errorCode: preRegisterResult.signal.errorCode });
@@ -1172,7 +1203,8 @@ var sendEmailChangeConfirmation = async ({ userId, newEmail, userName, brand })
1172
1203
  const { sendEmail } = await loadEmailModule();
1173
1204
  const token = await createEmailChangeToken(userId, newEmail);
1174
1205
  const baseUrl = (config.app.publicUrl || "").replace(/\/+$/, "");
1175
- const confirmUrl = `${baseUrl}/settings/confirm-email?token=${encodeURIComponent(token)}`;
1206
+ const confirmPath = config.auth.emailChangeConfirmPath || "/settings/confirm-email";
1207
+ const confirmUrl = `${baseUrl}${confirmPath}?token=${encodeURIComponent(token)}`;
1176
1208
  const ttlMinutes = Math.round(config.auth.emailChangeTtlSeconds / 60);
1177
1209
  const result = await sendEmail({
1178
1210
  to: newEmail,