@bajustone/fortress 1.0.0-rc.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 (465) hide show
  1. package/CHANGELOG.md +1011 -0
  2. package/LICENSE +21 -0
  3. package/README.md +760 -0
  4. package/SECURITY.md +197 -0
  5. package/bin/fortress.ts +667 -0
  6. package/dist/auth-service-BcyQ2PuZ.d.cts +307 -0
  7. package/dist/auth-service-BkzZkWfH.d.ts +307 -0
  8. package/dist/config-B2366s_G.d.ts +665 -0
  9. package/dist/config-GtlVt6ZD.d.cts +665 -0
  10. package/dist/convert-routes-BLCQT57f.d.ts +72 -0
  11. package/dist/convert-routes-BdMvP2_X.d.cts +72 -0
  12. package/dist/crypto.cjs +61 -0
  13. package/dist/crypto.d.cts +36 -0
  14. package/dist/crypto.d.ts +36 -0
  15. package/dist/crypto.js +35 -0
  16. package/dist/drift-BT1wtMDJ.d.cts +22 -0
  17. package/dist/drift-k9LiYpRP.d.ts +22 -0
  18. package/dist/drizzle/pg.cjs +481 -0
  19. package/dist/drizzle/pg.d.cts +15 -0
  20. package/dist/drizzle/pg.d.ts +15 -0
  21. package/dist/drizzle/pg.js +454 -0
  22. package/dist/drizzle.cjs +1442 -0
  23. package/dist/drizzle.d.cts +85 -0
  24. package/dist/drizzle.d.ts +85 -0
  25. package/dist/drizzle.js +1411 -0
  26. package/dist/express.cjs +1357 -0
  27. package/dist/express.d.cts +333 -0
  28. package/dist/express.d.ts +333 -0
  29. package/dist/express.js +1314 -0
  30. package/dist/fetcher.cjs +77 -0
  31. package/dist/fetcher.d.cts +7 -0
  32. package/dist/fetcher.d.ts +7 -0
  33. package/dist/fetcher.js +41 -0
  34. package/dist/fortress-BmSvFfqK.d.cts +1089 -0
  35. package/dist/fortress-uA-_cheR.d.ts +1089 -0
  36. package/dist/hono.cjs +1530 -0
  37. package/dist/hono.d.cts +514 -0
  38. package/dist/hono.d.ts +514 -0
  39. package/dist/hono.js +1483 -0
  40. package/dist/index-CxEkfCA0.d.cts +77 -0
  41. package/dist/index-CxEkfCA0.d.ts +77 -0
  42. package/dist/index-D054pcb-.d.cts +146 -0
  43. package/dist/index-jAMbbUAY.d.ts +146 -0
  44. package/dist/index.cjs +9046 -0
  45. package/dist/index.d.cts +717 -0
  46. package/dist/index.d.ts +717 -0
  47. package/dist/index.js +8935 -0
  48. package/dist/jwt.cjs +255 -0
  49. package/dist/jwt.d.cts +90 -0
  50. package/dist/jwt.d.ts +90 -0
  51. package/dist/jwt.js +227 -0
  52. package/dist/otel.cjs +108 -0
  53. package/dist/otel.d.cts +62 -0
  54. package/dist/otel.d.ts +62 -0
  55. package/dist/otel.js +73 -0
  56. package/dist/plugins/account-lockout.cjs +322 -0
  57. package/dist/plugins/account-lockout.d.cts +42 -0
  58. package/dist/plugins/account-lockout.d.ts +42 -0
  59. package/dist/plugins/account-lockout.js +295 -0
  60. package/dist/plugins/admin.cjs +2378 -0
  61. package/dist/plugins/admin.d.cts +72 -0
  62. package/dist/plugins/admin.d.ts +72 -0
  63. package/dist/plugins/admin.js +2351 -0
  64. package/dist/plugins/api-key.cjs +792 -0
  65. package/dist/plugins/api-key.d.cts +121 -0
  66. package/dist/plugins/api-key.d.ts +121 -0
  67. package/dist/plugins/api-key.js +765 -0
  68. package/dist/plugins/audit-log.cjs +493 -0
  69. package/dist/plugins/audit-log.d.cts +86 -0
  70. package/dist/plugins/audit-log.d.ts +86 -0
  71. package/dist/plugins/audit-log.js +468 -0
  72. package/dist/plugins/data-isolation.cjs +211 -0
  73. package/dist/plugins/data-isolation.d.cts +49 -0
  74. package/dist/plugins/data-isolation.d.ts +49 -0
  75. package/dist/plugins/data-isolation.js +186 -0
  76. package/dist/plugins/email-verification.cjs +273 -0
  77. package/dist/plugins/email-verification.d.cts +44 -0
  78. package/dist/plugins/email-verification.d.ts +44 -0
  79. package/dist/plugins/email-verification.js +246 -0
  80. package/dist/plugins/magic-link.cjs +231 -0
  81. package/dist/plugins/magic-link.d.cts +39 -0
  82. package/dist/plugins/magic-link.d.ts +39 -0
  83. package/dist/plugins/magic-link.js +204 -0
  84. package/dist/plugins/oauth.cjs +1696 -0
  85. package/dist/plugins/oauth.d.cts +406 -0
  86. package/dist/plugins/oauth.d.ts +406 -0
  87. package/dist/plugins/oauth.js +1669 -0
  88. package/dist/plugins/openapi.cjs +1185 -0
  89. package/dist/plugins/openapi.d.cts +6 -0
  90. package/dist/plugins/openapi.d.ts +6 -0
  91. package/dist/plugins/openapi.js +1158 -0
  92. package/dist/plugins/rate-limit/express.cjs +55 -0
  93. package/dist/plugins/rate-limit/express.d.cts +64 -0
  94. package/dist/plugins/rate-limit/express.d.ts +64 -0
  95. package/dist/plugins/rate-limit/express.js +30 -0
  96. package/dist/plugins/rate-limit/hono.cjs +51 -0
  97. package/dist/plugins/rate-limit/hono.d.cts +59 -0
  98. package/dist/plugins/rate-limit/hono.d.ts +59 -0
  99. package/dist/plugins/rate-limit/hono.js +26 -0
  100. package/dist/plugins/rate-limit/sveltekit.cjs +49 -0
  101. package/dist/plugins/rate-limit/sveltekit.d.cts +59 -0
  102. package/dist/plugins/rate-limit/sveltekit.d.ts +59 -0
  103. package/dist/plugins/rate-limit/sveltekit.js +24 -0
  104. package/dist/plugins/rate-limit.cjs +354 -0
  105. package/dist/plugins/rate-limit.d.cts +140 -0
  106. package/dist/plugins/rate-limit.d.ts +140 -0
  107. package/dist/plugins/rate-limit.js +325 -0
  108. package/dist/plugins/social-login.cjs +905 -0
  109. package/dist/plugins/social-login.d.cts +114 -0
  110. package/dist/plugins/social-login.d.ts +114 -0
  111. package/dist/plugins/social-login.js +880 -0
  112. package/dist/plugins/tenancy.cjs +780 -0
  113. package/dist/plugins/tenancy.d.cts +108 -0
  114. package/dist/plugins/tenancy.d.ts +108 -0
  115. package/dist/plugins/tenancy.js +753 -0
  116. package/dist/plugins/two-factor.cjs +555 -0
  117. package/dist/plugins/two-factor.d.cts +67 -0
  118. package/dist/plugins/two-factor.d.ts +67 -0
  119. package/dist/plugins/two-factor.js +526 -0
  120. package/dist/plugins/webauthn.cjs +786 -0
  121. package/dist/plugins/webauthn.d.cts +72 -0
  122. package/dist/plugins/webauthn.d.ts +72 -0
  123. package/dist/plugins/webauthn.js +766 -0
  124. package/dist/plugins/webhook.cjs +819 -0
  125. package/dist/plugins/webhook.d.cts +254 -0
  126. package/dist/plugins/webhook.d.ts +254 -0
  127. package/dist/plugins/webhook.js +789 -0
  128. package/dist/protect-D1v_0upK.d.cts +123 -0
  129. package/dist/protect-DsxV_-dJ.d.ts +123 -0
  130. package/dist/sveltekit.cjs +1258 -0
  131. package/dist/sveltekit.d.cts +420 -0
  132. package/dist/sveltekit.d.ts +420 -0
  133. package/dist/sveltekit.js +1207 -0
  134. package/dist/testing.cjs +4294 -0
  135. package/dist/testing.d.cts +197 -0
  136. package/dist/testing.d.ts +197 -0
  137. package/dist/testing.js +4264 -0
  138. package/dist/types-et0R8tsC.d.cts +217 -0
  139. package/dist/types-et0R8tsC.d.ts +217 -0
  140. package/docs/adapter-typed-helpers.md +192 -0
  141. package/docs/admin-recipes.md +227 -0
  142. package/docs/architecture.md +434 -0
  143. package/docs/ci/github-actions.yml +59 -0
  144. package/docs/ci.md +109 -0
  145. package/docs/compatibility.md +115 -0
  146. package/docs/deployment.md +305 -0
  147. package/docs/hardening.md +118 -0
  148. package/docs/host-owned-routes.md +154 -0
  149. package/docs/migrations/0001-schema-version.md +54 -0
  150. package/docs/migrations/0002-initial-schema.md +84 -0
  151. package/docs/migrations/upgrade-guide.md +160 -0
  152. package/docs/observability.md +394 -0
  153. package/docs/plugins/account-lockout.md +131 -0
  154. package/docs/plugins/admin.md +402 -0
  155. package/docs/plugins/api-key.md +327 -0
  156. package/docs/plugins/audit-log.md +261 -0
  157. package/docs/plugins/data-isolation.md +186 -0
  158. package/docs/plugins/email-verification.md +121 -0
  159. package/docs/plugins/magic-link.md +123 -0
  160. package/docs/plugins/oauth.md +544 -0
  161. package/docs/plugins/openapi.md +250 -0
  162. package/docs/plugins/rate-limit.md +223 -0
  163. package/docs/plugins/social-login.md +337 -0
  164. package/docs/plugins/tenancy.md +122 -0
  165. package/docs/plugins/two-factor.md +191 -0
  166. package/docs/plugins/webauthn.md +188 -0
  167. package/docs/plugins/webhook.md +242 -0
  168. package/docs/policy-as-code.md +156 -0
  169. package/docs/route-manifest.md +46 -0
  170. package/docs/security.md +261 -0
  171. package/docs/threat-model.md +161 -0
  172. package/examples/README.md +119 -0
  173. package/examples/express-app/index.ts +747 -0
  174. package/examples/hono-app/docker-compose.yml +14 -0
  175. package/examples/hono-app/index.ts +1009 -0
  176. package/examples/policy/fortress.policy.json +47 -0
  177. package/examples/sveltekit-app/README.md +125 -0
  178. package/examples/sveltekit-app/src/app.d.ts +16 -0
  179. package/examples/sveltekit-app/src/hooks.server.ts +23 -0
  180. package/examples/sveltekit-app/src/lib/server/fortress.ts +81 -0
  181. package/examples/sveltekit-app/src/routes/api/echo/[id]/+server.ts +32 -0
  182. package/examples/sveltekit-app/src/routes/api/fortress/[...path]/+server.ts +15 -0
  183. package/examples/sveltekit-app/src/routes/dashboard/+page.server.ts +20 -0
  184. package/examples/sveltekit-app/src/routes/login/+page.server.ts +48 -0
  185. package/examples/sveltekit-app/src/routes/oauth/consent/+page.server.ts +69 -0
  186. package/examples/sveltekit-app/src/routes/oauth/consent/+page.svelte +83 -0
  187. package/migrations/pg/0001_schema_version.down.sql +2 -0
  188. package/migrations/pg/0001_schema_version.sql +8 -0
  189. package/migrations/pg/0002_initial_schema.down.sql +36 -0
  190. package/migrations/pg/0002_initial_schema.sql +376 -0
  191. package/migrations/pg/0003_auth_continuation.down.sql +6 -0
  192. package/migrations/pg/0003_auth_continuation.sql +30 -0
  193. package/migrations/pg/0004_tenant_default_unique.down.sql +1 -0
  194. package/migrations/pg/0004_tenant_default_unique.sql +14 -0
  195. package/migrations/pg/0005_hot_indexes_timestamptz.down.sql +71 -0
  196. package/migrations/pg/0005_hot_indexes_timestamptz.sql +71 -0
  197. package/migrations/pg/0006_canonical_email.down.sql +3 -0
  198. package/migrations/pg/0006_canonical_email.sql +8 -0
  199. package/migrations/pg/0007_audit_chain_anchor.down.sql +1 -0
  200. package/migrations/pg/0007_audit_chain_anchor.sql +8 -0
  201. package/migrations/pg/0008_two_factor_hardening.down.sql +8 -0
  202. package/migrations/pg/0008_two_factor_hardening.sql +8 -0
  203. package/migrations/pg/0009_encrypt_totp_secrets.down.sql +2 -0
  204. package/migrations/pg/0009_encrypt_totp_secrets.sql +5 -0
  205. package/migrations/pg/0010_bigint_append_only_ids.down.sql +2 -0
  206. package/migrations/pg/0010_bigint_append_only_ids.sql +23 -0
  207. package/migrations/sqlite/0001_schema_version.down.sql +2 -0
  208. package/migrations/sqlite/0001_schema_version.sql +8 -0
  209. package/migrations/sqlite/0002_initial_schema.down.sql +36 -0
  210. package/migrations/sqlite/0002_initial_schema.sql +376 -0
  211. package/migrations/sqlite/0003_auth_continuation.down.sql +27 -0
  212. package/migrations/sqlite/0003_auth_continuation.sql +45 -0
  213. package/migrations/sqlite/0004_tenant_default_unique.down.sql +1 -0
  214. package/migrations/sqlite/0004_tenant_default_unique.sql +13 -0
  215. package/migrations/sqlite/0005_hot_indexes_timestamptz.down.sql +10 -0
  216. package/migrations/sqlite/0005_hot_indexes_timestamptz.sql +10 -0
  217. package/migrations/sqlite/0006_canonical_email.down.sql +3 -0
  218. package/migrations/sqlite/0006_canonical_email.sql +8 -0
  219. package/migrations/sqlite/0007_audit_chain_anchor.down.sql +1 -0
  220. package/migrations/sqlite/0007_audit_chain_anchor.sql +8 -0
  221. package/migrations/sqlite/0008_two_factor_hardening.down.sql +14 -0
  222. package/migrations/sqlite/0008_two_factor_hardening.sql +7 -0
  223. package/migrations/sqlite/0009_encrypt_totp_secrets.down.sql +2 -0
  224. package/migrations/sqlite/0009_encrypt_totp_secrets.sql +5 -0
  225. package/migrations/sqlite/0010_bigint_append_only_ids.down.sql +1 -0
  226. package/migrations/sqlite/0010_bigint_append_only_ids.sql +2 -0
  227. package/package.json +398 -0
  228. package/src/adapters/database/index.ts +63 -0
  229. package/src/adapters/database/types.ts +22 -0
  230. package/src/changelog-script.test.ts +21 -0
  231. package/src/cli.test.ts +79 -0
  232. package/src/core/auth/auth-admin.test.ts +247 -0
  233. package/src/core/auth/auth-endpoints.ts +558 -0
  234. package/src/core/auth/auth-observer.test.ts +191 -0
  235. package/src/core/auth/auth-service.ts +1464 -0
  236. package/src/core/auth/email.test.ts +17 -0
  237. package/src/core/auth/email.ts +11 -0
  238. package/src/core/auth/hooks.test.ts +251 -0
  239. package/src/core/auth/impersonation.test.ts +179 -0
  240. package/src/core/auth/jwt.test.ts +184 -0
  241. package/src/core/auth/jwt.ts +239 -0
  242. package/src/core/auth/login-timing.test.ts +77 -0
  243. package/src/core/auth/password-policy.test.ts +253 -0
  244. package/src/core/auth/password-policy.ts +220 -0
  245. package/src/core/auth/password.test.ts +42 -0
  246. package/src/core/auth/password.ts +62 -0
  247. package/src/core/auth/post-auth-gate.test.ts +258 -0
  248. package/src/core/auth/post-auth-gate.ts +228 -0
  249. package/src/core/auth/refresh-token.test.ts +86 -0
  250. package/src/core/auth/refresh-token.ts +105 -0
  251. package/src/core/auth/timing-safe.ts +23 -0
  252. package/src/core/config.ts +212 -0
  253. package/src/core/endpoint.ts +149 -0
  254. package/src/core/errors.ts +199 -0
  255. package/src/core/fetcher-interop.test.ts +106 -0
  256. package/src/core/fortress.test.ts +354 -0
  257. package/src/core/fortress.ts +550 -0
  258. package/src/core/http/call.test.ts +148 -0
  259. package/src/core/http/call.ts +149 -0
  260. package/src/core/http/cookie-serialize.test.ts +198 -0
  261. package/src/core/http/cookie-serialize.ts +107 -0
  262. package/src/core/http/csrf.test.ts +140 -0
  263. package/src/core/http/csrf.ts +173 -0
  264. package/src/core/http/dispatch.ts +652 -0
  265. package/src/core/http/error-response.test.ts +69 -0
  266. package/src/core/http/error-response.ts +93 -0
  267. package/src/core/http/fortress-rbac.test.ts +43 -0
  268. package/src/core/http/fortress-rbac.ts +127 -0
  269. package/src/core/http/handle-request.test.ts +441 -0
  270. package/src/core/http/handle-request.ts +354 -0
  271. package/src/core/http/match.test.ts +122 -0
  272. package/src/core/http/match.ts +126 -0
  273. package/src/core/http/outbound.test.ts +34 -0
  274. package/src/core/http/outbound.ts +105 -0
  275. package/src/core/http/plugin-middleware.ts +67 -0
  276. package/src/core/http/principal.test.ts +345 -0
  277. package/src/core/http/principal.ts +86 -0
  278. package/src/core/http/protect.test.ts +220 -0
  279. package/src/core/http/protect.ts +394 -0
  280. package/src/core/http/token-extraction.test.ts +59 -0
  281. package/src/core/http/token-extraction.ts +53 -0
  282. package/src/core/iam/explain.test.ts +177 -0
  283. package/src/core/iam/explain.ts +302 -0
  284. package/src/core/iam/iam-endpoints.ts +779 -0
  285. package/src/core/iam/iam-service.test.ts +829 -0
  286. package/src/core/iam/iam-service.ts +1137 -0
  287. package/src/core/iam/permission-cache.test.ts +115 -0
  288. package/src/core/iam/permission-cache.ts +71 -0
  289. package/src/core/iam/permission-check-observer.test.ts +90 -0
  290. package/src/core/iam/permission-evaluator.test.ts +291 -0
  291. package/src/core/iam/permission-evaluator.ts +198 -0
  292. package/src/core/iam/permission-sync.test.ts +166 -0
  293. package/src/core/iam/permission-sync.ts +192 -0
  294. package/src/core/iam/resource-sync.test.ts +70 -0
  295. package/src/core/iam/resource-sync.ts +182 -0
  296. package/src/core/iam/service-account-http.test.ts +529 -0
  297. package/src/core/iam/service-account.test.ts +282 -0
  298. package/src/core/internal-adapter.test.ts +389 -0
  299. package/src/core/internal-adapter.ts +435 -0
  300. package/src/core/json-schema.ts +50 -0
  301. package/src/core/manifest/__snapshots__/route-manifest.test.ts.snap +192 -0
  302. package/src/core/manifest/drift.ts +103 -0
  303. package/src/core/manifest/route-manifest.test.ts +142 -0
  304. package/src/core/manifest/route-manifest.ts +154 -0
  305. package/src/core/migrate.test.ts +72 -0
  306. package/src/core/migrations/engine.test.ts +308 -0
  307. package/src/core/migrations/engine.ts +548 -0
  308. package/src/core/migrations/migrations.ts +1771 -0
  309. package/src/core/migrations/pg-migration.integration-test.ts +353 -0
  310. package/src/core/migrations/upgrade-fixture.test.ts +378 -0
  311. package/src/core/observability/db-instrumentation.ts +205 -0
  312. package/src/core/observability/listener-list.test.ts +124 -0
  313. package/src/core/observability/listener-list.ts +73 -0
  314. package/src/core/observability/logger.test.ts +43 -0
  315. package/src/core/observability/logger.ts +49 -0
  316. package/src/core/observability/spans.test.ts +193 -0
  317. package/src/core/observability/types.ts +80 -0
  318. package/src/core/openapi-drift.test.ts +41 -0
  319. package/src/core/openapi.ts +47 -0
  320. package/src/core/plugin-methods-map.ts +75 -0
  321. package/src/core/plugin-runner.test.ts +233 -0
  322. package/src/core/plugin-runner.ts +276 -0
  323. package/src/core/plugin.ts +210 -0
  324. package/src/core/policy/apply.ts +272 -0
  325. package/src/core/policy/diff.ts +288 -0
  326. package/src/core/policy/loader.test.ts +63 -0
  327. package/src/core/policy/loader.ts +214 -0
  328. package/src/core/policy/policy.test.ts +232 -0
  329. package/src/core/policy/types.ts +105 -0
  330. package/src/core/schema-builder.test.ts +660 -0
  331. package/src/core/schema-builder.ts +881 -0
  332. package/src/core/standard-schema.ts +56 -0
  333. package/src/core/types.ts +258 -0
  334. package/src/core/validation.test.ts +195 -0
  335. package/src/core/validation.ts +192 -0
  336. package/src/drizzle/adapter.test.ts +425 -0
  337. package/src/drizzle/adapter.ts +474 -0
  338. package/src/drizzle/index.ts +31 -0
  339. package/src/drizzle/pg/adapter.integration-test.ts +456 -0
  340. package/src/drizzle/pg/index.ts +13 -0
  341. package/src/drizzle/pg/pg.integration-test.ts +1539 -0
  342. package/src/drizzle/pg/schema.ts +539 -0
  343. package/src/drizzle/pg-error-map.test.ts +218 -0
  344. package/src/drizzle/pg-error-map.ts +151 -0
  345. package/src/drizzle/schema.ts +544 -0
  346. package/src/drizzle/sqlite-serialization.test.ts +106 -0
  347. package/src/express/express-api-key-user-routes.test.ts +241 -0
  348. package/src/express/express.test.ts +442 -0
  349. package/src/express/handle.ts +191 -0
  350. package/src/express/index.ts +62 -0
  351. package/src/express/middleware.ts +551 -0
  352. package/src/express/protect.ts +154 -0
  353. package/src/express/validated.test.ts +86 -0
  354. package/src/express/validated.ts +99 -0
  355. package/src/fetcher/index.ts +81 -0
  356. package/src/hono/convert-routes.test.ts +220 -0
  357. package/src/hono/convert-routes.ts +196 -0
  358. package/src/hono/converters.test.ts +50 -0
  359. package/src/hono/converters.ts +43 -0
  360. package/src/hono/handle.ts +79 -0
  361. package/src/hono/helpers.ts +2 -0
  362. package/src/hono/hono-api-key-user-routes.test.ts +225 -0
  363. package/src/hono/hono-handlers.test.ts +861 -0
  364. package/src/hono/hono.test.ts +454 -0
  365. package/src/hono/index.ts +101 -0
  366. package/src/hono/middleware/auth.ts +236 -0
  367. package/src/hono/middleware/auth.types.test.ts +94 -0
  368. package/src/hono/middleware/csrf.test.ts +127 -0
  369. package/src/hono/middleware/csrf.ts +68 -0
  370. package/src/hono/middleware/error-handler.ts +12 -0
  371. package/src/hono/middleware/plugin-middleware.ts +35 -0
  372. package/src/hono/middleware/rbac.ts +131 -0
  373. package/src/hono/middleware/security-headers.test.ts +88 -0
  374. package/src/hono/middleware/security-headers.ts +68 -0
  375. package/src/hono/openapi.ts +237 -0
  376. package/src/hono/protect.ts +87 -0
  377. package/src/hono/validated.test.ts +123 -0
  378. package/src/hono/validated.ts +62 -0
  379. package/src/index.ts +309 -0
  380. package/src/integration.test.ts +718 -0
  381. package/src/internal/changelog.ts +56 -0
  382. package/src/otel/index.ts +158 -0
  383. package/src/otel/otel-types.test.ts +35 -0
  384. package/src/otel/otel.test.ts +235 -0
  385. package/src/plugins/account-lockout/account-lockout.test.ts +367 -0
  386. package/src/plugins/account-lockout/index.ts +267 -0
  387. package/src/plugins/admin/admin-api-key.test.ts +438 -0
  388. package/src/plugins/admin/index.ts +1612 -0
  389. package/src/plugins/api-key/api-key.test.ts +684 -0
  390. package/src/plugins/api-key/core.ts +336 -0
  391. package/src/plugins/api-key/index.ts +315 -0
  392. package/src/plugins/audit-log/audit-log.test.ts +749 -0
  393. package/src/plugins/audit-log/index.ts +680 -0
  394. package/src/plugins/data-isolation/data-isolation.test.ts +197 -0
  395. package/src/plugins/data-isolation/index.ts +157 -0
  396. package/src/plugins/email-verification/email-verification.test.ts +199 -0
  397. package/src/plugins/email-verification/index.ts +190 -0
  398. package/src/plugins/magic-link/index.ts +129 -0
  399. package/src/plugins/magic-link/magic-link.test.ts +156 -0
  400. package/src/plugins/oauth/id-token.ts +86 -0
  401. package/src/plugins/oauth/index.ts +2145 -0
  402. package/src/plugins/oauth/jwks.test.ts +32 -0
  403. package/src/plugins/oauth/jwks.ts +182 -0
  404. package/src/plugins/oauth/oauth.test.ts +2220 -0
  405. package/src/plugins/oauth/pkce.ts +58 -0
  406. package/src/plugins/openapi/index.ts +298 -0
  407. package/src/plugins/openapi/openapi.test.ts +425 -0
  408. package/src/plugins/openapi/spec-builder.ts +287 -0
  409. package/src/plugins/rate-limit/express.test.ts +89 -0
  410. package/src/plugins/rate-limit/express.ts +86 -0
  411. package/src/plugins/rate-limit/hono.test.ts +60 -0
  412. package/src/plugins/rate-limit/hono.ts +74 -0
  413. package/src/plugins/rate-limit/index.ts +383 -0
  414. package/src/plugins/rate-limit/memory-store.ts +61 -0
  415. package/src/plugins/rate-limit/rate-limit.test.ts +756 -0
  416. package/src/plugins/rate-limit/sveltekit.test.ts +58 -0
  417. package/src/plugins/rate-limit/sveltekit.ts +66 -0
  418. package/src/plugins/social-login/index.ts +715 -0
  419. package/src/plugins/social-login/providers/apple.ts +34 -0
  420. package/src/plugins/social-login/providers/discord.ts +26 -0
  421. package/src/plugins/social-login/providers/github.ts +54 -0
  422. package/src/plugins/social-login/providers/google.ts +23 -0
  423. package/src/plugins/social-login/providers/index.ts +22 -0
  424. package/src/plugins/social-login/providers/microsoft.ts +35 -0
  425. package/src/plugins/social-login/providers/oidc.ts +37 -0
  426. package/src/plugins/social-login/providers/providers.test.ts +211 -0
  427. package/src/plugins/social-login/social-login.test.ts +675 -0
  428. package/src/plugins/social-login/types.ts +80 -0
  429. package/src/plugins/tenancy/index.ts +544 -0
  430. package/src/plugins/tenancy/tenancy.test.ts +323 -0
  431. package/src/plugins/two-factor/index.ts +569 -0
  432. package/src/plugins/two-factor/two-factor.test.ts +235 -0
  433. package/src/plugins/webauthn/index.ts +554 -0
  434. package/src/plugins/webauthn/webauthn.test.ts +472 -0
  435. package/src/plugins/webhook/builtin-events.ts +29 -0
  436. package/src/plugins/webhook/delivery.test.ts +51 -0
  437. package/src/plugins/webhook/delivery.ts +154 -0
  438. package/src/plugins/webhook/hooks.ts +89 -0
  439. package/src/plugins/webhook/index.ts +445 -0
  440. package/src/plugins/webhook/queue/database.ts +67 -0
  441. package/src/plugins/webhook/queue/in-memory.test.ts +48 -0
  442. package/src/plugins/webhook/queue/in-memory.ts +71 -0
  443. package/src/plugins/webhook/queue/types.ts +51 -0
  444. package/src/plugins/webhook/registry.test.ts +48 -0
  445. package/src/plugins/webhook/registry.ts +64 -0
  446. package/src/plugins/webhook/signing.ts +45 -0
  447. package/src/plugins/webhook/ssrf.ts +198 -0
  448. package/src/plugins/webhook/types.ts +138 -0
  449. package/src/plugins/webhook/webhook.test.ts +324 -0
  450. package/src/sveltekit/actions.ts +233 -0
  451. package/src/sveltekit/catch-all.ts +43 -0
  452. package/src/sveltekit/cookies.ts +136 -0
  453. package/src/sveltekit/handle.ts +356 -0
  454. package/src/sveltekit/helpers.ts +69 -0
  455. package/src/sveltekit/index.ts +74 -0
  456. package/src/sveltekit/protect.ts +80 -0
  457. package/src/sveltekit/sveltekit-types.test.ts +31 -0
  458. package/src/sveltekit/sveltekit.test.ts +799 -0
  459. package/src/sveltekit/types.ts +134 -0
  460. package/src/sveltekit/validated.test.ts +117 -0
  461. package/src/sveltekit/validated.ts +78 -0
  462. package/src/testing/adapter-conformance.test.ts +408 -0
  463. package/src/testing/checks.test.ts +124 -0
  464. package/src/testing/checks.ts +304 -0
  465. package/src/testing/index.ts +107 -0
