@luckystack/login 0.4.1 → 0.5.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.
@@ -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\";\r\nimport { tryCatch, socketEventNames, dispatchHook, getLogger } from '@luckystack/core';\r\nimport { deleteSession } from \"./session\";\r\n\r\nexport const logout = async ({ token, socket, userId, skipSessionDelete }: {\r\n token: string | null,\r\n socket: Socket | undefined,\r\n userId: string | null,\r\n skipSessionDelete?: boolean,\r\n}) => {\r\n const [error, result] = await tryCatch(async () => {\r\n if (!socket) {\r\n getLogger().warn('logout: invalid socket');\r\n return;\r\n }\r\n if (!token) {\r\n getLogger().warn('logout: no token provided');\r\n return;\r\n }\r\n\r\n const preLogoutResult = await dispatchHook('preLogout', { userId, token });\r\n if (preLogoutResult.stopped) {\r\n getLogger().warn('logout: aborted by preLogout hook', { errorCode: preLogoutResult.signal.errorCode });\r\n socket.emit(socketEventNames.logout, \"error\");\r\n return;\r\n }\r\n\r\n getLogger().debug(`logout: user ${userId ?? '?'}`, { tokenPrefix: token ? token.slice(0, 8) : null });\r\n\r\n if (!skipSessionDelete) {\r\n //? `deleteSession` handles both the key delete AND `adapter.untrackActive`\r\n //? through the registered SessionAdapter (LOGIN-02). A separate raw\r\n //? `redis.srem(activeUsersKeyFor(userId), token)` call here was a direct\r\n //? bypass that broke custom adapters and duplicated work the adapter already\r\n //? performed; removed.\r\n await deleteSession(token);\r\n }\r\n //? `socket.leave` returns a Promise in socket.io's adapter contract but the\r\n //? room-leave is fire-and-forget here (the session is already being torn\r\n //? down) — `void` marks it intentionally un-awaited without changing timing.\r\n void socket.leave(token);\r\n\r\n // Presence state (disconnectTimers, tempDisconnectedSockets) is cleaned up\r\n // by `@luckystack/presence`'s `postLogout` hook handler, registered at\r\n // server startup via `registerPresenceHooks()`. Keeps login → presence\r\n // a one-way dep: presence knows about login (uses getSession/deleteSession),\r\n // login does not know about presence.\r\n await dispatchHook('postLogout', { userId, token });\r\n return true;\r\n });\r\n if (error) {\r\n if (socket) {\r\n socket.emit(socketEventNames.logout, \"error\");\r\n }\r\n } else if (result && socket) {\r\n //? Opt-in logout tracing. This fires on EVERY successful logout, so an\r\n //? unconditional warn+stacktrace spams the server log. Set\r\n //? LUCKYSTACK_TRACE_SESSION_DELETES=1 when hunting a \"why was I logged out?\"\r\n //? bug to print the originating caller's stack; silent otherwise. The\r\n //? client still receives the success-emit and clears its session regardless.\r\n if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === '1') {\r\n getLogger().warn('[session] logout success — emitting logout to socket', {\r\n tokenPrefix: token ? token.slice(0, 8) : null,\r\n userId,\r\n socketId: socket.id,\r\n stack: new Error('logout invoked').stack,\r\n });\r\n }\r\n socket.emit(socketEventNames.logout, \"success\");\r\n }\r\n}\r\n","//? @adr 0018 — Token-exposure contract: `saveSession`'s `updateSession`\r\n//? broadcast surfaces the session token to page JS only in `sessionBasedToken`\r\n//? mode; cookie mode gets the token-stripped projection. See ADR 0018.\r\nimport type { BaseSessionLayout as SessionLayout } from \"./sessionLayout\";\r\nimport { randomBytes } from 'node:crypto';\r\nimport {\r\n socketEventNames,\r\n dispatchHook,\r\n getCsrfConfig,\r\n getLogger,\r\n getProjectConfig,\r\n formatKey,\r\n tryCatch,\r\n tryCatchSync,\r\n} from '@luckystack/core';\r\n\r\nimport { getSessionAdapter } from './sessionAdapter';\r\nimport { applySessionSanitizer } from './sessionSanitizer';\r\n\r\n//? Resolved at call time so project registration order doesn't matter.\r\n//? Session TTL comes from projectConfig and is honoured by every adapter\r\n//? (Redis default, or any custom adapter the consumer registers).\r\nconst getSessionTtl = (): number => 60 * 60 * 24 * getProjectConfig().session.expiryDays;\r\n\r\nconst isSessionLayout = (value: unknown): value is SessionLayout => {\r\n return value !== null && typeof value === 'object' && 'id' in value;\r\n};\r\n\r\nconst parseSessionLayout = (value: string): SessionLayout | null => {\r\n const parsed = JSON.parse(value) as unknown;\r\n return isSessionLayout(parsed) ? parsed : null;\r\n};\r\n\r\n/**\r\n * Save or update a user session through the active session adapter.\r\n *\r\n * @param token - The session token (unique identifier)\r\n * @param data - The session data to store\r\n * @param newUser - If true, this is a new login (triggers single-session enforcement)\r\n * @param options.supersedeToken - A token belonging to the SAME browser that is\r\n * being replaced by this login (e.g. re-login while already signed in). It is\r\n * excluded from single-session enforcement so the user's current device is not\r\n * kicked by its own new session. The caller is responsible for deleting it\r\n * afterwards (use `deleteSession(supersedeToken, { skipSocketLogout: true })`).\r\n */\r\nconst saveSession = async (\r\n token: string,\r\n data: SessionLayout,\r\n newUser?: boolean,\r\n options?: { supersedeToken?: string },\r\n): Promise<{ ok: true } | { ok: false; errorCode: string }> => {\r\n //? Reject a session with an empty `id` (B30). `isSessionLayout` only checks\r\n //? the `id` KEY is present, so a custom adapter / caller handing us an\r\n //? empty-string id would mint a session that is never tracked in the\r\n //? activeUsers set keyed by id — making it invisible to `revokeUserSessions`\r\n //? / \"sign out everywhere\". A real authenticated user always has a non-empty\r\n //? id, so this can only reject a malformed/untrackable session.\r\n if (!data.id) {\r\n //? Log only the first 8 chars of the token (LOGIN-F2) — enough to correlate\r\n //? with a specific request in logs/traces without exposing a live credential.\r\n getLogger().error('saveSession: refusing to persist a session with an empty user id', { tokenPrefix: token.slice(0, 8) });\r\n return { ok: false, errorCode: 'api.internalServerError' };\r\n }\r\n\r\n const [error, outcome] = await tryCatch(async () => {\r\n const adapter = getSessionAdapter();\r\n\r\n if (newUser) {\r\n const preSessionCreateResult = await dispatchHook('preSessionCreate', {\r\n token,\r\n user: data,\r\n persistent: !getProjectConfig().session.basedToken,\r\n });\r\n if (preSessionCreateResult.stopped) {\r\n getLogger().warn(`session create aborted by preSessionCreate hook`, { errorCode: preSessionCreateResult.signal.errorCode });\r\n return { ok: false, errorCode: preSessionCreateResult.signal.errorCode || 'api.internalServerError' } as const;\r\n }\r\n\r\n //? `onConflict: 'rejectNew'` enforcement. Previously this was documented\r\n //? as \"enforced at the API layer\" but NO code refused the login — the new\r\n //? session was persisted regardless and sessions accumulated unbounded,\r\n //? making the documented cap a silent no-op. Enforce it HERE (before any\r\n //? persist) so every login surface — credentials AND OAuth — is covered by\r\n //? construction: when `perUser: 'multiple'` with a `maxConcurrentPerUser`\r\n //? cap already reached and `onConflict === 'rejectNew'`, refuse the new\r\n //? login with a dedicated reason key instead of kicking an existing\r\n //? session. `'single'` and `'revokeOld'` keep kicking (handled below).\r\n const cfg = getProjectConfig().session;\r\n const perUser = cfg.perUser;\r\n if (\r\n perUser === 'multiple' &&\r\n cfg.onConflict === 'rejectNew' &&\r\n cfg.maxConcurrentPerUser !== null &&\r\n data.id\r\n ) {\r\n //? SOFT-CAP (LOGIN-F4): this is a read-then-decide, not an atomic\r\n //? reserve, so two concurrent logins for the same user can both pass the\r\n //? count check and temporarily exceed `maxConcurrentPerUser` by one.\r\n //? A Lua SETNX atomic counter would close the gap, but at the cost of a\r\n //? separate primitive and significant complexity. The window is narrowly\r\n //? time-bounded (< round-trip latency of two simultaneous logins), the\r\n //? worst case is one extra session that cleans up at normal TTL, and\r\n //? consumers operating strict caps should keep `maxConcurrentPerUser` at\r\n //? a margin that absorbs the race. Documented here so no future change\r\n //? re-tightens without understanding the trade-off.\r\n const existing = await getSessionAdapter().listActive(data.id);\r\n const others = existing.filter((t) => t !== token && t !== options?.supersedeToken);\r\n if (others.length + 1 > cfg.maxConcurrentPerUser) {\r\n getLogger().warn('session create rejected — concurrent-session cap reached (onConflict: rejectNew)', {\r\n userId: data.id,\r\n cap: cfg.maxConcurrentPerUser,\r\n active: others.length,\r\n });\r\n return { ok: false, errorCode: 'login.sessionLimitReached' } as const;\r\n }\r\n }\r\n }\r\n\r\n //? Mint a CSRF token on first session write. Subsequent (non-new) writes\r\n //? preserve the existing token so the client doesn't have to re-fetch on\r\n //? every session update. The token is rotated on logout via `deleteSession`\r\n //? AND whenever a genuinely new session is established (`newUser`) — a fresh\r\n //? login / OAuth re-login must not inherit a CSRF token that may have been\r\n //? carried into `data` from a prior session, so we always mint a new one.\r\n //? Token length is consumer-configurable via `registerCsrfConfig({ tokenLength })`.\r\n if (newUser || !data.csrfToken) {\r\n data.csrfToken = randomBytes(getCsrfConfig().tokenLength).toString('hex');\r\n }\r\n\r\n //? Apply the optional session sanitizer (M7) before BOTH persist and the\r\n //? client broadcast, so a consumer-registered redactor strips sensitive\r\n //? non-password columns (2FA secrets, billing ids, internal flags) that the\r\n //? default `sanitizeUserForSession` (password-only) leaves on the record. A\r\n //? throwing sanitizer must not break login — fall back to the raw record and\r\n //? log. `data.id` (used below for tracking/enforcement) is read from the\r\n //? original record, which is unaffected.\r\n //? Fail-CLOSED on a throwing sanitizer (M6): the previous fallback persisted\r\n //? AND broadcast the RAW record, leaking exactly the sensitive columns\r\n //? (2FA secrets, billing ids, internal flags) the sanitizer exists to strip.\r\n //? On error, fall back to a minimal known-safe projection — drop `password`\r\n //? (always) and any field a registered sanitizer was meant to redact is at\r\n //? least not leaked verbatim; the user can re-login if a field they needed is\r\n //? missing, but we never broadcast secrets.\r\n let persisted = data;\r\n const [sanitizeErr, sanitized] = tryCatchSync(() => applySessionSanitizer(data));\r\n if (sanitizeErr) {\r\n getLogger().error('saveSession: session sanitizer threw — falling back to password-stripped projection', sanitizeErr, { tokenPrefix: token.slice(0, 8) });\r\n const { password: _password, ...safe } = data as SessionLayout & { password?: unknown };\r\n persisted = safe;\r\n } else if (sanitized) {\r\n persisted = sanitized;\r\n }\r\n\r\n const ttl = getSessionTtl();\r\n //? Strip `token` from the stored value (LOGIN-M9). The token is the Redis key\r\n //? itself, so embedding it in the value is redundant AND means a Redis keyspace\r\n //? dump / `getAllSessions` / any admin log that prints a session record exposes\r\n //? a live replayable credential. `getSession` re-attaches it from the lookup\r\n //? key (`{ ...parsed, token }`) so callers receive the expected shape.\r\n const { token: _stripToken, ...persistedWithoutToken } = persisted as SessionLayout & { token?: unknown };\r\n await adapter.setRaw(token, JSON.stringify(persistedWithoutToken), ttl);\r\n\r\n const userId = data.id;\r\n\r\n //? Always track active tokens BEFORE the io check — `trackActive` is a\r\n //? Redis-only write, independent of any live socket. Gating it behind a\r\n //? live io meant processes without a Socket.io instance (the test harness,\r\n //? background workers, CLI tasks) persisted the session but never populated\r\n //? the active-tokens set, so `listSessions` / `revokeUserSessions` saw\r\n //? nothing for that user.\r\n if (userId) {\r\n await adapter.trackActive(userId, token, ttl);\r\n }\r\n\r\n const { getIoInstance } = await import('@luckystack/core');\r\n const io = getIoInstance();\r\n //? Socket fanout + single-session enforcement below need a live io; skip\r\n //? them when there isn't one. The session IS already persisted + tracked, so\r\n //? this is a success (background workers / CLI tasks have no io).\r\n if (!io) return { ok: true } as const;\r\n\r\n // Handle session-limit enforcement on new login. Logic:\r\n // 1. perUser = 'single' (default) →\r\n // kick every prior session for this user.\r\n // 2. perUser = 'multiple' with `maxConcurrentPerUser` cap reached:\r\n // `onConflict === 'revokeOld'` kicks oldest until under cap,\r\n // `onConflict === 'rejectNew'` already refused the login above (before\r\n // persisting), so by here the cap has room.\r\n // 3. perUser = 'multiple' with no cap → no kick.\r\n if (newUser && userId) {\r\n const sessionCfg = getProjectConfig().session;\r\n const effectivePerUser = sessionCfg.perUser;\r\n const cap = sessionCfg.maxConcurrentPerUser;\r\n\r\n const allTokens = await adapter.listActive(userId);\r\n //? Exclude the current token — it was just added and has no socket room yet.\r\n //? Also exclude `supersedeToken`: when the same browser re-logs-in, its old\r\n //? token is being replaced (not a separate device), so kicking it would log\r\n //? the user out of the very browser performing the login.\r\n const previousTokens = allTokens.filter(\r\n (t) => t !== token && t !== options?.supersedeToken,\r\n );\r\n\r\n let tokensToKick: string[] = [];\r\n if (effectivePerUser === 'single') {\r\n tokensToKick = previousTokens;\r\n } else if (cap !== null && previousTokens.length + 1 > cap && sessionCfg.onConflict === 'revokeOld') {\r\n // Kick oldest until under cap. Without ordering metadata in the adapter,\r\n // we drop the head of the list — most adapters return insertion-order\r\n // for set-like structures, but consumers needing strict LRU should\r\n // implement a custom adapter that orders deterministically.\r\n const excess = previousTokens.length + 1 - cap;\r\n tokensToKick = previousTokens.slice(0, excess);\r\n }\r\n\r\n if (tokensToKick.length > 0) {\r\n const { logout } = await import('./logout');\r\n\r\n await Promise.all(tokensToKick.map(async (previousToken) => {\r\n const sockets = io.sockets.adapter.rooms.get(previousToken);\r\n if (sockets) {\r\n getLogger().debug(`Kicking previous session for user ${userId}`);\r\n // Optional UI notification before the disconnect lands.\r\n if (sessionCfg.notifyOldDeviceOnRevoke) {\r\n io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({\r\n reason: 'session-replaced',\r\n userId,\r\n }));\r\n }\r\n for (const socketId of sockets) {\r\n const socket = io.sockets.sockets.get(socketId);\r\n if (socket) {\r\n await logout({ token: previousToken, socket, userId });\r\n }\r\n }\r\n } else {\r\n // No active sockets, just clean up the adapter state\r\n await adapter.delete(previousToken);\r\n await adapter.untrackActive(userId, previousToken);\r\n }\r\n }));\r\n }\r\n }\r\n\r\n // Broadcast session updates to connected clients (sanitized copy).\r\n //? @adr 0018 — Token-exposure contract: NEVER broadcast the session token to\r\n //? page JS. In cookie mode it is the HttpOnly-cookie credential; in\r\n //? `sessionBasedToken` mode the client already holds it in sessionStorage\r\n //? (set at login / OAuth handoff). The client never consumes the token from\r\n //? this payload in either mode (the socket handshake reads sessionStorage /\r\n //? the cookie), so we always send the token-stripped projection — the same one\r\n //? persisted to the adapter at LOGIN-M9 — matching the CLIENT `ClientSessionLayout`.\r\n if (io.sockets.adapter.rooms.has(token)) {\r\n io.to(token).emit(socketEventNames.updateSession, JSON.stringify(persistedWithoutToken));\r\n }\r\n\r\n if (newUser) {\r\n await dispatchHook('postSessionCreate', { token, user: data, persistent: !getProjectConfig().session.basedToken });\r\n //? Core-level observational session-lifecycle hook (CORE-40). Dispatched\r\n //? alongside the login-owned `postSessionCreate` so consumers (audit,\r\n //? presence, error-tracking) can react to a minted session via core's\r\n //? `registerHook('sessionCreated', …)` WITHOUT importing @luckystack/login.\r\n //? Fire-and-forget — the session already exists, so a handler error must\r\n //? not change the login outcome.\r\n if (userId) {\r\n void dispatchHook('sessionCreated', { token, userId });\r\n }\r\n }\r\n\r\n return { ok: true } as const;\r\n }, undefined, { fn: 'saveSession', token });\r\n\r\n if (error) {\r\n getLogger().error('saveSession failed', error, { tokenPrefix: token.slice(0, 8) });\r\n //? Surface the failure instead of swallowing it. Callers (credentials/OAuth\r\n //? login) MUST NOT mint a token + delete the prior session when the new\r\n //? session never persisted — a transient adapter blip would otherwise log\r\n //? the user out with no usable replacement.\r\n return { ok: false, errorCode: 'api.internalServerError' };\r\n }\r\n return outcome ?? { ok: false, errorCode: 'api.internalServerError' };\r\n};\r\n\r\n/**\r\n * Retrieve a user session through the active session adapter.\r\n *\r\n * @param token - The session token\r\n * @param options.refresh - When false, skip the sliding-expiration TTL\r\n * extension. Defaults to true. Pass false for read-only probes (e.g. a\r\n * background audit, an admin lookup) where a stolen token should NOT earn\r\n * extra lifetime just because the server touched it (LOGIN-F9).\r\n * @returns The session data or null if not found\r\n */\r\nconst getSession = async (\r\n token: string | null,\r\n options?: { refresh?: boolean },\r\n): Promise<SessionLayout | null> => {\r\n if (!token) return null;\r\n\r\n const [error, value] = await tryCatch(async () => {\r\n const adapter = getSessionAdapter();\r\n\r\n const raw = await adapter.getRaw(token);\r\n if (!raw) return null;\r\n\r\n const parsed = parseSessionLayout(raw);\r\n const userId = parsed?.id ?? null;\r\n\r\n //? Sliding expiration: each successful authenticated access extends session\r\n //? lifetime. `options.refresh` defaults to true to preserve the existing\r\n //? behaviour on the hot request path. Pass `{ refresh: false }` from\r\n //? background / admin probes that must not extend a potentially stolen token.\r\n const shouldRefresh = options?.refresh !== false;\r\n\r\n // Sliding expiration: each successful authenticated access extends session lifetime.\r\n const newTtl = getSessionTtl();\r\n const oldTtl = await adapter.ttl(token);\r\n const preRefreshResult = shouldRefresh\r\n ? await dispatchHook('preSessionRefresh', { token, userId, oldTtl, newTtl })\r\n : { stopped: true };\r\n\r\n //? A `preSessionRefresh` handler can stop the TTL extension (e.g. admin\r\n //? freezing a session pending review). When stopped, we skip the\r\n //? `adapter.expire` call and surface `applied: false` on the post payload\r\n //? so audit handlers see the same signal.\r\n let applied = false;\r\n if (!preRefreshResult.stopped) {\r\n applied = await adapter.expire(token, newTtl);\r\n //? Also refresh the activeUsers-set TTL on a sliding read. `trackActive`\r\n //? (which sets that TTL) runs only on saveSession, so a session kept\r\n //? alive purely by reads would outlive its activeUsers entry — after\r\n //? which `revokeUserSessions` / single-session enforcement enumerate\r\n //? `listActive` -> [] and silently miss the live token (a stolen token\r\n //? would then survive a password reset / sign-out-everywhere). Refresh\r\n //? the set TTL in lock-step with the session-key TTL to close that drift.\r\n if (applied && userId && adapter.touchActive) {\r\n await adapter.touchActive(userId, newTtl);\r\n }\r\n }\r\n if (shouldRefresh) {\r\n await dispatchHook('postSessionRefresh', {\r\n token,\r\n userId,\r\n oldTtl,\r\n newTtl,\r\n applied,\r\n });\r\n }\r\n\r\n if (!parsed) return null;\r\n const merged: SessionLayout = { ...parsed, token };\r\n return merged;\r\n }, undefined, { fn: 'getSession', token });\r\n\r\n if (error) {\r\n getLogger().error('getSession failed', error, { tokenPrefix: token.slice(0, 8) });\r\n return null;\r\n }\r\n return value;\r\n};\r\n\r\n/**\r\n * Delete a user session through the active adapter and notify connected\r\n * clients.\r\n *\r\n * @param token - The session token to delete\r\n * @param options.skipSocketLogout - When true, the session data + active-token\r\n * tracking are cleaned up and the pre/post delete hooks still fire, but NO\r\n * `logout` is emitted to sockets in the token's room. Use this when the SAME\r\n * browser is replacing its session (re-login): emitting a logout would bounce\r\n * that browser to the login page and null its brand-new session.\r\n * @returns true if successful\r\n */\r\nconst deleteSession = async (\r\n token: string,\r\n options?: { skipSocketLogout?: boolean },\r\n): Promise<boolean> => {\r\n //? Opt-in spurious-delete tracing. `deleteSession` runs on EVERY legitimate\r\n //? logout / revokeSession / signOutEverywhere / deleteAccount, so an\r\n //? unconditional warn+stacktrace drowns the signal it's meant to surface and\r\n //? spams dev/test output with stacktrace-shaped lines that read like errors.\r\n //? Set LUCKYSTACK_TRACE_SESSION_DELETES=1 when hunting a \"why was I logged\r\n //? out?\" bug to print the originating caller's stack; silent otherwise.\r\n if (process.env.LUCKYSTACK_TRACE_SESSION_DELETES === '1') {\r\n getLogger().warn('[session] deleteSession invoked', {\r\n tokenPrefix: token.slice(0, 8),\r\n stack: new Error('deleteSession invoked').stack,\r\n });\r\n }\r\n\r\n const [error, ok] = await tryCatch(async () => {\r\n const adapter = getSessionAdapter();\r\n\r\n const raw = await adapter.getRaw(token);\r\n let resolvedUserId: string | null = null;\r\n\r\n if (raw) {\r\n resolvedUserId = parseSessionLayout(raw)?.id ?? null;\r\n }\r\n\r\n const preSessionDeleteResult = await dispatchHook('preSessionDelete', { token, userId: resolvedUserId });\r\n if (preSessionDeleteResult.stopped) {\r\n getLogger().warn(`session delete aborted by preSessionDelete hook`, { errorCode: preSessionDeleteResult.signal.errorCode });\r\n return false;\r\n }\r\n\r\n //? Delete the session KEY before untracking it from the activeUsers set. If\r\n //? `delete` threw transiently AFTER untrackActive, the token would stay\r\n //? readable (still in Redis) yet enumerable-as-gone — a stolen token could\r\n //? ride past a sign-out-everywhere. Deleting first means a transient throw\r\n //? leaves the token tracked + retried by the next `revokeUserSessions` sweep\r\n //? instead of orphaning a still-readable session. `delete` was already\r\n //? unconditional (ran even when `raw` was missing), so it just moves earlier.\r\n await adapter.delete(token);\r\n\r\n if (raw && resolvedUserId) {\r\n const { getIoInstance } = await import('@luckystack/core');\r\n const ioInstance = getIoInstance();\r\n\r\n // Reuse the same logout flow as single-session enforcement.\r\n if (ioInstance && !options?.skipSocketLogout) {\r\n const { logout } = await import('./logout');\r\n const sockets = ioInstance.sockets.adapter.rooms.get(token);\r\n\r\n if (sockets) {\r\n await Promise.all([...sockets].map(async (socketId) => {\r\n const socket = ioInstance.sockets.sockets.get(socketId);\r\n if (!socket) return;\r\n\r\n await logout({\r\n token,\r\n socket,\r\n userId: resolvedUserId,\r\n skipSessionDelete: true,\r\n });\r\n }));\r\n }\r\n }\r\n\r\n await adapter.untrackActive(resolvedUserId, token);\r\n }\r\n await dispatchHook('postSessionDelete', { token, userId: resolvedUserId });\r\n //? Core-level observational session-lifecycle hook (CORE-40), the revoke\r\n //? counterpart to `sessionCreated`. Lets consumers react to a revoked\r\n //? session via core's `registerHook('sessionRevoked', …)` without depending\r\n //? on login internals. Fire-and-forget.\r\n void dispatchHook('sessionRevoked', { token, userId: resolvedUserId });\r\n return true;\r\n }, undefined, { fn: 'deleteSession', token });\r\n\r\n if (error) {\r\n getLogger().error('deleteSession failed', error, { tokenPrefix: token.slice(0, 8) });\r\n return false;\r\n }\r\n return ok ?? false;\r\n};\r\n\r\n/**\r\n * Get all active sessions (admin utility). Returns empty when the active\r\n * adapter doesn't implement enumeration (signed-JWT-stateless, log-only).\r\n *\r\n * @returns Array of all session data\r\n */\r\nconst getAllSessions = async (): Promise<SessionLayout[]> => {\r\n const [error, sessions] = await tryCatch(async () => {\r\n const adapter = getSessionAdapter();\r\n if (!adapter.listAll) {\r\n getLogger().warn(`[session] getAllSessions: adapter '${adapter.name}' does not implement listAll — returning empty`);\r\n return [];\r\n }\r\n\r\n const rows = await adapter.listAll();\r\n const parsedSessions: SessionLayout[] = [];\r\n for (const { raw } of rows) {\r\n const parsed = parseSessionLayout(raw);\r\n if (parsed) parsedSessions.push(parsed);\r\n }\r\n return parsedSessions;\r\n }, undefined, { fn: 'getAllSessions' });\r\n\r\n if (error) {\r\n getLogger().error('getAllSessions failed', error);\r\n return [];\r\n }\r\n return sessions ?? [];\r\n};\r\n\r\n/**\r\n * Revoke every active session for a user, optionally keeping one alive.\r\n *\r\n * @param userId - The user whose sessions to revoke\r\n * @param exceptToken - If provided, this session is left untouched. Pass the\r\n * caller's own token when revoking after a password change so the user\r\n * isn't logged out of the device they just changed it from. Pass null/omit\r\n * to revoke everything (account deletion, \"sign out everywhere\", etc.).\r\n *\r\n * Each revocation goes through `deleteSession`, so connected sockets are\r\n * told to log out via the same flow as a normal logout. Returns the number\r\n * of sessions actually revoked.\r\n */\r\nconst revokeUserSessions = async (userId: string, exceptToken?: string | null): Promise<number> => {\r\n if (!userId) return 0;\r\n\r\n const adapter = getSessionAdapter();\r\n const tokens = await adapter.listActive(userId);\r\n const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;\r\n\r\n //? Count only the sessions that were ACTUALLY revoked (M13). `deleteSession`\r\n //? can return `false` (preSessionDelete veto, adapter error) yet was\r\n //? previously counted as revoked — so \"sign out everywhere\" / post-password-\r\n //? reset over-reported and a token a delete failed for could survive a reset.\r\n //? Summing truthy results means the count reflects reality; a failed token\r\n //? stays live and is logged by `deleteSession` for retry.\r\n const results = await Promise.all(targets.map((token) => deleteSession(token)));\r\n return results.filter(Boolean).length;\r\n};\r\n\r\n//? Legacy key-builders preserved as exports for downstream code that\r\n//? assumed a stable Redis layout (admin tooling, dev REPL scripts). New\r\n//? callers should use the active SessionAdapter instead.\r\nconst sessionKeyFor = (token: string): string => formatKey('-session', token);\r\nconst activeUsersKeyFor = (userId: string): string => formatKey('-activeUsers', userId);\r\n\r\nexport { saveSession, getSession, deleteSession, getAllSessions, revokeUserSessions, sessionKeyFor, activeUsersKeyFor };\r\n","//? Session storage DI surface. The default adapter persists session\r\n//? records in Redis (same behaviour as before extraction). Consumers can\r\n//? plug in DynamoDB / Memcached / Postgres-table / signed-JWT-stateless\r\n//? adapters by implementing `SessionAdapter` and calling\r\n//? `registerSessionAdapter(...)` at boot.\r\n//?\r\n//? The adapter owns ONLY storage primitives. Framework-level concerns\r\n//? (CSRF minting, hook dispatch, socket emission, single-session\r\n//? enforcement) stay in session.ts and call the adapter to read/write.\r\n\r\nimport { redis, tryCatch, formatKey } from '@luckystack/core';\r\n\r\nexport interface SessionAdapter {\r\n /** Human-readable identifier (used in logs/diagnostics). */\r\n name: string;\r\n\r\n /** Read the raw stored session string for `token`. Null when missing. */\r\n getRaw(token: string): Promise<string | null>;\r\n\r\n /**\r\n * Write the raw session string for `token` with a TTL. Implementations\r\n * MUST honour the TTL — sessions without one would never expire.\r\n */\r\n setRaw(token: string, value: string, ttlSeconds: number): Promise<void>;\r\n\r\n /** Delete the session record for `token`. Idempotent. */\r\n delete(token: string): Promise<void>;\r\n\r\n /**\r\n * Refresh the TTL on an existing session record (sliding-window\r\n * expiration). Returns true when the underlying record existed and the\r\n * TTL was applied, false otherwise. Implementations must not create a\r\n * record on miss.\r\n */\r\n expire(token: string, ttlSeconds: number): Promise<boolean>;\r\n\r\n /** Remaining TTL in seconds (or null when the record is missing). */\r\n ttl(token: string): Promise<number | null>;\r\n\r\n // ---- Active-tokens-per-user (for single-session enforcement and\r\n // revokeUserSessions). Implementations need a set-like structure keyed\r\n // by userId. Memcached/edge-KV adapters can serialise as a comma-list\r\n // if no native set type is available. ----\r\n\r\n trackActive(userId: string, token: string, ttlSeconds: number): Promise<void>;\r\n untrackActive(userId: string, token: string): Promise<void>;\r\n listActive(userId: string): Promise<string[]>;\r\n\r\n /**\r\n * Refresh the TTL on the active-tokens-per-user record WITHOUT adding a\r\n * token (sliding-window expiration parity with the session key). Called on\r\n * an authenticated read so a session kept alive purely by reads does not\r\n * outlive its activeUsers entry — which would otherwise let a stolen token\r\n * survive a sign-out-everywhere / password reset. Optional: backends whose\r\n * active-set entries do not expire independently (or that have no TTL\r\n * concept) can omit it. Must not create a record on miss.\r\n */\r\n touchActive?(userId: string, ttlSeconds: number): Promise<void>;\r\n\r\n /**\r\n * Admin walk: yield every active session record. Optional because\r\n * non-scannable backends (signed-JWT-stateless, log-only) cannot\r\n * enumerate. Callers fall back to a per-user listActive when omitted.\r\n *\r\n * **Dev/admin-only (LOGIN-F14).** Implementations scan the full keyspace and\r\n * load every session into memory in one call — O(all sessions) in both Redis\r\n * round-trips and memory. Do NOT call on the hot request path or from any\r\n * user-facing API. Use `listActive(userId)` for per-user lookups.\r\n */\r\n listAll?(): Promise<{ token: string; raw: string }[]>;\r\n}\r\n\r\n/**\r\n * Default adapter: Redis-backed. Key layout matches what the framework\r\n * has used since the first release, so registering this explicitly is\r\n * equivalent to not registering anything.\r\n *\r\n * Imports `redis` and `tryCatch` from `@luckystack/core`. Project name\r\n * + prefix are resolved at call time so projectConfig changes take\r\n * effect without reloading this module.\r\n */\r\n\r\nconst sessionKey = (token: string): string => formatKey('-session', token);\r\nconst activeUsersKey = (userId: string): string => formatKey('-activeUsers', userId);\r\n\r\nexport const redisSessionAdapter: SessionAdapter = {\r\n name: 'redis',\r\n\r\n async getRaw(token) {\r\n const [error, value] = await tryCatch(async () => await redis.get(sessionKey(token)));\r\n if (error) return null;\r\n return value ?? null;\r\n },\r\n\r\n async setRaw(token, value, ttlSeconds) {\r\n //? Atomic SET+EX (LOGIN-M7): a two-step SET then EXPIRE leaves a window\r\n //? where a crash or EXPIRE failure produces an immortal (TTL-less) key that\r\n //? a stolen token could use indefinitely. A single command eliminates that\r\n //? gap — the TTL is committed in the same atomic operation as the write.\r\n await redis.set(sessionKey(token), value, 'EX', ttlSeconds);\r\n },\r\n\r\n async delete(token) {\r\n await redis.del(sessionKey(token));\r\n },\r\n\r\n async expire(token, ttlSeconds) {\r\n const result = await redis.expire(sessionKey(token), ttlSeconds);\r\n return result === 1;\r\n },\r\n\r\n async ttl(token) {\r\n const [error, value] = await tryCatch(async () => await redis.ttl(sessionKey(token)));\r\n if (error) return null;\r\n return typeof value === 'number' ? value : null;\r\n },\r\n\r\n async trackActive(userId, token, ttlSeconds) {\r\n const key = activeUsersKey(userId);\r\n await redis.sadd(key, token);\r\n await redis.expire(key, ttlSeconds);\r\n },\r\n\r\n async untrackActive(userId, token) {\r\n await redis.srem(activeUsersKey(userId), token);\r\n },\r\n\r\n async touchActive(userId, ttlSeconds) {\r\n //? `expire` only sets a TTL on an EXISTING key (returns 0 on miss), so this\r\n //? never resurrects an emptied active-set — it just keeps a live one's TTL\r\n //? in lock-step with the session key it is read alongside.\r\n await redis.expire(activeUsersKey(userId), ttlSeconds);\r\n },\r\n\r\n async listActive(userId) {\r\n const [error, value] = await tryCatch(async () => await redis.smembers(activeUsersKey(userId)));\r\n if (error || !Array.isArray(value)) return [];\r\n return value;\r\n },\r\n\r\n async listAll() {\r\n const pattern = `${formatKey('-session', '')}:*`;\r\n const collected: { token: string; raw: string }[] = [];\r\n let cursor = '0';\r\n\r\n do {\r\n const [scanError, scanResponse] = await tryCatch(\r\n async () => await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100),\r\n );\r\n if (scanError || !scanResponse) break;\r\n\r\n cursor = scanResponse[0];\r\n const keys = scanResponse[1];\r\n\r\n if (Array.isArray(keys) && keys.length > 0) {\r\n const values = await Promise.all(keys.map((k) => redis.get(k)));\r\n for (const [idx, k] of keys.entries()) {\r\n const raw = values[idx];\r\n if (raw) {\r\n collected.push({\r\n token: k.replace(`${formatKey('-session', '')}:`, ''),\r\n raw,\r\n });\r\n }\r\n }\r\n }\r\n } while (cursor !== '0');\r\n\r\n return collected;\r\n },\r\n};\r\n\r\nlet activeAdapter: SessionAdapter = redisSessionAdapter;\r\n\r\n/**\r\n * Replace the active session storage backend. Call once at boot from\r\n * `luckystack/server/index.ts`, BEFORE the first login request. Common\r\n * use cases:\r\n *\r\n * - DynamoDB / Cosmos DB for serverless / edge deployments\r\n * - Postgres `sessions` table when you already operate Postgres\r\n * - signed-JWT-stateless (the adapter writes nothing, reads from the\r\n * JWT, and stores activeUsers per token in a small Redis hash)\r\n * - in-memory mock for integration tests\r\n *\r\n * Last-write-wins: subsequent calls overwrite the active adapter.\r\n */\r\nexport const registerSessionAdapter = (adapter: SessionAdapter): void => {\r\n activeAdapter = adapter;\r\n};\r\n\r\n/** Read the currently-active session adapter (defaults to Redis). */\r\nexport const getSessionAdapter = (): SessionAdapter => activeAdapter;\r\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.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  registerOAuthProviderFactory,
15
15
  registerOAuthProviders,
16
16
  registerUserAdapter
17
- } from "./chunk-RVRFZ4EI.js";
17
+ } from "./chunk-K5Z5KKKT.js";
18
18
  import {
19
19
  activeUsersKeyFor,
20
20
  deleteSession,
@@ -29,7 +29,7 @@ import {
29
29
  revokeUserSessions,
30
30
  saveSession,
31
31
  sessionKeyFor
32
- } from "./chunk-DUGC4FAV.js";
32
+ } from "./chunk-W6C2J7AW.js";
33
33
 
34
34
  // src/index.ts
35
35
  import { registerSessionProvider } from "@luckystack/core";