@warlock.js/auth 5.18.0 → 5.19.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/esm/contracts/index.d.mts +1 -1
  3. package/esm/contracts/types.d.mts +23 -2
  4. package/esm/contracts/types.d.mts.map +1 -1
  5. package/esm/contracts/types.mjs.map +1 -1
  6. package/esm/index.d.mts +3 -2
  7. package/esm/index.mjs +2 -1
  8. package/esm/middleware/auth.middleware.d.mts +9 -6
  9. package/esm/middleware/auth.middleware.d.mts.map +1 -1
  10. package/esm/middleware/auth.middleware.mjs +101 -80
  11. package/esm/middleware/auth.middleware.mjs.map +1 -1
  12. package/esm/models/access-token/access-token.model.d.mts +31 -7
  13. package/esm/models/access-token/access-token.model.d.mts.map +1 -1
  14. package/esm/models/access-token/access-token.model.mjs +21 -1
  15. package/esm/models/access-token/access-token.model.mjs.map +1 -1
  16. package/esm/models/access-token/family-association.migration.mjs +18 -0
  17. package/esm/models/access-token/family-association.migration.mjs.map +1 -0
  18. package/esm/models/auth-token-family/auth-token-family.model.d.mts +90 -0
  19. package/esm/models/auth-token-family/auth-token-family.model.d.mts.map +1 -0
  20. package/esm/models/auth-token-family/auth-token-family.model.mjs +80 -0
  21. package/esm/models/auth-token-family/auth-token-family.model.mjs.map +1 -0
  22. package/esm/models/auth-token-family/index.d.mts +1 -0
  23. package/esm/models/auth-token-family/index.mjs +3 -0
  24. package/esm/models/auth-token-family/migration.mjs +25 -0
  25. package/esm/models/auth-token-family/migration.mjs.map +1 -0
  26. package/esm/models/index.d.mts +1 -0
  27. package/esm/models/index.d.mts.map +1 -1
  28. package/esm/models/index.mjs +8 -0
  29. package/esm/models/index.mjs.map +1 -1
  30. package/esm/models/refresh-token/refresh-token.model.d.mts +43 -0
  31. package/esm/models/refresh-token/refresh-token.model.d.mts.map +1 -1
  32. package/esm/models/refresh-token/refresh-token.model.mjs +17 -0
  33. package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
  34. package/esm/models/refresh-token/successor-association.migration.mjs +22 -0
  35. package/esm/models/refresh-token/successor-association.migration.mjs.map +1 -0
  36. package/esm/services/auth.service.d.mts +22 -1
  37. package/esm/services/auth.service.d.mts.map +1 -1
  38. package/esm/services/auth.service.mjs +216 -32
  39. package/esm/services/auth.service.mjs.map +1 -1
  40. package/esm/services/token-family-operation.mjs +74 -0
  41. package/esm/services/token-family-operation.mjs.map +1 -0
  42. package/llms-full.txt +63 -58
  43. package/llms.txt +9 -9
  44. package/package.json +8 -8
  45. package/skills/auth-basics/SKILL.md +3 -2
  46. package/skills/customize-token-storage/SKILL.md +1 -1
  47. package/skills/customize-user-type/SKILL.md +1 -1
  48. package/skills/handle-login-and-logout/SKILL.md +1 -1
  49. package/skills/login-with-providers/SKILL.md +1 -1
  50. package/skills/manage-tokens/SKILL.md +19 -6
  51. package/skills/overview/SKILL.md +3 -3
  52. package/skills/protect-routes/SKILL.md +30 -39
  53. package/skills/register-user/SKILL.md +1 -1
  54. package/skills/run-auth-commands/SKILL.md +1 -1
  55. package/skills/throttle-login-attempts/SKILL.md +1 -1
  56. package/skills/verify-email-and-reset-password/SKILL.md +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"auth.service.mjs","names":[],"sources":["../../../../../../../auth/src/services/auth.service.ts"],"sourcesContent":["import { Random } from \"@mongez/reinforcements\";\nimport type { ChildModel } from \"@warlock.js/cascade\";\nimport {\n config,\n ForbiddenError,\n hashPassword,\n type Response,\n verifyPassword,\n} from \"@warlock.js/core\";\nimport type {\n AccessTokenOutput,\n AuthCredentials,\n ClearAuthCookieOptions,\n DeviceInfo,\n LoginResult,\n SetAuthCookieOptions,\n TokenPair,\n} from \"../contracts/types\";\nimport { AccessToken } from \"../models/access-token\";\nimport type { Auth } from \"../models/auth.model\";\nimport { OneTimeToken } from \"../models/one-time-token\";\nimport { RefreshToken } from \"../models/refresh-token\";\nimport { authConfig } from \"./auth-config\";\nimport { authEvents } from \"./auth-events\";\nimport { isInvalidCredentialError, jwt } from \"./jwt\";\n\nclass AuthService {\n /**\n * Resolve the active access-token model — the package default, or a subclass\n * an app registered under `config.auth.accessToken.model` (e.g. to add a\n * tenant column). The service never references the concrete class directly so\n * an override is a pure config change.\n */\n private get accessTokenModel(): typeof AccessToken {\n return config.key(\"auth.accessToken.model\", AccessToken);\n }\n\n /**\n * Resolve the active refresh-token model (default or registered override).\n */\n private get refreshTokenModel(): typeof RefreshToken {\n return config.key(\"auth.refreshToken.model\", RefreshToken);\n }\n\n /**\n * Resolve the active one-time-token model (default or registered override).\n */\n private get oneTimeTokenModel(): typeof OneTimeToken {\n return config.key(\"auth.oneTimeToken.model\", OneTimeToken);\n }\n\n /**\n * Build the default access-token JWT payload from a user.\n */\n public buildAccessTokenPayload(user: Auth): Record<string, unknown> {\n return {\n id: user.id,\n userType: user.userType,\n created_at: Date.now(),\n };\n }\n\n /** One policy seam keeps account-state checks identical across all auth paths. */\n public async canAuthenticate(user: Auth): Promise<boolean> {\n return authConfig.canAuthenticate(user);\n }\n\n private async assertCanAuthenticate(user: Auth): Promise<void> {\n if (!(await this.canAuthenticate(user))) {\n throw new ForbiddenError(\"This user cannot authenticate.\");\n }\n }\n\n private async issueAccessToken(\n user: Auth,\n payload?: Record<string, unknown>,\n ): Promise<AccessTokenOutput> {\n const data = payload || this.buildAccessTokenPayload(user);\n // Validate before signing so an unusable lifetime cannot mint an immortal token.\n const expiresIn = authConfig.accessToken.expiresInMs();\n\n const token = await jwt.generate(data, { expiresIn });\n const expiresAt = new Date(Date.now() + expiresIn);\n\n await this.accessTokenModel.issue(user, token, expiresAt);\n\n return { token, expiresAt: expiresAt.toISOString() };\n }\n\n /**\n * Sign + persist an access token for the user and return the token with its\n * expiry. The expiry is computed locally from `expiresIn` rather than by\n * re-verifying the token we just signed.\n */\n public async generateAccessToken(\n user: Auth,\n payload?: Record<string, unknown>,\n ): Promise<AccessTokenOutput> {\n await this.assertCanAuthenticate(user);\n\n return this.issueAccessToken(user, payload);\n }\n\n private async issueRefreshToken(\n user: Auth,\n deviceInfo?: DeviceInfo,\n ): Promise<RefreshToken | undefined> {\n if (!authConfig.refreshToken.enabled()) return;\n\n // Validate the lifetime first — before the per-user cap is enforced, before\n // anything is signed — so a bad value can never revoke a user's oldest\n // session on its way to failing.\n const expiresIn = authConfig.refreshToken.expiresInMs();\n\n const familyId = deviceInfo?.familyId || Random.string(32);\n\n const payload = {\n userId: user.id,\n userType: user.userType,\n familyId,\n };\n\n const expiresAt = new Date(Date.now() + expiresIn).toISOString();\n\n await this.refreshTokenModel.enforceMax(user, authConfig.refreshToken.maxPerUser());\n\n const token = await jwt.generateRefreshToken(payload, { expiresIn });\n\n return this.refreshTokenModel.issue(user, token, { familyId, expiresAt, deviceInfo });\n }\n\n /**\n * Sign + persist a refresh token for the user (enforcing the per-user cap\n * first). Resolves to `undefined` when refresh tokens are disabled in config.\n */\n public async createRefreshToken(\n user: Auth,\n deviceInfo?: DeviceInfo,\n ): Promise<RefreshToken | undefined> {\n if (!authConfig.refreshToken.enabled()) return;\n\n await this.assertCanAuthenticate(user);\n\n return this.issueRefreshToken(user, deviceInfo);\n }\n\n private async issueTokenPair(user: Auth, deviceInfo?: DeviceInfo): Promise<TokenPair> {\n const accessToken = await this.issueAccessToken(user, deviceInfo?.payload);\n const refreshToken = await this.issueRefreshToken(user, deviceInfo);\n\n const tokenPair: TokenPair = {\n accessToken,\n refreshToken: refreshToken\n ? {\n token: refreshToken.get(\"token\"),\n expiresAt: refreshToken.get(\"expires_at\"),\n }\n : undefined,\n };\n\n authEvents.emit(\"token.created\", user, tokenPair);\n\n if (refreshToken) {\n authEvents.emit(\"session.created\", user, refreshToken, deviceInfo);\n }\n\n return tokenPair;\n }\n\n /**\n * Issue both an access and a refresh token, emitting the creation events.\n */\n public async createTokenPair(user: Auth, deviceInfo?: DeviceInfo): Promise<TokenPair> {\n await this.assertCanAuthenticate(user);\n\n return this.issueTokenPair(user, deviceInfo);\n }\n\n /**\n * Exchange a refresh token for a new token pair, with rotation + replay\n * detection. A concurrent reuse of the same token loses the atomic revoke and\n * is treated as a breach — the whole family is revoked and the request fails.\n */\n public async refreshTokens(\n refreshTokenString: string,\n deviceInfo?: DeviceInfo,\n ): Promise<TokenPair | null> {\n let decoded: {\n userId: number;\n userType: string;\n familyId: string;\n } | null;\n\n try {\n decoded = await jwt.verifyRefreshToken<{\n userId: number;\n userType: string;\n familyId: string;\n }>(refreshTokenString);\n } catch (error) {\n if (isInvalidCredentialError(error)) return null;\n\n throw error;\n }\n\n if (!decoded) return null;\n\n const refreshToken = await this.refreshTokenModel.findByToken(refreshTokenString);\n\n if (!refreshToken?.isValid) {\n // An already-invalid token may be a replay of a rotated credential.\n if (refreshToken) {\n await this.revokeTokenFamily(refreshToken.familyId);\n }\n\n return null;\n }\n\n const UserModel = config.key(`auth.userType.${decoded.userType}`);\n\n if (!UserModel) {\n throw new Error(`User type ${decoded.userType} is unknown type.`);\n }\n\n const user = (await UserModel.find(decoded.userId)) as Auth | null;\n\n if (!user) return null;\n\n if (!(await this.canAuthenticate(user))) return null;\n\n const rotationEnabled = authConfig.refreshToken.rotation();\n\n if (rotationEnabled) {\n const won = await refreshToken.revokeIfActive();\n\n if (!won) {\n // A concurrent request already rotated this token.\n await this.revokeTokenFamily(refreshToken.familyId);\n\n return null;\n }\n } else {\n await refreshToken.markAsUsed();\n }\n\n const newTokenPair = await this.issueTokenPair(user, {\n ...deviceInfo,\n familyId: refreshToken.familyId,\n });\n\n authEvents.emit(\"token.refreshed\", user, newTokenPair, refreshToken);\n\n return newTokenPair;\n }\n\n /**\n * Verify a plaintext password against a stored hash.\n */\n public async verifyPassword(plainPassword: string, hashedPassword: string): Promise<boolean> {\n return verifyPassword(plainPassword, hashedPassword);\n }\n\n /**\n * Hash a plaintext password.\n */\n public async hashPassword(password: string): Promise<string> {\n return hashPassword(password);\n }\n\n /**\n * Resolve a user by credentials, verifying the password. Returns `null` on a\n * missing user or a wrong password, emitting `login.failed` either way.\n */\n public async attemptLogin<T extends Auth>(\n Model: ChildModel<T>,\n data: AuthCredentials,\n ): Promise<T | null> {\n const { password, ...otherData } = data;\n\n authEvents.emit(\"login.attempt\", otherData);\n\n const user = (await Model.first(otherData)) as T | null;\n\n if (!user) {\n authEvents.emit(\"login.failed\", otherData, \"User not found\");\n\n return null;\n }\n\n if (!(await this.verifyPassword(password, user.string(\"password\")!))) {\n authEvents.emit(\"login.failed\", otherData, \"Invalid password\");\n\n return null;\n }\n\n if (!(await this.canAuthenticate(user))) {\n authEvents.emit(\"login.failed\", otherData, \"Authentication not allowed\");\n\n return null;\n }\n\n return user;\n }\n\n /**\n * Full login flow: validate credentials, issue tokens, emit events. Returns\n * the user + token pair on success, `null` on failure.\n */\n public async login<T extends Auth>(\n Model: ChildModel<T>,\n credentials: AuthCredentials,\n deviceInfo?: DeviceInfo,\n ): Promise<LoginResult<T> | null> {\n const user = await this.attemptLogin(Model, credentials);\n\n if (!user) {\n return null;\n }\n\n return this.finalizeLogin(user, deviceInfo);\n }\n\n /**\n * Log in a user some OTHER method already authenticated — a provider\n * callback, a passkey assertion, a one-time code. Applies the\n * `auth.canAuthenticate` policy (403 on refusal), then produces exactly\n * what {@link login} produces: the same tokens, rows and events. Pair with\n * {@link setAuthCookie} for a cookie session, as with password login.\n *\n * Never call this for a user you have not authenticated.\n */\n public async completeLogin<T extends Auth>(\n user: T,\n deviceInfo?: DeviceInfo,\n ): Promise<LoginResult<T>> {\n await this.assertCanAuthenticate(user);\n\n return this.finalizeLogin(user, deviceInfo);\n }\n\n /** The one token-issuing tail every login method shares. */\n private async finalizeLogin<T extends Auth>(\n user: T,\n deviceInfo?: DeviceInfo,\n ): Promise<LoginResult<T>> {\n if (!authConfig.refreshToken.enabled()) {\n const accessToken = await this.issueAccessToken(user, deviceInfo?.payload);\n\n return { user, tokens: { accessToken } };\n }\n\n const tokens = await this.issueTokenPair(user, deviceInfo);\n\n authEvents.emit(\"login.success\", user, tokens, deviceInfo);\n\n return { user, tokens };\n }\n\n /**\n * Log a user out.\n *\n * @param accessToken - access token string to revoke (optional)\n * @param refreshToken - refresh token string to revoke (optional)\n *\n * When no refresh token is supplied, `config.auth.refreshToken.logoutWithoutToken`\n * decides the behavior: `\"revoke-all\"` (default, fail-safe) revokes every\n * refresh token; `\"error\"` requires the caller to pass one.\n */\n public async logout(user: Auth, accessToken?: string, refreshToken?: string): Promise<void> {\n if (accessToken) {\n await this.removeAccessToken(user, accessToken);\n }\n\n if (refreshToken) {\n const token = await this.refreshTokenModel.findForUser(user, refreshToken);\n\n if (token) {\n await token.revoke();\n authEvents.emit(\"session.destroyed\", user, token);\n }\n } else {\n const behavior = authConfig.refreshToken.logoutWithoutToken();\n\n if (behavior === \"error\") {\n throw new Error(\"Refresh token required for logout\");\n }\n\n await this.revokeAllTokens(user);\n authEvents.emit(\"logout.failsafe\", user);\n }\n\n authEvents.emit(\"logout\", user);\n }\n\n /**\n * Remove a specific access token belonging to the user.\n */\n public async removeAccessToken(user: Auth, token: string): Promise<void> {\n await this.accessTokenModel.deleteForUser(user, token);\n }\n\n /**\n * Remove every access token belonging to the user.\n */\n public async removeAllAccessTokens(user: Auth): Promise<void> {\n await this.accessTokenModel.deleteAllForUser(user);\n }\n\n /**\n * Remove a specific refresh token belonging to the user.\n */\n public async removeRefreshToken(user: Auth, token: string): Promise<void> {\n await this.refreshTokenModel.deleteForUser(user, token);\n }\n\n /**\n * Revoke every active refresh token for the user and delete their access\n * tokens — \"log out of all devices\". Revocation is a single bulk update; an\n * event fires per revoked token.\n */\n public async revokeAllTokens(user: Auth): Promise<void> {\n const revokedTokens = await this.refreshTokenModel.revokeAllFor(user);\n\n for (const token of revokedTokens) {\n authEvents.emit(\"token.revoked\", user, token);\n }\n\n await this.removeAllAccessTokens(user);\n\n authEvents.emit(\"logout.all\", user);\n }\n\n /**\n * Revoke an entire token family — rotation breach containment.\n */\n public async revokeTokenFamily(familyId: string): Promise<void> {\n const revokedTokens = await this.refreshTokenModel.revokeFamily(familyId);\n\n authEvents.emit(\"token.familyRevoked\", familyId, revokedTokens);\n }\n\n /**\n * Delete expired tokens (refresh + access) and spent one-time tokens\n * (expired or consumed verification/reset/OTP/passkey rows). Emits `token.expired` per refresh\n * token and `cleanup.completed` with the refresh count. Drives the\n * `auth.cleanup` CLI command.\n */\n public async cleanupExpiredTokens(): Promise<number> {\n const expiredTokens = await this.refreshTokenModel.purgeExpired();\n\n for (const token of expiredTokens) {\n authEvents.emit(\"token.expired\", token);\n }\n\n await this.accessTokenModel.purgeExpired();\n await this.oneTimeTokenModel.purgeSpent();\n\n authEvents.emit(\"cleanup.completed\", expiredTokens.length);\n\n return expiredTokens.length;\n }\n\n /**\n * Find every persisted token — access and refresh — that can never retire\n * itself: an unusable `expires_at`, or a token carrying no `exp` claim.\n *\n * This is the remediation read for the pre-4.12.0 `expiresIn` defect (#25).\n * Nothing else surfaces these rows: `auth.cleanup` selects `expires_at < now`,\n * which an `Invalid Date` never satisfies, and a row whose date column is\n * perfectly fine can still hold a token with no deadline in it. Read-only —\n * pair with {@link purgeNeverExpiringTokens} to act on the answer.\n */\n public async findNeverExpiringTokens(): Promise<{\n accessTokens: AccessToken[];\n refreshTokens: RefreshToken[];\n }> {\n return {\n accessTokens: await this.accessTokenModel.findNeverExpiring(),\n refreshTokens: await this.refreshTokenModel.findNeverExpiring(),\n };\n }\n\n /**\n * Revoke every never-expiring token by deleting its row, emitting\n * `token.revoked` per refresh token. Drives `warlock auth.purge-never-expiring`.\n *\n * Access tokens first: deleting one is immediate revocation, while a refresh\n * token left in place for a moment can only mint an access token through a\n * verifier that now rejects it for the missing `exp`.\n */\n public async purgeNeverExpiringTokens(): Promise<{\n accessTokens: number;\n refreshTokens: number;\n }> {\n const accessTokens = await this.accessTokenModel.purgeNeverExpiring();\n const refreshTokens = await this.refreshTokenModel.purgeNeverExpiring();\n\n // `token.expired` (the event `auth.cleanup` already emits for a removed\n // refresh token) rather than `token.revoked`, which carries a loaded `Auth`\n // this batch path has no reason to fetch a user row for.\n for (const token of refreshTokens) {\n authEvents.emit(\"token.expired\", token);\n }\n\n return { accessTokens: accessTokens.length, refreshTokens: refreshTokens.length };\n }\n\n /**\n * Active, unexpired sessions for the user, newest first.\n */\n public async getActiveSessions(user: Auth): Promise<RefreshToken[]> {\n return this.refreshTokenModel.activeFor(user);\n }\n\n /**\n * `Max-Age` (seconds) `setAuthCookie` should apply, derived from an\n * `AccessTokenOutput`'s `expiresAt` — or `undefined` for a bare token\n * string (a session cookie) or an unparseable `expiresAt`.\n */\n private cookieMaxAgeFromToken(token: string | AccessTokenOutput): number | undefined {\n if (typeof token === \"string\") return undefined;\n\n const expiresAt = new Date(token.expiresAt).getTime();\n\n if (Number.isNaN(expiresAt)) return undefined;\n\n return Math.max(0, Math.round((expiresAt - Date.now()) / 1000));\n }\n\n /**\n * Write the auth cookie on `response` — the write side of the `cookie:<name>`\n * token source `authMiddleware([], \"cookie:<name>\")` already reads. An explicit\n * app-controller call, never a side effect of {@link login}, so an existing\n * bearer-only app never starts emitting `Set-Cookie` just by upgrading.\n *\n * Attribute flags (`HttpOnly`, `SameSite=Lax`, `Secure` outside dev) come\n * from `response.cookie()`'s own `secureCookieDefaults()` floor — this\n * method never overrides them, only `name`/`path`/`maxAge`. `raw: true`\n * writes the token unquoted, so the same string a `cookie:<name>` source\n * reads back is exactly what was issued, not a JSON-wrapped copy.\n *\n * @param token - the token string, or an `AccessTokenOutput` (e.g. from\n * {@link login}) whose `expiresAt` sets `Max-Age` automatically when\n * `options.maxAge` is not given. A bare string with no `options.maxAge`\n * produces a session cookie.\n *\n * @example\n * const { user, tokens } = await authService.login(User, credentials);\n * authService.setAuthCookie(response, tokens.accessToken);\n */\n public setAuthCookie(\n response: Response,\n token: string | AccessTokenOutput,\n options: SetAuthCookieOptions = {},\n ): void {\n const name = options.name ?? authConfig.cookie.name();\n const path = options.path ?? authConfig.cookie.path();\n const tokenValue = typeof token === \"string\" ? token : token.token;\n const maxAge = options.maxAge ?? this.cookieMaxAgeFromToken(token);\n\n response.cookie(name, tokenValue, {\n raw: true,\n path,\n ...(maxAge !== undefined ? { maxAge } : {}),\n });\n }\n\n /**\n * Clear the auth cookie {@link setAuthCookie} wrote. An explicit\n * app-controller call — pair it with {@link logout} after the token row is\n * revoked. `path` must match what the cookie was set with, or the browser\n * silently ignores the clear (see `response.clearCookie`'s own doc comment).\n *\n * @example\n * await authService.logout(user, accessToken, refreshToken);\n * authService.clearAuthCookie(response);\n */\n public clearAuthCookie(response: Response, options: ClearAuthCookieOptions = {}): void {\n const name = options.name ?? authConfig.cookie.name();\n const path = options.path ?? authConfig.cookie.path();\n\n response.clearCookie(name, { path });\n }\n}\n\nexport const authService = new AuthService();\n"],"mappings":"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"}