@@ -0,0 +1,367 @@
1
+ import type { Fortress } from '../../core/fortress';
2
+ import type { LockoutStatus } from './index';
3
+ import { beforeEach, describe, expect, it } from 'vitest';
4
+ import { createFortress } from '../../core/fortress';
5
+ import { assertSuccess } from '../../core/types';
6
+ import { createTestAdapter } from '../../testing';
7
+ import { accountLockout } from './index';
8
+
9
+ const SECRET = 'lockout-test-secret-at-least-32!!';
10
+
11
+ interface LockoutMethods {
12
+ getLockoutStatus: (identifier: string) => Promise<LockoutStatus>;
13
+ resetLockout: (identifier: string) => Promise<void>;
14
+ }
15
+
16
+ describe('account-lockout plugin', () => {
17
+ let fortress: Fortress;
18
+ let methods: LockoutMethods;
19
+ const email = 'alice@example.com';
20
+
21
+ beforeEach(async () => {
22
+ fortress = createFortress({
23
+ jwt: { key: SECRET },
24
+ database: createTestAdapter(),
25
+ plugins: [accountLockout({
26
+ maxFailedAttempts: 3,
27
+ lockoutDurationSeconds: 60,
28
+ escalation: true,
29
+ maxLockoutSeconds: 300,
30
+ })],
31
+ });
32
+
33
+ methods = fortress.plugins['account-lockout'] as unknown as LockoutMethods;
34
+
35
+ await fortress.auth.createUser({
36
+ email,
37
+ name: 'Alice',
38
+ password: 'correct-password',
39
+ });
40
+ });
41
+
42
+ /** Simulate a failed login attempt by calling the onLoginFailure hook directly. */
43
+ async function simulateLoginFailure(identifier: string): Promise<void> {
44
+ const plugin = fortress.config.plugins!.find(p => p.name === 'account-lockout')!;
45
+ await plugin.hooks!.onLoginFailure!({
46
+ db: fortress.config.database,
47
+ config: fortress.config,
48
+ identifier,
49
+ error: new Error('Invalid credentials'),
50
+ });
51
+ }
52
+
53
+ describe('allows login within failure limit', () => {
54
+ it('does not lock account before maxFailedAttempts', async () => {
55
+ await simulateLoginFailure(email);
56
+ await simulateLoginFailure(email);
57
+
58
+ // 2 failures, max is 3 — should not be locked
59
+ const status = await methods.getLockoutStatus(email);
60
+ expect(status.failedAttempts).toBe(2);
61
+ expect(status.isLocked).toBe(false);
62
+
63
+ // Login should still succeed
64
+ const result = await fortress.auth.login(email, 'correct-password');
65
+ assertSuccess(result);
66
+ expect(result.accessToken).toBeTruthy();
67
+ });
68
+ });
69
+
70
+ describe('locks account after maxFailedAttempts', () => {
71
+ it('sets lockedUntil when threshold is reached', async () => {
72
+ await simulateLoginFailure(email);
73
+ await simulateLoginFailure(email);
74
+ await simulateLoginFailure(email);
75
+
76
+ const status = await methods.getLockoutStatus(email);
77
+ expect(status.failedAttempts).toBe(3);
78
+ expect(status.isLocked).toBe(true);
79
+ expect(status.lockedUntil).toBeTruthy();
80
+ expect(status.lockoutCount).toBe(1);
81
+ });
82
+ });
83
+
84
+ describe('locked account rejects login attempts', () => {
85
+ it('throws unauthorized for locked account on beforeLogin', async () => {
86
+ await simulateLoginFailure(email);
87
+ await simulateLoginFailure(email);
88
+ await simulateLoginFailure(email);
89
+
90
+ await expect(
91
+ fortress.auth.login(email, 'correct-password'),
92
+ ).rejects.toThrow('Account temporarily locked. Try again later.');
93
+ });
94
+ });
95
+
96
+ describe('lockout expires after duration', () => {
97
+ it('allows login after lockout duration passes', async () => {
98
+ // Use a very short lockout for this test
99
+ const shortFortress = createFortress({
100
+ jwt: { key: SECRET },
101
+ database: createTestAdapter(),
102
+ plugins: [accountLockout({
103
+ maxFailedAttempts: 2,
104
+ lockoutDurationSeconds: -1, // already expired
105
+ escalation: false,
106
+ })],
107
+ });
108
+
109
+ await shortFortress.auth.createUser({
110
+ email: 'bob@example.com',
111
+ name: 'Bob',
112
+ password: 'correct-password',
113
+ });
114
+
115
+ const shortPlugin = shortFortress.config.plugins!.find(p => p.name === 'account-lockout')!;
116
+
117
+ // Trigger lockout
118
+ await shortPlugin.hooks!.onLoginFailure!({
119
+ db: shortFortress.config.database,
120
+ config: shortFortress.config,
121
+ identifier: 'bob@example.com',
122
+ error: new Error('Invalid credentials'),
123
+ });
124
+ await shortPlugin.hooks!.onLoginFailure!({
125
+ db: shortFortress.config.database,
126
+ config: shortFortress.config,
127
+ identifier: 'bob@example.com',
128
+ error: new Error('Invalid credentials'),
129
+ });
130
+
131
+ // Lockout is set but with negative duration, so lockedUntil is in the past
132
+ const result = await shortFortress.auth.login('bob@example.com', 'correct-password');
133
+ assertSuccess(result);
134
+ expect(result.accessToken).toBeTruthy();
135
+ });
136
+ });
137
+
138
+ describe('window-expiry reset, re-lock guard & identifier normalization (#34/#35/M1)', () => {
139
+ /** A fortress whose lockout window is already in the past (-1s) the moment it's set. */
140
+ function expiredLockoutFortress(): { f: Fortress; plugin: NonNullable<Fortress['config']['plugins']>[number]; m: LockoutMethods } {
141
+ const f = createFortress({
142
+ jwt: { key: SECRET },
143
+ database: createTestAdapter(),
144
+ plugins: [accountLockout({ maxFailedAttempts: 2, lockoutDurationSeconds: -1, escalation: false })],
145
+ });
146
+ const plugin = f.config.plugins!.find(p => p.name === 'account-lockout')!;
147
+ const m = f.plugins['account-lockout'] as unknown as LockoutMethods;
148
+ return { f, plugin, m };
149
+ }
150
+
151
+ it('resets failedAttempts to zero when a login arrives after the window expired', async () => {
152
+ const { f, plugin, m } = expiredLockoutFortress();
153
+ await f.auth.createUser({ email: 'dave@example.com', name: 'Dave', password: 'correct-password' });
154
+ const fail = () => plugin.hooks!.onLoginFailure!({ db: f.config.database, config: f.config, identifier: 'dave@example.com', error: new Error('Invalid credentials') });
155
+
156
+ await fail();
157
+ await fail(); // reaches the threshold, but lockedUntil is already in the past
158
+
159
+ let status = await m.getLockoutStatus('dave@example.com');
160
+ expect(status.failedAttempts).toBe(2);
161
+ expect(status.isLocked).toBe(false); // window already expired
162
+
163
+ // A login attempt runs beforeLogin, which clears the stale counter.
164
+ await plugin.hooks!.beforeLogin!({ db: f.config.database, config: f.config, email: 'dave@example.com' });
165
+
166
+ status = await m.getLockoutStatus('dave@example.com');
167
+ expect(status.failedAttempts).toBe(0);
168
+ expect(status.lockedUntil).toBeNull();
169
+ expect(status.lastFailedAt).toBeNull();
170
+ });
171
+
172
+ it('cannot be re-locked by a single failure after the window expired (no self-DoS)', async () => {
173
+ const { f, plugin, m } = expiredLockoutFortress();
174
+ await f.auth.createUser({ email: 'erin@example.com', name: 'Erin', password: 'correct-password' });
175
+ const fail = () => plugin.hooks!.onLoginFailure!({ db: f.config.database, config: f.config, identifier: 'erin@example.com', error: new Error('Invalid credentials') });
176
+
177
+ await fail();
178
+ await fail(); // threshold reached, immediately expired
179
+ expect((await m.getLockoutStatus('erin@example.com')).failedAttempts).toBe(2);
180
+
181
+ // One more failure after expiry must reset-then-increment to 1, NOT stack to 3 and re-lock.
182
+ await fail();
183
+ const status = await m.getLockoutStatus('erin@example.com');
184
+ expect(status.failedAttempts).toBe(1);
185
+ expect(status.isLocked).toBe(false);
186
+ });
187
+
188
+ it('keys lockout state by normalized identifier (case / whitespace / NFC)', async () => {
189
+ // Three surface forms of the same identifier all accumulate against one
190
+ // normalized row (the default fortress: max 3) and reach the threshold.
191
+ await simulateLoginFailure('ALICE@example.com');
192
+ await simulateLoginFailure(' alice@example.com ');
193
+ await simulateLoginFailure('Alice@Example.com');
194
+
195
+ const status = await methods.getLockoutStatus('alice@example.com');
196
+ expect(status.failedAttempts).toBe(3);
197
+ expect(status.isLocked).toBe(true);
198
+ });
199
+ });
200
+
201
+ describe('successful login resets failure counter', () => {
202
+ it('clears failedAttempts on successful login via afterLogin hook', async () => {
203
+ await simulateLoginFailure(email);
204
+ await simulateLoginFailure(email);
205
+
206
+ let status = await methods.getLockoutStatus(email);
207
+ expect(status.failedAttempts).toBe(2);
208
+
209
+ // Successful login
210
+ await fortress.auth.login(email, 'correct-password');
211
+
212
+ status = await methods.getLockoutStatus(email);
213
+ expect(status.failedAttempts).toBe(0);
214
+ expect(status.isLocked).toBe(false);
215
+ });
216
+ });
217
+
218
+ describe('escalation doubles lockout duration on repeated lockouts', () => {
219
+ it('increases lockoutCount and duration with each lockout', async () => {
220
+ // First lockout: 3 failures
221
+ await simulateLoginFailure(email);
222
+ await simulateLoginFailure(email);
223
+ await simulateLoginFailure(email);
224
+
225
+ let status = await methods.getLockoutStatus(email);
226
+ expect(status.lockoutCount).toBe(1);
227
+ expect(status.lockedUntil).toBeTruthy();
228
+
229
+ // Admin reset to allow further testing
230
+ await methods.resetLockout(email);
231
+
232
+ // Second lockout: 3 failures again
233
+ await simulateLoginFailure(email);
234
+ await simulateLoginFailure(email);
235
+ await simulateLoginFailure(email);
236
+
237
+ status = await methods.getLockoutStatus(email);
238
+ // resetLockout clears lockoutCount, so this is a fresh lockout
239
+ expect(status.lockoutCount).toBe(1);
240
+
241
+ // Now test escalation without resetting lockoutCount.
242
+ // We need to simulate repeated lockouts without full reset.
243
+ // The onLoginFailure hook increments lockoutCount each time it locks.
244
+ // To test escalation, we manually set things up:
245
+ // After first lockout, lockoutCount = 1. Let lockout expire, then fail again.
246
+ // For this test, we use a fortress with short lockout.
247
+ const escFortress = createFortress({
248
+ jwt: { key: SECRET },
249
+ database: createTestAdapter(),
250
+ plugins: [accountLockout({
251
+ maxFailedAttempts: 1,
252
+ lockoutDurationSeconds: 60,
253
+ escalation: true,
254
+ maxLockoutSeconds: 300,
255
+ })],
256
+ });
257
+
258
+ await escFortress.auth.createUser({
259
+ email: 'carol@example.com',
260
+ name: 'Carol',
261
+ password: 'correct-password',
262
+ });
263
+
264
+ const escPlugin = escFortress.config.plugins!.find(p => p.name === 'account-lockout')!;
265
+ const escMethods = escFortress.plugins['account-lockout'] as unknown as LockoutMethods;
266
+
267
+ // First lockout (lockoutCount goes 0 -> 1, duration = 60 * 2^0 = 60s)
268
+ await escPlugin.hooks!.onLoginFailure!({
269
+ db: escFortress.config.database,
270
+ config: escFortress.config,
271
+ identifier: 'carol@example.com',
272
+ error: new Error('Invalid credentials'),
273
+ });
274
+
275
+ let escStatus = await escMethods.getLockoutStatus('carol@example.com');
276
+ expect(escStatus.lockoutCount).toBe(1);
277
+ const firstDuration = new Date(escStatus.lockedUntil!).getTime()
278
+ - new Date(escStatus.lastFailedAt!).getTime();
279
+
280
+ // Manually reset only failedAttempts and lockedUntil (not lockoutCount)
281
+ // to simulate an expired lockout. We do this through the DB directly.
282
+ await escFortress.config.database.update({
283
+ model: 'account_lockout',
284
+ where: [{ field: 'identifier', operator: '=', value: 'carol@example.com' }],
285
+ data: { failedAttempts: 0, lockedUntil: null },
286
+ });
287
+
288
+ // Second lockout (lockoutCount goes 1 -> 2, duration = 60 * 2^1 = 120s)
289
+ await escPlugin.hooks!.onLoginFailure!({
290
+ db: escFortress.config.database,
291
+ config: escFortress.config,
292
+ identifier: 'carol@example.com',
293
+ error: new Error('Invalid credentials'),
294
+ });
295
+
296
+ escStatus = await escMethods.getLockoutStatus('carol@example.com');
297
+ expect(escStatus.lockoutCount).toBe(2);
298
+ const secondDuration = new Date(escStatus.lockedUntil!).getTime()
299
+ - new Date(escStatus.lastFailedAt!).getTime();
300
+
301
+ // Second lockout should be roughly double the first
302
+ expect(secondDuration).toBeGreaterThan(firstDuration);
303
+ expect(Math.round(secondDuration / firstDuration)).toBe(2);
304
+ });
305
+ });
306
+
307
+ describe('getLockoutStatus', () => {
308
+ it('returns clean status for unknown identifier', async () => {
309
+ const status = await methods.getLockoutStatus('unknown@example.com');
310
+ expect(status.failedAttempts).toBe(0);
311
+ expect(status.lockoutCount).toBe(0);
312
+ expect(status.lockedUntil).toBeNull();
313
+ expect(status.lastFailedAt).toBeNull();
314
+ expect(status.isLocked).toBe(false);
315
+ });
316
+
317
+ it('returns current status for tracked identifier', async () => {
318
+ await simulateLoginFailure(email);
319
+
320
+ const status = await methods.getLockoutStatus(email);
321
+ expect(status.identifier).toBe(email);
322
+ expect(status.failedAttempts).toBe(1);
323
+ expect(status.lastFailedAt).toBeTruthy();
324
+ expect(status.isLocked).toBe(false);
325
+ });
326
+ });
327
+
328
+ describe('resetLockout', () => {
329
+ it('clears all lockout data for an identifier', async () => {
330
+ // Lock the account
331
+ await simulateLoginFailure(email);
332
+ await simulateLoginFailure(email);
333
+ await simulateLoginFailure(email);
334
+
335
+ let status = await methods.getLockoutStatus(email);
336
+ expect(status.isLocked).toBe(true);
337
+ expect(status.lockoutCount).toBe(1);
338
+
339
+ // Admin reset
340
+ await methods.resetLockout(email);
341
+
342
+ status = await methods.getLockoutStatus(email);
343
+ expect(status.failedAttempts).toBe(0);
344
+ expect(status.lockoutCount).toBe(0);
345
+ expect(status.lockedUntil).toBeNull();
346
+ expect(status.lastFailedAt).toBeNull();
347
+ expect(status.isLocked).toBe(false);
348
+ });
349
+
350
+ it('is a no-op for unknown identifier', async () => {
351
+ // Should not throw
352
+ await methods.resetLockout('nonexistent@example.com');
353
+ });
354
+ });
355
+
356
+ describe('lockout for non-existent accounts', () => {
357
+ it('tracks lockouts for identifiers without user accounts', async () => {
358
+ await simulateLoginFailure('phantom@example.com');
359
+ await simulateLoginFailure('phantom@example.com');
360
+ await simulateLoginFailure('phantom@example.com');
361
+
362
+ const status = await methods.getLockoutStatus('phantom@example.com');
363
+ expect(status.isLocked).toBe(true);
364
+ expect(status.failedAttempts).toBe(3);
365
+ });
366
+ });
367
+ });
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Account lockout plugin for fortress.
3
+ *
4
+ * Tracks failed sign-in attempts per identifier and applies a progressive
5
+ * lockout (each successive lockout extends the cooldown). Hooks into the
6
+ * sign-in flow to reject attempts during a lockout window.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { FortressPlugin } from '../../core/plugin';
12
+ import { Errors } from '../../core/errors';
13
+
14
+ export interface AccountLockoutConfig {
15
+ /** Max failed attempts before lockout. Default: 5. */
16
+ maxFailedAttempts?: number;
17
+ /** Initial lockout duration in seconds. Default: 900 (15 minutes). */
18
+ lockoutDurationSeconds?: number;
19
+ /** Double lockout duration on repeated lockouts. Default: true. */
20
+ escalation?: boolean;
21
+ /** Maximum lockout duration in seconds. Default: 3600 (1 hour). */
22
+ maxLockoutSeconds?: number;
23
+ }
24
+
25
+ export interface LockoutStatus {
26
+ identifier: string;
27
+ failedAttempts: number;
28
+ lockoutCount: number;
29
+ lockedUntil: Date | null;
30
+ lastFailedAt: Date | null;
31
+ isLocked: boolean;
32
+ }
33
+
34
+ interface LockoutRecord {
35
+ id: string;
36
+ identifier: string;
37
+ failedAttempts: number;
38
+ lastFailedAt: Date | null;
39
+ lockedUntil: Date | null;
40
+ lockoutCount: number;
41
+ createdAt: Date;
42
+ }
43
+
44
+ /**
45
+ * Account lockout plugin factory. Returns a {@link FortressPlugin} that
46
+ * tracks failed sign-in attempts per identifier and applies a progressive
47
+ * lockout (each successive lockout extends the cooldown window).
48
+ */
49
+ export function accountLockout(config: AccountLockoutConfig = {}): FortressPlugin {
50
+ const maxFailedAttempts = config.maxFailedAttempts ?? 5;
51
+ const lockoutDurationSeconds = config.lockoutDurationSeconds ?? 900;
52
+ const escalation = config.escalation ?? true;
53
+ const maxLockoutSeconds = config.maxLockoutSeconds ?? 3600;
54
+
55
+ function normalizeIdentifier(identifier: string): string {
56
+ return identifier.trim().normalize('NFC').toLowerCase();
57
+ }
58
+
59
+ function calculateLockoutDuration(lockoutCount: number): number {
60
+ if (!escalation) {
61
+ return lockoutDurationSeconds;
62
+ }
63
+ const duration = lockoutDurationSeconds * (2 ** lockoutCount);
64
+ return Math.min(duration, maxLockoutSeconds);
65
+ }
66
+
67
+ return {
68
+ name: 'account-lockout',
69
+
70
+ models: [{
71
+ name: 'account_lockout',
72
+ fields: {
73
+ id: { type: 'number', required: true },
74
+ identifier: { type: 'string', required: true, unique: true },
75
+ failedAttempts: { type: 'number', required: true },
76
+ lastFailedAt: { type: 'date' },
77
+ lockedUntil: { type: 'date' },
78
+ lockoutCount: { type: 'number', required: true },
79
+ createdAt: { type: 'date', required: true },
80
+ },
81
+ }],
82
+
83
+ hooks: {
84
+ async beforeLogin(ctx) {
85
+ const identifier = normalizeIdentifier(ctx.email);
86
+
87
+ const record = await ctx.db.findOne<LockoutRecord>({
88
+ model: 'account_lockout',
89
+ where: [{ field: 'identifier', operator: '=', value: identifier }],
90
+ });
91
+
92
+ if (!record) {
93
+ return undefined;
94
+ }
95
+
96
+ const now = new Date();
97
+ if (record.lockedUntil && record.lockedUntil > now) {
98
+ throw Errors.unauthorized('Account temporarily locked. Try again later.');
99
+ }
100
+
101
+ if (record.lockedUntil && record.lockedUntil <= now) {
102
+ await ctx.db.update({
103
+ model: 'account_lockout',
104
+ where: [{ field: 'id', operator: '=', value: record.id }],
105
+ data: { failedAttempts: 0, lockedUntil: null, lastFailedAt: null },
106
+ });
107
+ }
108
+
109
+ return undefined;
110
+ },
111
+
112
+ async onLoginFailure(ctx) {
113
+ if (ctx.error.message.includes('temporarily locked'))
114
+ return;
115
+ const identifier = normalizeIdentifier(ctx.identifier);
116
+
117
+ // Compare-and-swap retry loop. DatabaseAdapter has no arithmetic
118
+ // update expression, so we make the read-then-write safe by including
119
+ // the observed counters in the UPDATE predicate. A concurrent failure
120
+ // that wins the race changes those counters and makes our update
121
+ // return null; we then re-read and retry instead of losing a count.
122
+ for (let attempt = 0; attempt < 5; attempt++) {
123
+ const now = new Date();
124
+ let record = await ctx.db.findOne<LockoutRecord>({
125
+ model: 'account_lockout',
126
+ where: [{ field: 'identifier', operator: '=', value: identifier }],
127
+ });
128
+
129
+ if (!record) {
130
+ const newFailedAttempts = 1;
131
+ const updateData: Record<string, unknown> = {
132
+ identifier,
133
+ failedAttempts: newFailedAttempts,
134
+ lastFailedAt: now,
135
+ lockedUntil: null,
136
+ lockoutCount: 0,
137
+ };
138
+ if (newFailedAttempts >= maxFailedAttempts) {
139
+ updateData.lockedUntil = new Date(now.getTime() + calculateLockoutDuration(0) * 1000);
140
+ updateData.lockoutCount = 1;
141
+ }
142
+ try {
143
+ await ctx.db.create<LockoutRecord>({ model: 'account_lockout', data: updateData });
144
+ return;
145
+ }
146
+ catch {
147
+ continue;
148
+ }
149
+ }
150
+
151
+ const expired = record.lockedUntil !== null && record.lockedUntil <= now;
152
+ const observedFailedAttempts = record.failedAttempts;
153
+ const observedLockoutCount = record.lockoutCount;
154
+ if (expired) {
155
+ record = { ...record, failedAttempts: 0, lockedUntil: null, lastFailedAt: null };
156
+ }
157
+
158
+ const newFailedAttempts = record.failedAttempts + 1;
159
+ const updateData: Record<string, unknown> = {
160
+ failedAttempts: newFailedAttempts,
161
+ lastFailedAt: now,
162
+ lockedUntil: expired ? null : record.lockedUntil,
163
+ };
164
+
165
+ if (newFailedAttempts >= maxFailedAttempts) {
166
+ const duration = calculateLockoutDuration(record.lockoutCount);
167
+ updateData.lockedUntil = new Date(now.getTime() + duration * 1000);
168
+ updateData.lockoutCount = record.lockoutCount + 1;
169
+ }
170
+ else if (expired) {
171
+ updateData.lockoutCount = record.lockoutCount;
172
+ }
173
+
174
+ const updated = await ctx.db.update<LockoutRecord>({
175
+ model: 'account_lockout',
176
+ where: [
177
+ { field: 'id', operator: '=', value: record.id },
178
+ { field: 'failedAttempts', operator: '=', value: observedFailedAttempts },
179
+ { field: 'lockoutCount', operator: '=', value: observedLockoutCount },
180
+ ],
181
+ data: updateData,
182
+ });
183
+ if (updated)
184
+ return;
185
+ }
186
+ },
187
+
188
+ async afterLogin(ctx, result) {
189
+ const identifier = normalizeIdentifier(ctx.identifier ?? result.user.email);
190
+
191
+ const record = await ctx.db.findOne<LockoutRecord>({
192
+ model: 'account_lockout',
193
+ where: [{ field: 'identifier', operator: '=', value: identifier }],
194
+ });
195
+
196
+ if (record && record.failedAttempts > 0) {
197
+ await ctx.db.update({
198
+ model: 'account_lockout',
199
+ where: [{ field: 'id', operator: '=', value: record.id }],
200
+ data: {
201
+ failedAttempts: 0,
202
+ lockedUntil: null,
203
+ },
204
+ });
205
+ }
206
+
207
+ return result;
208
+ },
209
+ },
210
+
211
+ methods: ctx => ({
212
+ async getLockoutStatus(identifier: string): Promise<LockoutStatus> {
213
+ const normalized = normalizeIdentifier(identifier);
214
+ const record = await ctx.db.findOne<LockoutRecord>({
215
+ model: 'account_lockout',
216
+ where: [{ field: 'identifier', operator: '=', value: normalized }],
217
+ });
218
+
219
+ if (!record) {
220
+ return {
221
+ identifier: normalized,
222
+ failedAttempts: 0,
223
+ lockoutCount: 0,
224
+ lockedUntil: null,
225
+ lastFailedAt: null,
226
+ isLocked: false,
227
+ };
228
+ }
229
+
230
+ const isLocked = record.lockedUntil !== null
231
+ && record.lockedUntil > new Date();
232
+
233
+ return {
234
+ identifier: record.identifier,
235
+ failedAttempts: record.failedAttempts,
236
+ lockoutCount: record.lockoutCount,
237
+ lockedUntil: record.lockedUntil,
238
+ lastFailedAt: record.lastFailedAt,
239
+ isLocked,
240
+ };
241
+ },
242
+
243
+ async resetLockout(identifier: string): Promise<void> {
244
+ const normalized = normalizeIdentifier(identifier);
245
+ const record = await ctx.db.findOne<LockoutRecord>({
246
+ model: 'account_lockout',
247
+ where: [{ field: 'identifier', operator: '=', value: normalized }],
248
+ });
249
+
250
+ if (!record) {
251
+ return;
252
+ }
253
+
254
+ await ctx.db.update({
255
+ model: 'account_lockout',
256
+ where: [{ field: 'id', operator: '=', value: record.id }],
257
+ data: {
258
+ failedAttempts: 0,
259
+ lockedUntil: null,
260
+ lockoutCount: 0,
261
+ lastFailedAt: null,
262
+ },
263
+ });
264
+ },
265
+ }),
266
+ };
267
+ }