@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,756 @@
1
+ import type { Fortress } from '../../core/fortress';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { createFortress } from '../../core/fortress';
4
+ import { createTestAdapter } from '../../testing';
5
+ import { normalizeAccountIdentifier, rateLimit } from './index';
6
+ import { createMemoryStore } from './memory-store';
7
+
8
+ const SECRET = 'rate-limit-test-secret-32-chars!!!';
9
+
10
+ describe('rate-limit plugin', () => {
11
+ describe('login rate limiting', () => {
12
+ let fortress: Fortress;
13
+
14
+ beforeEach(async () => {
15
+ fortress = createFortress({
16
+ jwt: { key: SECRET },
17
+ database: createTestAdapter(),
18
+ plugins: [
19
+ rateLimit({
20
+ login: { maxPerIp: 3, maxPerAccount: 2, windowSeconds: 60 },
21
+ }),
22
+ ],
23
+ });
24
+
25
+ await fortress.auth.createUser({
26
+ email: 'test@example.com',
27
+ name: 'Test',
28
+ password: 'valid-password-123456',
29
+ });
30
+ });
31
+
32
+ it('allows requests within limits', async () => {
33
+ // 2 attempts within both limits (per-IP: 3, per-account: 2)
34
+ for (let i = 0; i < 2; i++) {
35
+ try {
36
+ await fortress.auth.login('test@example.com', 'wrong-password', { ipAddress: '1.2.3.4' });
37
+ }
38
+ catch (e: any) {
39
+ // Unauthorized is expected for wrong password, RATE_LIMITED is not expected yet
40
+ expect(e.code).toBe('UNAUTHORIZED');
41
+ }
42
+ }
43
+ });
44
+
45
+ it('blocks requests exceeding per-IP limit', async () => {
46
+ const meta = { ipAddress: '1.2.3.4' };
47
+
48
+ // Exhaust per-IP limit (3 attempts)
49
+ for (let i = 0; i < 3; i++) {
50
+ try {
51
+ await fortress.auth.login('test@example.com', 'wrong', meta);
52
+ }
53
+ catch { /* ignore auth errors */ }
54
+ }
55
+
56
+ // 4th attempt should be rate limited
57
+ try {
58
+ await fortress.auth.login('test@example.com', 'wrong', meta);
59
+ expect.fail('Should have thrown');
60
+ }
61
+ catch (e: any) {
62
+ expect(e.code).toBe('RATE_LIMITED');
63
+ expect(e.statusCode).toBe(429);
64
+ expect(e.retryAfter).toBeGreaterThan(0);
65
+ }
66
+ });
67
+
68
+ it('blocks requests exceeding per-account limit', async () => {
69
+ // Use different IPs but same account
70
+ for (let i = 0; i < 2; i++) {
71
+ try {
72
+ await fortress.auth.login('test@example.com', 'wrong', { ipAddress: `10.0.0.${i}` });
73
+ }
74
+ catch { /* ignore auth errors */ }
75
+ }
76
+
77
+ // 3rd attempt on same account (different IP) should be rate limited
78
+ try {
79
+ await fortress.auth.login('test@example.com', 'wrong', { ipAddress: '10.0.0.99' });
80
+ expect.fail('Should have thrown');
81
+ }
82
+ catch (e: any) {
83
+ expect(e.code).toBe('RATE_LIMITED');
84
+ }
85
+ });
86
+
87
+ it('allows different accounts under per-IP limit', async () => {
88
+ await fortress.auth.createUser({
89
+ email: 'other@example.com',
90
+ name: 'Other',
91
+ password: 'valid-password-123456',
92
+ });
93
+
94
+ const meta = { ipAddress: '5.5.5.5' };
95
+
96
+ // 1 attempt on each account = 2 IP hits, both under per-account limit
97
+ try {
98
+ await fortress.auth.login('test@example.com', 'wrong', meta);
99
+ }
100
+ catch { /* expected */ }
101
+
102
+ try {
103
+ await fortress.auth.login('other@example.com', 'wrong', meta);
104
+ }
105
+ catch (e: any) {
106
+ // Should be UNAUTHORIZED (wrong password), not RATE_LIMITED
107
+ expect(e.code).toBe('UNAUTHORIZED');
108
+ }
109
+ });
110
+
111
+ it('successful login still counts against rate limit', async () => {
112
+ const meta = { ipAddress: '7.7.7.7' };
113
+
114
+ // 2 successful logins (at the per-account limit)
115
+ for (let i = 0; i < 2; i++) {
116
+ await fortress.auth.login('test@example.com', 'valid-password-123456', meta);
117
+ }
118
+
119
+ // 3rd attempt should be rate limited even though credentials are valid
120
+ try {
121
+ await fortress.auth.login('test@example.com', 'valid-password-123456', meta);
122
+ expect.fail('Should have thrown');
123
+ }
124
+ catch (e: any) {
125
+ expect(e.code).toBe('RATE_LIMITED');
126
+ }
127
+ });
128
+ });
129
+
130
+ describe('registration rate limiting', () => {
131
+ let fortress: Fortress;
132
+
133
+ beforeEach(() => {
134
+ fortress = createFortress({
135
+ jwt: { key: SECRET },
136
+ database: createTestAdapter(),
137
+ plugins: [
138
+ rateLimit({
139
+ register: { maxPerIp: 2, windowSeconds: 60 },
140
+ }),
141
+ ],
142
+ });
143
+ });
144
+
145
+ it('allows registrations within limit', async () => {
146
+ await fortress.auth.createUser({
147
+ email: 'user1@example.com',
148
+ name: 'User 1',
149
+ password: 'password-123456',
150
+ });
151
+
152
+ await fortress.auth.createUser({
153
+ email: 'user2@example.com',
154
+ name: 'User 2',
155
+ password: 'password-123456',
156
+ });
157
+ });
158
+
159
+ it('blocks registrations exceeding per-IP limit', async () => {
160
+ // Note: createUser doesn't pass meta by default, so IP is 'unknown'
161
+ // We can't easily pass meta to createUser, so all registrations share the same 'unknown' IP key
162
+ await fortress.auth.createUser({
163
+ email: 'u1@test.com',
164
+ name: 'U1',
165
+ password: 'password-123456',
166
+ });
167
+
168
+ await fortress.auth.createUser({
169
+ email: 'u2@test.com',
170
+ name: 'U2',
171
+ password: 'password-123456',
172
+ });
173
+
174
+ // 3rd registration should be rate limited
175
+ try {
176
+ await fortress.auth.createUser({
177
+ email: 'u3@test.com',
178
+ name: 'U3',
179
+ password: 'password-123456',
180
+ });
181
+ expect.fail('Should have thrown');
182
+ }
183
+ catch (e: any) {
184
+ expect(e.code).toBe('RATE_LIMITED');
185
+ expect(e.retryAfter).toBeGreaterThan(0);
186
+ }
187
+ });
188
+ });
189
+
190
+ describe('iPv6 normalization', () => {
191
+ let fortress: Fortress;
192
+
193
+ beforeEach(async () => {
194
+ fortress = createFortress({
195
+ jwt: { key: SECRET },
196
+ database: createTestAdapter(),
197
+ plugins: [
198
+ rateLimit({
199
+ login: { maxPerIp: 2, windowSeconds: 60 },
200
+ }),
201
+ ],
202
+ });
203
+
204
+ await fortress.auth.createUser({
205
+ email: 'test@example.com',
206
+ name: 'Test',
207
+ password: 'valid-password-123456',
208
+ });
209
+ });
210
+
211
+ it('normalizes IPv6 addresses to /64 prefix', async () => {
212
+ // These two IPv6 addresses share the same /64 prefix
213
+ const ip1 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334';
214
+ const ip2 = '2001:0db8:85a3:0000:1111:2222:3333:4444';
215
+
216
+ try {
217
+ await fortress.auth.login('test@example.com', 'wrong', { ipAddress: ip1 });
218
+ }
219
+ catch { /* expected */ }
220
+
221
+ try {
222
+ await fortress.auth.login('test@example.com', 'wrong', { ipAddress: ip2 });
223
+ }
224
+ catch { /* expected */ }
225
+
226
+ // 3rd attempt from same /64 should be rate limited
227
+ try {
228
+ await fortress.auth.login('test@example.com', 'wrong', { ipAddress: ip1 });
229
+ expect.fail('Should have thrown');
230
+ }
231
+ catch (e: any) {
232
+ expect(e.code).toBe('RATE_LIMITED');
233
+ }
234
+ });
235
+ });
236
+
237
+ it('normalizes account-scoped keys across case, whitespace, and Unicode forms', () => {
238
+ expect(normalizeAccountIdentifier(' UsÉr@Example.COM ')).toBe(
239
+ normalizeAccountIdentifier('use\u0301r@example.com'),
240
+ );
241
+ });
242
+
243
+ describe('memory store', () => {
244
+ it('tracks request counts per key', async () => {
245
+ const store = createMemoryStore();
246
+
247
+ const r1 = await store.increment('key1', 60_000);
248
+ expect(r1.count).toBe(1);
249
+
250
+ const r2 = await store.increment('key1', 60_000);
251
+ expect(r2.count).toBe(2);
252
+
253
+ // Different key should be independent
254
+ const r3 = await store.increment('key2', 60_000);
255
+ expect(r3.count).toBe(1);
256
+ });
257
+
258
+ it('returns null for unknown keys', async () => {
259
+ const store = createMemoryStore();
260
+ const result = await store.get('nonexistent');
261
+ expect(result).toBeNull();
262
+ });
263
+
264
+ it('retains counters for configured windows longer than one hour', async () => {
265
+ vi.useFakeTimers();
266
+ try {
267
+ vi.setSystemTime(0);
268
+ const store = createMemoryStore();
269
+ await store.increment('long-window', 7_200_000);
270
+ await vi.advanceTimersByTimeAsync(3_660_000);
271
+ expect(await store.get('long-window')).toMatchObject({ count: 1 });
272
+ }
273
+ finally {
274
+ vi.useRealTimers();
275
+ }
276
+ });
277
+
278
+ it('preserves long-window history when the same key also uses a short window', async () => {
279
+ vi.useFakeTimers();
280
+ try {
281
+ vi.setSystemTime(0);
282
+ const store = createMemoryStore();
283
+ await store.increment('mixed-window', 7_200_000);
284
+ await vi.advanceTimersByTimeAsync(1_000);
285
+ await store.increment('mixed-window', 60_000);
286
+ await vi.advanceTimersByTimeAsync(61_000);
287
+ expect(await store.increment('mixed-window', 7_200_000)).toMatchObject({ count: 3 });
288
+ }
289
+ finally {
290
+ vi.useRealTimers();
291
+ }
292
+ });
293
+
294
+ it('returns null from get after the most recent window expires', async () => {
295
+ vi.useFakeTimers();
296
+ try {
297
+ vi.setSystemTime(0);
298
+ const store = createMemoryStore();
299
+ await store.increment('expired', 1);
300
+ await vi.advanceTimersByTimeAsync(2);
301
+ expect(await store.get('expired')).toBeNull();
302
+ }
303
+ finally {
304
+ vi.useRealTimers();
305
+ }
306
+ });
307
+
308
+ it('returns resetAt time', async () => {
309
+ const store = createMemoryStore();
310
+ const before = Date.now();
311
+ const result = await store.increment('key', 60_000);
312
+ expect(result.resetAt).toBeGreaterThanOrEqual(before + 60_000);
313
+ });
314
+ });
315
+
316
+ describe('custom store', () => {
317
+ it('uses provided custom store', async () => {
318
+ const calls: string[] = [];
319
+ const customStore = {
320
+ async increment(key: string, _windowMs: number) {
321
+ calls.push(key);
322
+ return { count: 1, resetAt: Date.now() + 60_000 };
323
+ },
324
+ async get() {
325
+ return null;
326
+ },
327
+ };
328
+
329
+ const fortress = createFortress({
330
+ jwt: { key: SECRET },
331
+ database: createTestAdapter(),
332
+ plugins: [rateLimit({
333
+ login: { maxPerIp: 10, maxPerAccount: 5, windowSeconds: 60 },
334
+ store: customStore,
335
+ })],
336
+ });
337
+
338
+ await fortress.auth.createUser({
339
+ email: 'test@example.com',
340
+ name: 'Test',
341
+ password: 'valid-password-123456',
342
+ });
343
+
344
+ await fortress.auth.login('test@example.com', 'valid-password-123456');
345
+
346
+ // Should have called increment for IP and account keys
347
+ expect(calls.some(k => k.startsWith('login:ip:'))).toBe(true);
348
+ expect(calls.some(k => k.startsWith('login:account:'))).toBe(true);
349
+ });
350
+ });
351
+
352
+ describe('refresh rate limiting', () => {
353
+ it('blocks excessive refresh attempts from the same IP', async () => {
354
+ const fortress = createFortress({
355
+ jwt: { key: SECRET },
356
+ database: createTestAdapter(),
357
+ plugins: [
358
+ rateLimit({
359
+ refresh: { maxPerIp: 2, windowSeconds: 60 },
360
+ }),
361
+ ],
362
+ });
363
+
364
+ await fortress.auth.createUser({
365
+ email: 'r@example.com',
366
+ name: 'R',
367
+ password: 'valid-password-123456',
368
+ });
369
+ const meta = { ipAddress: '9.9.9.9' };
370
+ const login = await fortress.auth.login('r@example.com', 'valid-password-123456', meta);
371
+ if (login.status !== 'success')
372
+ throw new Error('expected successful login');
373
+ const { refreshToken } = login;
374
+
375
+ // Two refreshes within limit
376
+ let current = refreshToken;
377
+ for (let i = 0; i < 2; i++) {
378
+ const next = await fortress.auth.refresh(current, meta);
379
+ current = next.refreshToken;
380
+ }
381
+
382
+ // Third is blocked
383
+ try {
384
+ await fortress.auth.refresh(current, meta);
385
+ expect.fail('Should have thrown');
386
+ }
387
+ catch (e: any) {
388
+ expect(e.code).toBe('RATE_LIMITED');
389
+ expect(e.retryAfter).toBeGreaterThan(0);
390
+ }
391
+ });
392
+ });
393
+
394
+ describe('programmatic check() surface', () => {
395
+ it('rejects when rule exceeded and prefixes keys by rule name', async () => {
396
+ const calls: string[] = [];
397
+ const customStore = {
398
+ async increment(key: string, _windowMs: number) {
399
+ calls.push(key);
400
+ return { count: calls.filter(c => c === key).length, resetAt: Date.now() + 60_000 };
401
+ },
402
+ async get() {
403
+ return null;
404
+ },
405
+ };
406
+
407
+ const fortress = createFortress({
408
+ jwt: { key: SECRET },
409
+ database: createTestAdapter(),
410
+ plugins: [
411
+ rateLimit({
412
+ rules: {
413
+ api: { maxPerIp: 2, windowSeconds: 60 },
414
+ },
415
+ store: customStore,
416
+ }),
417
+ ],
418
+ });
419
+
420
+ const methods = fortress.plugins['rate-limit'] as unknown as {
421
+ check: (r: string, k: { ip?: string; userId?: string }) => Promise<void>;
422
+ listRules: () => string[];
423
+ };
424
+
425
+ expect(methods.listRules()).toContain('api');
426
+
427
+ await methods.check('api', { ip: '1.2.3.4' });
428
+ await methods.check('api', { ip: '1.2.3.4' });
429
+
430
+ await expect(methods.check('api', { ip: '1.2.3.4' })).rejects.toMatchObject({
431
+ code: 'RATE_LIMITED',
432
+ });
433
+
434
+ expect(calls.every(k => k.startsWith('api:ip:'))).toBe(true);
435
+ });
436
+
437
+ it('keys independently by user vs ip', async () => {
438
+ const calls: string[] = [];
439
+ const customStore = {
440
+ async increment(key: string, _windowMs: number) {
441
+ calls.push(key);
442
+ return { count: calls.filter(c => c === key).length, resetAt: Date.now() + 60_000 };
443
+ },
444
+ async get() {
445
+ return null;
446
+ },
447
+ };
448
+
449
+ const fortress = createFortress({
450
+ jwt: { key: SECRET },
451
+ database: createTestAdapter(),
452
+ plugins: [
453
+ rateLimit({
454
+ rules: {
455
+ mixed: { maxPerIp: 10, maxPerUser: 10, windowSeconds: 60 },
456
+ },
457
+ store: customStore,
458
+ }),
459
+ ],
460
+ });
461
+
462
+ const methods = fortress.plugins['rate-limit'] as unknown as {
463
+ check: (r: string, k: { ip?: string; userId?: string }) => Promise<void>;
464
+ };
465
+
466
+ await methods.check('mixed', { ip: '1.2.3.4', userId: '42' });
467
+ expect(calls).toContain('mixed:ip:1.2.3.4');
468
+ expect(calls).toContain('mixed:user:42');
469
+ });
470
+
471
+ it('throws when referencing an unknown rule', async () => {
472
+ const fortress = createFortress({
473
+ jwt: { key: SECRET },
474
+ database: createTestAdapter(),
475
+ plugins: [rateLimit({ rules: { api: { maxPerIp: 5, windowSeconds: 60 } } })],
476
+ });
477
+ const methods = fortress.plugins['rate-limit'] as unknown as {
478
+ check: (r: string, k: { ip?: string }) => Promise<void>;
479
+ };
480
+ await expect(methods.check('missing', { ip: '1.1.1.1' })).rejects.toThrow(/unknown rule/);
481
+ });
482
+ });
483
+
484
+ describe('middleware registration', () => {
485
+ it('registers middleware for oauthToken and apiKeyIssue when configured', () => {
486
+ const plugin = rateLimit({
487
+ oauthToken: { maxPerIp: 100, windowSeconds: 60 },
488
+ apiKeyIssue: { maxPerIp: 10, windowSeconds: 3600 },
489
+ });
490
+ const paths = (plugin.middleware ?? []).map(m => ({ path: m.path, pos: m.position }));
491
+ expect(paths).toContainEqual({ path: '/oauth/token', pos: 'before-auth' });
492
+ expect(paths).toContainEqual({ path: '/api-key/keys', pos: 'after-auth' });
493
+ });
494
+
495
+ it('registers middleware for user-defined paths', () => {
496
+ const plugin = rateLimit({
497
+ rules: { strict: { maxPerIp: 5, windowSeconds: 60 } },
498
+ paths: [
499
+ { match: '/webhooks/*', methods: ['POST'], rule: 'strict' },
500
+ { match: '/public/*', position: 'before-auth', rule: { maxPerIp: 100, windowSeconds: 60 } },
501
+ ],
502
+ });
503
+ expect(plugin.middleware).toHaveLength(2);
504
+ expect(plugin.middleware?.[0].path).toBe('/webhooks/*');
505
+ expect(plugin.middleware?.[1].path).toBe('/public/*');
506
+ });
507
+
508
+ it('fails closed when invoked without a valid PluginRequestContext', async () => {
509
+ const plugin = rateLimit({
510
+ paths: [{ match: '/api/*', rule: { maxPerIp: 1, windowSeconds: 60 } }],
511
+ });
512
+ const middleware = plugin.middleware?.[0];
513
+ if (!middleware)
514
+ throw new Error('Expected path middleware');
515
+
516
+ await expect(middleware.handler(
517
+ {} as never,
518
+ {} as never,
519
+ async () => {},
520
+ )).rejects.toMatchObject({ code: 'BAD_REQUEST' });
521
+ });
522
+
523
+ it('emits no middleware when only hook-based configs are present', () => {
524
+ const plugin = rateLimit({
525
+ login: { maxPerIp: 5, windowSeconds: 60 },
526
+ register: { maxPerIp: 5, windowSeconds: 60 },
527
+ });
528
+ expect(plugin.middleware).toBeUndefined();
529
+ });
530
+ });
531
+
532
+ describe('end-to-end via fortress.handleRequest', () => {
533
+ it('oauthToken middleware rate-limits POST /oauth/token through handleRequest', async () => {
534
+ // Configure a tight limit and drive requests through handleRequest.
535
+ // We intentionally don't mount the oauth plugin — the before-auth
536
+ // middleware runs ahead of route matching, so rate-limiting fires
537
+ // before the 404. That confirms end-to-end wiring without dragging
538
+ // in the oauth dependency graph.
539
+ const fortress = createFortress({
540
+ jwt: { key: SECRET },
541
+ database: createTestAdapter(),
542
+ plugins: [rateLimit({ oauthToken: { maxPerIp: 2, windowSeconds: 60 } })],
543
+ });
544
+
545
+ const makeRequest = (): Request =>
546
+ new Request('http://localhost/oauth/token', {
547
+ method: 'POST',
548
+ headers: {
549
+ 'content-type': 'application/x-www-form-urlencoded',
550
+ 'x-forwarded-for': '9.9.9.9',
551
+ },
552
+ body: 'grant_type=client_credentials',
553
+ });
554
+
555
+ // Two within limit — not blocked by rate-limit (they'll 404 because
556
+ // oauth isn't mounted; either way NOT 429).
557
+ const r1 = await fortress.handleRequest(makeRequest());
558
+ const r2 = await fortress.handleRequest(makeRequest());
559
+ expect(r1.status).not.toBe(429);
560
+ expect(r2.status).not.toBe(429);
561
+
562
+ // Third hits the rate limiter.
563
+ const r3 = await fortress.handleRequest(makeRequest());
564
+ expect(r3.status).toBe(429);
565
+ expect(r3.headers.get('Retry-After')).toBeTruthy();
566
+ });
567
+
568
+ it('paths binding enforces method filter (GET passes, POST is limited)', async () => {
569
+ const fortress = createFortress({
570
+ jwt: { key: SECRET },
571
+ database: createTestAdapter(),
572
+ plugins: [
573
+ rateLimit({
574
+ rules: { mutations: { maxPerIp: 1, windowSeconds: 60 } },
575
+ paths: [{ match: '/things/*', methods: ['POST'], rule: 'mutations' }],
576
+ }),
577
+ ],
578
+ });
579
+
580
+ // GET is never rate-limited (method filter excludes it). Both GETs
581
+ // return 404 from handleRequest because /things is not a fortress route,
582
+ // but neither is a 429.
583
+ const g1 = await fortress.handleRequest(new Request('http://localhost/things/a', {
584
+ headers: { 'x-forwarded-for': '1.1.1.1' },
585
+ }));
586
+ const g2 = await fortress.handleRequest(new Request('http://localhost/things/b', {
587
+ headers: { 'x-forwarded-for': '1.1.1.1' },
588
+ }));
589
+ expect(g1.status).not.toBe(429);
590
+ expect(g2.status).not.toBe(429);
591
+
592
+ // First POST passes the rate-limit check, second is blocked with 429.
593
+ const p1 = await fortress.handleRequest(new Request('http://localhost/things/a', {
594
+ method: 'POST',
595
+ headers: { 'x-forwarded-for': '1.1.1.1' },
596
+ }));
597
+ const p2 = await fortress.handleRequest(new Request('http://localhost/things/b', {
598
+ method: 'POST',
599
+ headers: { 'x-forwarded-for': '1.1.1.1' },
600
+ }));
601
+ expect(p1.status).not.toBe(429);
602
+ expect(p2.status).toBe(429);
603
+ });
604
+
605
+ it('method filter is case-insensitive', async () => {
606
+ const fortress = createFortress({
607
+ jwt: { key: SECRET },
608
+ database: createTestAdapter(),
609
+ plugins: [
610
+ rateLimit({
611
+ rules: { r: { maxPerIp: 1, windowSeconds: 60 } },
612
+ // Lowercase on purpose — the plugin should uppercase before matching.
613
+ paths: [{ match: '/x/*', methods: ['post'], rule: 'r' }],
614
+ }),
615
+ ],
616
+ });
617
+
618
+ const hit = async (): Promise<number> => (await fortress.handleRequest(new Request('http://localhost/x/a', {
619
+ method: 'POST',
620
+ headers: { 'x-forwarded-for': '2.2.2.2' },
621
+ }))).status;
622
+
623
+ expect(await hit()).not.toBe(429);
624
+ expect(await hit()).toBe(429);
625
+ });
626
+
627
+ it('cross-rule isolation: exceeding one rule does not affect another', async () => {
628
+ const fortress = createFortress({
629
+ jwt: { key: SECRET },
630
+ database: createTestAdapter(),
631
+ plugins: [
632
+ rateLimit({
633
+ rules: {
634
+ a: { maxPerIp: 1, windowSeconds: 60 },
635
+ b: { maxPerIp: 1, windowSeconds: 60 },
636
+ },
637
+ }),
638
+ ],
639
+ });
640
+ const methods = fortress.plugins['rate-limit'] as unknown as {
641
+ check: (r: string, k: { ip: string }) => Promise<void>;
642
+ };
643
+
644
+ await methods.check('a', { ip: '3.3.3.3' });
645
+ await expect(methods.check('a', { ip: '3.3.3.3' })).rejects.toMatchObject({ code: 'RATE_LIMITED' });
646
+
647
+ // Rule 'b' is still fresh even though 'a' is exhausted.
648
+ await methods.check('b', { ip: '3.3.3.3' });
649
+ });
650
+ });
651
+
652
+ describe('gate-block defaults (login/register always-on)', () => {
653
+ it('login is rate-limited with defaults even when the config block is omitted', async () => {
654
+ // Registering the plugin with NO login config must still enforce the
655
+ // default per-IP login limit (10 / 15min). Guards against the 0.0.37
656
+ // silent-upgrade regression where opt-in meant "missing block = no limit."
657
+ const fortress = createFortress({
658
+ jwt: { key: SECRET },
659
+ database: createTestAdapter(),
660
+ plugins: [rateLimit({})],
661
+ });
662
+
663
+ await fortress.auth.createUser({
664
+ email: 'default@example.com',
665
+ name: 'Default',
666
+ password: 'valid-password-123456',
667
+ });
668
+
669
+ const meta = { ipAddress: '8.8.8.8' };
670
+ // Burn through the per-IP default of 10.
671
+ for (let i = 0; i < 10; i++) {
672
+ try {
673
+ await fortress.auth.login('default@example.com', 'wrong', meta);
674
+ }
675
+ catch { /* UNAUTHORIZED — expected */ }
676
+ }
677
+
678
+ // 11th attempt hits the rate-limit default.
679
+ try {
680
+ await fortress.auth.login('default@example.com', 'wrong', meta);
681
+ expect.fail('Should have thrown RATE_LIMITED');
682
+ }
683
+ catch (e: any) {
684
+ expect(e.code).toBe('RATE_LIMITED');
685
+ }
686
+ });
687
+
688
+ it('login: { disabled: true } fully turns the hook off', async () => {
689
+ const fortress = createFortress({
690
+ jwt: { key: SECRET },
691
+ database: createTestAdapter(),
692
+ plugins: [rateLimit({ login: { disabled: true } })],
693
+ });
694
+
695
+ await fortress.auth.createUser({
696
+ email: 'off@example.com',
697
+ name: 'Off',
698
+ password: 'valid-password-123456',
699
+ });
700
+
701
+ // Past the default per-IP limit of 10 — no RATE_LIMITED should fire.
702
+ // Password hashing dominates the clock here, so keep the loop small.
703
+ const meta = { ipAddress: '4.4.4.4' };
704
+ for (let i = 0; i < 15; i++) {
705
+ try {
706
+ await fortress.auth.login('off@example.com', 'wrong', meta);
707
+ }
708
+ catch (e: any) {
709
+ // The only error we should see is UNAUTHORIZED for wrong password.
710
+ expect(e.code).toBe('UNAUTHORIZED');
711
+ }
712
+ }
713
+ }, 20000);
714
+
715
+ it('register is rate-limited with defaults when the config block is omitted', async () => {
716
+ const fortress = createFortress({
717
+ jwt: { key: SECRET },
718
+ database: createTestAdapter(),
719
+ plugins: [rateLimit({})],
720
+ });
721
+
722
+ // Default `register.maxPerIp` is 3 per hour. createUser doesn't pass
723
+ // meta so all hits share the 'unknown' IP key — fine for the test.
724
+ await fortress.auth.createUser({ email: 'a@t.com', name: 'A', password: 'password-123456' });
725
+ await fortress.auth.createUser({ email: 'b@t.com', name: 'B', password: 'password-123456' });
726
+ await fortress.auth.createUser({ email: 'c@t.com', name: 'C', password: 'password-123456' });
727
+
728
+ try {
729
+ await fortress.auth.createUser({ email: 'd@t.com', name: 'D', password: 'password-123456' });
730
+ expect.fail('Should have thrown RATE_LIMITED');
731
+ }
732
+ catch (e: any) {
733
+ expect(e.code).toBe('RATE_LIMITED');
734
+ }
735
+ });
736
+
737
+ it('register: { disabled: true } fully turns the hook off', async () => {
738
+ const fortress = createFortress({
739
+ jwt: { key: SECRET },
740
+ database: createTestAdapter(),
741
+ plugins: [rateLimit({ register: { disabled: true } })],
742
+ });
743
+
744
+ // Well past the default register limit — should all succeed.
745
+ // Each createUser does a full Argon2id hash, so the loop dominates the
746
+ // clock; bump the timeout like the login: { disabled } sibling above.
747
+ for (let i = 0; i < 10; i++) {
748
+ await fortress.auth.createUser({
749
+ email: `reg-off-${i}@t.com`,
750
+ name: `U${i}`,
751
+ password: 'password-123456',
752
+ });
753
+ }
754
+ }, 20000);
755
+ });
756
+ });