1
+ {"version":3,"file":"auth.service.mjs","names":[],"sources":["../../../../../../../auth/src/services/auth.service.ts"],"sourcesContent":["import { Random } from \"@mongez/reinforcements\";\r\nimport { randomUUID } from \"node:crypto\";\r\nimport type { ChildModel } from \"@warlock.js/cascade\";\r\nimport {\r\n config,\r\n ForbiddenError,\r\n hashPassword,\r\n type Response,\r\n verifyPassword,\r\n} from \"@warlock.js/core\";\r\nimport type {\r\n AccessTokenOutput,\r\n AuthCredentials,\r\n ClearAuthCookieOptions,\r\n DeviceInfo,\r\n LoginResult,\r\n SetAuthCookieOptions,\r\n TokenPair,\r\n} from \"../contracts/types\";\r\nimport { AccessToken } from \"../models/access-token\";\r\nimport type { Auth } from \"../models/auth.model\";\r\nimport { AuthTokenFamily } from \"../models/auth-token-family\";\r\nimport { OneTimeToken } from \"../models/one-time-token\";\r\nimport { RefreshToken } from \"../models/refresh-token\";\r\nimport { authConfig } from \"./auth-config\";\r\nimport { authEvents } from \"./auth-events\";\r\nimport { isInvalidCredentialError, jwt } from \"./jwt\";\r\nimport {\r\n afterTokenFamilyOperation,\r\n runTokenFamilyOperation,\r\n TokenFamilyUnavailableError,\r\n} from \"./token-family-operation\";\r\n\r\nclass AuthService {\r\n /**\r\n * Resolve the active access-token model — the package default, or a subclass\r\n * an app registered under `config.auth.accessToken.model` (e.g. to add a\r\n * tenant column). The service never references the concrete class directly so\r\n * an override is a pure config change.\r\n */\r\n private get accessTokenModel(): typeof AccessToken {\r\n return config.key(\"auth.accessToken.model\", AccessToken);\r\n }\r\n\r\n /**\r\n * Resolve the active refresh-token model (default or registered override).\r\n */\r\n private get refreshTokenModel(): typeof RefreshToken {\r\n return config.key(\"auth.refreshToken.model\", RefreshToken);\r\n }\r\n\r\n /**\r\n * Resolve the active one-time-token model (default or registered override).\r\n */\r\n private get oneTimeTokenModel(): typeof OneTimeToken {\r\n return config.key(\"auth.oneTimeToken.model\", OneTimeToken);\r\n }\r\n\r\n /**\r\n * Build the default access-token JWT payload from a user.\r\n */\r\n public buildAccessTokenPayload(user: Auth): Record<string, unknown> {\r\n return {\r\n id: user.id,\r\n userType: user.userType,\r\n created_at: Date.now(),\r\n };\r\n }\r\n\r\n /** One policy seam keeps account-state checks identical across all auth paths. */\r\n public async canAuthenticate(user: Auth): Promise<boolean> {\r\n return authConfig.canAuthenticate(user);\r\n }\r\n\r\n private async assertCanAuthenticate(user: Auth): Promise<void> {\r\n if (!(await this.canAuthenticate(user))) {\r\n throw new ForbiddenError(\"This user cannot authenticate.\");\r\n }\r\n }\r\n\r\n private async issueAccessToken(\r\n user: Auth,\r\n payload?: Record<string, unknown>,\r\n familyId?: string,\r\n ): Promise<AccessTokenOutput> {\r\n const data = { ...(payload || this.buildAccessTokenPayload(user)), jti: randomUUID() };\r\n // Validate before signing so an unusable lifetime cannot mint an immortal token.\r\n const expiresIn = authConfig.accessToken.expiresInMs();\r\n\r\n const token = await jwt.generate(data, { expiresIn });\r\n const expiresAt = new Date(Date.now() + expiresIn);\r\n\r\n if (familyId) {\r\n await this.accessTokenModel.issue(user, token, expiresAt, familyId);\r\n } else {\r\n await this.accessTokenModel.issue(user, token, expiresAt);\r\n }\r\n\r\n return { token, expiresAt: expiresAt.toISOString() };\r\n }\r\n\r\n /**\r\n * Sign + persist an access token for the user and return the token with its\r\n * expiry. The expiry is computed locally from `expiresIn` rather than by\r\n * re-verifying the token we just signed.\r\n */\r\n public async generateAccessToken(\r\n user: Auth,\r\n payload?: Record<string, unknown>,\r\n ): Promise<AccessTokenOutput> {\r\n await this.assertCanAuthenticate(user);\r\n\r\n return this.issueAccessToken(user, payload);\r\n }\r\n\r\n private async issueRefreshToken(\n user: Auth,\r\n deviceInfo?: DeviceInfo,\r\n ): Promise<RefreshToken | undefined> {\r\n if (!authConfig.refreshToken.enabled()) return;\r\n\r\n // Validate the lifetime first — before the per-user cap is enforced, before\r\n // anything is signed — so a bad value can never revoke a user's oldest\r\n // session on its way to failing.\r\n const expiresIn = authConfig.refreshToken.expiresInMs();\r\n\r\n const familyId = deviceInfo?.familyId || Random.string(32);\r\n\r\n const family = await AuthTokenFamily.ensure(user, familyId);\r\n\r\n if (family.isRevoked) {\r\n throw new Error(\"Cannot issue tokens for a revoked token family\");\r\n }\r\n\r\n const payload = {\r\n userId: user.id,\r\n userType: user.userType,\r\n familyId,\r\n jti: randomUUID(),\r\n };\r\n\r\n const expiresAt = new Date(Date.now() + expiresIn).toISOString();\r\n\r\n await this.refreshTokenModel.enforceMax(user, authConfig.refreshToken.maxPerUser());\r\n\r\n const token = await jwt.generateRefreshToken(payload, { expiresIn });\r\n\r\n return this.refreshTokenModel.issue(user, token, { familyId, expiresAt, deviceInfo });\n }\n\n /**\n * Keep issuance into an explicitly reused family in the same durable\n * transition boundary as rotation and revocation. A new, caller-unspecified\n * family has no concurrent session to protect and retains the lightweight\n * issuance path.\n */\n private async issueInSuppliedFamily<T>(\n user: Auth,\n familyId: string,\n issue: () => Promise<T>,\n ): Promise<T> {\n // Preserve the established error for a family that was already revoked\n // before issuance started. The coordinator covers a revoke that races\n // after this observation.\n const family = await AuthTokenFamily.ensure(user, familyId);\n\n if (family.isRevoked) {\n throw new Error(\"Cannot issue tokens for a revoked token family\");\n }\n\n try {\n return await runTokenFamilyOperation(familyId, async () => issue());\n } catch (error) {\n // Do not expose a timing-dependent error shape to callers: a family\n // revoked between ensure() and the transaction is the same unusable\n // family condition as one found above.\n if (error instanceof TokenFamilyUnavailableError) {\n throw new Error(\"Cannot issue tokens for a revoked token family\");\n }\n\n throw error;\n }\n }\n\r\n /**\r\n * Sign + persist a refresh token for the user (enforcing the per-user cap\r\n * first). Resolves to `undefined` when refresh tokens are disabled in config.\r\n */\r\n public async createRefreshToken(\r\n user: Auth,\r\n deviceInfo?: DeviceInfo,\r\n ): Promise<RefreshToken | undefined> {\r\n if (!authConfig.refreshToken.enabled()) return;\n\n await this.assertCanAuthenticate(user);\n\n if (deviceInfo?.familyId) {\n return this.issueInSuppliedFamily(user, deviceInfo.familyId, () =>\n this.issueRefreshToken(user, deviceInfo),\n );\n }\n\n return this.issueRefreshToken(user, deviceInfo);\n }\r\n\r\n private async issueTokenPair(user: Auth, deviceInfo?: DeviceInfo): Promise<TokenPair> {\r\n const refreshEnabled = authConfig.refreshToken.enabled();\r\n const familyId = deviceInfo?.familyId || Random.string(32);\r\n\r\n if (refreshEnabled) {\r\n const family = await AuthTokenFamily.ensure(user, familyId);\r\n\r\n if (family.isRevoked) {\r\n throw new Error(\"Cannot issue tokens for a revoked token family\");\r\n }\r\n }\r\n\r\n const accessToken = await this.issueAccessToken(\r\n user,\r\n deviceInfo?.payload,\r\n refreshEnabled ? familyId : undefined,\r\n );\r\n const refreshToken = await this.issueRefreshToken(\r\n user,\r\n refreshEnabled ? { ...deviceInfo, familyId } : deviceInfo,\r\n );\r\n\r\n const tokenPair: TokenPair = {\r\n accessToken,\r\n refreshToken: refreshToken\r\n ? {\r\n token: refreshToken.get(\"token\"),\r\n expiresAt: refreshToken.get(\"expires_at\"),\r\n }\r\n : undefined,\r\n };\r\n\r\n afterTokenFamilyOperation(() => authEvents.emit(\"token.created\", user, tokenPair));\r\n\r\n if (refreshToken) {\r\n afterTokenFamilyOperation(() =>\r\n authEvents.emit(\"session.created\", user, refreshToken, deviceInfo),\r\n );\r\n }\r\n\r\n return tokenPair;\r\n }\r\n\r\n /**\r\n * Issue both an access and a refresh token, emitting the creation events.\r\n */\r\n public async createTokenPair(user: Auth, deviceInfo?: DeviceInfo): Promise<TokenPair> {\n await this.assertCanAuthenticate(user);\n\n if (authConfig.refreshToken.enabled() && deviceInfo?.familyId) {\n return this.issueInSuppliedFamily(user, deviceInfo.familyId, () =>\n this.issueTokenPair(user, deviceInfo),\n );\n }\n\n return this.issueTokenPair(user, deviceInfo);\n }\n\r\n /**\r\n * Exchange a refresh token for a new token pair, with rotation + replay\r\n * detection. A concurrent reuse of the same token loses the atomic revoke and\r\n * is treated as a breach — the whole family is revoked and the request fails.\r\n */\r\n public async refreshTokens(\r\n refreshTokenString: string,\r\n deviceInfo?: DeviceInfo,\r\n ): Promise<TokenPair | null> {\r\n let decoded: {\r\n userId: number;\r\n userType: string;\r\n familyId: string;\r\n } | null;\r\n\r\n try {\r\n decoded = await jwt.verifyRefreshToken<{\r\n userId: number;\r\n userType: string;\r\n familyId: string;\r\n }>(refreshTokenString);\r\n } catch (error) {\r\n if (isInvalidCredentialError(error)) return null;\r\n\r\n throw error;\r\n }\r\n\r\n if (!decoded) return null;\r\n\r\n const presentedToken = await this.refreshTokenModel.findByToken(refreshTokenString);\r\n\r\n if (!presentedToken || !this.refreshIdentityMatches(presentedToken, decoded)) return null;\r\n\r\n await AuthTokenFamily.ensure(\r\n { id: decoded.userId, userType: decoded.userType } as Auth,\r\n presentedToken.familyId,\r\n );\r\n return runTokenFamilyOperation(presentedToken.familyId, async () => {\r\n // Each serializable attempt must read the current row in its own snapshot.\r\n const refreshToken = await this.refreshTokenModel.findByToken(refreshTokenString);\r\n if (!refreshToken || !this.refreshIdentityMatches(refreshToken, decoded)) return null;\r\n if (!refreshToken.isValid) {\r\n // An already-invalid token may be a replay of a rotated credential.\r\n if (refreshToken) {\r\n await this.revokeTokenFamily(refreshToken.familyId);\r\n }\r\n\r\n return null;\r\n }\r\n\r\n const UserModel = config.key(`auth.userType.${decoded.userType}`);\r\n\r\n if (!UserModel) {\r\n throw new Error(`User type ${decoded.userType} is unknown type.`);\r\n }\r\n\r\n const user = (await UserModel.find(decoded.userId)) as Auth | null;\r\n\r\n if (!user) return null;\r\n\r\n if (!(await this.canAuthenticate(user))) return null;\r\n\r\n const rotationEnabled = authConfig.refreshToken.rotation();\r\n\r\n if (rotationEnabled) {\r\n const won = await refreshToken.revokeIfActive();\r\n\r\n if (!won) {\r\n // A concurrent request already rotated this token.\r\n await this.revokeTokenFamily(refreshToken.familyId);\r\n\r\n return null;\r\n }\r\n } else {\r\n await refreshToken.markAsUsed();\r\n }\r\n\r\n const newTokenPair = await this.issueTokenPair(user, {\r\n ...deviceInfo,\r\n familyId: refreshToken.familyId,\r\n });\r\n\r\n afterTokenFamilyOperation(() =>\r\n authEvents.emit(\"token.refreshed\", user, newTokenPair, refreshToken),\r\n );\r\n\r\n return newTokenPair;\r\n }).catch((error) => {\r\n if (error instanceof TokenFamilyUnavailableError) return null;\r\n throw error;\r\n });\r\n }\r\n\r\n /**\r\n * Opt-in browser-session renewal. Unlike {@link refreshTokens}, this permits\r\n * a short duplicate presentation to receive the exact committed successor\r\n * pair. It is deliberately not used by the legacy/public refresh API.\r\n */\r\n public async renewAutomaticSession(\r\n refreshTokenString: string,\r\n expectedUserType: string,\r\n options: { overlapMs?: number; deviceInfo?: DeviceInfo } = {},\r\n ): Promise<TokenPair | null> {\r\n let decoded: { userId: number; userType: string; familyId: string } | null;\r\n try {\r\n decoded = await jwt.verifyRefreshToken<{\r\n userId: number;\r\n userType: string;\r\n familyId: string;\r\n }>(refreshTokenString);\r\n } catch (error) {\r\n if (isInvalidCredentialError(error)) return null;\r\n throw error;\r\n }\r\n\r\n if (!decoded || decoded.userType !== expectedUserType) return null;\r\n const presentedToken = await this.refreshTokenModel.findByToken(refreshTokenString);\r\n if (!presentedToken || !this.refreshIdentityMatches(presentedToken, decoded)) return null;\r\n\r\n await AuthTokenFamily.ensure(\r\n { id: decoded.userId, userType: decoded.userType } as Auth,\r\n presentedToken.familyId,\r\n );\r\n return runTokenFamilyOperation(presentedToken.familyId, async () => {\r\n // A retry can follow another process's committed rotation. Never reuse\r\n // a model captured before the transaction or before a failed attempt.\r\n const oldToken = await this.refreshTokenModel.findByToken(refreshTokenString);\r\n if (!oldToken || !this.refreshIdentityMatches(oldToken, decoded)) return null;\r\n if (!oldToken.isValid) {\r\n const successor = await this.readAutomaticSuccessor(oldToken, decoded, options.overlapMs);\r\n if (successor) return successor;\r\n if (\r\n this.isAutomaticOverlapOpen(oldToken, options.overlapMs) &&\r\n this.hasSuccessor(oldToken)\r\n ) {\r\n return null;\r\n }\r\n await this.revokeTokenFamily(oldToken.familyId);\r\n return null;\r\n }\r\n\r\n const UserModel = config.key(`auth.userType.${decoded.userType}`);\r\n if (!UserModel) throw new Error(`User type ${decoded.userType} is unknown type.`);\r\n const user = (await UserModel.find(decoded.userId)) as Auth | null;\r\n if (!user || !(await this.canAuthenticate(user))) return null;\r\n\r\n if (!(await oldToken.revokeIfActive())) {\r\n // A newer successor is never substituted here. A stale automatic\r\n // response must fail quietly rather than revoke a legitimate family.\r\n const currentToken = await this.refreshTokenModel.findByToken(refreshTokenString);\r\n if (!currentToken) return null;\r\n const successor = await this.readAutomaticSuccessor(\r\n currentToken,\r\n decoded,\r\n options.overlapMs,\r\n );\r\n if (successor) return successor;\r\n if (\r\n this.isAutomaticOverlapOpen(currentToken, options.overlapMs) &&\r\n this.hasSuccessor(currentToken)\r\n )\r\n return null;\r\n await this.revokeTokenFamily(oldToken.familyId);\r\n return null;\r\n }\r\n\r\n const pair = await this.issueTokenPair(user, {\r\n ...options.deviceInfo,\r\n familyId: oldToken.familyId,\r\n });\r\n const successorAccess = await this.accessTokenModel.findByToken(pair.accessToken.token);\r\n const successorRefresh = pair.refreshToken\r\n ? await this.refreshTokenModel.findByToken(pair.refreshToken.token)\r\n : null;\r\n if (!successorAccess || !successorRefresh) {\r\n throw new Error(\"Automatic renewal did not persist a complete successor pair\");\r\n }\r\n\r\n // `revokeIfActive()` updates the database, not this hydrated instance.\r\n // Saving that stale instance would write its old `revoked_at: null` back\r\n // and reopen the predecessor. Update only the successor columns instead.\r\n const linked = await this.refreshTokenModel.atomic(\r\n { id: oldToken.id },\r\n {\r\n $set: {\r\n successor_access_token_id: successorAccess.id,\r\n successor_refresh_token_id: successorRefresh.id,\r\n },\r\n },\r\n );\r\n if (linked !== 1) throw new Error(\"Automatic renewal could not link its successor pair\");\r\n afterTokenFamilyOperation(() => authEvents.emit(\"token.refreshed\", user, pair, oldToken));\r\n return pair;\r\n }).catch((error) => {\r\n if (error instanceof TokenFamilyUnavailableError) return null;\r\n throw error;\r\n });\r\n }\r\n\r\n private automaticOverlapMs(overlapMs: number | undefined): number {\r\n return typeof overlapMs === \"number\" && Number.isFinite(overlapMs)\r\n ? Math.max(0, Math.min(10_000, overlapMs))\r\n : 5000;\r\n }\r\n\r\n private refreshIdentityMatches(\r\n token: RefreshToken,\r\n decoded: { userId: string | number; userType: string; familyId: string },\r\n ): boolean {\r\n return (\r\n token.familyId === decoded.familyId &&\r\n token.get(\"user_id\") === decoded.userId &&\r\n token.get(\"user_type\") === decoded.userType\r\n );\r\n }\r\n\r\n private hasSuccessor(oldToken: RefreshToken): boolean {\r\n return Boolean(\r\n oldToken.get(\"successor_access_token_id\") && oldToken.get(\"successor_refresh_token_id\"),\r\n );\r\n }\r\n\r\n private isAutomaticOverlapOpen(oldToken: RefreshToken, overlapMs: number | undefined): boolean {\r\n const revokedAt = oldToken.get<Date | undefined>(\"revoked_at\");\r\n const window = this.automaticOverlapMs(overlapMs);\r\n const elapsed = revokedAt ? Date.now() - new Date(revokedAt).getTime() : NaN;\r\n return window > 0 && elapsed >= 0 && elapsed <= window;\r\n }\r\n\r\n private async readAutomaticSuccessor(\r\n oldToken: RefreshToken,\r\n decoded: { userId: number; userType: string; familyId: string },\r\n overlapMs: number | undefined,\r\n ): Promise<TokenPair | null> {\r\n if (!this.isAutomaticOverlapOpen(oldToken, overlapMs)) return null;\r\n const accessId = oldToken.get<string | undefined>(\"successor_access_token_id\");\r\n const refreshId = oldToken.get<string | undefined>(\"successor_refresh_token_id\");\r\n if (!accessId || !refreshId) return null;\r\n const [access, refresh] = await Promise.all([\r\n this.accessTokenModel.find(accessId),\r\n this.refreshTokenModel.find(refreshId),\r\n ]);\r\n if (!access || access.isExpired || !refresh?.isValid) return null;\r\n if (\r\n access.get(\"family_id\") !== decoded.familyId ||\r\n refresh.get(\"family_id\") !== decoded.familyId ||\r\n access.get(\"user_id\") !== decoded.userId ||\r\n refresh.get(\"user_id\") !== decoded.userId ||\r\n access.get(\"user_type\") !== decoded.userType ||\r\n refresh.get(\"user_type\") !== decoded.userType\r\n )\r\n return null;\r\n const accessExpiry = new Date(access.get(\"expires_at\")).getTime();\r\n if (!Number.isFinite(accessExpiry) || accessExpiry <= Date.now()) return null;\r\n return {\r\n accessToken: { token: access.get(\"token\"), expiresAt: access.get(\"expires_at\") },\r\n refreshToken: { token: refresh.get(\"token\"), expiresAt: refresh.get(\"expires_at\") },\r\n };\r\n }\r\n\r\n /**\r\n * Verify a plaintext password against a stored hash.\r\n */\r\n public async verifyPassword(plainPassword: string, hashedPassword: string): Promise<boolean> {\r\n return verifyPassword(plainPassword, hashedPassword);\r\n }\r\n\r\n /**\r\n * Hash a plaintext password.\r\n */\r\n public async hashPassword(password: string): Promise<string> {\r\n return hashPassword(password);\r\n }\r\n\r\n /**\r\n * Resolve a user by credentials, verifying the password. Returns `null` on a\r\n * missing user or a wrong password, emitting `login.failed` either way.\r\n */\r\n public async attemptLogin<T extends Auth>(\r\n Model: ChildModel<T>,\r\n data: AuthCredentials,\r\n ): Promise<T | null> {\r\n const { password, ...otherData } = data;\r\n\r\n authEvents.emit(\"login.attempt\", otherData);\r\n\r\n const user = (await Model.first(otherData)) as T | null;\r\n\r\n if (!user) {\r\n authEvents.emit(\"login.failed\", otherData, \"User not found\");\r\n\r\n return null;\r\n }\r\n\r\n if (!(await this.verifyPassword(password, user.string(\"password\")!))) {\r\n authEvents.emit(\"login.failed\", otherData, \"Invalid password\");\r\n\r\n return null;\r\n }\r\n\r\n if (!(await this.canAuthenticate(user))) {\r\n authEvents.emit(\"login.failed\", otherData, \"Authentication not allowed\");\r\n\r\n return null;\r\n }\r\n\r\n return user;\r\n }\r\n\r\n /**\r\n * Full login flow: validate credentials, issue tokens, emit events. Returns\r\n * the user + token pair on success, `null` on failure.\r\n */\r\n public async login<T extends Auth>(\r\n Model: ChildModel<T>,\r\n credentials: AuthCredentials,\r\n deviceInfo?: DeviceInfo,\r\n ): Promise<LoginResult<T> | null> {\r\n const user = await this.attemptLogin(Model, credentials);\r\n\r\n if (!user) {\r\n return null;\r\n }\r\n\r\n return this.finalizeLogin(user, deviceInfo);\r\n }\r\n\r\n /**\r\n * Log in a user some OTHER method already authenticated — a provider\r\n * callback, a passkey assertion, a one-time code. Applies the\r\n * `auth.canAuthenticate` policy (403 on refusal), then produces exactly\r\n * what {@link login} produces: the same tokens, rows and events. Pair with\r\n * {@link setAuthCookie} for a cookie session, as with password login.\r\n *\r\n * Never call this for a user you have not authenticated.\r\n */\r\n public async completeLogin<T extends Auth>(\r\n user: T,\r\n deviceInfo?: DeviceInfo,\r\n ): Promise<LoginResult<T>> {\r\n await this.assertCanAuthenticate(user);\r\n\r\n return this.finalizeLogin(user, deviceInfo);\r\n }\r\n\r\n /** The one token-issuing tail every login method shares. */\r\n private async finalizeLogin<T extends Auth>(\r\n user: T,\r\n deviceInfo?: DeviceInfo,\r\n ): Promise<LoginResult<T>> {\r\n if (!authConfig.refreshToken.enabled()) {\r\n const accessToken = await this.issueAccessToken(user, deviceInfo?.payload);\r\n\r\n return { user, tokens: { accessToken } };\r\n }\r\n\r\n const tokens = await this.issueTokenPair(user, deviceInfo);\r\n\r\n authEvents.emit(\"login.success\", user, tokens, deviceInfo);\r\n\r\n return { user, tokens };\r\n }\r\n\r\n /**\r\n * Log a user out.\r\n *\r\n * @param accessToken - access token string to revoke (optional)\r\n * @param refreshToken - refresh token string to revoke (optional)\r\n *\r\n * When no refresh token is supplied, `config.auth.refreshToken.logoutWithoutToken`\r\n * decides the behavior: `\"revoke-all\"` (default, fail-safe) revokes every\r\n * refresh token; `\"error\"` requires the caller to pass one.\r\n */\r\n public async logout(user: Auth, accessToken?: string, refreshToken?: string): Promise<void> {\r\n if (accessToken) {\r\n await this.removeAccessToken(user, accessToken);\r\n }\r\n\r\n if (refreshToken) {\r\n const token = await this.refreshTokenModel.findForUser(user, refreshToken);\r\n\r\n if (token) {\r\n await this.revokeTokenFamily(token.familyId);\r\n authEvents.emit(\"session.destroyed\", user, token);\r\n }\r\n } else {\r\n const behavior = authConfig.refreshToken.logoutWithoutToken();\r\n\r\n if (behavior === \"error\") {\r\n throw new Error(\"Refresh token required for logout\");\r\n }\r\n\r\n await this.revokeAllTokens(user);\r\n authEvents.emit(\"logout.failsafe\", user);\r\n }\r\n\r\n authEvents.emit(\"logout\", user);\r\n }\r\n\r\n /**\r\n * Remove a specific access token belonging to the user.\r\n */\r\n public async removeAccessToken(user: Auth, token: string): Promise<void> {\r\n await this.accessTokenModel.deleteForUser(user, token);\r\n }\r\n\r\n /**\r\n * Remove every access token belonging to the user.\r\n */\r\n public async removeAllAccessTokens(user: Auth): Promise<void> {\r\n await this.accessTokenModel.deleteAllForUser(user);\r\n }\r\n\r\n /**\r\n * Remove a specific refresh token belonging to the user.\r\n */\r\n public async removeRefreshToken(user: Auth, token: string): Promise<void> {\r\n await this.refreshTokenModel.deleteForUser(user, token);\r\n }\r\n\r\n /**\r\n * Revoke every active refresh token for the user and delete their access\r\n * tokens — \"log out of all devices\". Each durable family is first marked through its revision coordinator; an\r\n * event fires per revoked token.\r\n */\r\n public async revokeAllTokens(user: Auth): Promise<void> {\r\n // Upgrade existing installations lazily before the family scan. Include\r\n // revoked rows: a rotation may have revoked its predecessor while its\r\n // durable family still needs logout's marker.\r\n const existingRows = await this.refreshTokenModel.familiesForUser(user);\r\n\r\n for (const token of existingRows) {\r\n await AuthTokenFamily.ensure(user, token.familyId);\r\n }\r\n\r\n const families = await AuthTokenFamily.activeFor(user);\r\n\r\n for (const family of families) {\r\n await this.revokeTokenFamily(family.get<string>(\"family_id\"));\r\n }\r\n\r\n // A deployment can still have rows created before the additive migration\r\n // ran; retain the old bulk cleanup as a final conservative backstop.\r\n const legacyTokens = await this.refreshTokenModel.revokeAllFor(user);\r\n\r\n for (const token of legacyTokens) {\r\n authEvents.emit(\"token.revoked\", user, token);\r\n }\r\n await this.removeAllAccessTokens(user);\r\n\r\n authEvents.emit(\"logout.all\", user);\r\n }\r\n\r\n /**\r\n * Revoke an entire token family — rotation breach containment.\r\n */\r\n public async revokeTokenFamily(familyId: string): Promise<void> {\r\n let family = await AuthTokenFamily.findByFamilyId(familyId);\r\n\r\n if (!family) {\r\n const legacyToken = await this.refreshTokenModel.findInFamily(familyId);\r\n\r\n if (!legacyToken) return;\r\n\r\n family = await AuthTokenFamily.ensure(\r\n {\r\n id: legacyToken.get(\"user_id\"),\r\n userType: legacyToken.get<string>(\"user_type\"),\r\n } as Auth,\r\n familyId,\r\n );\r\n }\r\n\r\n const user = {\r\n id: family.get(\"user_id\"),\r\n userType: family.get<string>(\"user_type\"),\r\n } as Auth;\r\n\r\n if (family.isRevoked) {\r\n // Logout and replay containment are idempotent. Re-run cleanup because a\r\n // request that lost the original race may have observed stale rows.\r\n const revokedTokens = await this.refreshTokenModel.revokeFamily(familyId);\r\n await this.accessTokenModel.deleteFamilyAndLegacyForUser(user, familyId);\r\n afterTokenFamilyOperation(() =>\r\n authEvents.emit(\"token.familyRevoked\", familyId, revokedTokens),\r\n );\r\n\r\n return;\r\n }\r\n\r\n let revokedTokens: RefreshToken[];\r\n try {\r\n revokedTokens = await runTokenFamilyOperation(familyId, async (currentFamily) => {\r\n const familyUser = {\r\n id: currentFamily.get(\"user_id\"),\r\n userType: currentFamily.get<string>(\"user_type\"),\r\n } as Auth;\r\n const revision = currentFamily.get<number>(\"revision\");\r\n const revoked = await AuthTokenFamily.revoke(familyId, revision);\r\n\r\n if (revoked !== 1) {\r\n throw new Error(\"Token family revoke lost its revision transition\");\r\n }\r\n\r\n const familyTokens = await this.refreshTokenModel.revokeFamily(familyId);\r\n await this.accessTokenModel.deleteFamilyAndLegacyForUser(familyUser, familyId);\r\n\r\n return familyTokens;\r\n });\r\n } catch (error) {\r\n if (!(error instanceof TokenFamilyUnavailableError)) throw error;\r\n\r\n // Another request committed the revoke between the read above and its\r\n // CAS. A second logout is successful after idempotent cleanup.\r\n revokedTokens = await this.refreshTokenModel.revokeFamily(familyId);\r\n await this.accessTokenModel.deleteFamilyAndLegacyForUser(user, familyId);\r\n }\r\n\r\n afterTokenFamilyOperation(() =>\r\n authEvents.emit(\"token.familyRevoked\", familyId, revokedTokens),\r\n );\r\n }\r\n\r\n /**\r\n * Delete expired tokens (refresh + access) and spent one-time tokens\r\n * (expired or consumed verification/reset/OTP/passkey rows). Emits `token.expired` per refresh\r\n * token and `cleanup.completed` with the refresh count. Drives the\r\n * `auth.cleanup` CLI command.\r\n */\r\n public async cleanupExpiredTokens(): Promise<number> {\r\n const expiredTokens = await this.refreshTokenModel.purgeExpired();\r\n\r\n for (const token of expiredTokens) {\r\n authEvents.emit(\"token.expired\", token);\r\n }\r\n\r\n await this.accessTokenModel.purgeExpired();\r\n await this.oneTimeTokenModel.purgeSpent();\r\n\r\n authEvents.emit(\"cleanup.completed\", expiredTokens.length);\r\n\r\n return expiredTokens.length;\r\n }\r\n\r\n /**\r\n * Find every persisted token — access and refresh — that can never retire\r\n * itself: an unusable `expires_at`, or a token carrying no `exp` claim.\r\n *\r\n * This is the remediation read for the pre-4.12.0 `expiresIn` defect (#25).\r\n * Nothing else surfaces these rows: `auth.cleanup` selects `expires_at < now`,\r\n * which an `Invalid Date` never satisfies, and a row whose date column is\r\n * perfectly fine can still hold a token with no deadline in it. Read-only —\r\n * pair with {@link purgeNeverExpiringTokens} to act on the answer.\r\n */\r\n public async findNeverExpiringTokens(): Promise<{\r\n accessTokens: AccessToken[];\r\n refreshTokens: RefreshToken[];\r\n }> {\r\n return {\r\n accessTokens: await this.accessTokenModel.findNeverExpiring(),\r\n refreshTokens: await this.refreshTokenModel.findNeverExpiring(),\r\n };\r\n }\r\n\r\n /**\r\n * Revoke every never-expiring token by deleting its row, emitting\r\n * `token.revoked` per refresh token. Drives `warlock auth.purge-never-expiring`.\r\n *\r\n * Access tokens first: deleting one is immediate revocation, while a refresh\r\n * token left in place for a moment can only mint an access token through a\r\n * verifier that now rejects it for the missing `exp`.\r\n */\r\n public async purgeNeverExpiringTokens(): Promise<{\r\n accessTokens: number;\r\n refreshTokens: number;\r\n }> {\r\n const accessTokens = await this.accessTokenModel.purgeNeverExpiring();\r\n const refreshTokens = await this.refreshTokenModel.purgeNeverExpiring();\r\n\r\n // `token.expired` (the event `auth.cleanup` already emits for a removed\r\n // refresh token) rather than `token.revoked`, which carries a loaded `Auth`\r\n // this batch path has no reason to fetch a user row for.\r\n for (const token of refreshTokens) {\r\n authEvents.emit(\"token.expired\", token);\r\n }\r\n\r\n return { accessTokens: accessTokens.length, refreshTokens: refreshTokens.length };\r\n }\r\n\r\n /**\r\n * Active, unexpired sessions for the user, newest first.\r\n */\r\n public async getActiveSessions(user: Auth): Promise<RefreshToken[]> {\r\n return this.refreshTokenModel.activeFor(user);\r\n }\r\n\r\n /**\r\n * `Max-Age` (seconds) `setAuthCookie` should apply, derived from an\r\n * `AccessTokenOutput`'s `expiresAt` — or `undefined` for a bare token\r\n * string (a session cookie) or an unparseable `expiresAt`.\r\n */\r\n private cookieMaxAgeFromToken(token: string | AccessTokenOutput): number | undefined {\r\n if (typeof token === \"string\") return undefined;\r\n\r\n const expiresAt = new Date(token.expiresAt).getTime();\r\n\r\n if (Number.isNaN(expiresAt)) return undefined;\r\n\r\n return Math.max(0, Math.round((expiresAt - Date.now()) / 1000));\r\n }\r\n\r\n /**\r\n * Write the auth cookie on `response` — the write side of the `cookie:<name>`\r\n * token source `authMiddleware([], \"cookie:<name>\")` already reads. An explicit\r\n * app-controller call, never a side effect of {@link login}, so an existing\r\n * bearer-only app never starts emitting `Set-Cookie` just by upgrading.\r\n *\r\n * Attribute flags (`HttpOnly`, `SameSite=Lax`, `Secure` outside dev) come\r\n * from `response.cookie()`'s own `secureCookieDefaults()` floor — this\r\n * method never overrides them, only `name`/`path`/`maxAge`. `raw: true`\r\n * writes the token unquoted, so the same string a `cookie:<name>` source\r\n * reads back is exactly what was issued, not a JSON-wrapped copy.\r\n *\r\n * @param token - the token string, or an `AccessTokenOutput` (e.g. from\r\n * {@link login}) whose `expiresAt` sets `Max-Age` automatically when\r\n * `options.maxAge` is not given. A bare string with no `options.maxAge`\r\n * produces a session cookie.\r\n *\r\n * @example\r\n * const { user, tokens } = await authService.login(User, credentials);\r\n * authService.setAuthCookie(response, tokens.accessToken);\r\n */\r\n public setAuthCookie(\r\n response: Response,\r\n token: string | AccessTokenOutput,\r\n options: SetAuthCookieOptions = {},\r\n ): void {\r\n const name = options.name ?? authConfig.cookie.name();\r\n const path = options.path ?? authConfig.cookie.path();\r\n const tokenValue = typeof token === \"string\" ? token : token.token;\r\n const maxAge = options.maxAge ?? this.cookieMaxAgeFromToken(token);\r\n\r\n response.cookie(name, tokenValue, {\r\n raw: true,\r\n path,\r\n ...(maxAge !== undefined ? { maxAge } : {}),\r\n });\r\n }\r\n\r\n /**\r\n * Clear the auth cookie {@link setAuthCookie} wrote. An explicit\r\n * app-controller call — pair it with {@link logout} after the token row is\r\n * revoked. `path` must match what the cookie was set with, or the browser\r\n * silently ignores the clear (see `response.clearCookie`'s own doc comment).\r\n *\r\n * @example\r\n * await authService.logout(user, accessToken, refreshToken);\r\n * authService.clearAuthCookie(response);\r\n */\r\n public clearAuthCookie(response: Response, options: ClearAuthCookieOptions = {}): void {\r\n const name = options.name ?? authConfig.cookie.name();\r\n const path = options.path ?? authConfig.cookie.path();\r\n\r\n response.clearCookie(name, { path });\r\n }\r\n}\r\n\r\nexport const authService = new AuthService();\r\n"],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ import { AuthTokenFamily } from "../models/auth-token-family/auth-token-family.model.mjs";
2
+ import "../models/auth-token-family/index.mjs";
3
+ import { databaseTransactionContext, transaction } from "@warlock.js/cascade";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+
6
+ //#region ../auth/src/services/token-family-operation.ts
7
+ const operationEffects = new AsyncLocalStorage();
8
+ /** Defer events until this coordinator's owned transaction commits. */
9
+ function afterTokenFamilyOperation(effect) {
10
+ const effects = operationEffects.getStore();
11
+ if (effects) effects.push(effect);
12
+ else effect();
13
+ }
14
+ /** A CAS miss is retried only when this helper owns the transaction boundary. */
15
+ var TokenFamilyConflictError = class extends Error {
16
+ constructor() {
17
+ super("Token family changed concurrently");
18
+ this.name = "TokenFamilyConflictError";
19
+ }
20
+ };
21
+ /** A missing or revoked family cannot become usable through a retry. */
22
+ var TokenFamilyUnavailableError = class extends Error {
23
+ constructor() {
24
+ super("Token family is unavailable");
25
+ this.name = "TokenFamilyUnavailableError";
26
+ }
27
+ };
28
+ const MAX_OWNED_TRANSACTION_ATTEMPTS = 3;
29
+ function isRetryableTransactionError(error) {
30
+ if (!(error instanceof Error)) return false;
31
+ const transactionError = error;
32
+ return transactionError.code === "40001" || transactionError.hasErrorLabel?.("TransientTransactionError") === true;
33
+ }
34
+ /**
35
+ * Perform one family state transition in a transaction and advance its durable
36
+ * revision with a conditional update. An existing Cascade transaction is
37
+ * joined, never retried here: PostgreSQL marks an outer transaction aborted on
38
+ * a serialization error, so only that transaction's owner can safely retry.
39
+ *
40
+ * The finite internal cap prevents request handlers from retrying forever.
41
+ * Transaction setup failures are deliberately propagated; this auth boundary
42
+ * has no non-transactional fallback.
43
+ */
44
+ async function runTokenFamilyOperation(familyId, operation) {
45
+ const joinsExistingTransaction = databaseTransactionContext.hasActiveTransaction();
46
+ const attempts = joinsExistingTransaction ? 1 : MAX_OWNED_TRANSACTION_ATTEMPTS;
47
+ let lastError;
48
+ for (let attempt = 0; attempt < attempts; attempt++) {
49
+ const parentEffects = operationEffects.getStore();
50
+ const effects = [];
51
+ let result;
52
+ try {
53
+ result = await operationEffects.run(effects, () => transaction(async () => {
54
+ const family = await AuthTokenFamily.findByFamilyId(familyId);
55
+ if (!family || family.isRevoked) throw new TokenFamilyUnavailableError();
56
+ const revision = family.get("revision");
57
+ if (await AuthTokenFamily.advanceRevision(familyId, revision) !== 1) throw new TokenFamilyConflictError();
58
+ return operation(family);
59
+ }, { isolationLevel: "SERIALIZABLE" }));
60
+ } catch (error) {
61
+ lastError = error;
62
+ if (joinsExistingTransaction || !isRetryableTransactionError(error) && !(error instanceof TokenFamilyConflictError)) throw error;
63
+ continue;
64
+ }
65
+ if (parentEffects) parentEffects.push(...effects);
66
+ else for (const effect of effects) effect();
67
+ return result;
68
+ }
69
+ throw lastError;
70
+ }
71
+
72
+ //#endregion
73
+ export { TokenFamilyUnavailableError, afterTokenFamilyOperation, runTokenFamilyOperation };
74
+ //# sourceMappingURL=token-family-operation.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-family-operation.mjs","names":[],"sources":["../../../../../../../auth/src/services/token-family-operation.ts"],"sourcesContent":["import { databaseTransactionContext, transaction } from \"@warlock.js/cascade\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\nimport { AuthTokenFamily } from \"../models/auth-token-family\";\n\nconst operationEffects = new AsyncLocalStorage<Array<() => void>>();\n\n/** Defer events until this coordinator's owned transaction commits. */\nexport function afterTokenFamilyOperation(effect: () => void): void {\n const effects = operationEffects.getStore();\n if (effects) effects.push(effect);\n else effect();\n}\n\n/** A CAS miss is retried only when this helper owns the transaction boundary. */\nclass TokenFamilyConflictError extends Error {\n public constructor() {\n super(\"Token family changed concurrently\");\n this.name = \"TokenFamilyConflictError\";\n }\n}\n\n/** A missing or revoked family cannot become usable through a retry. */\nexport class TokenFamilyUnavailableError extends Error {\n public constructor() {\n super(\"Token family is unavailable\");\n this.name = \"TokenFamilyUnavailableError\";\n }\n}\n\ntype TransactionError = {\n code?: unknown;\n hasErrorLabel?: (label: string) => boolean;\n};\n\nconst MAX_OWNED_TRANSACTION_ATTEMPTS = 3;\n\nfunction isRetryableTransactionError(error: unknown): boolean {\n if (!(error instanceof Error)) return false;\n\n const transactionError = error as Error & TransactionError;\n\n return (\n transactionError.code === \"40001\" ||\n transactionError.hasErrorLabel?.(\"TransientTransactionError\") === true\n );\n}\n\n/**\n * Perform one family state transition in a transaction and advance its durable\n * revision with a conditional update. An existing Cascade transaction is\n * joined, never retried here: PostgreSQL marks an outer transaction aborted on\n * a serialization error, so only that transaction's owner can safely retry.\n *\n * The finite internal cap prevents request handlers from retrying forever.\n * Transaction setup failures are deliberately propagated; this auth boundary\n * has no non-transactional fallback.\n */\nexport async function runTokenFamilyOperation<T>(\n familyId: string,\n operation: (family: AuthTokenFamily) => Promise<T>,\n): Promise<T> {\n const joinsExistingTransaction = databaseTransactionContext.hasActiveTransaction();\n const attempts = joinsExistingTransaction ? 1 : MAX_OWNED_TRANSACTION_ATTEMPTS;\n let lastError: unknown;\n\n for (let attempt = 0; attempt < attempts; attempt++) {\n const parentEffects = operationEffects.getStore();\n const effects: Array<() => void> = [];\n let result: T;\n try {\n result = await operationEffects.run(effects, () =>\n transaction(\n async () => {\n const family = await AuthTokenFamily.findByFamilyId(familyId);\n\n if (!family || family.isRevoked) {\n throw new TokenFamilyUnavailableError();\n }\n\n const revision = family.get<number>(\"revision\");\n const advanced = await AuthTokenFamily.advanceRevision(familyId, revision);\n\n if (advanced !== 1) {\n throw new TokenFamilyConflictError();\n }\n\n return operation(family);\n },\n { isolationLevel: \"SERIALIZABLE\" },\n ),\n );\n } catch (error) {\n lastError = error;\n\n if (\n joinsExistingTransaction ||\n (!isRetryableTransactionError(error) && !(error instanceof TokenFamilyConflictError))\n ) {\n throw error;\n }\n continue;\n }\n\n // Listener failures must never retry a transaction that already committed.\n // Nested family operations join their parent's queue. An app-owned outer\n // transaction has no Cascade commit hook, so its existing event timing is\n // retained; its owner remains responsible for external side effects.\n if (parentEffects) parentEffects.push(...effects);\n else for (const effect of effects) effect();\n return result;\n }\n\n throw lastError;\n}\n\nexport { TokenFamilyConflictError };\n"],"mappings":";;;;;;AAIA,MAAM,mBAAmB,IAAI,kBAAqC;;AAGlE,SAAgB,0BAA0B,QAA0B;CAClE,MAAM,UAAU,iBAAiB,SAAS;CAC1C,IAAI,SAAS,QAAQ,KAAK,MAAM;MAC3B,OAAO;AACd;;AAGA,IAAM,2BAAN,cAAuC,MAAM;CAC3C,AAAO,cAAc;EACnB,MAAM,mCAAmC;EACzC,KAAK,OAAO;CACd;AACF;;AAGA,IAAa,8BAAb,cAAiD,MAAM;CACrD,AAAO,cAAc;EACnB,MAAM,6BAA6B;EACnC,KAAK,OAAO;CACd;AACF;AAOA,MAAM,iCAAiC;AAEvC,SAAS,4BAA4B,OAAyB;CAC5D,IAAI,EAAE,iBAAiB,QAAQ,OAAO;CAEtC,MAAM,mBAAmB;CAEzB,OACE,iBAAiB,SAAS,WAC1B,iBAAiB,gBAAgB,2BAA2B,MAAM;AAEtE;;;;;;;;;;;AAYA,eAAsB,wBACpB,UACA,WACY;CACZ,MAAM,2BAA2B,2BAA2B,qBAAqB;CACjF,MAAM,WAAW,2BAA2B,IAAI;CAChD,IAAI;CAEJ,KAAK,IAAI,UAAU,GAAG,UAAU,UAAU,WAAW;EACnD,MAAM,gBAAgB,iBAAiB,SAAS;EAChD,MAAM,UAA6B,CAAC;EACpC,IAAI;EACJ,IAAI;GACF,SAAS,MAAM,iBAAiB,IAAI,eAClC,YACE,YAAY;IACV,MAAM,SAAS,MAAM,gBAAgB,eAAe,QAAQ;IAE5D,IAAI,CAAC,UAAU,OAAO,WACpB,MAAM,IAAI,4BAA4B;IAGxC,MAAM,WAAW,OAAO,IAAY,UAAU;IAG9C,IAAI,MAFmB,gBAAgB,gBAAgB,UAAU,QAAQ,MAExD,GACf,MAAM,IAAI,yBAAyB;IAGrC,OAAO,UAAU,MAAM;GACzB,GACA,EAAE,gBAAgB,eAAe,CACnC,CACF;EACF,SAAS,OAAO;GACd,YAAY;GAEZ,IACE,4BACC,CAAC,4BAA4B,KAAK,KAAK,EAAE,iBAAiB,2BAE3D,MAAM;GAER;EACF;EAMA,IAAI,eAAe,cAAc,KAAK,GAAG,OAAO;OAC3C,KAAK,MAAM,UAAU,SAAS,OAAO;EAC1C,OAAO;CACT;CAEA,MAAM;AACR"}
package/llms-full.txt CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ---
10
10
  name: auth-basics
11
- description: 'Start with @warlock.js/auth — JWT auth, Auth base model, authMiddleware route gate, authService (login / logout / refresh), AccessToken + RefreshToken persistence, multi-user-type support. Triggers: `Auth`, `authMiddleware`, `authService`, `AccessToken`, `RefreshToken`, `authMigrations`; "set up auth in a new app", "which auth skill do I need", "JWT authentication overview", "wire warlock auth"; typical import `import { authMiddleware, authService, Auth, authMigrations } from "@warlock.js/auth"`. Skip: routing — `@warlock.js/auth/protect-routes/SKILL.md`; login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; competing libs `passport`, `next-auth`, `lucia-auth`, `auth0`.'
11
+ description: "Start here for @warlock.js/auth configuration, user models, credentials, and application session flows."
12
12
  ---
13
13
 
14
14
  # Auth basics
@@ -28,7 +28,7 @@ npm install @warlock.js/auth
28
28
  1. **Users extend `Auth`.** Your `User`, `Admin`, etc. extend the shared base model that knows how to issue tokens and verify passwords. Multiple user types coexist (see [`@warlock.js/auth/customize-user-type/SKILL.md`](@warlock.js/auth/customize-user-type/SKILL.md)).
29
29
  2. **`auth.userType.<name>` config maps a user-type slug to the model class.** The middleware uses this to hydrate the right model from a token.
30
30
  3. **Tokens persist.** Both `AccessToken` and `RefreshToken` are Cascade models — issuing a token writes a row; logout / revoke deletes or marks-revoked. Stateless JWT verification + stateful revocation list.
31
- 4. **`authMiddleware(allowedUserType)` gates routes.** The argument is required and a valid token is always required. `[]` → any authenticated user; a user-type → required auth scoped to those types. Public routes omit the middleware entirely.
31
+ 4. **`authMiddleware` gates or resolves routes.** Use `authMiddleware("user")` for a typed hard gate, `authMiddleware()` for the configured/default user type, or `authMiddleware({ source: "cookie", key: "token", optional: true })` for a public route that receives a valid user when present. The object form can set a page-local login redirect. Legacy `authMiddleware(userType, "cookie:name")` remains valid.
32
32
  5. **`authService.login(Model, credentials, deviceInfo?)` is the full happy path.** Verifies credentials, creates token pair (access + refresh), emits events, returns `{ user, tokens }`.
33
33
  6. **Refresh-token rotation is on by default.** Each refresh consumes the old token and issues new ones from the same "family" — replay detection revokes the family.
34
34
  7. **JWT secret lives in the env.** Generate with `warlock jwt.generate` (see [`@warlock.js/auth/run-auth-commands/SKILL.md`](@warlock.js/auth/run-auth-commands/SKILL.md)).
@@ -79,6 +79,7 @@ export default {
79
79
  user: User,
80
80
  // admin: Admin, // for multi-user-type
81
81
  },
82
+ defaultUserType: "user", // needed for authMiddleware() when multiple types exist
82
83
  accessToken: {
83
84
  secret: env("JWT_SECRET"),
84
85
  expiresIn: "1h",
@@ -131,7 +132,7 @@ Two values that look right and are not:
131
132
 
132
133
  ---
133
134
  name: customize-token-storage
134
- description: 'Override the persisted AccessToken / RefreshToken models to add columns (multi-tenant `organization_id`, device metadata), rename, or change storage — without forking the package. Extend the model + `schema.extend(...)`, register it under `config.auth.accessToken.model` / `config.auth.refreshToken.model`, override `issue()` to populate the new column, and add a migration. Triggers: `accessToken.model`, `refreshToken.model`, `AccessToken.issue`, `RefreshToken.issue`, `accessTokenSchema`, `refreshTokenSchema`, "add a column to the token table", "multi-tenant tokens", "organization_id on access token", "override the token model", "custom token storage"; typical import `import { AccessToken, accessTokenSchema } from "@warlock.js/auth"`. Skip: multiple user TYPES (not token storage) — `@warlock.js/auth/customize-user-type/SKILL.md`; the token lifecycle API — `@warlock.js/auth/manage-tokens/SKILL.md`; the config blocks themselves — `@warlock.js/auth/auth-basics/SKILL.md`.'
135
+ description: "Customize token storage in @warlock.js/auth; use when you need to customize token storage."
135
136
  ---
136
137
 
137
138
  # Customize token storage
@@ -295,7 +296,7 @@ If you rename a column, override the statics **and these getters** so they map t
295
296
 
296
297
  ---
297
298
  name: customize-user-type
298
- description: 'Support multiple user types (user / admin / client / staff) in one auth system — each Auth subclass overrides userType, config.auth.userType.<slug> maps slug to model class, authMiddleware(''admin'') gates per type. Triggers: `Auth`, `userType`, `config.auth.userType`, `Authenticable`, `@RegisterModel`, `confirmPassword`; "add admins and users", "multiple user types", "separate client and vendor personas", "per-type login"; typical import `import { Auth } from "@warlock.js/auth"`. Skip: `authMiddleware` semantics — `@warlock.js/auth/protect-routes/SKILL.md`; login flow — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; RBAC libs `casl`, `accesscontrol`, `rbac`.'
299
+ description: "Customize user type (multi-user-type auth) in @warlock.js/auth; use when you need to customize user type."
299
300
  ---
300
301
 
301
302
  # Customize user type (multi-user-type auth)
@@ -467,7 +468,7 @@ abstract class Auth<TSchema> extends Model<TSchema> implements Authenticable {
467
468
 
468
469
  ---
469
470
  name: handle-login-and-logout
470
- description: 'Run the full login flow via authService.login(Model, credentials, deviceInfo?) — verify password, create access + refresh token pair, fire events. Logout via authService.logout(user, accessToken?, refreshToken?) revokes tokens. Triggers: `authService.login`, `authService.logout`, `authService.attemptLogin`, `authService.refreshTokens`, `authService.revokeAllTokens`, `authEvents`; "build a login endpoint", "POST /login controller", "logout from all devices", "verify credentials and issue tokens"; typical import `import { authService, authEvents } from "@warlock.js/auth"`. Skip: token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; sign-up — `@warlock.js/auth/register-user/SKILL.md`; competing libs `passport-local`, `next-auth` credentials.'
471
+ description: "Login + logout in @warlock.js/auth; use when you need to handle login and logout."
471
472
  ---
472
473
 
473
474
  # Login + logout
@@ -650,7 +651,7 @@ Full event list: `login.attempt`, `login.success`, `login.failed`, `logout`, `lo
650
651
 
651
652
  ---
652
653
  name: login-with-providers
653
- description: 'Log in without a password — Google (OIDC code + PKCE + state + nonce, id_token verified with `jose`), GitHub, Discord, LinkedIn, Apple (`form_post` callback), Facebook and X, passkeys (WebAuthn via `@simplewebauthn/server`) and phone one-time codes (delivered through a `@warlock.js/notifications` channel). Every method ends in `authService.completeLogin(user)`, the same tokens, rows and events as password login. `startProviderLogin`, `completeProviderLogin`, `generatePasskeyRegistrationOptions`, `verifyPasskeyRegistration`, `generatePasskeyAuthenticationOptions`, `verifyPasskeyAuthentication`, `requestOtp`, `verifyOtp`, `otpRequestThrottleMiddleware`, `otpVerifyThrottleMiddleware`, `AuthProvider`. Triggers: "login with Google", "sign in with Google", "login with GitHub", "Sign in with Apple", "login with Discord", "login with LinkedIn", "login with Facebook", "login with X", `auth.providers.github`, "OAuth", "OIDC", "passkeys", "WebAuthn", "passwordless", "SMS code login", "WhatsApp OTP", "phone login", `warlock add auth-google`, `warlock add auth-passkeys`, `AuthProviderSdkMissingError`, `InvalidProviderCallbackError`, `ProviderEmailNotVerifiedError`, `InvalidPasskeyError`, `EC009`, `EC010`, `EC011`, `auth.providers`, `auth.passkeys`, `auth.otp`; typical import `import { completeProviderLogin, verifyOtp } from "@warlock.js/auth"`. Skip: password login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; email tokens — `@warlock.js/auth/verify-email-and-reset-password/SKILL.md`; defining an SMS channel — `@warlock.js/notifications/define-channel/SKILL.md`.'
654
+ description: "Login with an OAuth provider, passkeys, or a phone code in @warlock.js/auth; use when you need to login with providers."
654
655
  ---
655
656
 
656
657
  # Login with an OAuth provider, passkeys, or a phone code
@@ -937,7 +938,7 @@ router.post("/auth/otp/verify", async ({ request, response }) => {
937
938
 
938
939
  ---
939
940
  name: manage-tokens
940
- description: 'Token lifecycle — generateAccessToken, createRefreshToken, createTokenPair, refreshTokens (with rotation + replay detection), revokeAllTokens, revokeTokenFamily, cleanupExpiredTokens, getActiveSessions. Triggers: `createTokenPair`, `refreshTokens`, `revokeTokenFamily`, `cleanupExpiredTokens`, `getActiveSessions`, `jwt.generate`, `jwt.verify`, `AccessToken`, `RefreshToken`; "rotate refresh tokens", "detect token replay", "logout from all devices", "list active sessions", "clean up expired tokens"; typical import `import { authService, jwt } from "@warlock.js/auth"`. Skip: login flow — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; CLI cleanup — `@warlock.js/auth/run-auth-commands/SKILL.md`; competing libs `jsonwebtoken`, `jose`, `fast-jwt`.'
941
+ description: "Manage tokens in @warlock.js/auth; use when you need to manage tokens."
941
942
  ---
942
943
 
943
944
  # Manage tokens
@@ -973,7 +974,20 @@ const pair = await authService.createTokenPair(user, deviceInfo);
973
974
 
974
975
  `createTokenPair` is the typical issuance path. It respects `config.auth.refreshToken.enabled` — if disabled, returns only `accessToken`.
975
976
 
976
- ## Refresh with rotation — `refreshTokens`
977
+ ## Refresh with rotation — `refreshTokens`
978
+
979
+ Refresh and family revocation use durable family revision checks inside a
980
+ serializable Cascade transaction. Run the additive `authMigrations` before
981
+ using the upgraded APIs. Transaction support is required: the service fails
982
+ rather than falling back to uncoordinated writes. MongoDB deployments therefore
983
+ need a transaction-capable topology.
984
+
985
+ An operation that owns its transaction retries serialization conflicts at most
986
+ three times. Inside an application-owned transaction it joins once and leaves
987
+ retry/rollback to the caller. Auth notifications from owned attempts are emitted
988
+ only after successful commit; notifications inside an application-owned outer
989
+ transaction retain their existing timing before that outer commit. Keep external
990
+ side effects outside such a transaction.
977
991
 
978
992
  ```ts
979
993
  const next = await authService.refreshTokens(oldRefreshToken, deviceInfo);
@@ -1006,7 +1020,7 @@ refresh (B) → revokes B; creates C in family X
1006
1020
  refresh (A again)→ A is revoked → revoke family X entirely
1007
1021
  ```
1008
1022
 
1009
- The family ties together "successive rotations of one session." Logout of one device kills only that device's family — other devices keep their own families.
1023
+ The family ties together "successive rotations of one session." In 5.19 it is also a durable `AuthTokenFamily` row: family revoke marks that family, revokes its refresh rows, and deletes its associated access rows atomically. Access rows from before family association are conservatively deleted only for the same user and user type; they are never guessed into a family.
1010
1024
 
1011
1025
  ## Listing active sessions
1012
1026
 
@@ -1028,9 +1042,8 @@ Use this for "active sessions" UIs. Revoke a specific session by calling `.revok
1028
1042
  // Specific access token
1029
1043
  await authService.removeAccessToken(user, accessTokenString);
1030
1044
 
1031
- // Specific refresh token (via the RefreshToken instance)
1032
- const rt = await RefreshToken.findByToken(refreshString);
1033
- await rt?.revoke();
1045
+ // Specific session/family
1046
+ await authService.revokeTokenFamily(familyId);
1034
1047
 
1035
1048
  // All access tokens for a user
1036
1049
  await authService.removeAllAccessTokens(user);
@@ -1112,6 +1125,7 @@ The package signs access and refresh tokens with independent secrets — `config
1112
1125
  - Don't disable rotation (`config.auth.refreshToken.rotation = false`) unless you genuinely understand the tradeoff — you lose replay detection.
1113
1126
  - Don't increase `maxPerUser` to a huge number "to be safe." Each active refresh token is a revocation surface; fewer simultaneous tokens means less attack surface.
1114
1127
  - Don't manually delete `AccessToken` rows in a service. The user might be hitting a request mid-revoke and get an inconsistent state. Use the `authService` helpers.
1128
+ - Run the additive `authMigrations` on upgrade. Do not alter or backfill token rows yourself; legacy access tokens are handled conservatively on family revoke.
1115
1129
 
1116
1130
  ## See also
1117
1131
 
@@ -1124,7 +1138,7 @@ The package signs access and refresh tokens with independent secrets — `config
1124
1138
 
1125
1139
  ---
1126
1140
  name: overview
1127
- description: 'Front-door orientation for `@warlock.js/auth` — JWT authentication for Warlock apps: the `Auth` base model, `authMiddleware` route gate, `authService` (login / logout / refresh with token rotation + replay detection), persisted AccessToken + RefreshToken, multi-user-type support, auth lifecycle events, and two CLI commands. Coupled to `@warlock.js/core`. TRIGGER when: code imports anything from `@warlock.js/auth`; user asks "what does @warlock.js/auth do", "how do I add login to my Warlock app", "JWT auth in Warlock", "protect a route", "multiple user types / admin + user", "refresh token rotation"; package.json adds `@warlock.js/auth`. Skip: specific task already known — load the matching task skill directly (`auth-basics`, `protect-routes`, `handle-login-and-logout`, `register-user`, `manage-tokens`, `customize-user-type`, `run-auth-commands`); non-Warlock apps (this package depends on core); session-cookie auth (this is JWT/token-based).'
1141
+ description: "@warlock.js/auth overview for orientation and choosing the focused skill for the task."
1128
1142
  ---
1129
1143
 
1130
1144
  # `@warlock.js/auth` — overview
@@ -1145,7 +1159,7 @@ Skip if you're not on `@warlock.js/core` (the package depends on it), or if you
1145
1159
 
1146
1160
  ## The mental model in one paragraph
1147
1161
 
1148
- Your user model extends the `Auth` base model and declares its `userType`. A login flows through `authService.login(Model, credentials, deviceInfo?)`: it verifies the password, issues an access + refresh token pair (persisted as `AccessToken` / `RefreshToken` records), and fires events. `authMiddleware(allowedUserType)` gates routes — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types (401 otherwise). There is no anonymous mode; public routes simply omit the middleware. Refresh rotates the refresh token and detects replay by revoking the whole token family. CLI commands generate the JWT secret and clean up expired tokens.
1162
+ Your user model extends the `Auth` base model and declares its `userType`. A login flows through `authService.login(Model, credentials, deviceInfo?)`: it verifies the password, issues a persisted access + refresh pair, and fires events. `authMiddleware("user")` is a typed hard gate; `authMiddleware()` resolves the configured default type; the object form selects header/cookie credentials, `optional`, and a page-local redirect. Refresh rotation uses a durable token family: family revocation invalidates its refresh and associated access rows. CLI commands generate the JWT secret and clean up expired tokens.
1149
1163
 
1150
1164
  ## Skills index
1151
1165
 
@@ -1165,7 +1179,7 @@ Start here. The `Auth` base model, `authMiddleware` gate, `authService` (login/l
1165
1179
  Sign up a new user and issue the first token pair — `User.create({ ...password: await hashPassword(plain) })` then `authService.createTokenPair(user)`. For `POST /register`.
1166
1180
 
1167
1181
  #### [`protect-routes`](@warlock.js/auth/protect-routes/SKILL.md)
1168
- `authMiddleware(allowedUserType)` — the argument is required and always requires a valid token: `[]` allows any authenticated user, a user-type argument restricts to those types. Sets `request.locals.user` + `request.decodedAccessToken`, responds 401 on failure.
1182
+ `authMiddleware` object and legacy overloads: typed/default hard gates, optional resolution, credential source, and page-local redirects. Sets `request.locals.user` + `request.decodedAccessToken` when a user resolves.
1169
1183
 
1170
1184
  ### Going deeper
1171
1185
 
@@ -1202,12 +1216,12 @@ Two CLI commands — `warlock jwt.generate` (strong JWT secret → `.env`) and `
1202
1216
 
1203
1217
  ---
1204
1218
  name: protect-routes
1205
- description: 'Gate HTTP routes via authMiddleware(allowedUserType) — the argument is required and a valid token is always required: [] allows any authenticated user, a user-type restricts to those types. Sets request.locals.user + request.decodedAccessToken on success, 401 on failure. Triggers: `authMiddleware`, `request.locals.user`, `request.decodedAccessToken`, `AuthErrorCodes`, `MissingAccessToken`, `InvalidAccessToken`; "how do I protect a route", "restrict route by user type", "require any logged-in user"; typical import `import { authMiddleware } from "@warlock.js/auth"`. Skip: multi-user-type config — `@warlock.js/auth/customize-user-type/SKILL.md`; issuing the token — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; competing libs `passport`, `express-jwt`, `next-auth` middleware.'
1219
+ description: "Gate routes with authMiddleware in @warlock.js/auth; use when you need to protect routes."
1206
1220
  ---
1207
1221
 
1208
1222
  # Gate routes with `authMiddleware`
1209
1223
 
1210
- `authMiddleware(allowedUserType: string | string[])` returns a Warlock middleware. Attach it to routes or route groups. The argument is **required** — there is no anonymous/optional mode. A request without a valid access token is always rejected with `401`; public routes simply omit the middleware.
1224
+ `authMiddleware` returns a Warlock middleware. Attach it to routes or route groups. The legacy string overload is preserved; the object overload makes credential source, optional resolution, and a page-local redirect explicit.
1211
1225
 
1212
1226
  ```ts title="src/app/users/models/user/user.model.ts"
1213
1227
  import { Auth } from "@warlock.js/auth";
@@ -1235,14 +1249,13 @@ const accountController: RequestHandler = async ({ response }) => response.succe
1235
1249
  const adminController: RequestHandler = async ({ response }) => response.success({});
1236
1250
  const staffController: RequestHandler = async ({ response }) => response.success({});
1237
1251
 
1238
- // Mode 1 — required, any user type
1239
- // Rejects with 401 if no valid token; any authenticated user passes.
1240
- router.get("/account", accountController, {
1241
- middleware: [authMiddleware([])], // empty array = "any logged-in user"
1252
+ // Required default type (`auth.defaultUserType`, or the sole configured type)
1253
+ router.get("/account", accountController, {
1254
+ middleware: [authMiddleware()],
1242
1255
  });
1243
1256
 
1244
1257
  // Mode 2 — required, specific user type(s)
1245
- // Rejects with 401 if no token OR if token's userType isn't allowed.
1258
+ // Rejects missing/invalid credentials with 401 and a disallowed user type with 403.
1246
1259
  router.get("/admin", adminController, {
1247
1260
  middleware: [authMiddleware("admin")],
1248
1261
  });
@@ -1271,13 +1284,13 @@ A token passes three separate checks, in order — a token can fail any one of t
1271
1284
  2. **The access-token row still exists** — deleting it (logout) invalidates the token immediately, before its JWT expiry.
1272
1285
  3. **The row's own `expires_at` has not passed** (since 4.12.0). The database is the authority on the session: a row whose expiry has elapsed is rejected and deleted, even if the JWT itself is still within its lifetime. A row with a missing or unparseable `expires_at` is treated as expired, not as never-expiring.
1273
1286
 
1274
- On failure, the middleware returns one of these 401 responses:
1287
+ Missing or invalid credentials return 401; an authenticated disallowed user type returns 403:
1275
1288
 
1276
1289
  | Error code | When |
1277
1290
  | --- | --- |
1278
- | `MissingAccessToken` | No `Authorization` header |
1291
+ | `MissingAccessToken` | No credential at the configured source |
1279
1292
  | `InvalidAccessToken` | Token doesn't verify (signature, missing/passed `exp`, wrong token type), has no DB row, or the row's `expires_at` has passed |
1280
- | `Unauthorized` | Token valid but user-type isn't in the allowed list |
1293
+ | `Unauthorized` | Token valid but user-type isn't in the allowed list (403) |
1281
1294
 
1282
1295
  ## Page routes: redirect to login instead of the JSON 401
1283
1296
 
@@ -1305,7 +1318,7 @@ Behavior, precisely:
1305
1318
 
1306
1319
  The page-vs-API signal is `request.route.isPage`. Config is typed as `PageAuthConfig` on `AuthConfigurations` (`loginPath?: string`, `returnUrlParam?: string`, default `"returnUrl"`); read at runtime through `authConfig.pageAuth`.
1307
1320
 
1308
- > `auth.pageAuth` is **not a second auth mechanism** — it is a convenience *adapter* over the same "not authenticated" outcome the gate already produces. It only changes the **representation** of that outcome on a page route (a browser-friendly login redirect) instead of the JSON `401`; the decision that the request is unauthenticated is unchanged. The sanctioned *generic* way to make authentication soft is the app-owned optional-auth middleware below — `pageAuth` is the built-in adapter for the common "redirect a logged-out human to /login" case, so most apps never need to hand-roll it.
1321
+ > A local `redirect` object is preferred for a page that needs a different login destination; absent it, `auth.pageAuth` remains the compatibility fallback.
1309
1322
 
1310
1323
  ## Reading the user in a controller
1311
1324
 
@@ -1344,31 +1357,23 @@ router.group({ prefix: "/admin", middleware: [authMiddleware("admin")] }, () =>
1344
1357
 
1345
1358
  Every route inside the group is gated — the group's `middleware` array applies to each route in the callback. Cleaner than repeating the middleware on each route.
1346
1359
 
1347
- ## Optional auth is an app-owned middleware (the sanctioned pattern)
1348
-
1349
- `authMiddleware` is a **hard gate** — it always requires a valid token and rejects when one is absent. There is deliberately no built-in "hydrate `request.locals.user` if a token is present, otherwise continue" mode, because the *sanctioned generic pattern* for soft/optional auth is a small **app-owned optional-auth middleware**: resolve the user when a valid token is present, and otherwise leave the route to decide. The app owns it because the "what to do when absent" policy is the app's, not the framework's.
1350
-
1351
- ```ts title="src/app/middleware/optional-auth.middleware.ts"
1352
- import { type Middleware } from "@warlock.js/core";
1353
- import { jwt } from "@warlock.js/auth";
1354
-
1355
- // The one sanctioned optional-auth shape: resolve if present, never reject.
1356
- // Middleware receives the context object ({ request, response }), like a handler.
1357
- export const optionalAuth: Middleware = async ({ request }) => {
1358
- const token = request.authorizationValue;
1359
- if (!token) return; // absent → continue anonymously; the route decides
1360
- try {
1361
- const decoded = await jwt.verify(token);
1362
- // hydrate request.locals.user from your token/user model when valid
1363
- } catch {
1364
- // invalid token on an optional route → treat as anonymous, don't reject
1365
- }
1366
- // returning nothing (undefined) === continue to the handler
1367
- };
1368
- ```
1369
-
1370
- Wire it like any middleware (`{ middleware: [optionalAuth] }`), then branch on `request.locals.user` in the controller. Use `authMiddleware` when the route must be gated; use this when the route is public but personalizes for a signed-in user. `auth.pageAuth` (above) is the built-in **adapter** over this same "unauthenticated" outcome for the common page-redirect case — it is not a competing mechanism.
1360
+ ## Optional auth
1361
+
1362
+ Use `optional: true` in the object form when a public route should receive a verified user when one is present. Missing or invalid credentials continue anonymously; a hard gate remains the default.
1363
+
1364
+ ```ts
1365
+ middleware: [authMiddleware({ source: "header", optional: true })]
1366
+ ```
1367
+
1368
+ ## Automatic cookie renewal
1371
1369
 
1370
+ Add `refresh: { source: "cookie", key: "refresh_token", overlapMs?: 5000 }`
1371
+ to a typed cookie middleware to renew an absent, invalid, or expired access
1372
+ cookie before the handler. It writes the successor access and refresh cookies
1373
+ through `authService.setAuthCookie`; it never replays a handler. `overlapMs` is
1374
+ clamped to 0–10,000 ms (default 5,000). Only the exact immediate successor is
1375
+ tolerated during that window. The legacy `refreshTokens` API stays strict; a
1376
+ later old-token presentation revokes its family.
1372
1377
  ## Custom error responses
1373
1378
 
1374
1379
  The middleware uses the framework's `response.unauthorized({...})` shape. To override the response globally, hook the framework's error transformer to remap `AuthErrorCodes.*` codes.
@@ -1410,13 +1415,13 @@ router.post("/articles", articlesController, { middleware: [authMiddleware([]),
1410
1415
 
1411
1416
  Use `authMiddleware` to establish *who the caller is*, and `access` to decide *what they may do*. They compose — `access` reads the user `authMiddleware` put on the request.
1412
1417
 
1413
- ## Cookie sessions: `authMiddleware([], "cookie:<name>")`
1418
+ ## Cookie credentials
1414
1419
 
1415
- `authMiddleware`'s second argument, `tokenFrom`, selects the credential source and defaults to `"header"`. Pass `` `cookie:<name>` `` to read the credential from a named cookie instead of the `Authorization` header — the same gate, the same three checks, the same `request.locals.user` / `request.decodedAccessToken` outcome, just a different place to find the token:
1420
+ Use `{ source: "cookie", key: "token" }` in the object form. The same checks and `request.locals.user` outcome apply; legacy `authMiddleware("user", "cookie:token")` is also supported.
1416
1421
 
1417
1422
  ```ts
1418
1423
  router.get("/browser-account", accountController, {
1419
- middleware: [authMiddleware([], "cookie:token")],
1424
+ middleware: [authMiddleware({ source: "cookie", key: "token" })],
1420
1425
  });
1421
1426
  ```
1422
1427
 
@@ -1514,7 +1519,7 @@ This is an Origin check, not a double-submit CSRF token — cheaper, and closes
1514
1519
 
1515
1520
  ---
1516
1521
  name: register-user
1517
- description: 'Sign up a new user and issue the initial token pair — User.create({...password: await hashPassword(plain)}) then authService.createTokenPair(user). Triggers: `User.create`, `hashPassword`, `verifyPassword`, `authService.createTokenPair`, `toJsonColumns`, `strongPassword`, `authEvents`; "build a register endpoint", "POST /register controller", "sign up a new user", "hash password on signup", "email verification flow"; typical import `import { authService } from "@warlock.js/auth"; import { hashPassword } from "@warlock.js/core"`. Skip: login — `@warlock.js/auth/handle-login-and-logout/SKILL.md`; token internals — `@warlock.js/auth/manage-tokens/SKILL.md`; competing libs `bcrypt`, `bcryptjs`, `argon2`.'
1522
+ description: "Register-and-issue-tokens flow in @warlock.js/auth; use when you need to register user."
1518
1523
  ---
1519
1524
 
1520
1525
  # Register-and-issue-tokens flow
@@ -1665,7 +1670,7 @@ Cleaner alternative: emit your own `user.registered` event from the controller a
1665
1670
 
1666
1671
  ---
1667
1672
  name: run-auth-commands
1668
- description: 'Three bundled CLI commands — warlock jwt.generate (creates strong JWT secret + writes to .env), warlock auth.cleanup (removes expired refresh tokens), and warlock auth.purge-never-expiring (finds and revokes token rows that can never expire). Register via registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand() and registerAuthPurgeNeverExpiringCommand(). Triggers: `registerJWTSecretGeneratorCommand`, `registerAuthCleanupCommand`, `registerAuthPurgeNeverExpiringCommand`, `warlock jwt.generate`, `warlock auth.cleanup`, `warlock auth.purge-never-expiring`, `cleanupExpiredTokens`, `purgeNeverExpiringTokens`, `command`; "token never expires", "no exp claim", "Invalid Date expires_at"; "generate JWT secret", "bootstrap .env JWT_SECRET", "cron job for expired tokens", "schedule auth cleanup"; typical import `import { registerJWTSecretGeneratorCommand, registerAuthCleanupCommand, registerAuthPurgeNeverExpiringCommand } from "@warlock.js/auth"`. Skip: programmatic cleanup — `@warlock.js/auth/manage-tokens/SKILL.md`; in-process scheduling — `@warlock.js/scheduler/scheduler-basics/SKILL.md`; competing tools `dotenv-cli`, `node-cron`.'
1673
+ description: "Run auth commands in @warlock.js/auth; use when you need to run auth commands."
1669
1674
  ---
1670
1675
 
1671
1676
  # Run auth commands
@@ -1828,7 +1833,7 @@ Register it the same way as the bundled commands — call the factory inside `de
1828
1833
 
1829
1834
  ---
1830
1835
  name: throttle-login-attempts
1831
- description: 'Brute-force / credential-stuffing protection via `loginThrottleMiddleware` — a failure-aware route gate that counts only failed logins (resets on success), locks per-account and per-source after a threshold, and rejects pre-controller with 429 so the DB lookup and bcrypt verify are skipped. Cache-backed (shared across replicas), fixed-window, fails open on a cache outage. Triggers: `loginThrottleMiddleware`, `AuthErrorCodes.TooManyAttempts`, `EC004`, "rate limit login", "brute force protection", "lock account after failed logins", "throttle login attempts", "too many login attempts 429"; typical import `import { loginThrottleMiddleware } from "@warlock.js/auth"`. Skip: generic per-route request rate limiting that counts every request (use core `middleware.rateLimit`); gating a route by auth — `@warlock.js/auth/protect-routes/SKILL.md`; issuing tokens — `@warlock.js/auth/handle-login-and-logout/SKILL.md`.'
1836
+ description: "Throttle login attempts with loginThrottleMiddleware in @warlock.js/auth; use when you need to throttle login attempts."
1832
1837
  ---
1833
1838
 
1834
1839
  # Throttle login attempts with `loginThrottleMiddleware`
@@ -1902,7 +1907,7 @@ On lockout the middleware sends:
1902
1907
  { "error": "...", "errorCode": "EC004" } // AuthErrorCodes.TooManyAttempts
1903
1908
  ```
1904
1909
 
1905
- Define the `auth.errors.tooManyAttempts` translation key (or pass `errorMessage`) so clients see a real message instead of the raw key. Map `EC004` in your error transformer the same way you map the other `AuthErrorCodes`.
1910
+ `@warlock.js/auth` ships built-in `en` and `ar` text for `auth.errors.tooManyAttempts` (and every other `auth.errors.*` key it uses); define the key in your own locale files, or pass `errorMessage`, to override it. Map `EC004` in your error transformer the same way you map the other `AuthErrorCodes`.
1906
1911
 
1907
1912
  ## Beyond login
1908
1913
 
@@ -1948,7 +1953,7 @@ They compose. For a hard ceiling on request volume *and* failure-aware account l
1948
1953
 
1949
1954
  ---
1950
1955
  name: verify-email-and-reset-password
1951
- description: 'Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.'
1956
+ description: "Verify email + reset password in @warlock.js/auth; use when you need to verify email and reset password."
1952
1957
  ---
1953
1958
 
1954
1959
  # Verify email + reset password