@gatwech/auth 1.0.0-beta.18 → 1.0.0-beta.20

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 (225) hide show
  1. package/README.md +8 -14
  2. package/dist/{access-C6O_osEz.mjs → access-CrwEFPHE.mjs} +1 -1
  3. package/dist/{access-DT1f6l5k.mjs → access-bhklJd7m.mjs} +1 -1
  4. package/dist/{adapter-factory-BZ2Qiqpn.mjs → adapter-factory-xbLUJt85.mjs} +2 -2
  5. package/dist/adapters/drizzle-adapter/index.mjs +6 -5
  6. package/dist/adapters/index.d.mts +5 -5
  7. package/dist/adapters/index.mjs +6 -5
  8. package/dist/adapters/kysely-adapter/index.d.mts +1 -1
  9. package/dist/adapters/kysely-adapter/index.mjs +8 -7
  10. package/dist/adapters/memory-adapter/index.d.mts +1 -1
  11. package/dist/adapters/memory-adapter/index.mjs +6 -5
  12. package/dist/adapters/mongodb-adapter/index.mjs +6 -5
  13. package/dist/adapters/prisma-adapter/index.mjs +6 -5
  14. package/dist/adapters/test.mjs +4 -3
  15. package/dist/{admin-Bcq53Nwj.mjs → admin-BzUJL6tr.mjs} +6 -6
  16. package/dist/{anonymous-CRt1imBW.mjs → anonymous-BjlTk4gl.mjs} +5 -5
  17. package/dist/api/index.d.mts +5 -5
  18. package/dist/api/index.mjs +12 -11
  19. package/dist/{api-0pR_I-Nx.mjs → api-Dk8Yj_J5.mjs} +15 -5
  20. package/dist/audit-log-DKsUWR8O.mjs +772 -0
  21. package/dist/{auth-CBUoBX6o.mjs → auth-vfCyIIeV.mjs} +8 -8
  22. package/dist/{bearer-Djf_awfo.mjs → bearer-DvQLM00l.mjs} +2 -2
  23. package/dist/client/index.d.mts +19 -19
  24. package/dist/client/index.mjs +4 -4
  25. package/dist/client/plugins/index.d.mts +42 -34
  26. package/dist/client/plugins/index.mjs +16 -12
  27. package/dist/client/react/index.d.mts +5 -5
  28. package/dist/client/react/index.mjs +2 -2
  29. package/dist/client-4QEb0rUr.mjs +95 -0
  30. package/dist/client-C_qmyez2.d.mts +75 -0
  31. package/dist/client-DzOqXJUm.mjs +15 -0
  32. package/dist/client-F67q-Lbi.d.mts +14 -0
  33. package/dist/{client-aExCkfwm.mjs → client-xga7yFIX.mjs} +2 -2
  34. package/dist/cookies/index.d.mts +6 -6
  35. package/dist/cookies/index.mjs +2 -2
  36. package/dist/{cookies-cJObLwn1.mjs → cookies-BuvlgcA-.mjs} +1 -1
  37. package/dist/crypto/index.mjs +2 -1
  38. package/dist/{crypto-Bb42eMrH.mjs → crypto-CCyta8xj.mjs} +1 -6
  39. package/dist/{custom-session-L3t7ecsd.mjs → custom-session-DN_-pfi_.mjs} +2 -2
  40. package/dist/db/index.d.mts +5 -5
  41. package/dist/db/index.mjs +9 -8
  42. package/dist/{db-BrstmDhc.mjs → db-7vHnL1XA.mjs} +15 -9
  43. package/dist/{device-authorization-V4s3h4Mj.mjs → device-authorization-BKnNhlsz.mjs} +4 -4
  44. package/dist/{dialect-CeddeGHf.mjs → dialect-HsWUfpYY.mjs} +2 -2
  45. package/dist/{email-otp-BtDHSw14.mjs → email-otp-Dw0DypjQ.mjs} +7 -6
  46. package/dist/{generic-oauth-CL5X27ZI.mjs → generic-oauth-DFj27JY8.mjs} +4 -4
  47. package/dist/{has-permission-BpAr7uCn.mjs → has-permission-Bvg8de8g.mjs} +1 -1
  48. package/dist/{haveibeenpwned-cMpJvmzI.mjs → haveibeenpwned-CzNda7Tn.mjs} +28 -15
  49. package/dist/{index-BYMI9r_d.d.mts → index-4I1YhYhk.d.mts} +4 -4
  50. package/dist/{index-Cxi7TbLE.d.mts → index-BNhJglHC.d.mts} +122 -122
  51. package/dist/{index-DlaMq1nb.d.mts → index-BX7rl07Z.d.mts} +14 -14
  52. package/dist/{client-BOX93C3d.d.mts → index-Bml-PCqP.d.mts} +52 -52
  53. package/dist/{index-zjjNBRY2.d.mts → index-BsSKcKgO.d.mts} +6 -6
  54. package/dist/{index-Buhn3hkR.d.mts → index-CBiGsvDd.d.mts} +8 -8
  55. package/dist/{index-D_k662QG.d.mts → index-CC920PPp.d.mts} +14 -14
  56. package/dist/{index-B3n8e37d.d.mts → index-CNd5jqLl.d.mts} +7 -7
  57. package/dist/{index-BqhKCbPH.d.mts → index-CP8x1TFq.d.mts} +6 -6
  58. package/dist/{index-DKNNV1kb.d.mts → index-CofuaNMl.d.mts} +8 -8
  59. package/dist/{index-DR0xXno9.d.mts → index-CsxtcAO0.d.mts} +1 -1
  60. package/dist/{index-C7fQuk-V.d.mts → index-CxlWnL9c.d.mts} +1 -1
  61. package/dist/{index-CuFzhoS6.d.mts → index-CzpdkTdv.d.mts} +10 -10
  62. package/dist/index-D57DuhZR.d.mts +3769 -0
  63. package/dist/{index-C-u1h_WX.d.mts → index-D6ku0rUC.d.mts} +5 -5
  64. package/dist/{client-CNY0tn9g.d.mts → index-DFSRYWFI.d.mts} +21 -21
  65. package/dist/{index-BBm8nQ9Q.d.mts → index-DGeoJ4gu.d.mts} +2 -2
  66. package/dist/{index-G6V0Z_Mn.d.mts → index-DTkcY7xH.d.mts} +10 -10
  67. package/dist/{index-65G5U5DB-DSdjzreF.d.mts → index-Df0KhQRf-BEa5tdwJ.d.mts} +3 -2
  68. package/dist/{index-34ONrP8l.d.mts → index-Dnd8S4S4.d.mts} +11 -11
  69. package/dist/{index-B-XKqFvY.d.mts → index-DwPAddfE.d.mts} +11 -4
  70. package/dist/{index-DoXZYQpq.d.mts → index-DyXi7S2M.d.mts} +10 -10
  71. package/dist/{index-BHoQlf7a.d.mts → index-KhbNmMmG.d.mts} +178 -178
  72. package/dist/{index-z0KSvE5i.d.mts → index-QmocqGM-.d.mts} +1 -1
  73. package/dist/{admin-DtfO1PLg.d.mts → index-XfE0WjTN.d.mts} +36 -36
  74. package/dist/{index-DaBcikv9.d.mts → index-sb-UFLuo.d.mts} +2 -2
  75. package/dist/index.d.mts +5 -5
  76. package/dist/index.mjs +16 -15
  77. package/dist/integrations/next-js.d.mts +4 -4
  78. package/dist/integrations/next-js.mjs +2 -2
  79. package/dist/integrations/node.d.mts +5 -5
  80. package/dist/integrations/react-start.d.mts +4 -4
  81. package/dist/integrations/react-start.mjs +2 -2
  82. package/dist/{jwt-lHV4QHpD.mjs → jwt-DBN2mu0r.mjs} +4 -4
  83. package/dist/{kysely-adapter-DpzYO8Yf.mjs → kysely-adapter-Bkm1liPW.mjs} +1 -1
  84. package/dist/{magic-link-Bk-ueorR.mjs → magic-link-D9Em1aOR.mjs} +3 -3
  85. package/dist/{multi-session-DRrLKrg9.mjs → multi-session-Dno_8DxC.mjs} +5 -5
  86. package/dist/{oauth-proxy-D1sWh9B_.mjs → oauth-proxy-DW-zt8wv.mjs} +2 -2
  87. package/dist/oauth2/index.d.mts +5 -5
  88. package/dist/oauth2/index.mjs +12 -11
  89. package/dist/{one-tap-DMswkTEc.mjs → one-tap-CodeyQb8.mjs} +2 -2
  90. package/dist/{organization-BArHO_5v.mjs → organization-CkTKs7bx.mjs} +9 -9
  91. package/dist/{passkey-CcD4CnhM.mjs → passkey-JNQTz5a0.mjs} +5 -5
  92. package/dist/{phone-number-rHDIlh-2.mjs → phone-number-Dqz8aeyF.mjs} +4 -4
  93. package/dist/plugins/access/index.d.mts +2 -2
  94. package/dist/plugins/access/index.mjs +1 -1
  95. package/dist/plugins/admin/access/index.d.mts +2 -2
  96. package/dist/plugins/admin/access/index.mjs +2 -2
  97. package/dist/plugins/admin/index.d.mts +7 -8
  98. package/dist/plugins/admin/index.mjs +17 -16
  99. package/dist/plugins/anonymous/index.d.mts +6 -6
  100. package/dist/plugins/anonymous/index.mjs +13 -12
  101. package/dist/plugins/audit-log/client.d.mts +30 -0
  102. package/dist/plugins/audit-log/client.mjs +3 -0
  103. package/dist/plugins/audit-log/index.d.mts +29 -0
  104. package/dist/plugins/audit-log/index.mjs +20 -0
  105. package/dist/plugins/bearer/index.d.mts +1 -1
  106. package/dist/plugins/bearer/index.mjs +3 -3
  107. package/dist/plugins/captcha/index.d.mts +1 -1
  108. package/dist/plugins/captcha/index.mjs +1 -1
  109. package/dist/plugins/custom-session/index.d.mts +6 -6
  110. package/dist/plugins/custom-session/index.mjs +14 -13
  111. package/dist/plugins/device-authorization/index.d.mts +3 -3
  112. package/dist/plugins/device-authorization/index.mjs +13 -12
  113. package/dist/plugins/email-otp/index.d.mts +2 -2
  114. package/dist/plugins/email-otp/index.mjs +14 -13
  115. package/dist/plugins/generic-oauth/index.d.mts +6 -6
  116. package/dist/plugins/generic-oauth/index.mjs +13 -12
  117. package/dist/plugins/haveibeenpwned/index.d.mts +2 -2
  118. package/dist/plugins/haveibeenpwned/index.mjs +14 -13
  119. package/dist/plugins/index.d.mts +29 -139
  120. package/dist/plugins/index.mjs +47 -42
  121. package/dist/plugins/jwt/index.d.mts +6 -6
  122. package/dist/plugins/jwt/index.mjs +13 -12
  123. package/dist/plugins/magic-link/index.d.mts +1 -1
  124. package/dist/plugins/magic-link/index.mjs +13 -12
  125. package/dist/plugins/multi-session/index.d.mts +2 -2
  126. package/dist/plugins/multi-session/index.mjs +13 -12
  127. package/dist/plugins/oauth-proxy/index.d.mts +1 -1
  128. package/dist/plugins/oauth-proxy/index.mjs +13 -12
  129. package/dist/plugins/one-tap/index.d.mts +1 -1
  130. package/dist/plugins/one-tap/index.mjs +13 -12
  131. package/dist/plugins/one-time-token/index.d.mts +6 -6
  132. package/dist/plugins/one-time-token/index.mjs +46 -41
  133. package/dist/plugins/organization/access/index.d.mts +3 -3
  134. package/dist/plugins/organization/access/index.mjs +2 -2
  135. package/dist/plugins/organization/index.d.mts +8 -8
  136. package/dist/plugins/organization/index.mjs +17 -16
  137. package/dist/plugins/passkey/index.d.mts +6 -6
  138. package/dist/plugins/passkey/index.mjs +13 -12
  139. package/dist/plugins/phone-number/index.d.mts +6 -6
  140. package/dist/plugins/phone-number/index.mjs +13 -12
  141. package/dist/plugins/sentinel/client.d.mts +30 -0
  142. package/dist/plugins/sentinel/client.mjs +5 -0
  143. package/dist/plugins/sentinel/index.d.mts +29 -0
  144. package/dist/plugins/sentinel/index.mjs +22 -0
  145. package/dist/plugins/two-factor/index.d.mts +6 -6
  146. package/dist/plugins/two-factor/index.mjs +15 -14
  147. package/dist/plugins/username/index.d.mts +3 -3
  148. package/dist/plugins/username/index.mjs +13 -12
  149. package/dist/{plugins-hFZT3hmK.d.mts → plugins-Dy_FrD4Y.d.mts} +1 -1
  150. package/dist/{plugins-3jfNste3.mjs → plugins-yF8CFUDR.mjs} +11 -8
  151. package/dist/pow-OK01yPG3.mjs +61 -0
  152. package/dist/{proxy-CL9PZXJA.mjs → proxy-C_KXfOhk.mjs} +1 -1
  153. package/dist/random-32E3cU5o.mjs +7 -0
  154. package/dist/registry-CqG_3Qnx.mjs +194 -0
  155. package/dist/sentinel-DiPqydKG.mjs +1426 -0
  156. package/dist/{session-Dgp133Yt.mjs → session-BB3oqApE.mjs} +2 -2
  157. package/dist/src/adapters/memory-adapter/memory-adapter.d.ts +1 -1
  158. package/dist/src/adapters/prisma-adapter/test/auth.d.ts +1 -1
  159. package/dist/src/adapters/tests/auth-flow.d.ts +3 -3
  160. package/dist/src/adapters/tests/normal.d.ts +1 -1
  161. package/dist/src/adapters/tests/number-id.d.ts +3 -3
  162. package/dist/src/adapters/tests/performance.d.ts +3 -3
  163. package/dist/src/adapters/tests/transactions.d.ts +3 -3
  164. package/dist/src/api/index.d.ts +8 -8
  165. package/dist/src/client/plugins/index.d.ts +2 -0
  166. package/dist/src/plugins/admin/admin.d.ts +2 -2
  167. package/dist/src/plugins/api-key/index.d.ts +2 -2
  168. package/dist/src/plugins/audit-log/client.d.ts +10 -0
  169. package/dist/src/plugins/audit-log/events.d.ts +90 -0
  170. package/dist/src/plugins/audit-log/index.d.ts +614 -0
  171. package/dist/src/plugins/audit-log/registry.d.ts +13 -0
  172. package/dist/src/plugins/audit-log/remote.d.ts +34 -0
  173. package/dist/src/plugins/audit-log/schema.d.ts +50 -0
  174. package/dist/src/plugins/audit-log/types.d.ts +117 -0
  175. package/dist/src/plugins/email-otp/index.d.ts +1 -1
  176. package/dist/src/plugins/haveibeenpwned/index.d.ts +6 -0
  177. package/dist/src/plugins/index.d.ts +2 -0
  178. package/dist/src/plugins/multi-session/index.d.ts +8 -8
  179. package/dist/src/plugins/organization/organization.d.ts +5 -5
  180. package/dist/src/plugins/organization/routes/crud-access-control.d.ts +8 -8
  181. package/dist/src/plugins/organization/routes/crud-invites.d.ts +46 -46
  182. package/dist/src/plugins/organization/routes/crud-members.d.ts +40 -40
  183. package/dist/src/plugins/organization/routes/crud-org.d.ts +36 -36
  184. package/dist/src/plugins/organization/routes/crud-team.d.ts +50 -50
  185. package/dist/src/plugins/sentinel/actions.d.ts +64 -0
  186. package/dist/src/plugins/sentinel/client.d.ts +67 -0
  187. package/dist/src/plugins/sentinel/email.d.ts +28 -0
  188. package/dist/src/plugins/sentinel/geo.d.ts +22 -0
  189. package/dist/src/plugins/sentinel/index.d.ts +326 -0
  190. package/dist/src/plugins/sentinel/intel.d.ts +45 -0
  191. package/dist/src/plugins/sentinel/pow.d.ts +21 -0
  192. package/dist/src/plugins/sentinel/schema.d.ts +110 -0
  193. package/dist/src/plugins/sentinel/store.d.ts +37 -0
  194. package/dist/src/plugins/sentinel/types.d.ts +412 -0
  195. package/dist/src/test-utils/memory-secondary-storage.d.ts +16 -0
  196. package/dist/src/test-utils/password.d.ts +12 -0
  197. package/dist/src/test-utils/test-instance.d.ts +25 -25
  198. package/dist/test-utils/index.d.mts +293 -293
  199. package/dist/test-utils/index.mjs +53 -48
  200. package/dist/{two-factor-ClUXxom2.mjs → two-factor-CDVjxx4f.mjs} +6 -5
  201. package/dist/types/index.d.mts +5 -5
  202. package/dist/types/index.mjs +1 -1
  203. package/dist/{username-DiGklOGl.mjs → username-BHNvcvsY.mjs} +3 -3
  204. package/dist/{utils-SOMB4Px5.mjs → utils-B03WRaGi.mjs} +2 -1
  205. package/package.json +36 -3
  206. package/dist/index-CM-CFWwr.d.mts +0 -1919
  207. package/dist/index-DB7t8whN.d.mts +0 -1
  208. /package/dist/{access-BO2g7QVe.mjs → access-BM6jSA5j.mjs} +0 -0
  209. /package/dist/{bun-sqlite-dialect-CeNvMaZz.mjs → bun-sqlite-dialect-CDGusFqF.mjs} +0 -0
  210. /package/dist/{captcha-BqaMxlKC.mjs → captcha-DGP9lCRe.mjs} +0 -0
  211. /package/dist/{client-CygJhqeB.mjs → client-BuveH3MN.mjs} +0 -0
  212. /package/dist/{client-WiN43tAS.mjs → client-cBiytuOL.mjs} +0 -0
  213. /package/dist/{date-Cx2yCiC4.mjs → date-CMr04reD.mjs} +0 -0
  214. /package/dist/{esm-C-QicDNz.mjs → esm-BbvI87Z2.mjs} +0 -0
  215. /package/dist/{get-tables-BQP7YW9c.mjs → get-tables-CKvSyDST.mjs} +0 -0
  216. /package/dist/{helper-tmxW3BHj.d.mts → helper-BgOIGHsu.d.mts} +0 -0
  217. /package/dist/{misc-beA1w3XV.mjs → misc-fKH4d5XQ.mjs} +0 -0
  218. /package/dist/{node-sqlite-dialect-BcKxBQnm.mjs → node-sqlite-dialect-CIOjcg31.mjs} +0 -0
  219. /package/dist/{parser-DcrIEuXE.mjs → parser-D7EkhAjZ.mjs} +0 -0
  220. /package/dist/{password-sVC4nePO.mjs → password-5CdcAVaH.mjs} +0 -0
  221. /package/dist/{permission-DLYUF3cW.mjs → permission-CDDWgA_O.mjs} +0 -0
  222. /package/dist/{plugin-helper-4L-lzsjZ.mjs → plugin-helper-RJJBTOba.mjs} +0 -0
  223. /package/dist/{promise-8rrEHRXd.mjs → promise-D7Oo6_cY.mjs} +0 -0
  224. /package/dist/{types-Bjn15Uy6.mjs → types-DCPpFKUB.mjs} +0 -0
  225. /package/dist/{types-B1OL7hyf.d.mts → types-DNQ9XQrB.d.mts} +0 -0
