@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,287 @@
1
+ import type { ComponentSchemas, EndpointDefinition, SecurityRequirement } from '../../core/endpoint';
2
+ import type { JSONSchema } from '../../core/json-schema';
3
+
4
+ /** Minimal OpenAPI 3.1 spec shape produced by {@link buildOpenAPISpec}. */
5
+ export interface OpenAPISpec {
6
+ openapi: string;
7
+ info: { title: string; version: string; description?: string };
8
+ servers?: Array<{ url: string; description?: string }>;
9
+ tags?: Array<{ name: string; description?: string }>;
10
+ paths: Record<string, Record<string, OpenAPIOperation>>;
11
+ components: {
12
+ schemas: Record<string, JSONSchema>;
13
+ securitySchemes: Record<string, OpenAPISecurityScheme>;
14
+ };
15
+ }
16
+
17
+ interface OpenAPIOperation {
18
+ operationId?: string;
19
+ summary?: string;
20
+ description?: string;
21
+ tags?: string[];
22
+ deprecated?: boolean;
23
+ security?: Array<Record<string, string[]>>;
24
+ parameters?: OpenAPIParameter[];
25
+ requestBody?: {
26
+ required?: boolean;
27
+ content: { 'application/json': { schema: JSONSchema } };
28
+ };
29
+ responses: Record<string, {
30
+ description: string;
31
+ content?: { 'application/json': { schema: JSONSchema } };
32
+ }>;
33
+ }
34
+
35
+ interface OpenAPIParameter {
36
+ name: string;
37
+ in: 'query' | 'path';
38
+ required?: boolean;
39
+ description?: string;
40
+ schema: JSONSchema;
41
+ }
42
+
43
+ interface OpenAPISecurityScheme {
44
+ type: string;
45
+ scheme?: string;
46
+ bearerFormat?: string;
47
+ name?: string;
48
+ in?: string;
49
+ }
50
+
51
+ /** Options accepted by {@link buildOpenAPISpec}. */
52
+ export interface SpecBuilderOptions {
53
+ title: string;
54
+ version: string;
55
+ description?: string;
56
+ servers?: Array<{ url: string; description?: string }>;
57
+ /** Optional top-level OpenAPI tags. */
58
+ tags?: Array<{ name: string; description?: string }>;
59
+ /**
60
+ * Operation ID strategy. Defaults to the historical Fortress method+path
61
+ * generator. `fortress.toOpenAPI()` defaults this to `'handler'` so host
62
+ * specs match the endpoint contract names consumers already chose.
63
+ */
64
+ operationId?: 'methodPath' | 'handler' | ((endpoint: EndpointDefinition) => string | undefined);
65
+ }
66
+
67
+ const SECURITY_SCHEMES: Record<string, OpenAPISecurityScheme> = {
68
+ bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
69
+ basic: { type: 'http', scheme: 'basic' },
70
+ apiKey: { type: 'apiKey', name: 'X-API-Key', in: 'header' },
71
+ };
72
+
73
+ const SECURITY_MAP: Record<SecurityRequirement, Record<string, string[]>> = {
74
+ bearer: { bearerAuth: [] },
75
+ basic: { basicAuth: [] },
76
+ apiKey: { apiKeyAuth: [] },
77
+ none: {},
78
+ };
79
+
80
+ const RE_LEADING_SLASH = /^\//;
81
+ const RE_PARAM_COLON = /\/:(\w+)/g;
82
+ const RE_SLASH = /\//g;
83
+ const RE_NON_WORD = /\W/g;
84
+
85
+ function toOperationId(method: string, path: string): string {
86
+ // /auth/sessions/:id → auth_sessions_id, POST → post_auth_sessions_id
87
+ const normalized = path
88
+ .replace(RE_LEADING_SLASH, '')
89
+ .replace(RE_PARAM_COLON, '/$1')
90
+ .replace(RE_SLASH, '_')
91
+ .replace(RE_NON_WORD, '_');
92
+ return `${method.toLowerCase()}_${normalized}`;
93
+ }
94
+
95
+ function extractPathParams(path: string, paramSchemas?: JSONSchema): OpenAPIParameter[] {
96
+ const params: OpenAPIParameter[] = [];
97
+ const matches = path.matchAll(/:(\w+)/g);
98
+
99
+ for (const match of matches) {
100
+ const name = match[1];
101
+ const schema = paramSchemas?.properties?.[name] ?? { type: 'string' as const };
102
+ params.push({
103
+ name,
104
+ in: 'path',
105
+ required: true,
106
+ description: schema.description,
107
+ schema: { type: schema.type ?? 'string' },
108
+ });
109
+ }
110
+
111
+ return params;
112
+ }
113
+
114
+ function extractQueryParams(querySchema?: JSONSchema): OpenAPIParameter[] {
115
+ if (!querySchema?.properties)
116
+ return [];
117
+
118
+ const params: OpenAPIParameter[] = [];
119
+ const required = new Set(querySchema.required ?? []);
120
+
121
+ for (const [name, schema] of Object.entries(querySchema.properties)) {
122
+ params.push({
123
+ name,
124
+ in: 'query',
125
+ required: required.has(name),
126
+ description: schema.description,
127
+ schema,
128
+ });
129
+ }
130
+
131
+ return params;
132
+ }
133
+
134
+ function toOpenAPIPath(path: string): string {
135
+ return path.replace(/:(\w+)/g, '{$1}');
136
+ }
137
+
138
+ /**
139
+ * Deep-clean Standard Schema / runtime-only fields from schema objects before
140
+ * embedding them in OpenAPI. Fortress schemas are JSON Schema objects with a
141
+ * `~standard` validator attached; external schema adapters can also hang
142
+ * functions or undefined values off the object. OpenAPI must receive pure
143
+ * JSON-serializable JSON Schema, so strip any key beginning with `~`, any
144
+ * function value, and any `undefined` value recursively.
145
+ */
146
+ function cleanSchema<T>(value: T): T {
147
+ if (Array.isArray(value))
148
+ return value.map(cleanSchema) as T;
149
+
150
+ if (typeof value === 'object' && value !== null) {
151
+ return Object.fromEntries(
152
+ Object.entries(value as Record<string, unknown>)
153
+ .filter(([key]) => !key.startsWith('~'))
154
+ .filter(([, entry]) => typeof entry !== 'function' && entry !== undefined)
155
+ .map(([key, entry]) => [key, cleanSchema(entry)]),
156
+ ) as T;
157
+ }
158
+
159
+ return value;
160
+ }
161
+
162
+ function resolveOperationId(endpoint: EndpointDefinition, options: SpecBuilderOptions): string | undefined {
163
+ if (typeof options.operationId === 'function')
164
+ return options.operationId(endpoint);
165
+ if (options.operationId === 'handler')
166
+ return endpoint.handler;
167
+ return toOperationId(endpoint.method, endpoint.path);
168
+ }
169
+
170
+ /** Walk every endpoint definition and component-schemas record and emit a complete OpenAPI 3.1 {@link OpenAPISpec}. */
171
+ export function buildOpenAPISpec(
172
+ endpoints: EndpointDefinition[],
173
+ componentSchemas: ComponentSchemas,
174
+ options: SpecBuilderOptions,
175
+ ): OpenAPISpec {
176
+ const paths: Record<string, Record<string, OpenAPIOperation>> = {};
177
+ const usedSecuritySchemes = new Set<string>();
178
+
179
+ for (const ep of endpoints) {
180
+ const openAPIPath = toOpenAPIPath(ep.path);
181
+ const method = ep.method.toLowerCase();
182
+
183
+ if (!paths[openAPIPath]) {
184
+ paths[openAPIPath] = {};
185
+ }
186
+
187
+ const operation: OpenAPIOperation = {
188
+ operationId: resolveOperationId(ep, options),
189
+ responses: {},
190
+ };
191
+
192
+ // Meta
193
+ if (ep.meta) {
194
+ if (ep.meta.summary)
195
+ operation.summary = ep.meta.summary;
196
+ if (ep.meta.description)
197
+ operation.description = ep.meta.description;
198
+ if (ep.meta.tags)
199
+ operation.tags = ep.meta.tags;
200
+ if (ep.meta.deprecated)
201
+ operation.deprecated = true;
202
+
203
+ // Security
204
+ if (ep.meta.security) {
205
+ const secArr: Array<Record<string, string[]>> = [];
206
+ for (const s of ep.meta.security) {
207
+ if (s === 'none') {
208
+ secArr.push({});
209
+ }
210
+ else {
211
+ secArr.push(SECURITY_MAP[s]);
212
+ usedSecuritySchemes.add(s);
213
+ }
214
+ }
215
+ operation.security = secArr;
216
+ }
217
+ }
218
+
219
+ // Parameters
220
+ const params: OpenAPIParameter[] = [
221
+ ...extractPathParams(ep.path, cleanSchema(ep.input?.params)),
222
+ ...extractQueryParams(cleanSchema(ep.input?.query)),
223
+ ];
224
+ if (params.length > 0) {
225
+ operation.parameters = params;
226
+ }
227
+
228
+ // Request body
229
+ if (ep.input?.body) {
230
+ operation.requestBody = {
231
+ required: true,
232
+ content: { 'application/json': { schema: cleanSchema(ep.input.body) } },
233
+ };
234
+ }
235
+
236
+ // Responses
237
+ if (ep.responses) {
238
+ for (const [status, resp] of Object.entries(ep.responses)) {
239
+ const entry: { description: string; content?: { 'application/json': { schema: JSONSchema } } } = {
240
+ description: resp.description,
241
+ };
242
+ if (resp.schema) {
243
+ entry.content = { 'application/json': { schema: cleanSchema(resp.schema) } };
244
+ }
245
+ operation.responses[status] = entry;
246
+ }
247
+ }
248
+ else {
249
+ operation.responses['200'] = { description: 'Success' };
250
+ }
251
+
252
+ paths[openAPIPath][method] = operation;
253
+ }
254
+
255
+ // Build security schemes (only include those actually used)
256
+ const securitySchemes: Record<string, OpenAPISecurityScheme> = {};
257
+ const schemeNameMap: Record<string, string> = { bearer: 'bearerAuth', basic: 'basicAuth', apiKey: 'apiKeyAuth' };
258
+ for (const scheme of usedSecuritySchemes) {
259
+ const name = schemeNameMap[scheme];
260
+ if (name && SECURITY_SCHEMES[scheme]) {
261
+ securitySchemes[name] = SECURITY_SCHEMES[scheme];
262
+ }
263
+ }
264
+
265
+ const spec: OpenAPISpec = {
266
+ openapi: '3.1.0',
267
+ info: {
268
+ title: options.title,
269
+ version: options.version,
270
+ ...(options.description && { description: options.description }),
271
+ },
272
+ paths,
273
+ components: {
274
+ schemas: cleanSchema(componentSchemas),
275
+ securitySchemes,
276
+ },
277
+ };
278
+
279
+ if (options.servers && options.servers.length > 0) {
280
+ spec.servers = options.servers;
281
+ }
282
+ if (options.tags && options.tags.length > 0) {
283
+ spec.tags = options.tags;
284
+ }
285
+
286
+ return spec;
287
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Drives the expressRateLimit middleware directly (no real Express runtime)
3
+ * to confirm it calls the plugin's check() and surfaces errors via next(err).
4
+ */
5
+
6
+ import type { MinimalExpressRequest } from './express';
7
+ import { describe, expect, it } from 'vitest';
8
+ import { FortressError } from '../../core/errors';
9
+ import { createFortress } from '../../core/fortress';
10
+ import { createTestAdapter } from '../../testing';
11
+ import { expressRateLimit } from './express';
12
+ import { rateLimit } from './index';
13
+
14
+ const SECRET = 'express-rate-limit-wrapper-test-secret32!';
15
+
16
+ function setup(maxPerIp: number) {
17
+ const fortress = createFortress({
18
+ jwt: { key: SECRET },
19
+ database: createTestAdapter(),
20
+ plugins: [rateLimit({ rules: { api: { maxPerIp, windowSeconds: 60 } } })],
21
+ });
22
+ return fortress;
23
+ }
24
+
25
+ function mockReq(overrides: Partial<MinimalExpressRequest> = {}): MinimalExpressRequest {
26
+ return { headers: {}, ...overrides };
27
+ }
28
+
29
+ async function invoke(
30
+ mw: (req: MinimalExpressRequest, res: unknown, next: (err?: unknown) => void) => void,
31
+ req: MinimalExpressRequest,
32
+ ): Promise<{ err?: unknown; called: boolean }> {
33
+ return new Promise((resolve) => {
34
+ let called = false;
35
+ mw(req, {}, (err?: unknown) => {
36
+ called = true;
37
+ resolve({ err, called });
38
+ });
39
+ });
40
+ }
41
+
42
+ describe('expressRateLimit (framework wrapper)', () => {
43
+ it('calls next() under the limit and next(FortressError) when exceeded', async () => {
44
+ const fortress = setup(2);
45
+ const mw = expressRateLimit(fortress, 'api');
46
+
47
+ const r1 = await invoke(mw, mockReq({ ip: '10.0.0.1' }));
48
+ const r2 = await invoke(mw, mockReq({ ip: '10.0.0.1' }));
49
+ expect(r1.err).toBeUndefined();
50
+ expect(r2.err).toBeUndefined();
51
+
52
+ const r3 = await invoke(mw, mockReq({ ip: '10.0.0.1' }));
53
+ expect(r3.err).toBeInstanceOf(FortressError);
54
+ expect((r3.err as FortressError).code).toBe('RATE_LIMITED');
55
+ expect((r3.err as FortressError).retryAfter).toBeGreaterThan(0);
56
+ });
57
+
58
+ it('falls back to x-forwarded-for when req.ip is missing', async () => {
59
+ const fortress = setup(1);
60
+ const mw = expressRateLimit(fortress, 'api');
61
+
62
+ // First request with XFF only — should pass.
63
+ const r1 = await invoke(mw, mockReq({ headers: { 'x-forwarded-for': '10.0.0.9' } }));
64
+ expect(r1.err).toBeUndefined();
65
+
66
+ // Same XFF, second hit — should be limited.
67
+ const r2 = await invoke(mw, mockReq({ headers: { 'x-forwarded-for': '10.0.0.9' } }));
68
+ expect(r2.err).toBeInstanceOf(FortressError);
69
+ });
70
+
71
+ it('keys by user when fortressUserId is present and keyByUser is on (default)', async () => {
72
+ const fortress = setup(1);
73
+ const mw = expressRateLimit(fortress, 'api');
74
+
75
+ // User 1 gets one hit, then blocked on same IP+user combo.
76
+ const r1 = await invoke(mw, mockReq({ ip: '10.0.0.5', fortressUserId: '1' }));
77
+ const r2 = await invoke(mw, mockReq({ ip: '10.0.0.5', fortressUserId: '1' }));
78
+ expect(r1.err).toBeUndefined();
79
+ expect(r2.err).toBeInstanceOf(FortressError);
80
+ });
81
+
82
+ it('throws synchronously during setup when the plugin is not registered', () => {
83
+ const fortress = createFortress({
84
+ jwt: { key: SECRET },
85
+ database: createTestAdapter(),
86
+ });
87
+ expect(() => expressRateLimit(fortress, 'api')).toThrow(/rate-limit plugin is not registered/);
88
+ });
89
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Express middleware wrapper for the rate-limit plugin.
3
+ *
4
+ * Thin adapter that extracts the client IP (and optional authenticated
5
+ * userId) from an Express request and delegates to
6
+ * `fortress.plugins['rate-limit'].check(rule, keys)`. A `FortressError` with
7
+ * code `RATE_LIMITED` is passed to `next(err)` on exceed — the Express error
8
+ * middleware (`createErrorMiddleware` from `@bajustone/fortress/express`)
9
+ * renders a proper 429 with `Retry-After`.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import express from 'express';
14
+ * import { expressRateLimit } from '@bajustone/fortress/plugins/rate-limit/express';
15
+ *
16
+ * const app = express();
17
+ * app.use('/api', expressRateLimit(fortress, 'api'));
18
+ * ```
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import type { Fortress } from '../../core/fortress';
24
+
25
+ // Minimal Express-compatible types so users bring their own express version.
26
+ /** Shape fortress reads from; compatible with any modern Express request. */
27
+ export interface MinimalExpressRequest {
28
+ ip?: string;
29
+ headers: Record<string, string | string[] | undefined>;
30
+ fortressUserId?: string;
31
+ }
32
+
33
+ export interface ExpressRateLimitOptions {
34
+ /**
35
+ * Include the authenticated user in the key (requires fortress auth
36
+ * middleware to have run first, populating `req.fortressUserId`). Defaults
37
+ * to `true` — falls back to IP-only when no user is present.
38
+ */
39
+ keyByUser?: boolean;
40
+ /** Override IP extraction. Default uses `req.ip` then forwarding headers. */
41
+ extractIp?: (req: MinimalExpressRequest) => string | undefined;
42
+ }
43
+
44
+ interface RateLimitCheck {
45
+ check: (rule: string, keys: { ip?: string; userId?: string }) => Promise<void>;
46
+ }
47
+
48
+ function defaultExtractIp(req: MinimalExpressRequest): string | undefined {
49
+ if (req.ip)
50
+ return req.ip;
51
+ const xff = req.headers['x-forwarded-for'];
52
+ if (typeof xff === 'string')
53
+ return xff.split(',')[0].trim();
54
+ if (Array.isArray(xff) && xff.length > 0)
55
+ return xff[0].split(',')[0].trim();
56
+ const xri = req.headers['x-real-ip'];
57
+ return typeof xri === 'string' ? xri : undefined;
58
+ }
59
+
60
+ /**
61
+ * Returns an Express middleware that rate-limits requests against the named
62
+ * rule defined in your `rateLimit({ rules: { ... } })` config.
63
+ */
64
+ export function expressRateLimit(
65
+ fortress: Fortress,
66
+ ruleName: string,
67
+ options: ExpressRateLimitOptions = {},
68
+ ): (req: MinimalExpressRequest, _res: unknown, next: (err?: unknown) => void) => void {
69
+ const methods = fortress.plugins['rate-limit'] as unknown as RateLimitCheck | undefined;
70
+ if (!methods?.check) {
71
+ throw new Error(
72
+ 'rate-limit plugin is not registered — add rateLimit({...}) to your FortressConfig.plugins',
73
+ );
74
+ }
75
+ const extractIp = options.extractIp ?? defaultExtractIp;
76
+ const keyByUser = options.keyByUser ?? true;
77
+
78
+ return (req, _res, next) => {
79
+ const ip = extractIp(req);
80
+ const userId = keyByUser ? req.fortressUserId : undefined;
81
+ methods
82
+ .check(ruleName, { ip, userId })
83
+ .then(() => next())
84
+ .catch(next);
85
+ };
86
+ }
@@ -0,0 +1,60 @@
1
+ import { Hono } from 'hono';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createFortress } from '../../core/fortress';
4
+ import { createHonoMiddleware } from '../../hono';
5
+ import { createTestAdapter } from '../../testing';
6
+ import { honoRateLimit } from './hono';
7
+ import { rateLimit } from './index';
8
+
9
+ const SECRET = 'hono-rate-limit-wrapper-test-secret-32!';
10
+
11
+ describe('honoRateLimit (framework wrapper)', () => {
12
+ function setup(ruleMaxPerIp: number) {
13
+ const fortress = createFortress({
14
+ jwt: { key: SECRET },
15
+ database: createTestAdapter(),
16
+ plugins: [
17
+ rateLimit({
18
+ rules: { api: { maxPerIp: ruleMaxPerIp, windowSeconds: 60 } },
19
+ }),
20
+ ],
21
+ });
22
+ const { errorHandler } = createHonoMiddleware(fortress);
23
+ const app = new Hono();
24
+ app.onError(errorHandler);
25
+ app.use('/api/*', honoRateLimit(fortress, 'api'));
26
+ app.get('/api/things', c => c.json({ ok: true }));
27
+ return { fortress, app };
28
+ }
29
+
30
+ it('allows requests under the limit and blocks the next with 429 + Retry-After', async () => {
31
+ const { app } = setup(2);
32
+ const headers = { 'x-forwarded-for': '10.0.0.1' };
33
+
34
+ const r1 = await app.request('/api/things', { headers });
35
+ const r2 = await app.request('/api/things', { headers });
36
+ expect(r1.status).toBe(200);
37
+ expect(r2.status).toBe(200);
38
+
39
+ const r3 = await app.request('/api/things', { headers });
40
+ expect(r3.status).toBe(429);
41
+ expect(r3.headers.get('Retry-After')).toBeTruthy();
42
+ });
43
+
44
+ it('keys by IP — different forwarded-for values have separate buckets', async () => {
45
+ const { app } = setup(1);
46
+
47
+ const a = await app.request('/api/things', { headers: { 'x-forwarded-for': '10.0.0.2' } });
48
+ const b = await app.request('/api/things', { headers: { 'x-forwarded-for': '10.0.0.3' } });
49
+ expect(a.status).toBe(200);
50
+ expect(b.status).toBe(200); // different IP, still in budget
51
+ });
52
+
53
+ it('throws synchronously during setup when the plugin is not registered', () => {
54
+ const fortress = createFortress({
55
+ jwt: { key: SECRET },
56
+ database: createTestAdapter(),
57
+ });
58
+ expect(() => honoRateLimit(fortress, 'api')).toThrow(/rate-limit plugin is not registered/);
59
+ });
60
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Hono middleware wrapper for the rate-limit plugin.
3
+ *
4
+ * Thin adapter that extracts the client IP (and optional authenticated
5
+ * userId) from a Hono context and delegates to
6
+ * `fortress.plugins['rate-limit'].check(rule, keys)`. A `FortressError` with
7
+ * code `RATE_LIMITED` is thrown when the limit is exceeded — pair this with
8
+ * `createHonoMiddleware(fortress).errorHandler` (or any handler that calls
9
+ * `errorToResponse`) to render a proper 429 with `Retry-After`.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { Hono } from 'hono';
14
+ * import { honoRateLimit } from '@bajustone/fortress/plugins/rate-limit/hono';
15
+ *
16
+ * const app = new Hono();
17
+ * app.use('/api/*', honoRateLimit(fortress, 'api'));
18
+ * ```
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import type { Context, MiddlewareHandler } from 'hono';
24
+ import type { Fortress } from '../../core/fortress';
25
+
26
+ export interface HonoRateLimitOptions {
27
+ /**
28
+ * Include the authenticated user in the key (requires fortress auth
29
+ * middleware to have run first, populating `fortressUserId`). Defaults to
30
+ * `true` — falls back to IP-only when no user is present.
31
+ */
32
+ keyByUser?: boolean;
33
+ /** Override IP extraction. Default reads `X-Forwarded-For` then `X-Real-IP`. */
34
+ extractIp?: (c: Context) => string | undefined;
35
+ }
36
+
37
+ interface RateLimitCheck {
38
+ check: (rule: string, keys: { ip?: string; userId?: string }) => Promise<void>;
39
+ }
40
+
41
+ function defaultExtractIp(c: Context): string | undefined {
42
+ const xff = c.req.header('x-forwarded-for');
43
+ if (xff)
44
+ return xff.split(',')[0].trim();
45
+ return c.req.header('x-real-ip');
46
+ }
47
+
48
+ /**
49
+ * Returns a Hono middleware that rate-limits requests against the named rule
50
+ * defined in your `rateLimit({ rules: { ... } })` config.
51
+ */
52
+ export function honoRateLimit(
53
+ fortress: Fortress,
54
+ ruleName: string,
55
+ options: HonoRateLimitOptions = {},
56
+ ): MiddlewareHandler {
57
+ const methods = fortress.plugins['rate-limit'] as unknown as RateLimitCheck | undefined;
58
+ if (!methods?.check) {
59
+ throw new Error(
60
+ 'rate-limit plugin is not registered — add rateLimit({...}) to your FortressConfig.plugins',
61
+ );
62
+ }
63
+ const extractIp = options.extractIp ?? defaultExtractIp;
64
+ const keyByUser = options.keyByUser ?? true;
65
+
66
+ return async (c, next) => {
67
+ const ip = extractIp(c);
68
+ const userId = keyByUser
69
+ ? (c.get('fortressUserId' as never) as string | undefined)
70
+ : undefined;
71
+ await methods.check(ruleName, { ip, userId });
72
+ await next();
73
+ };
74
+ }