@luckystack/login 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ import {
2
+ logout
3
+ } from "./chunk-6H42NTVX.js";
4
+ export {
5
+ logout
6
+ };
7
+ //# sourceMappingURL=logout-65O7OTJW.js.map
package/dist/register.js CHANGED
@@ -12,12 +12,12 @@ import {
12
12
  } from "./chunk-2WYYKD2D.js";
13
13
 
14
14
  // src/register.ts
15
- import { getProjectConfig } from "@luckystack/core";
15
+ import { getProjectConfig, resolveEnvKey } from "@luckystack/core";
16
16
  if (!isUserAdapterRegistered()) {
17
17
  registerUserAdapter(defaultPrismaUserAdapter());
18
18
  }
19
19
  var buildProviders = () => {
20
- const dev = process.env.NODE_ENV !== "production";
20
+ const dev = resolveEnvKey() !== "production";
21
21
  const env = (key) => (dev ? process.env[`DEV_${key}`] : process.env[key]) ?? "";
22
22
  const projectConfig = getProjectConfig();
23
23
  const configuredCallbackBase = projectConfig.oauthCallbackBase ?? "";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/register.ts"],"sourcesContent":["//? Side-effect `./register` entry for @luckystack/login. Auto-imported at boot by\n//? @luckystack/server's `bootstrapLuckyStack` when this package is installed, so\n//? OAuth providers + the default user adapter wire themselves from env with NO\n//? consumer code edit — adding an OAuth provider is just env vars + restart.\n//?\n//? (Session-provider registration lives in this package's index module load —\n//? `bootstrapLuckyStack` force-loads it via `getLogin()`. This register entry\n//? covers the parts that USED to live in the consumer\n//? `luckystack/login/{oauthProviders,userAdapter}.ts` overlay.)\n//?\n//? A provider becomes active (its button shows on the login form via\n//? `GET /auth/providers` AND its `/auth/api/<name>` route works) as soon as BOTH\n//? its *_CLIENT_ID and *_CLIENT_SECRET are set for the current environment:\n//? `DEV_<KEY>` in dev, the unprefixed `<KEY>` in prod. The callback origin comes\n//? from `getProjectConfig().oauthCallbackBase` (set in the consumer's config.ts)\n//? — the redirect URI to register with each provider is\n//? `<oauthCallbackBase>/auth/callback/<provider>`.\n//?\n//? A consumer overlay (`luckystack/login/*.ts`) runs AFTER this import, so a\n//? hand-written `registerOAuthProviders(...)` / `registerUserAdapter(...)` still\n//? wins (last writer).\n\nimport { getProjectConfig } from '@luckystack/core';\nimport {\n registerOAuthProviderFactory,\n credentialsProvider,\n googleProvider,\n githubProvider,\n discordProvider,\n facebookProvider,\n microsoftProvider,\n type OAuthProvider,\n} from './oauthProviders';\nimport {\n registerUserAdapter,\n isUserAdapterRegistered,\n defaultPrismaUserAdapter,\n} from './userAdapter';\n\n//? Default Prisma-backed user store — guard so a consumer overlay that\n//? registered a custom adapter before this runs is never clobbered.\nif (!isUserAdapterRegistered()) {\n registerUserAdapter(defaultPrismaUserAdapter());\n}\n\n//? LOGIN-01: all config/env reads are deferred into a factory function that\n//? executes at first-request time, not at module-load time. This prevents\n//? stale-config footguns when `getProjectConfig()` is overridden after import\n//? (e.g. in tests or multi-step boot sequences). Module-load side effects\n//? in the block below are now limited to adapter registration (which is safe\n//? because the adapter registry itself does not depend on project config).\nconst buildProviders = (): OAuthProvider[] => {\n const dev = process.env.NODE_ENV !== 'production';\n\n //? Reads `DEV_<key>` in dev, the unprefixed `<key>` in prod. Empty when unset.\n const env = (key: string): string => (dev ? process.env[`DEV_${key}`] : process.env[key]) ?? '';\n\n //? Backend origin for the OAuth redirect URI. Prefer the explicit\n //? `oauthCallbackBase` slot; fall back to `app.publicUrl` so a consumer that\n //? only set `publicUrl` still gets a usable callback.\n const projectConfig = getProjectConfig();\n //? Empty-string slot (the default) must fall through to publicUrl. Use an\n //? explicit length check so `??` (which would keep the empty string) is wrong here.\n const configuredCallbackBase = projectConfig.oauthCallbackBase ?? '';\n const callbackBase = configuredCallbackBase.length > 0 ? configuredCallbackBase : projectConfig.app.publicUrl;\n const callback = (name: string): string => `${callbackBase}/auth/callback/${name}`;\n\n //? Credentials (email+password) is a registry entry too, gated by a single\n //? config flag so the registry — exposed via GET /auth/providers — is the ONE\n //? source the login form reads (no duplicate static `config.providers` list).\n const providers: OAuthProvider[] = [];\n if (projectConfig.auth.credentials) {\n providers.push(credentialsProvider());\n }\n\n if (env('GOOGLE_CLIENT_ID') && env('GOOGLE_CLIENT_SECRET')) {\n providers.push(googleProvider({\n clientId: env('GOOGLE_CLIENT_ID'),\n clientSecret: env('GOOGLE_CLIENT_SECRET'),\n callbackUrl: callback('google'),\n }));\n }\n\n if (env('GITHUB_CLIENT_ID') && env('GITHUB_CLIENT_SECRET')) {\n providers.push(githubProvider({\n clientId: env('GITHUB_CLIENT_ID'),\n clientSecret: env('GITHUB_CLIENT_SECRET'),\n callbackUrl: callback('github'),\n }));\n }\n\n if (env('DISCORD_CLIENT_ID') && env('DISCORD_CLIENT_SECRET')) {\n providers.push(discordProvider({\n clientId: env('DISCORD_CLIENT_ID'),\n clientSecret: env('DISCORD_CLIENT_SECRET'),\n callbackUrl: callback('discord'),\n }));\n }\n\n if (env('FACEBOOK_CLIENT_ID') && env('FACEBOOK_CLIENT_SECRET')) {\n providers.push(facebookProvider({\n clientId: env('FACEBOOK_CLIENT_ID'),\n clientSecret: env('FACEBOOK_CLIENT_SECRET'),\n callbackUrl: callback('facebook'),\n }));\n }\n\n if (env('MICROSOFT_CLIENT_ID') && env('MICROSOFT_CLIENT_SECRET')) {\n providers.push(microsoftProvider({\n clientId: env('MICROSOFT_CLIENT_ID'),\n clientSecret: env('MICROSOFT_CLIENT_SECRET'),\n callbackUrl: callback('microsoft'),\n tenant: env('MICROSOFT_TENANT_ID') || undefined,\n }));\n }\n\n return providers;\n};\n\nregisterOAuthProviderFactory(buildProviders);\n"],"mappings":";;;;;;;;;;;;;;AAsBA,SAAS,wBAAwB;AAmBjC,IAAI,CAAC,wBAAwB,GAAG;AAC9B,sBAAoB,yBAAyB,CAAC;AAChD;AAQA,IAAM,iBAAiB,MAAuB;AAC5C,QAAM,MAAM,QAAQ,IAAI,aAAa;AAGrC,QAAM,MAAM,CAAC,SAAyB,MAAM,QAAQ,IAAI,OAAO,GAAG,EAAE,IAAI,QAAQ,IAAI,GAAG,MAAM;AAK7F,QAAM,gBAAgB,iBAAiB;AAGvC,QAAM,yBAAyB,cAAc,qBAAqB;AAClE,QAAM,eAAe,uBAAuB,SAAS,IAAI,yBAAyB,cAAc,IAAI;AACpG,QAAM,WAAW,CAAC,SAAyB,GAAG,YAAY,kBAAkB,IAAI;AAKhF,QAAM,YAA6B,CAAC;AACpC,MAAI,cAAc,KAAK,aAAa;AAClC,cAAU,KAAK,oBAAoB,CAAC;AAAA,EACtC;AAEA,MAAI,IAAI,kBAAkB,KAAK,IAAI,sBAAsB,GAAG;AAC1D,cAAU,KAAK,eAAe;AAAA,MAC5B,UAAU,IAAI,kBAAkB;AAAA,MAChC,cAAc,IAAI,sBAAsB;AAAA,MACxC,aAAa,SAAS,QAAQ;AAAA,IAChC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,kBAAkB,KAAK,IAAI,sBAAsB,GAAG;AAC1D,cAAU,KAAK,eAAe;AAAA,MAC5B,UAAU,IAAI,kBAAkB;AAAA,MAChC,cAAc,IAAI,sBAAsB;AAAA,MACxC,aAAa,SAAS,QAAQ;AAAA,IAChC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,mBAAmB,KAAK,IAAI,uBAAuB,GAAG;AAC5D,cAAU,KAAK,gBAAgB;AAAA,MAC7B,UAAU,IAAI,mBAAmB;AAAA,MACjC,cAAc,IAAI,uBAAuB;AAAA,MACzC,aAAa,SAAS,SAAS;AAAA,IACjC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,oBAAoB,KAAK,IAAI,wBAAwB,GAAG;AAC9D,cAAU,KAAK,iBAAiB;AAAA,MAC9B,UAAU,IAAI,oBAAoB;AAAA,MAClC,cAAc,IAAI,wBAAwB;AAAA,MAC1C,aAAa,SAAS,UAAU;AAAA,IAClC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,qBAAqB,KAAK,IAAI,yBAAyB,GAAG;AAChE,cAAU,KAAK,kBAAkB;AAAA,MAC/B,UAAU,IAAI,qBAAqB;AAAA,MACnC,cAAc,IAAI,yBAAyB;AAAA,MAC3C,aAAa,SAAS,WAAW;AAAA,MACjC,QAAQ,IAAI,qBAAqB,KAAK;AAAA,IACxC,CAAC,CAAC;AAAA,EACJ;AAEA,SAAO;AACT;AAEA,6BAA6B,cAAc;","names":[]}
1
+ {"version":3,"sources":["../src/register.ts"],"sourcesContent":["//? Side-effect `./register` entry for @luckystack/login. Auto-imported at boot by\n//? @luckystack/server's `bootstrapLuckyStack` when this package is installed, so\n//? OAuth providers + the default user adapter wire themselves from env with NO\n//? consumer code edit — adding an OAuth provider is just env vars + restart.\n//?\n//? (Session-provider registration lives in this package's index module load —\n//? `bootstrapLuckyStack` force-loads it via `getLogin()`. This register entry\n//? covers the parts that USED to live in the consumer\n//? `luckystack/login/{oauthProviders,userAdapter}.ts` overlay.)\n//?\n//? A provider becomes active (its button shows on the login form via\n//? `GET /auth/providers` AND its `/auth/api/<name>` route works) as soon as BOTH\n//? its *_CLIENT_ID and *_CLIENT_SECRET are set for the current environment:\n//? `DEV_<KEY>` in dev, the unprefixed `<KEY>` in prod. The callback origin comes\n//? from `getProjectConfig().oauthCallbackBase` (set in the consumer's config.ts)\n//? — the redirect URI to register with each provider is\n//? `<oauthCallbackBase>/auth/callback/<provider>`.\n//?\n//? A consumer overlay (`luckystack/login/*.ts`) runs AFTER this import, so a\n//? hand-written `registerOAuthProviders(...)` / `registerUserAdapter(...)` still\n//? wins (last writer).\n\nimport { getProjectConfig, resolveEnvKey } from '@luckystack/core';\nimport {\n registerOAuthProviderFactory,\n credentialsProvider,\n googleProvider,\n githubProvider,\n discordProvider,\n facebookProvider,\n microsoftProvider,\n type OAuthProvider,\n} from './oauthProviders';\nimport {\n registerUserAdapter,\n isUserAdapterRegistered,\n defaultPrismaUserAdapter,\n} from './userAdapter';\n\n//? Default Prisma-backed user store — guard so a consumer overlay that\n//? registered a custom adapter before this runs is never clobbered.\nif (!isUserAdapterRegistered()) {\n registerUserAdapter(defaultPrismaUserAdapter());\n}\n\n//? LOGIN-01: all config/env reads are deferred into a factory function that\n//? executes at first-request time, not at module-load time. This prevents\n//? stale-config footguns when `getProjectConfig()` is overridden after import\n//? (e.g. in tests or multi-step boot sequences). Module-load side effects\n//? in the block below are now limited to adapter registration (which is safe\n//? because the adapter registry itself does not depend on project config).\nconst buildProviders = (): OAuthProvider[] => {\n const dev = resolveEnvKey() !== 'production';\n\n //? Reads `DEV_<key>` in dev, the unprefixed `<key>` in prod. Empty when unset.\n const env = (key: string): string => (dev ? process.env[`DEV_${key}`] : process.env[key]) ?? '';\n\n //? Backend origin for the OAuth redirect URI. Prefer the explicit\n //? `oauthCallbackBase` slot; fall back to `app.publicUrl` so a consumer that\n //? only set `publicUrl` still gets a usable callback.\n const projectConfig = getProjectConfig();\n //? Empty-string slot (the default) must fall through to publicUrl. Use an\n //? explicit length check so `??` (which would keep the empty string) is wrong here.\n const configuredCallbackBase = projectConfig.oauthCallbackBase ?? '';\n const callbackBase = configuredCallbackBase.length > 0 ? configuredCallbackBase : projectConfig.app.publicUrl;\n const callback = (name: string): string => `${callbackBase}/auth/callback/${name}`;\n\n //? Credentials (email+password) is a registry entry too, gated by a single\n //? config flag so the registry — exposed via GET /auth/providers — is the ONE\n //? source the login form reads (no duplicate static `config.providers` list).\n const providers: OAuthProvider[] = [];\n if (projectConfig.auth.credentials) {\n providers.push(credentialsProvider());\n }\n\n if (env('GOOGLE_CLIENT_ID') && env('GOOGLE_CLIENT_SECRET')) {\n providers.push(googleProvider({\n clientId: env('GOOGLE_CLIENT_ID'),\n clientSecret: env('GOOGLE_CLIENT_SECRET'),\n callbackUrl: callback('google'),\n }));\n }\n\n if (env('GITHUB_CLIENT_ID') && env('GITHUB_CLIENT_SECRET')) {\n providers.push(githubProvider({\n clientId: env('GITHUB_CLIENT_ID'),\n clientSecret: env('GITHUB_CLIENT_SECRET'),\n callbackUrl: callback('github'),\n }));\n }\n\n if (env('DISCORD_CLIENT_ID') && env('DISCORD_CLIENT_SECRET')) {\n providers.push(discordProvider({\n clientId: env('DISCORD_CLIENT_ID'),\n clientSecret: env('DISCORD_CLIENT_SECRET'),\n callbackUrl: callback('discord'),\n }));\n }\n\n if (env('FACEBOOK_CLIENT_ID') && env('FACEBOOK_CLIENT_SECRET')) {\n providers.push(facebookProvider({\n clientId: env('FACEBOOK_CLIENT_ID'),\n clientSecret: env('FACEBOOK_CLIENT_SECRET'),\n callbackUrl: callback('facebook'),\n }));\n }\n\n if (env('MICROSOFT_CLIENT_ID') && env('MICROSOFT_CLIENT_SECRET')) {\n providers.push(microsoftProvider({\n clientId: env('MICROSOFT_CLIENT_ID'),\n clientSecret: env('MICROSOFT_CLIENT_SECRET'),\n callbackUrl: callback('microsoft'),\n tenant: env('MICROSOFT_TENANT_ID') || undefined,\n }));\n }\n\n return providers;\n};\n\nregisterOAuthProviderFactory(buildProviders);\n"],"mappings":";;;;;;;;;;;;;;AAsBA,SAAS,kBAAkB,qBAAqB;AAmBhD,IAAI,CAAC,wBAAwB,GAAG;AAC9B,sBAAoB,yBAAyB,CAAC;AAChD;AAQA,IAAM,iBAAiB,MAAuB;AAC5C,QAAM,MAAM,cAAc,MAAM;AAGhC,QAAM,MAAM,CAAC,SAAyB,MAAM,QAAQ,IAAI,OAAO,GAAG,EAAE,IAAI,QAAQ,IAAI,GAAG,MAAM;AAK7F,QAAM,gBAAgB,iBAAiB;AAGvC,QAAM,yBAAyB,cAAc,qBAAqB;AAClE,QAAM,eAAe,uBAAuB,SAAS,IAAI,yBAAyB,cAAc,IAAI;AACpG,QAAM,WAAW,CAAC,SAAyB,GAAG,YAAY,kBAAkB,IAAI;AAKhF,QAAM,YAA6B,CAAC;AACpC,MAAI,cAAc,KAAK,aAAa;AAClC,cAAU,KAAK,oBAAoB,CAAC;AAAA,EACtC;AAEA,MAAI,IAAI,kBAAkB,KAAK,IAAI,sBAAsB,GAAG;AAC1D,cAAU,KAAK,eAAe;AAAA,MAC5B,UAAU,IAAI,kBAAkB;AAAA,MAChC,cAAc,IAAI,sBAAsB;AAAA,MACxC,aAAa,SAAS,QAAQ;AAAA,IAChC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,kBAAkB,KAAK,IAAI,sBAAsB,GAAG;AAC1D,cAAU,KAAK,eAAe;AAAA,MAC5B,UAAU,IAAI,kBAAkB;AAAA,MAChC,cAAc,IAAI,sBAAsB;AAAA,MACxC,aAAa,SAAS,QAAQ;AAAA,IAChC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,mBAAmB,KAAK,IAAI,uBAAuB,GAAG;AAC5D,cAAU,KAAK,gBAAgB;AAAA,MAC7B,UAAU,IAAI,mBAAmB;AAAA,MACjC,cAAc,IAAI,uBAAuB;AAAA,MACzC,aAAa,SAAS,SAAS;AAAA,IACjC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,oBAAoB,KAAK,IAAI,wBAAwB,GAAG;AAC9D,cAAU,KAAK,iBAAiB;AAAA,MAC9B,UAAU,IAAI,oBAAoB;AAAA,MAClC,cAAc,IAAI,wBAAwB;AAAA,MAC1C,aAAa,SAAS,UAAU;AAAA,IAClC,CAAC,CAAC;AAAA,EACJ;AAEA,MAAI,IAAI,qBAAqB,KAAK,IAAI,yBAAyB,GAAG;AAChE,cAAU,KAAK,kBAAkB;AAAA,MAC/B,UAAU,IAAI,qBAAqB;AAAA,MACnC,cAAc,IAAI,yBAAyB;AAAA,MAC3C,aAAa,SAAS,WAAW;AAAA,MACjC,QAAQ,IAAI,qBAAqB,KAAK;AAAA,IACxC,CAAC,CAAC;AAAA,EACJ;AAEA,SAAO;AACT;AAEA,6BAA6B,cAAc;","names":[]}
@@ -86,6 +86,15 @@ const consumeOAuthState = async (provider, state) => {
86
86
 
87
87
  This is a one-time-use redemption — replaying the same `state` on a second callback fails. Combined with the short TTL it defeats both CSRF and replay attacks on the callback.
88
88
 
89
+ > **Implementation note (current):** the two snippets above are simplified. The
90
+ > real `createOAuthState` stores a JSON envelope `{ nonceHash, codeVerifier?,
91
+ > returnUrl? }` (not the literal `'1'`), and the authorize route sets a short-lived
92
+ > HttpOnly `ls-oauth-state` browser-binding cookie holding the nonce. `consumeOAuthState`
93
+ > does the atomic `GET`+`DEL`, then timing-safe-compares the cookie's nonce hash
94
+ > against the stored `nonceHash` (browser binding, F1); for PKCE providers (`usePkce`)
95
+ > it also returns the stored `codeVerifier` for the S256 token exchange (F11). So the
96
+ > flow defends against CSRF, replay, AND cross-browser state theft.
97
+
89
98
  ## `OAuthProvider` discriminated union
90
99
 
91
100
  ```ts
@@ -236,7 +245,7 @@ Defaults:
236
245
  | `nameKey` | `'login'` (GitHub username — `name` can be null for users who never set one) |
237
246
  | `emailKey` | `'email'` |
238
247
  | `avatarKey` | `'avatar_url'` |
239
- | `getEmail` | Falls back to `GET /user/emails` and picks the entry with `primary: true`, then the first one. |
248
+ | `getEmail` | Falls back to `GET /user/emails`, filters to `verified === true`, then prefers the `primary` verified address (else the first verified one). Unverified addresses are never used. |
240
249
 
241
250
  GitHub returns `email: null` from `/user` when the user has hidden their email from their profile. `getEmail` fetches `/user/emails` (which requires the `user:email` scope) and selects an address.
242
251
 
@@ -320,9 +320,9 @@ The right mental model: `isAllowedRedirectUrl` is the "I trust this URL enough t
320
320
 
321
321
  ## Validation gotchas
322
322
 
323
- ### `localhost` is NOT auto-allowed
323
+ ### `localhost` is auto-allowed ONLY when `http.cors.allowLocalhost` is on
324
324
 
325
- The validation does not give `localhost` / `127.0.0.1` a free pass. If your resolver wants to return an absolute `http://localhost:5173/...` URL during dev, add it to `allowedOrigins`:
325
+ When `http.cors.allowLocalhost` is `true` (a dev convenience; defaults `false` in prod), an absolute redirect whose hostname is exactly `localhost` IS auto-allowed (note: only the `localhost` hostname — `127.0.0.1` is NOT covered by this branch). With `allowLocalhost` off (the prod default) `localhost` gets no free pass. To allow a specific loopback origin regardless, add it to `allowedOrigins`:
326
326
 
327
327
  ```ts
328
328
  registerProjectConfig({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/login",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -59,7 +59,7 @@
59
59
  "test": "vitest run"
60
60
  },
61
61
  "dependencies": {
62
- "@luckystack/core": "^0.2.7",
62
+ "@luckystack/core": "^0.3.0",
63
63
  "bcryptjs": "^3.0.0",
64
64
  "dotenv": "^17.0.0",
65
65
  "validator": "^13.15.35"
@@ -67,7 +67,7 @@
67
67
  "peerDependencies": {
68
68
  "@prisma/client": "^6.19.0",
69
69
  "socket.io": "^4.8.0",
70
- "@luckystack/email": "^0.2.7"
70
+ "@luckystack/email": "^0.3.0"
71
71
  },
72
72
  "peerDependenciesMeta": {
73
73
  "@luckystack/email": {
@@ -1 +0,0 @@
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 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\n await adapter.delete(token);\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"]}
@@ -1,7 +0,0 @@
1
- import {
2
- logout
3
- } from "./chunk-Q2PZ3VZU.js";
4
- export {
5
- logout
6
- };
7
- //# sourceMappingURL=logout-E3FUC4Q6.js.map