@@ -0,0 +1,1426 @@
1
+ import { t as getIp } from "./get-request-ip-DSjrEpGo.mjs";
2
+ import { t as safeJSONParse } from "./json-D0TBLy4q.mjs";
3
+ import { n as deleteSessionCookie } from "./cookies-BuvlgcA-.mjs";
4
+ import { n as mergeSchema } from "./date-CMr04reD.mjs";
5
+ import { a as CREDENTIAL_SIGN_IN_PATHS, c as SIGN_IN_PATHS, l as VERIFICATION_PATHS, s as SECURITY_EVENT_TYPES, t as getAuditRecorder } from "./registry-CqG_3Qnx.mjs";
6
+ import { t as getPasswordBreachCount } from "./haveibeenpwned-CzNda7Tn.mjs";
7
+ import { c as verifySolution, i as createNonce, n as POW_REASON_HEADER, o as parsePowHeader, r as POW_SOLUTION_HEADER, t as POW_CHALLENGE_HEADER } from "./pow-OK01yPG3.mjs";
8
+ import { env } from "@gatwech/core/env";
9
+ import { PayvoAuthError } from "@gatwech/core/error";
10
+ import { defineErrorCodes } from "@gatwech/core/utils";
11
+ import { APIError } from "better-call";
12
+ import { createAuthMiddleware } from "@gatwech/core/api";
13
+ import { betterFetch } from "@better-fetch/fetch";
14
+
15
+ //#region src/plugins/sentinel/actions.ts
16
+ const CHALLENGE_TTL_MS = 6e4;
17
+ /**
18
+ * Applies a security action: records the event and decides whether the
19
+ * request continues, gets a proof-of-work challenge or is blocked
20
+ */
21
+ async function executeAction(params) {
22
+ const { auth, event, action, reason } = params;
23
+ const recorder = getAuditRecorder(auth);
24
+ const record = async (eventType, status) => {
25
+ if (!recorder) {
26
+ auth.logger.warn(`[sentinel] ${reason}: ${status}`, params.metadata);
27
+ return;
28
+ }
29
+ await recorder(event, {
30
+ eventType,
31
+ status,
32
+ userId: params.userId ?? null,
33
+ identifier: params.identifier ?? null,
34
+ ipAddress: params.ipAddress,
35
+ visitorId: params.visitorId,
36
+ eventData: {
37
+ reason,
38
+ action,
39
+ ...params.metadata
40
+ }
41
+ });
42
+ };
43
+ if (action === "log") {
44
+ await record(params.eventType, "success");
45
+ return { outcome: "allow" };
46
+ }
47
+ if (action === "challenge") {
48
+ if (params.challengePassed) {
49
+ await record(SECURITY_EVENT_TYPES.ALLOWED_AFTER_CHALLENGE, "success");
50
+ return { outcome: "allow" };
51
+ }
52
+ const nonce = createNonce();
53
+ try {
54
+ await params.store.saveChallenge({
55
+ nonce,
56
+ difficulty: params.difficulty,
57
+ reason,
58
+ ipAddress: params.ipAddress,
59
+ visitorId: params.visitorId,
60
+ expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS)
61
+ });
62
+ } catch (e) {
63
+ auth.logger.error("[sentinel] Failed to save challenge", e);
64
+ }
65
+ await record(params.eventType, "challenged");
66
+ return {
67
+ outcome: "challenge",
68
+ reason,
69
+ nonce,
70
+ difficulty: params.difficulty
71
+ };
72
+ }
73
+ await record(params.eventType, "blocked");
74
+ await record(SECURITY_EVENT_TYPES.BLOCKED, "blocked");
75
+ return {
76
+ outcome: "block",
77
+ reason
78
+ };
79
+ }
80
+ function challengeHeaders(decision) {
81
+ return {
82
+ [POW_CHALLENGE_HEADER]: `${decision.nonce}:${decision.difficulty}`,
83
+ [POW_REASON_HEADER]: decision.reason,
84
+ "access-control-expose-headers": `${POW_CHALLENGE_HEADER}, ${POW_REASON_HEADER}`
85
+ };
86
+ }
87
+ const SENTINEL_ERROR_CODES = defineErrorCodes({
88
+ POW_CHALLENGE_REQUIRED: "Challenge required",
89
+ SECURITY_BLOCKED: "Request blocked for security reasons"
90
+ });
91
+ /** The error for a decision, or null when the request is allowed */
92
+ function errorForDecision(decision) {
93
+ if (decision.outcome === "allow") return null;
94
+ if (decision.outcome === "challenge") return new APIError("LOCKED", {
95
+ message: SENTINEL_ERROR_CODES.POW_CHALLENGE_REQUIRED,
96
+ code: "POW_CHALLENGE_REQUIRED",
97
+ challenge: {
98
+ nonce: decision.nonce,
99
+ difficulty: decision.difficulty,
100
+ reason: decision.reason
101
+ }
102
+ }, challengeHeaders(decision));
103
+ return new APIError("FORBIDDEN", {
104
+ message: SENTINEL_ERROR_CODES.SECURITY_BLOCKED,
105
+ code: "SECURITY_BLOCKED",
106
+ reason: decision.reason
107
+ });
108
+ }
109
+ /** Throws the error for a decision, for use in endpoint hooks */
110
+ function throwForDecision(decision) {
111
+ const error = errorForDecision(decision);
112
+ if (error) throw error;
113
+ }
114
+ /** Builds the response for a decision, for use in `onRequest` */
115
+ function responseForDecision(decision) {
116
+ if (decision.outcome === "allow") return void 0;
117
+ if (decision.outcome === "challenge") return new Response(JSON.stringify({
118
+ message: SENTINEL_ERROR_CODES.POW_CHALLENGE_REQUIRED,
119
+ code: "POW_CHALLENGE_REQUIRED",
120
+ challenge: {
121
+ nonce: decision.nonce,
122
+ difficulty: decision.difficulty,
123
+ reason: decision.reason
124
+ }
125
+ }), {
126
+ status: 423,
127
+ headers: {
128
+ "content-type": "application/json",
129
+ ...challengeHeaders(decision)
130
+ }
131
+ });
132
+ return new Response(JSON.stringify({
133
+ message: SENTINEL_ERROR_CODES.SECURITY_BLOCKED,
134
+ code: "SECURITY_BLOCKED",
135
+ reason: decision.reason
136
+ }), {
137
+ status: 403,
138
+ headers: { "content-type": "application/json" }
139
+ });
140
+ }
141
+
142
+ //#endregion
143
+ //#region src/plugins/sentinel/email.ts
144
+ const GMAIL_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
145
+ /**
146
+ * Providers that deliver `user+tag@domain` to `user@domain`, so the tag can be
147
+ * dropped without changing the mailbox
148
+ */
149
+ const PLUS_ADDRESSING_DOMAINS = new Set([
150
+ "gmail.com",
151
+ "googlemail.com",
152
+ "outlook.com",
153
+ "hotmail.com",
154
+ "live.com",
155
+ "msn.com",
156
+ "icloud.com",
157
+ "me.com",
158
+ "mac.com",
159
+ "protonmail.com",
160
+ "proton.me",
161
+ "pm.me",
162
+ "fastmail.com",
163
+ "zoho.com"
164
+ ]);
165
+ /**
166
+ * Normalizes an email address so aliases of the same mailbox match:
167
+ * lowercases it, maps `googlemail.com` to `gmail.com`, removes dots in Gmail
168
+ * addresses and strips plus-address tags on providers that support them
169
+ */
170
+ function normalizeEmail(email) {
171
+ const trimmed = email.trim().toLowerCase();
172
+ const at = trimmed.lastIndexOf("@");
173
+ if (at < 1 || at === trimmed.length - 1) return trimmed;
174
+ let local = trimmed.slice(0, at);
175
+ let domain = trimmed.slice(at + 1);
176
+ if (domain === "googlemail.com") domain = "gmail.com";
177
+ if (PLUS_ADDRESSING_DOMAINS.has(domain)) local = local.split("+")[0];
178
+ if (GMAIL_DOMAINS.has(domain)) local = local.replace(/\./g, "");
179
+ return local ? `${local}@${domain}` : trimmed;
180
+ }
181
+ function getEmailDomain(email) {
182
+ const at = email.lastIndexOf("@");
183
+ return at < 0 ? "" : email.slice(at + 1).trim().toLowerCase();
184
+ }
185
+ let disposableDomains;
186
+ /**
187
+ * Whether a domain, or any parent domain, is a known disposable email
188
+ * provider. The list is loaded on first use.
189
+ */
190
+ async function isDisposableDomain(domain) {
191
+ disposableDomains ??= import("disposable-email-domains-js").then((module) => module.isDisposableEmailDomain);
192
+ const isDisposable = await disposableDomains;
193
+ const labels = domain.split(".");
194
+ for (let i = 0; i < labels.length - 1; i++) if (isDisposable(labels.slice(i).join("."))) return true;
195
+ return false;
196
+ }
197
+ /** How long a mail lookup may take before it counts as unknown */
198
+ const MAIL_LOOKUP_TIMEOUT_MS = 2e3;
199
+ /** The lookup found nothing, as opposed to failing */
200
+ const isMissingRecord = (e) => {
201
+ const code = e.code;
202
+ return code === "ENOTFOUND" || code === "ENODATA" || code === "ENOTIMP";
203
+ };
204
+ /** Whether a domain has an A or AAAA record, null when a lookup failed */
205
+ async function hasAddressRecords(dns, domain) {
206
+ const results = await Promise.allSettled([dns.resolve4(domain), dns.resolve6(domain)]);
207
+ if (results.some((r) => r.status === "fulfilled" && r.value.length > 0)) return true;
208
+ return results.some((r) => r.status === "rejected" && !isMissingRecord(r.reason)) ? null : false;
209
+ }
210
+ async function lookupMail(dns, domain) {
211
+ try {
212
+ const records = await dns.resolveMx(domain);
213
+ if (records.length === 1 && (records[0].exchange === "" || records[0].exchange === ".")) return false;
214
+ if (records.length > 0) return true;
215
+ } catch (e) {
216
+ const code = e.code;
217
+ if (code === "ENOTFOUND" || code === "ENOTIMP") return false;
218
+ if (!isMissingRecord(e)) return null;
219
+ }
220
+ return hasAddressRecords(dns, domain);
221
+ }
222
+ /**
223
+ * Whether a domain can receive mail: it has an MX record, or an address
224
+ * record to fall back to when it has none. A null MX record, which marks a
225
+ * domain that accepts no mail, doesn't count.
226
+ *
227
+ * Returns null when that can't be told: runtimes without DNS access, lookup
228
+ * failures, or a lookup that takes longer than `timeoutMs`.
229
+ */
230
+ async function hasMxRecords(domain, options = {}) {
231
+ let dns;
232
+ try {
233
+ dns = await import("node:dns/promises");
234
+ } catch {
235
+ return null;
236
+ }
237
+ let timer;
238
+ const timeout = new Promise((resolve) => {
239
+ timer = setTimeout(() => resolve(null), options.timeoutMs ?? MAIL_LOOKUP_TIMEOUT_MS);
240
+ });
241
+ try {
242
+ return await Promise.race([lookupMail(dns, domain), timeout]);
243
+ } finally {
244
+ clearTimeout(timer);
245
+ }
246
+ }
247
+ /**
248
+ * Heuristics for addresses that look generated rather than chosen by a
249
+ * person
250
+ */
251
+ function looksGenerated(email) {
252
+ const at = email.lastIndexOf("@");
253
+ const local = email.slice(0, at);
254
+ const domainName = email.slice(at + 1).split(".").slice(0, -1).join(".");
255
+ const letters = local.replace(/[^a-z]/gi, "");
256
+ return /\d{5,}/.test(local) || /\d{4,}/.test(domainName) || local.length >= 12 && letters.length / local.length < .4 || /(?:[a-z]\d){5,}/i.test(local) || /^[0-9a-f]{16,}$/i.test(local);
257
+ }
258
+
259
+ //#endregion
260
+ //#region src/plugins/sentinel/geo.ts
261
+ const EARTH_RADIUS_KM = 6371;
262
+ const toRadians = (degrees) => degrees * Math.PI / 180;
263
+ /** Great-circle distance between two coordinates, in kilometres */
264
+ function haversineDistanceKm(from, to) {
265
+ const dLat = toRadians(to.latitude - from.latitude);
266
+ const dLon = toRadians(to.longitude - from.longitude);
267
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(from.latitude)) * Math.cos(toRadians(to.latitude)) * Math.sin(dLon / 2) ** 2;
268
+ return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
269
+ }
270
+ /**
271
+ * Speed needed to travel between two sign-ins, in km/h. Distances under
272
+ * 50 km are treated as the same place, since IP geolocation isn't precise
273
+ * enough to tell them apart.
274
+ */
275
+ function travelSpeedKmh(from, to) {
276
+ const distance = haversineDistanceKm(from, to);
277
+ if (distance < 50) return 0;
278
+ return distance / (Math.max(Math.abs(to.at.getTime() - from.at.getTime()), 6e4) / 36e5);
279
+ }
280
+
281
+ //#endregion
282
+ //#region src/plugins/sentinel/intel.ts
283
+ const BOT_USER_AGENT = /bot|crawl|spider|slurp|headless|phantomjs|selenium|puppeteer|playwright|curl|wget|httpie|python-requests|python-urllib|aiohttp|go-http-client|okhttp|java\/|libwww|scrapy|postman|insomnia/i;
284
+ /**
285
+ * Local bot heuristics, used when neither a provider nor the hosted backend
286
+ * gives a verdict
287
+ */
288
+ function looksLikeBot(request) {
289
+ const userAgent = request.headers.get("user-agent")?.trim();
290
+ if (!userAgent) return true;
291
+ if (BOT_USER_AGENT.test(userAgent)) return true;
292
+ return !request.headers.get("accept-language") && !request.headers.get("x-visitor-id");
293
+ }
294
+ const UNKNOWN = {
295
+ suspiciousIp: null,
296
+ bot: null,
297
+ geo: null
298
+ };
299
+ /**
300
+ * Asks the hosted backend about a request's IP address and client. Verdicts
301
+ * are cached in secondary storage for as long as the backend allows.
302
+ * Returns null verdicts when the backend isn't configured or can't answer.
303
+ */
304
+ function createIntelClient(options) {
305
+ const apiKey = options.apiKey ?? env.PAYVO_INFRA_API_KEY;
306
+ const apiUrl = (options.apiUrl ?? env.PAYVO_INFRA_API_URL)?.replace(/\/+$/, "");
307
+ const kvUrl = (options.kvUrl ?? env.PAYVO_INFRA_KV_URL)?.replace(/\/+$/, "");
308
+ const configured = Boolean(apiKey && apiUrl);
309
+ const kv = {
310
+ timeout: options.kvOptions?.timeout ?? 1e3,
311
+ attempts: options.kvOptions?.retry?.attempts ?? 2,
312
+ baseDelay: options.kvOptions?.retry?.baseDelay ?? 400,
313
+ maxDelay: options.kvOptions?.retry?.maxDelay ?? 600
314
+ };
315
+ return {
316
+ configured,
317
+ verifiesVisitors: Boolean(kvUrl && apiKey),
318
+ misconfigured: Boolean((apiUrl || kvUrl) && !apiKey) || Boolean(apiKey && !apiUrl && !kvUrl),
319
+ async verifyVisitor(auth, request) {
320
+ if (!kvUrl || !apiKey) return request.visitorId;
321
+ const cacheKey = `sentinel:visitor:${request.visitorId}:${request.ip ?? ""}`;
322
+ const cached = await auth.secondaryStorage?.get(cacheKey);
323
+ if (typeof cached === "string") return cached || null;
324
+ for (let attempt = 0; attempt <= kv.attempts; attempt++) {
325
+ if (attempt > 0) {
326
+ const wait = Math.min(kv.baseDelay * 2 ** (attempt - 1), kv.maxDelay);
327
+ await new Promise((resolve) => setTimeout(resolve, wait));
328
+ }
329
+ const { data, error } = await betterFetch(`${kvUrl}/v1/visitors/verify`, {
330
+ method: "POST",
331
+ headers: {
332
+ ...options.kvOptions?.headers,
333
+ authorization: `Bearer ${apiKey}`
334
+ },
335
+ body: request,
336
+ timeout: kv.timeout
337
+ }).catch((e) => ({
338
+ data: null,
339
+ error: e
340
+ }));
341
+ if (data && !error) {
342
+ const visitorId = data.valid ? data.visitorId ?? request.visitorId : null;
343
+ await auth.secondaryStorage?.set(cacheKey, visitorId ?? "", 300);
344
+ return visitorId;
345
+ }
346
+ }
347
+ auth.logger.error("[sentinel] Failed to verify visitor id");
348
+ },
349
+ async lookup(auth, request) {
350
+ const { ip, visitorId } = request;
351
+ if (!configured || !ip) return UNKNOWN;
352
+ const cacheKey = `sentinel:intel:${ip}:${visitorId ?? ""}`;
353
+ const cached = safeJSONParse(await auth.secondaryStorage?.get(cacheKey));
354
+ if (cached) return cached;
355
+ const { data, error } = await betterFetch(`${apiUrl}/v1/intel/request`, {
356
+ method: "POST",
357
+ headers: {
358
+ ...options.apiOptions?.headers,
359
+ authorization: `Bearer ${apiKey}`
360
+ },
361
+ body: {
362
+ ip,
363
+ visitorId,
364
+ userAgent: request.userAgent,
365
+ headers: { "accept-language": request.acceptLanguage }
366
+ },
367
+ timeout: options.apiOptions?.timeout ?? 3e3
368
+ }).catch((e) => ({
369
+ data: null,
370
+ error: e
371
+ }));
372
+ if (error || !data) {
373
+ auth.logger.error("[sentinel] Failed to look up request intel", error);
374
+ return UNKNOWN;
375
+ }
376
+ const intel = {
377
+ suspiciousIp: data.ip?.suspicious ?? null,
378
+ bot: data.bot?.verdict === "bot" ? true : data.bot?.verdict === "human" ? false : null,
379
+ geo: data.geo ?? null
380
+ };
381
+ if (data.ttlSeconds && auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
382
+ return intel;
383
+ }
384
+ };
385
+ }
386
+
387
+ //#endregion
388
+ //#region src/plugins/sentinel/schema.ts
389
+ /** Added to users when `emailNormalization` is enabled */
390
+ const normalizedEmailSchema = { user: { fields: { normalizedEmail: {
391
+ type: "string",
392
+ required: false,
393
+ input: false,
394
+ returned: false
395
+ } } } };
396
+ const schema = {
397
+ sentinelCounter: { fields: {
398
+ key: {
399
+ type: "string",
400
+ required: true,
401
+ unique: true
402
+ },
403
+ count: {
404
+ type: "number",
405
+ required: true
406
+ },
407
+ expiresAt: {
408
+ type: "date",
409
+ required: true
410
+ }
411
+ } },
412
+ sentinelSignIn: { fields: {
413
+ userId: {
414
+ type: "string",
415
+ required: true,
416
+ references: {
417
+ model: "user",
418
+ field: "id",
419
+ onDelete: "cascade"
420
+ }
421
+ },
422
+ ipAddress: {
423
+ type: "string",
424
+ required: false
425
+ },
426
+ visitorId: {
427
+ type: "string",
428
+ required: false
429
+ },
430
+ userAgent: {
431
+ type: "string",
432
+ required: false
433
+ },
434
+ country: {
435
+ type: "string",
436
+ required: false
437
+ },
438
+ city: {
439
+ type: "string",
440
+ required: false
441
+ },
442
+ latitude: {
443
+ type: "number",
444
+ required: false
445
+ },
446
+ longitude: {
447
+ type: "number",
448
+ required: false
449
+ },
450
+ createdAt: {
451
+ type: "date",
452
+ required: true
453
+ }
454
+ } },
455
+ powChallenge: { fields: {
456
+ nonce: {
457
+ type: "string",
458
+ required: true,
459
+ unique: true
460
+ },
461
+ difficulty: {
462
+ type: "number",
463
+ required: true
464
+ },
465
+ reason: {
466
+ type: "string",
467
+ required: true
468
+ },
469
+ ipAddress: {
470
+ type: "string",
471
+ required: false
472
+ },
473
+ visitorId: {
474
+ type: "string",
475
+ required: false
476
+ },
477
+ expiresAt: {
478
+ type: "date",
479
+ required: true
480
+ },
481
+ createdAt: {
482
+ type: "date",
483
+ required: true
484
+ }
485
+ } }
486
+ };
487
+
488
+ //#endregion
489
+ //#region src/plugins/sentinel/store.ts
490
+ const PREFIX = "sentinel:";
491
+ /**
492
+ * Counters and challenges, kept in secondary storage when it's configured
493
+ * and in the database otherwise
494
+ */
495
+ function createSentinelStore(auth) {
496
+ const storage = auth.secondaryStorage;
497
+ async function findCounter(key) {
498
+ return auth.adapter.findOne({
499
+ model: "sentinelCounter",
500
+ where: [{
501
+ field: "key",
502
+ value: key
503
+ }]
504
+ });
505
+ }
506
+ return {
507
+ async increment(name, ttlSeconds, options = {}) {
508
+ const key = PREFIX + name;
509
+ if (storage?.increment) {
510
+ const count = await storage.increment(key, ttlSeconds);
511
+ if (options.sliding && count > 1) await storage.set(key, String(count), ttlSeconds);
512
+ return count;
513
+ }
514
+ if (storage) {
515
+ const current = safeJSONParse(await storage.get(key));
516
+ const now = Date.now();
517
+ const expiresAt = current && current.expiresAt > now && !options.sliding ? current.expiresAt : now + ttlSeconds * 1e3;
518
+ const count = current && current.expiresAt > now ? current.count + 1 : 1;
519
+ await storage.set(key, JSON.stringify({
520
+ count,
521
+ expiresAt
522
+ }), Math.max(Math.ceil((expiresAt - now) / 1e3), 1));
523
+ return count;
524
+ }
525
+ let row = await findCounter(key);
526
+ if (!row) {
527
+ if (await auth.adapter.create({
528
+ model: "sentinelCounter",
529
+ data: {
530
+ key,
531
+ count: 1,
532
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
533
+ }
534
+ }).then(() => true, () => false)) return 1;
535
+ row = await findCounter(key);
536
+ if (!row) throw new Error(`Failed to create counter ${key}`);
537
+ }
538
+ if (new Date(row.expiresAt).getTime() <= Date.now()) {
539
+ await auth.adapter.update({
540
+ model: "sentinelCounter",
541
+ where: [{
542
+ field: "id",
543
+ value: row.id
544
+ }],
545
+ update: {
546
+ count: 1,
547
+ expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
548
+ }
549
+ });
550
+ return 1;
551
+ }
552
+ await auth.adapter.update({
553
+ model: "sentinelCounter",
554
+ where: [{
555
+ field: "id",
556
+ value: row.id
557
+ }],
558
+ update: {
559
+ count: row.count + 1,
560
+ ...options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
561
+ }
562
+ });
563
+ return row.count + 1;
564
+ },
565
+ async get(name) {
566
+ const key = PREFIX + name;
567
+ if (storage) {
568
+ const value = await storage.get(key);
569
+ if (value === null || value === void 0) return 0;
570
+ if (storage.increment) return Number(value) || 0;
571
+ const current = safeJSONParse(value);
572
+ return current && current.expiresAt > Date.now() ? current.count : 0;
573
+ }
574
+ const row = await findCounter(key);
575
+ if (!row || new Date(row.expiresAt).getTime() <= Date.now()) return 0;
576
+ return row.count;
577
+ },
578
+ async reset(name) {
579
+ const key = PREFIX + name;
580
+ if (storage) {
581
+ await storage.delete(key);
582
+ return;
583
+ }
584
+ await auth.adapter.deleteMany({
585
+ model: "sentinelCounter",
586
+ where: [{
587
+ field: "key",
588
+ value: key
589
+ }]
590
+ });
591
+ },
592
+ async saveChallenge(challenge) {
593
+ const ttlSeconds = Math.max(Math.ceil((challenge.expiresAt.getTime() - Date.now()) / 1e3), 1);
594
+ if (storage) {
595
+ await storage.set(`${PREFIX}pow:${challenge.nonce}`, JSON.stringify(challenge), ttlSeconds);
596
+ return;
597
+ }
598
+ await auth.adapter.create({
599
+ model: "powChallenge",
600
+ data: {
601
+ ...challenge,
602
+ createdAt: /* @__PURE__ */ new Date()
603
+ }
604
+ });
605
+ },
606
+ async consumeChallenge(nonce) {
607
+ let challenge = null;
608
+ if (storage) {
609
+ const key = `${PREFIX}pow:${nonce}`;
610
+ let value;
611
+ if (storage.getAndDelete) value = await storage.getAndDelete(key);
612
+ else {
613
+ value = await storage.get(key);
614
+ if (value) await storage.delete(key);
615
+ }
616
+ challenge = safeJSONParse(value);
617
+ } else {
618
+ const row = await auth.adapter.findOne({
619
+ model: "powChallenge",
620
+ where: [{
621
+ field: "nonce",
622
+ value: nonce
623
+ }]
624
+ });
625
+ if (!row) return null;
626
+ if (!await auth.adapter.create({
627
+ model: "sentinelCounter",
628
+ data: {
629
+ key: `${PREFIX}pow-used:${nonce}`,
630
+ count: 1,
631
+ expiresAt: new Date(row.expiresAt)
632
+ }
633
+ }).then(() => true).catch(() => false)) return null;
634
+ await auth.adapter.delete({
635
+ model: "powChallenge",
636
+ where: [{
637
+ field: "nonce",
638
+ value: nonce
639
+ }]
640
+ });
641
+ challenge = row;
642
+ }
643
+ if (!challenge) return null;
644
+ if (new Date(challenge.expiresAt).getTime() <= Date.now()) return null;
645
+ return challenge;
646
+ }
647
+ };
648
+ }
649
+
650
+ //#endregion
651
+ //#region src/plugins/sentinel/index.ts
652
+ const SIGN_UP_PATHS = ["/sign-up/email"];
653
+ const PASSWORD_RESET_PATHS = ["/request-password-reset", "/forget-password/email-otp"];
654
+ /** The email field normalized on each endpoint */
655
+ const NORMALIZED_EMAIL_FIELDS = {
656
+ "/sign-up/email": "email",
657
+ "/sign-in/email": "email",
658
+ "/sign-in/email-otp": "email",
659
+ "/sign-in/magic-link": "email",
660
+ "/email-otp/send-verification-otp": "email",
661
+ "/email-otp/verify-email": "email",
662
+ "/email-otp/check-verification-otp": "email",
663
+ "/email-otp/reset-password": "email",
664
+ "/request-password-reset": "email",
665
+ "/forget-password/email-otp": "email",
666
+ "/change-email": "newEmail"
667
+ };
668
+ /** The new password field each endpoint accepts */
669
+ const NEW_PASSWORD_FIELDS = {
670
+ "/sign-up/email": "password",
671
+ "/change-password": "newPassword",
672
+ "/set-password": "newPassword",
673
+ "/reset-password": "newPassword",
674
+ "/email-otp/reset-password": "password"
675
+ };
676
+ /**
677
+ * Endpoints that check a password, one-time code or token, so failures count
678
+ * towards credential stuffing
679
+ */
680
+ const isAttemptPath = (path) => CREDENTIAL_SIGN_IN_PATHS.includes(path) || VERIFICATION_PATHS.includes(path);
681
+ /** Account creation that isn't done by the visitor signing up */
682
+ const isManagedAccountCreation = (path) => !path || path.startsWith("/admin/") || path === "/sign-in/anonymous";
683
+ /** Social sign-ups, whose email the provider has already verified */
684
+ const OAUTH_CALLBACK_PATHS = ["/callback/:id", "/oauth2/callback/:providerId"];
685
+ const DAY_SECONDS = 1440 * 60;
686
+ /**
687
+ * Sign-ins checked with a password, which can be repeated with a challenge
688
+ * solution
689
+ */
690
+ const REPEATABLE_SIGN_IN_PATHS = [
691
+ "/sign-in/email",
692
+ "/sign-in/username",
693
+ "/sign-in/phone-number"
694
+ ];
695
+ /** How long checks that passed on a first factor cover the second factor */
696
+ const SECOND_FACTOR_SECONDS = 600;
697
+ /** Response statuses that sentinel itself returns, not failed credentials */
698
+ const SENTINEL_STATUSES = new Set([
699
+ 403,
700
+ 423,
701
+ 429
702
+ ]);
703
+ const sentinel = (options) => {
704
+ const opts = {
705
+ ...options,
706
+ failOpen: options?.failOpen ?? true
707
+ };
708
+ const security = opts.security ?? {};
709
+ const difficulty = security.challengeDifficulty ?? 18;
710
+ const intel = createIntelClient(opts);
711
+ const credentialStuffing = security.credentialStuffing?.enabled ? {
712
+ action: security.credentialStuffing.action ?? "challenge",
713
+ thresholds: security.credentialStuffing.thresholds ?? {
714
+ challenge: 3,
715
+ block: 5
716
+ },
717
+ windowSeconds: security.credentialStuffing.windowSeconds ?? 3600,
718
+ cooldownSeconds: security.credentialStuffing.cooldownSeconds ?? 900
719
+ } : null;
720
+ const velocity = security.velocity?.enabled ? {
721
+ thresholds: security.velocity.thresholds ?? {
722
+ challenge: 10,
723
+ block: 20
724
+ },
725
+ maxSignupsPerVisitor: security.velocity.maxSignupsPerVisitor ?? 5,
726
+ maxPasswordResetsPerIp: security.velocity.maxPasswordResetsPerIp ?? 10,
727
+ maxSignInsPerIp: security.velocity.maxSignInsPerIp ?? 50,
728
+ windowSeconds: security.velocity.windowSeconds ?? 3600,
729
+ action: security.velocity.action ?? "challenge"
730
+ } : null;
731
+ const freeTrialAbuse = security.freeTrialAbuse?.enabled ? {
732
+ thresholds: security.freeTrialAbuse.thresholds,
733
+ maxAccountsPerVisitor: security.freeTrialAbuse.maxAccountsPerVisitor ?? 3,
734
+ retentionSeconds: Math.min(Math.max(security.freeTrialAbuse.retentionSeconds ?? 90 * DAY_SECONDS, DAY_SECONDS), 365 * DAY_SECONDS),
735
+ action: security.freeTrialAbuse.action ?? "block"
736
+ } : null;
737
+ const botBlocking = resolveToggle(security.botBlocking, "challenge");
738
+ const suspiciousIpBlocking = resolveToggle(security.suspiciousIpBlocking, "block");
739
+ if (security.geoBlocking?.allowList && security.geoBlocking.denyList) throw new PayvoAuthError("sentinel: use either geoBlocking.allowList or geoBlocking.denyList, not both");
740
+ const geoBlocking = security.geoBlocking?.allowList || security.geoBlocking?.denyList ? {
741
+ allowList: security.geoBlocking.allowList?.map((code) => code.toUpperCase()),
742
+ denyList: security.geoBlocking.denyList?.map((code) => code.toUpperCase()),
743
+ action: security.geoBlocking.action ?? "block"
744
+ } : null;
745
+ const impossibleTravel = security.impossibleTravel?.enabled ? {
746
+ maxSpeedKmh: security.impossibleTravel.maxSpeedKmh ?? 1e3,
747
+ action: security.impossibleTravel.action ?? "challenge"
748
+ } : null;
749
+ const compromisedPassword = security.compromisedPassword?.enabled ? {
750
+ action: security.compromisedPassword.action ?? "block",
751
+ minBreachCount: security.compromisedPassword.minBreachCount ?? 1
752
+ } : null;
753
+ const emailValidation = security.emailValidation?.enabled ? {
754
+ strictness: security.emailValidation.strictness ?? "medium",
755
+ action: security.emailValidation.action ?? "block",
756
+ domainAllowlist: new Set(security.emailValidation.domainAllowlist?.map((domain) => domain.toLowerCase()))
757
+ } : null;
758
+ const emailNormalization = security.emailNormalization?.enabled ?? false;
759
+ if (security.staleUsers?.notifyAdmin && !security.staleUsers.adminEmail) throw new PayvoAuthError("sentinel: staleUsers.adminEmail is required when notifyAdmin is true");
760
+ const staleUsers = security.staleUsers?.enabled ? {
761
+ staleDays: security.staleUsers.staleDays ?? 90,
762
+ action: security.staleUsers.action ?? "log",
763
+ notifyUser: security.staleUsers.notifyUser ?? false,
764
+ adminEmail: security.staleUsers.notifyAdmin ? security.staleUsers.adminEmail : void 0
765
+ } : null;
766
+ const unknownDeviceNotification = security.unknownDeviceNotification ?? false;
767
+ const tracksSignIns = Boolean(impossibleTravel || staleUsers || unknownDeviceNotification);
768
+ const checksAccountCreation = Boolean(freeTrialAbuse || velocity || emailValidation);
769
+ /** Challenge results, so each solution is checked once per request */
770
+ const challengeResults = /* @__PURE__ */ new WeakMap();
771
+ /** Verified visitor ids, so each request is checked once */
772
+ const visitorResults = /* @__PURE__ */ new WeakMap();
773
+ function clientIp(request, auth) {
774
+ if (!request) return null;
775
+ if (opts.providers?.getClientIp) return opts.providers.getClientIp(request);
776
+ return getIp(request, auth.options);
777
+ }
778
+ /**
779
+ * The visitor id sent by the client, checked with the identification
780
+ * service when one is configured
781
+ */
782
+ function resolveVisitor(auth, headers, ip) {
783
+ const sent = headers?.get("x-visitor-id") || null;
784
+ if (!headers || !sent) return Promise.resolve({
785
+ id: null,
786
+ verified: false
787
+ });
788
+ const existing = visitorResults.get(headers);
789
+ if (existing) return existing;
790
+ const unchecked = {
791
+ id: opts.failOpen ? sent : null,
792
+ verified: false
793
+ };
794
+ const result = intel.verifyVisitor(auth, {
795
+ visitorId: sent,
796
+ ip,
797
+ userAgent: headers.get("user-agent")
798
+ }).then((verified) => verified === void 0 ? unchecked : {
799
+ id: verified,
800
+ verified: intel.verifiesVisitors
801
+ }).catch(() => unchecked);
802
+ visitorResults.set(headers, result);
803
+ return result;
804
+ }
805
+ function relativePath(request, auth) {
806
+ const pathname = new URL(request.url).pathname;
807
+ const basePath = auth.options.basePath ?? "/api/auth";
808
+ return pathname.startsWith(basePath) ? pathname.slice(basePath.length) || "/" : pathname;
809
+ }
810
+ /**
811
+ * Whether the request carries a valid, unused solution to a challenge
812
+ * issued to the same IP address and visitor
813
+ */
814
+ function challengePassed(key, details, store, auth) {
815
+ const existing = challengeResults.get(key);
816
+ if (existing) return existing;
817
+ const result = (async () => {
818
+ const parsed = parsePowHeader(details.headers?.get(POW_SOLUTION_HEADER));
819
+ if (!parsed) return false;
820
+ try {
821
+ const challenge = await store.consumeChallenge(parsed.nonce);
822
+ if (!challenge) return false;
823
+ if (challenge.ipAddress && challenge.ipAddress !== details.ip) return false;
824
+ if (challenge.visitorId && challenge.visitorId !== details.visitorId) return false;
825
+ return await verifySolution(parsed.nonce, parsed.value, challenge.difficulty);
826
+ } catch (e) {
827
+ auth.logger.error("[sentinel] Failed to verify challenge", e);
828
+ return false;
829
+ }
830
+ })();
831
+ challengeResults.set(key, result);
832
+ return result;
833
+ }
834
+ /** Runs a check, applying `failOpen` when it can't complete */
835
+ async function guard(auth, check) {
836
+ try {
837
+ return await check();
838
+ } catch (e) {
839
+ if (e instanceof APIError) throw e;
840
+ auth.logger.error("[sentinel] Security check failed", e);
841
+ return opts.failOpen ? { outcome: "allow" } : {
842
+ outcome: "block",
843
+ reason: "check_failed"
844
+ };
845
+ }
846
+ }
847
+ async function requestDetails(ctx, identifier) {
848
+ const headers = ctx.headers ?? ctx.request?.headers;
849
+ const ip = clientIp(ctx.request ?? headers, ctx.context);
850
+ const body = ctx.body ?? {};
851
+ const value = identifier ?? [
852
+ body.email,
853
+ body.username,
854
+ body.phoneNumber
855
+ ].find((field) => typeof field === "string");
856
+ const visitor = await resolveVisitor(ctx.context, headers, ip);
857
+ return {
858
+ headers,
859
+ ip,
860
+ visitorId: visitor.id,
861
+ visitorVerified: visitor.verified,
862
+ identifier: value && emailNormalization && value.includes("@") ? normalizeEmail(value) : value ?? null,
863
+ userAgent: headers?.get("user-agent") ?? null,
864
+ acceptLanguage: headers?.get("accept-language") ?? null
865
+ };
866
+ }
867
+ function act(ctx, store, details, action, eventType, reason, metadata, userId) {
868
+ return guard(ctx.context, async () => executeAction({
869
+ auth: ctx.context,
870
+ event: ctx,
871
+ store,
872
+ action,
873
+ eventType,
874
+ reason,
875
+ ipAddress: details.ip,
876
+ visitorId: details.visitorId,
877
+ identifier: details.identifier,
878
+ userId,
879
+ challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context) : false,
880
+ difficulty,
881
+ metadata
882
+ }));
883
+ }
884
+ /** The key a request's challenge result is cached under */
885
+ function challengeKey(ctx, details) {
886
+ return ctx.request ?? details.headers ?? ctx;
887
+ }
888
+ /**
889
+ * Counters a sign-in attempt is tracked under: one per visitor and IP
890
+ * address, and one per visitor and IP address for the account it signs in
891
+ * to
892
+ */
893
+ function attemptKeys(details) {
894
+ const account = details.identifier?.toLowerCase();
895
+ return [details.visitorId ? `cs:visitor:${details.visitorId}` : null, details.ip ? `cs:ip:${details.ip}` : null].filter((scope) => scope !== null).map((scope) => ({
896
+ scope,
897
+ account: account ? `${scope}:account:${account}` : null
898
+ }));
899
+ }
900
+ /**
901
+ * Counts a sign-in attempt and returns the failed attempts before it.
902
+ * Attempts are counted before the password is checked, so attempts sent at
903
+ * the same time can't all pass the check. Successful sign-ins are counted
904
+ * separately and subtracted.
905
+ */
906
+ async function countAttempt(store, keys, options$1) {
907
+ if (!credentialStuffing) return 0;
908
+ const { windowSeconds } = credentialStuffing;
909
+ const { readOnly } = options$1;
910
+ const count = (name) => readOnly ? store.get(name) : store.increment(name, windowSeconds);
911
+ const failures = await Promise.all(keys.map(async ({ scope, account }) => {
912
+ const total = await count(scope);
913
+ if (total === 1 && !readOnly) await store.reset(`${scope}:succeeded`);
914
+ const succeeded = await store.get(`${scope}:succeeded`);
915
+ const own = account ? await count(account) : 0;
916
+ return Math.max(total - succeeded, own) - 1;
917
+ }));
918
+ return Math.max(0, ...failures);
919
+ }
920
+ function counterNames(keys) {
921
+ return keys.flatMap(({ scope, account }) => account ? [scope, account] : [scope]);
922
+ }
923
+ /**
924
+ * Identities a rate limit counts under. A visitor id that wasn't verified
925
+ * can be changed on every request, so the IP address counts too.
926
+ */
927
+ function rateIds(details) {
928
+ if (details.visitorId && details.visitorVerified) return [`visitor:${details.visitorId}`];
929
+ return [details.visitorId ? `visitor:${details.visitorId}` : null, details.ip ? `ip:${details.ip}` : null].filter((id) => id !== null);
930
+ }
931
+ /** Increments a counter for each identity and returns the highest count */
932
+ async function incrementAll(store, names, ttlSeconds) {
933
+ const counts = await Promise.all(names.map((name) => store.increment(name, ttlSeconds)));
934
+ return Math.max(0, ...counts);
935
+ }
936
+ /** The location of a request, from the provider or the hosted backend */
937
+ async function locate(auth, details) {
938
+ if (!details.ip) return null;
939
+ if (opts.providers?.resolveGeo) return opts.providers.resolveGeo(details.ip);
940
+ return (await intel.lookup(auth, details)).geo;
941
+ }
942
+ async function lastSignIn(auth, userId) {
943
+ const [row] = await auth.adapter.findMany({
944
+ model: "sentinelSignIn",
945
+ where: [{
946
+ field: "userId",
947
+ value: userId
948
+ }],
949
+ sortBy: {
950
+ field: "createdAt",
951
+ direction: "desc"
952
+ },
953
+ limit: 1
954
+ });
955
+ return row ?? null;
956
+ }
957
+ function daysSince(date) {
958
+ return Math.floor((Date.now() - new Date(date).getTime()) / (DAY_SECONDS * 1e3));
959
+ }
960
+ /** The travel speed from the previous sign-in, when both have coordinates */
961
+ function travel(previous, geo) {
962
+ if (previous?.latitude == null || previous.longitude == null || geo?.latitude == null || geo.longitude == null) return null;
963
+ const speedKmh = travelSpeedKmh({
964
+ latitude: previous.latitude,
965
+ longitude: previous.longitude,
966
+ at: new Date(previous.createdAt)
967
+ }, {
968
+ latitude: geo.latitude,
969
+ longitude: geo.longitude,
970
+ at: /* @__PURE__ */ new Date()
971
+ });
972
+ return {
973
+ speedKmh: Math.round(speedKmh),
974
+ from: {
975
+ country: previous.country,
976
+ city: previous.city
977
+ },
978
+ to: {
979
+ country: geo.country,
980
+ city: geo.city ?? null
981
+ }
982
+ };
983
+ }
984
+ async function notify(auth, notification) {
985
+ if (!opts.sendNotification) return;
986
+ try {
987
+ await opts.sendNotification(notification);
988
+ } catch (e) {
989
+ auth.logger.error("[sentinel] Failed to send notification", e);
990
+ }
991
+ }
992
+ /**
993
+ * The address of the account an email is an alias of, or null when the
994
+ * email is an account's address itself or no account matches
995
+ */
996
+ async function findAliasedAddress(auth, email) {
997
+ const exact = await auth.adapter.findOne({
998
+ model: "user",
999
+ where: [{
1000
+ field: "email",
1001
+ value: email.toLowerCase()
1002
+ }]
1003
+ });
1004
+ const normalizedEmail = normalizeEmail(email);
1005
+ if (exact) {
1006
+ if (!exact.normalizedEmail) await auth.adapter.update({
1007
+ model: "user",
1008
+ where: [{
1009
+ field: "id",
1010
+ value: exact.id
1011
+ }],
1012
+ update: { normalizedEmail }
1013
+ });
1014
+ return null;
1015
+ }
1016
+ return (await auth.adapter.findOne({
1017
+ model: "user",
1018
+ where: [{
1019
+ field: "normalizedEmail",
1020
+ value: normalizedEmail
1021
+ }]
1022
+ }))?.email ?? null;
1023
+ }
1024
+ /** Disposable domains, MX records and generated-looking addresses */
1025
+ async function validateEmail(ctx, store, details, email) {
1026
+ if (!emailValidation) return;
1027
+ const domain = getEmailDomain(email);
1028
+ if (!domain || emailValidation.domainAllowlist.has(domain)) return;
1029
+ throwForDecision(await guard(ctx.context, async () => {
1030
+ const { strictness } = emailValidation;
1031
+ let check = null;
1032
+ if (await isDisposableDomain(domain)) check = "disposable";
1033
+ else if (strictness !== "low" && await hasMxRecords(domain) === false) check = "no_mx_records";
1034
+ else if (strictness === "high" && looksGenerated(email.toLowerCase())) check = "generated";
1035
+ if (!check) return { outcome: "allow" };
1036
+ return act(ctx, store, details, emailValidation.action, SECURITY_EVENT_TYPES.INVALID_EMAIL, "invalid_email", {
1037
+ domain,
1038
+ check,
1039
+ strictness
1040
+ });
1041
+ }));
1042
+ }
1043
+ /**
1044
+ * Checks run when an account is about to be created, whichever endpoint
1045
+ * creates it: email validation, sign-up velocity and free trial abuse
1046
+ */
1047
+ async function checkAccountCreation(ctx, email) {
1048
+ if (isManagedAccountCreation(ctx.path)) return;
1049
+ const event = ctx;
1050
+ const store = createSentinelStore(ctx.context);
1051
+ const details = await requestDetails(event, email);
1052
+ if (!OAUTH_CALLBACK_PATHS.includes(ctx.path)) await validateEmail(event, store, details, email);
1053
+ if (velocity) {
1054
+ const ids = rateIds(details);
1055
+ if (ids.length) throwForDecision(await guard(ctx.context, async () => {
1056
+ const count = await incrementAll(store, ids.map((id) => `velocity:sign-up:${id}`), velocity.windowSeconds);
1057
+ if (count <= velocity.maxSignupsPerVisitor) return { outcome: "allow" };
1058
+ return act(event, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1059
+ count,
1060
+ limit: velocity.maxSignupsPerVisitor,
1061
+ windowSeconds: velocity.windowSeconds
1062
+ });
1063
+ }));
1064
+ }
1065
+ if (freeTrialAbuse && details.visitorId) {
1066
+ const visitorId = details.visitorId;
1067
+ throwForDecision(await guard(ctx.context, async () => {
1068
+ const created = await store.get(`trial:${visitorId}`);
1069
+ const { thresholds } = freeTrialAbuse;
1070
+ let action = null;
1071
+ if (thresholds) {
1072
+ if (created >= thresholds.block) action = "block";
1073
+ else if (created >= thresholds.challenge) action = "challenge";
1074
+ } else if (created >= freeTrialAbuse.maxAccountsPerVisitor) action = freeTrialAbuse.action;
1075
+ if (!action) return { outcome: "allow" };
1076
+ return act(event, store, details, action, SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "free_trial_abuse", { accounts: created });
1077
+ }));
1078
+ }
1079
+ }
1080
+ return {
1081
+ id: "sentinel",
1082
+ init(ctx) {
1083
+ if ((unknownDeviceNotification || staleUsers?.notifyUser || staleUsers?.adminEmail) && !opts.sendNotification) ctx.logger.warn("[sentinel] Notifications are enabled but sendNotification is not set, so none will be sent");
1084
+ if (intel.misconfigured) ctx.logger.warn("[sentinel] Missing PAYVO_INFRA_API_KEY or PAYVO_INFRA_API_URL. Security checks that use the hosted backend fall back to allow mode.");
1085
+ if (!checksAccountCreation && !emailNormalization) return;
1086
+ return { options: { databaseHooks: { user: {
1087
+ create: {
1088
+ async before(user, context) {
1089
+ if (context && checksAccountCreation) await checkAccountCreation(context, user.email);
1090
+ if (emailNormalization) return { data: {
1091
+ ...user,
1092
+ normalizedEmail: normalizeEmail(user.email)
1093
+ } };
1094
+ },
1095
+ async after(user, context) {
1096
+ if (!freeTrialAbuse || !context) return;
1097
+ if (isManagedAccountCreation(context.path)) return;
1098
+ const event = context;
1099
+ const headers = event.headers ?? event.request?.headers;
1100
+ const ip = clientIp(event.request ?? headers, context.context);
1101
+ const { id: visitorId } = await resolveVisitor(context.context, headers, ip);
1102
+ if (!visitorId) return;
1103
+ try {
1104
+ await createSentinelStore(context.context).increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
1105
+ } catch (e) {
1106
+ context.context.logger.error("[sentinel] Failed to track sign-up", e);
1107
+ }
1108
+ }
1109
+ },
1110
+ update: { async before(data) {
1111
+ if (!emailNormalization || typeof data.email !== "string") return;
1112
+ return { data: {
1113
+ ...data,
1114
+ normalizedEmail: normalizeEmail(data.email)
1115
+ } };
1116
+ } }
1117
+ } } } };
1118
+ },
1119
+ async onRequest(request, ctx) {
1120
+ if (!botBlocking && !suspiciousIpBlocking && !geoBlocking) return;
1121
+ const store = createSentinelStore(ctx);
1122
+ const event = {
1123
+ context: ctx,
1124
+ request,
1125
+ headers: request.headers,
1126
+ path: relativePath(request, ctx)
1127
+ };
1128
+ const response = responseForDecision(await guard(ctx, async () => {
1129
+ const details = await requestDetails(event);
1130
+ const { ip } = details;
1131
+ const remote = await intel.lookup(ctx, details);
1132
+ if (suspiciousIpBlocking && ip) {
1133
+ if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) : remote.suspiciousIp) {
1134
+ const result = await act(event, store, details, suspiciousIpBlocking.action, SECURITY_EVENT_TYPES.SUSPICIOUS_IP, "suspicious_ip", { ip });
1135
+ if (result.outcome !== "allow") return result;
1136
+ }
1137
+ }
1138
+ if (geoBlocking && ip) {
1139
+ const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : remote.geo)?.country?.toUpperCase() ?? null;
1140
+ if (country ? geoBlocking.allowList ? !geoBlocking.allowList.includes(country) : Boolean(geoBlocking.denyList?.includes(country)) : Boolean(geoBlocking.allowList) && !opts.failOpen) {
1141
+ const result = await act(event, store, details, geoBlocking.action, SECURITY_EVENT_TYPES.GEO_BLOCKED, "geo_blocking", { country });
1142
+ if (result.outcome !== "allow") return result;
1143
+ }
1144
+ }
1145
+ if (botBlocking) {
1146
+ if (opts.providers?.isBot ? await opts.providers.isBot(request) : remote.bot ?? looksLikeBot(request)) {
1147
+ const result = await act(event, store, details, botBlocking.action, SECURITY_EVENT_TYPES.BOT_BLOCKED, "bot", { userAgent: request.headers.get("user-agent") });
1148
+ if (result.outcome !== "allow") return result;
1149
+ }
1150
+ }
1151
+ return { outcome: "allow" };
1152
+ }));
1153
+ return response ? { response } : void 0;
1154
+ },
1155
+ hooks: {
1156
+ before: [{
1157
+ matcher(context) {
1158
+ return emailNormalization && context.path in NORMALIZED_EMAIL_FIELDS;
1159
+ },
1160
+ handler: createAuthMiddleware(async (ctx) => {
1161
+ const field = NORMALIZED_EMAIL_FIELDS[ctx.path];
1162
+ const email = ctx.body?.[field];
1163
+ if (typeof email !== "string") return;
1164
+ const stored = await findAliasedAddress(ctx.context, email).catch((e) => {
1165
+ ctx.context.logger.error("[sentinel] Failed to resolve alias", e);
1166
+ return null;
1167
+ });
1168
+ if (!stored) return;
1169
+ return { context: { body: {
1170
+ ...ctx.body,
1171
+ [field]: stored
1172
+ } } };
1173
+ })
1174
+ }, {
1175
+ matcher(context) {
1176
+ const isCredentialSignIn = CREDENTIAL_SIGN_IN_PATHS.includes(context.path);
1177
+ return Boolean(credentialStuffing && isAttemptPath(context.path) || velocity && (isCredentialSignIn || SIGN_UP_PATHS.includes(context.path) || PASSWORD_RESET_PATHS.includes(context.path)) || emailValidation && context.path === "/change-email" || compromisedPassword && context.path in NEW_PASSWORD_FIELDS);
1178
+ },
1179
+ handler: createAuthMiddleware(async (ctx) => {
1180
+ const store = createSentinelStore(ctx.context);
1181
+ const details = await requestDetails(ctx);
1182
+ const body = ctx.body ?? {};
1183
+ if (emailValidation && ctx.path === "/change-email" && typeof body.newEmail === "string") await validateEmail(ctx, store, details, body.newEmail);
1184
+ if (compromisedPassword && ctx.path in NEW_PASSWORD_FIELDS) {
1185
+ const password = body[NEW_PASSWORD_FIELDS[ctx.path]];
1186
+ if (typeof password === "string" && password) throwForDecision(await guard(ctx.context, async () => {
1187
+ const breachCount = await getPasswordBreachCount(password);
1188
+ if (breachCount < compromisedPassword.minBreachCount) return { outcome: "allow" };
1189
+ return act(ctx, store, details, compromisedPassword.action, SECURITY_EVENT_TYPES.COMPROMISED_PASSWORD, "compromised_password", { breachCount });
1190
+ }));
1191
+ }
1192
+ if (credentialStuffing && isAttemptPath(ctx.path)) throwForDecision(await guard(ctx.context, async () => {
1193
+ const keys = attemptKeys(details);
1194
+ const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context);
1195
+ const blocked = (await Promise.all(counterNames(keys).map((key) => store.get(`${key}:blocked`)))).some((value) => value > 0);
1196
+ const failures = await countAttempt(store, keys, { readOnly: solved || blocked });
1197
+ const { thresholds } = credentialStuffing;
1198
+ const metadata = {
1199
+ failedAttempts: failures,
1200
+ thresholds
1201
+ };
1202
+ if (blocked || failures >= thresholds.block) return act(ctx, store, details, credentialStuffing.action === "log" ? "log" : "block", SECURITY_EVENT_TYPES.CREDENTIAL_STUFFING, "credential_stuffing", metadata);
1203
+ if (failures >= thresholds.challenge) return act(ctx, store, details, credentialStuffing.action === "log" ? "log" : "challenge", SECURITY_EVENT_TYPES.CREDENTIAL_STUFFING, "credential_stuffing", metadata);
1204
+ return { outcome: "allow" };
1205
+ }));
1206
+ if (velocity) {
1207
+ const { windowSeconds } = velocity;
1208
+ let limit = null;
1209
+ if (CREDENTIAL_SIGN_IN_PATHS.includes(ctx.path) && details.ip) limit = {
1210
+ name: `velocity:sign-in:${details.ip}`,
1211
+ max: velocity.maxSignInsPerIp
1212
+ };
1213
+ else if (PASSWORD_RESET_PATHS.includes(ctx.path) && details.ip) limit = {
1214
+ name: `velocity:password-reset:${details.ip}`,
1215
+ max: velocity.maxPasswordResetsPerIp
1216
+ };
1217
+ const ids = rateIds(details);
1218
+ const isTracked = CREDENTIAL_SIGN_IN_PATHS.includes(ctx.path) || SIGN_UP_PATHS.includes(ctx.path) || PASSWORD_RESET_PATHS.includes(ctx.path);
1219
+ throwForDecision(await guard(ctx.context, async () => {
1220
+ if (limit) {
1221
+ const count = await store.increment(limit.name, windowSeconds);
1222
+ if (count > limit.max) {
1223
+ const result = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1224
+ count,
1225
+ limit: limit.max,
1226
+ windowSeconds
1227
+ });
1228
+ if (result.outcome !== "allow") return result;
1229
+ }
1230
+ }
1231
+ if (ids.length && isTracked) {
1232
+ const total = await incrementAll(store, ids.map((id) => `velocity:total:${id}`), windowSeconds);
1233
+ const { thresholds } = velocity;
1234
+ if (total > thresholds.challenge) return act(ctx, store, details, total > thresholds.block ? "block" : "challenge", SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
1235
+ count: total,
1236
+ thresholds,
1237
+ windowSeconds
1238
+ });
1239
+ }
1240
+ return { outcome: "allow" };
1241
+ }));
1242
+ }
1243
+ })
1244
+ }],
1245
+ after: [{
1246
+ matcher(context) {
1247
+ return Boolean(credentialStuffing && isAttemptPath(context.path));
1248
+ },
1249
+ handler: createAuthMiddleware(async (ctx) => {
1250
+ if (!credentialStuffing) return;
1251
+ const returned = ctx.context.returned;
1252
+ const status = returned instanceof APIError ? returned.statusCode : 200;
1253
+ try {
1254
+ const store = createSentinelStore(ctx.context);
1255
+ const keys = attemptKeys(await requestDetails(ctx));
1256
+ const { windowSeconds, cooldownSeconds, thresholds } = credentialStuffing;
1257
+ if (status < 400) for (const { scope, account } of keys) {
1258
+ const [total, succeeded, own] = await Promise.all([
1259
+ store.get(scope),
1260
+ store.get(`${scope}:succeeded`),
1261
+ account ? store.get(account) : 0
1262
+ ]);
1263
+ if (account) await store.reset(account);
1264
+ if (total - succeeded <= own) {
1265
+ await store.reset(scope);
1266
+ await store.reset(`${scope}:succeeded`);
1267
+ } else await store.increment(`${scope}:succeeded`, windowSeconds);
1268
+ }
1269
+ else if (!SENTINEL_STATUSES.has(status) && status < 500) {
1270
+ const block = async (key, also = []) => {
1271
+ await store.increment(`${key}:blocked`, cooldownSeconds);
1272
+ await Promise.all([key, ...also].map((name) => store.reset(name)));
1273
+ };
1274
+ for (const { scope, account } of keys) {
1275
+ const [total, succeeded] = await Promise.all([store.get(scope), store.get(`${scope}:succeeded`)]);
1276
+ if (total - succeeded >= thresholds.block) await block(scope, [`${scope}:succeeded`]);
1277
+ if (account && await store.get(account) >= thresholds.block) await block(account);
1278
+ }
1279
+ }
1280
+ } catch (e) {
1281
+ ctx.context.logger.error("[sentinel] Failed to track attempt", e);
1282
+ }
1283
+ })
1284
+ }, {
1285
+ matcher(context) {
1286
+ return tracksSignIns && (SIGN_IN_PATHS.includes(context.path) || SIGN_UP_PATHS.includes(context.path));
1287
+ },
1288
+ handler: createAuthMiddleware(async (ctx) => {
1289
+ const returned = ctx.context.returned;
1290
+ const newSession = ctx.context.newSession;
1291
+ if (!newSession || returned instanceof APIError && returned.statusCode >= 400) return;
1292
+ const auth = ctx.context;
1293
+ const store = createSentinelStore(auth);
1294
+ const user = newSession.user;
1295
+ const isSignUp = SIGN_UP_PATHS.includes(ctx.path);
1296
+ const awaitsSecondFactor = Boolean(returned && !(returned instanceof APIError) && "twoFactorRedirect" in returned);
1297
+ const passedKey = `checked:${user.id}`;
1298
+ const checkedBefore = ctx.path.startsWith("/two-factor/") && await store.get(passedKey).catch(() => 0) > 0;
1299
+ if (checkedBefore) await store.reset(passedKey).catch(() => {});
1300
+ const repeatable = REPEATABLE_SIGN_IN_PATHS.includes(ctx.path);
1301
+ /**
1302
+ * Undoes a sign-in that a check rejects after the session was
1303
+ * created. Sign-ins that can't be repeated with a solution are
1304
+ * blocked instead of challenged.
1305
+ */
1306
+ const reject = async (decision) => {
1307
+ if (decision.outcome === "allow") return null;
1308
+ await auth.internalAdapter.deleteSession(newSession.session.token).catch((e) => auth.logger.error("[sentinel] Failed to revoke session", e));
1309
+ deleteSessionCookie(ctx);
1310
+ auth.setNewSession(null);
1311
+ return errorForDecision(decision);
1312
+ };
1313
+ const enforce = (action) => action === "challenge" && !repeatable ? "block" : action;
1314
+ let details;
1315
+ let previous;
1316
+ let geo;
1317
+ try {
1318
+ details = await requestDetails(ctx, user.email);
1319
+ previous = await lastSignIn(auth, user.id);
1320
+ geo = await locate(auth, details).catch(() => null);
1321
+ } catch (e) {
1322
+ auth.logger.error("[sentinel] Failed to track sign-in", e);
1323
+ return;
1324
+ }
1325
+ const signIn = {
1326
+ at: /* @__PURE__ */ new Date(),
1327
+ ipAddress: details.ip,
1328
+ userAgent: details.userAgent,
1329
+ location: [geo?.city, geo?.country].filter(Boolean).join(", ") || null
1330
+ };
1331
+ if (!isSignUp && !checkedBefore && impossibleTravel && previous) {
1332
+ const trip = travel(previous, geo);
1333
+ if (trip && trip.speedKmh > impossibleTravel.maxSpeedKmh) {
1334
+ const rejected = await reject(await act(ctx, store, details, enforce(impossibleTravel.action), SECURITY_EVENT_TYPES.IMPOSSIBLE_TRAVEL, "impossible_travel", {
1335
+ ...trip,
1336
+ maxSpeedKmh: impossibleTravel.maxSpeedKmh
1337
+ }, user.id));
1338
+ if (rejected) return rejected;
1339
+ }
1340
+ }
1341
+ const daysInactive = !isSignUp && staleUsers && previous ? daysSince(previous.createdAt) : null;
1342
+ const isStale = Boolean(staleUsers && daysInactive !== null && daysInactive > staleUsers.staleDays);
1343
+ if (staleUsers && isStale && !checkedBefore) {
1344
+ const rejected = await reject(await act(ctx, store, details, enforce(staleUsers.action), SECURITY_EVENT_TYPES.STALE_ACCOUNT, "stale_account", {
1345
+ daysInactive,
1346
+ staleDays: staleUsers.staleDays
1347
+ }, user.id));
1348
+ if (rejected) return rejected;
1349
+ }
1350
+ if (awaitsSecondFactor) {
1351
+ await store.increment(passedKey, SECOND_FACTOR_SECONDS).catch((e) => auth.logger.error("[sentinel] Failed to track sign-in", e));
1352
+ return;
1353
+ }
1354
+ try {
1355
+ if (staleUsers && isStale && daysInactive !== null) {
1356
+ if (staleUsers.notifyUser) await notify(auth, {
1357
+ type: "stale-account",
1358
+ recipient: "user",
1359
+ email: user.email,
1360
+ user,
1361
+ signIn,
1362
+ daysInactive
1363
+ });
1364
+ if (staleUsers.adminEmail) await notify(auth, {
1365
+ type: "stale-account",
1366
+ recipient: "admin",
1367
+ email: staleUsers.adminEmail,
1368
+ user,
1369
+ signIn,
1370
+ daysInactive
1371
+ });
1372
+ }
1373
+ if (!isSignUp && unknownDeviceNotification && details.visitorId && previous) {
1374
+ if (!await auth.adapter.findOne({
1375
+ model: "sentinelSignIn",
1376
+ where: [{
1377
+ field: "userId",
1378
+ value: user.id
1379
+ }, {
1380
+ field: "visitorId",
1381
+ value: details.visitorId
1382
+ }]
1383
+ })) await notify(auth, {
1384
+ type: "unknown-device",
1385
+ recipient: "user",
1386
+ email: user.email,
1387
+ user,
1388
+ signIn
1389
+ });
1390
+ }
1391
+ await auth.adapter.create({
1392
+ model: "sentinelSignIn",
1393
+ data: {
1394
+ userId: user.id,
1395
+ ipAddress: details.ip,
1396
+ visitorId: details.visitorId,
1397
+ userAgent: details.userAgent,
1398
+ country: geo?.country ?? null,
1399
+ city: geo?.city ?? null,
1400
+ latitude: geo?.latitude ?? null,
1401
+ longitude: geo?.longitude ?? null,
1402
+ createdAt: /* @__PURE__ */ new Date()
1403
+ }
1404
+ });
1405
+ } catch (e) {
1406
+ if (e instanceof APIError) throw e;
1407
+ auth.logger.error("[sentinel] Failed to track sign-in", e);
1408
+ }
1409
+ })
1410
+ }]
1411
+ },
1412
+ schema: mergeSchema(emailNormalization ? {
1413
+ ...schema,
1414
+ ...normalizedEmailSchema
1415
+ } : schema, opts.schema),
1416
+ $ERROR_CODES: SENTINEL_ERROR_CODES,
1417
+ options: opts
1418
+ };
1419
+ };
1420
+ function resolveToggle(value, defaultAction) {
1421
+ if (!value) return null;
1422
+ return { action: value === true ? defaultAction : value.action };
1423
+ }
1424
+
1425
+ //#endregion
1426
+ export { SENTINEL_ERROR_CODES as i, haversineDistanceKm as n, normalizeEmail as r, sentinel as t };