@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,718 @@
1
+ import type { Fortress } from './core/fortress';
2
+ import type { AuthSuccess } from './core/types';
3
+ import { beforeEach, describe, expect, it } from 'vitest';
4
+ import { hashToken } from './core/auth/refresh-token';
5
+ import { createFortress } from './core/fortress';
6
+ import { assertSuccess } from './core/types';
7
+ import { createTestAdapter } from './testing';
8
+
9
+ let fortress: Fortress;
10
+
11
+ beforeEach(() => {
12
+ fortress = createFortress({
13
+ jwt: { key: 'integration-test-secret-32chars!!' },
14
+ database: createTestAdapter(),
15
+ });
16
+ });
17
+
18
+ describe('auth integration', () => {
19
+ it('creates a user and logs in', async () => {
20
+ const user = await fortress.auth.createUser({
21
+ email: 'alice@example.com',
22
+ name: 'Alice',
23
+ password: 'secure-password-123456',
24
+ });
25
+
26
+ expect(user.id).toBeDefined();
27
+ expect(user.email).toBe('alice@example.com');
28
+ expect(user.name).toBe('Alice');
29
+
30
+ const result = await fortress.auth.login('alice@example.com', 'secure-password-123456');
31
+ assertSuccess(result);
32
+
33
+ expect(result.user.email).toBe('alice@example.com');
34
+ expect(result.accessToken).toBeTruthy();
35
+ expect(result.refreshToken).toBeTruthy();
36
+ });
37
+
38
+ it('seeds refresh-family session metadata at issuance', async () => {
39
+ const database = createTestAdapter();
40
+ const localFortress = createFortress({
41
+ jwt: { key: 'integration-test-secret-32chars!!' },
42
+ database,
43
+ });
44
+ const user = await localFortress.auth.createUser({
45
+ email: 'session-metadata@example.com',
46
+ name: 'Session Metadata',
47
+ password: 'secure-password-123456',
48
+ });
49
+
50
+ await localFortress.auth.login('session-metadata@example.com', 'secure-password-123456');
51
+ const stored = await database.findOne<{
52
+ familyCreatedAt: Date;
53
+ lastActiveAt: Date;
54
+ successorTokenHash: string | null;
55
+ rotatedAt: Date | null;
56
+ }>({
57
+ model: 'refresh_token',
58
+ where: [{ field: 'userId', operator: '=', value: user.id }],
59
+ });
60
+
61
+ expect(stored?.familyCreatedAt).toBeInstanceOf(Date);
62
+ expect(stored?.lastActiveAt).toBeInstanceOf(Date);
63
+ expect(stored?.familyCreatedAt.getTime()).toBe(stored?.lastActiveAt.getTime());
64
+ expect(stored?.successorTokenHash).toBeNull();
65
+ expect(stored?.rotatedAt).toBeNull();
66
+ });
67
+
68
+ it('rejects invalid credentials', async () => {
69
+ await fortress.auth.createUser({
70
+ email: 'bob@example.com',
71
+ name: 'Bob',
72
+ password: 'correct-password',
73
+ });
74
+
75
+ await expect(
76
+ fortress.auth.login('bob@example.com', 'wrong-password'),
77
+ ).rejects.toThrow('Invalid credentials');
78
+ });
79
+
80
+ it('rejects login for non-existent user', async () => {
81
+ await expect(
82
+ fortress.auth.login('nobody@example.com', 'any-password'),
83
+ ).rejects.toThrow('Invalid credentials');
84
+ });
85
+
86
+ it('rejects duplicate email on createUser', async () => {
87
+ await fortress.auth.createUser({
88
+ email: 'dupe@example.com',
89
+ name: 'First',
90
+ password: 'password-123456',
91
+ });
92
+
93
+ await expect(
94
+ fortress.auth.createUser({
95
+ email: 'dupe@example.com',
96
+ name: 'Second',
97
+ password: 'password-456',
98
+ }),
99
+ ).rejects.toThrow('A user with this email already exists');
100
+ });
101
+
102
+ it('verifies a signed token', async () => {
103
+ await fortress.auth.createUser({
104
+ email: 'carol@example.com',
105
+ name: 'Carol',
106
+ password: 'password-123456',
107
+ });
108
+
109
+ const login = await fortress.auth.login('carol@example.com', 'password-123456');
110
+ assertSuccess(login);
111
+ const claims = await fortress.auth.verifyToken(login.accessToken as string);
112
+
113
+ expect(claims.name).toBe('Carol');
114
+ expect(claims.iss).toBe('fortress');
115
+ expect(claims.exp).toBeGreaterThan(claims.iat);
116
+ });
117
+
118
+ it('refreshes tokens', async () => {
119
+ await fortress.auth.createUser({
120
+ email: 'dave@example.com',
121
+ name: 'Dave',
122
+ password: 'password-123456',
123
+ });
124
+
125
+ const login = await fortress.auth.login('dave@example.com', 'password-123456');
126
+ assertSuccess(login);
127
+ const refreshed = await fortress.auth.refresh(login.refreshToken as string);
128
+
129
+ expect(refreshed.accessToken).toBeTruthy();
130
+ expect(refreshed.refreshToken).toBeTruthy();
131
+ // Refresh token must be different (new random bytes)
132
+ expect(refreshed.refreshToken).not.toBe(login.refreshToken as string);
133
+ // Access tokens may be identical if generated in the same second (same iat/exp)
134
+ // — that's fine, the important thing is we got a valid new token
135
+ });
136
+
137
+ it('strict refresh concurrency: one winner, loser is treated as replay and revokes the family', async () => {
138
+ await fortress.auth.createUser({
139
+ email: 'concurrent-refresh@example.com',
140
+ name: 'Concurrent',
141
+ password: 'password-123456',
142
+ });
143
+
144
+ const login = await fortress.auth.login('concurrent-refresh@example.com', 'password-123456');
145
+ assertSuccess(login);
146
+ const oldRefreshToken = login.refreshToken as string;
147
+
148
+ const results = await Promise.allSettled([
149
+ fortress.auth.refresh(oldRefreshToken),
150
+ fortress.auth.refresh(oldRefreshToken),
151
+ ]);
152
+
153
+ const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<{ refreshToken: string }>[];
154
+ const rejected = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[];
155
+ expect(fulfilled).toHaveLength(1);
156
+ expect(rejected).toHaveLength(1);
157
+ expect(String(rejected[0].reason)).toMatch(/Token reuse detected/);
158
+
159
+ // Fortress deliberately uses strict replay semantics: the losing
160
+ // duplicate invalidates the entire token family, including the winner's
161
+ // freshly issued refresh token. This favours theft detection over a
162
+ // concurrency grace window.
163
+ await expect(fortress.auth.refresh(fulfilled[0].value.refreshToken)).rejects.toThrow('Token reuse detected');
164
+ });
165
+
166
+ it('graces a concurrent double-refresh and returns the same successor', async () => {
167
+ const database = createTestAdapter();
168
+ const graceful = createFortress({
169
+ jwt: {
170
+ key: 'integration-test-secret-32chars!!',
171
+ session: { refreshGraceSeconds: 30 },
172
+ },
173
+ database,
174
+ });
175
+ await graceful.auth.createUser({
176
+ email: 'grace-refresh@example.com',
177
+ name: 'Grace Refresh',
178
+ password: 'password-123456',
179
+ });
180
+ const login = await graceful.auth.login('grace-refresh@example.com', 'password-123456');
181
+ assertSuccess(login);
182
+ const events: string[] = [];
183
+ graceful.auth.addAuthObserver(event => void events.push(event.eventType));
184
+
185
+ const [first, second] = await Promise.all([
186
+ graceful.auth.refresh(login.refreshToken as string),
187
+ graceful.auth.refresh(login.refreshToken as string),
188
+ ]);
189
+
190
+ expect(first.refreshToken).toBe(second.refreshToken);
191
+ expect(events).toContain('TOKEN_REUSE_GRACED');
192
+ await expect(graceful.auth.refresh(first.refreshToken)).resolves.toMatchObject({
193
+ refreshToken: expect.any(String),
194
+ });
195
+ });
196
+
197
+ it('recovers grace retries across JWT signing-key rotation', async () => {
198
+ const keys = [
199
+ 'refresh-key-a-at-least-thirty-two-bytes',
200
+ ];
201
+ const rotating = createFortress({
202
+ jwt: { key: keys, session: { refreshGraceSeconds: 30 } },
203
+ database: createTestAdapter(),
204
+ });
205
+ await rotating.auth.createUser({
206
+ email: 'rotating-grace@example.com',
207
+ name: 'Rotating Grace',
208
+ password: 'password-123456',
209
+ });
210
+ const login = await rotating.auth.login('rotating-grace@example.com', 'password-123456');
211
+ assertSuccess(login);
212
+ const successor = await rotating.auth.refresh(login.refreshToken as string);
213
+
214
+ keys.unshift('refresh-key-b-at-least-thirty-two-bytes');
215
+ const retry = await rotating.auth.refresh(login.refreshToken as string);
216
+ expect(retry.refreshToken).toBe(successor.refreshToken);
217
+ });
218
+
219
+ it('honors disabled, warn, and hard fingerprint modes during grace recovery', async () => {
220
+ for (const mode of [undefined, 'warn', true] as const) {
221
+ const database = createTestAdapter();
222
+ const configured = createFortress({
223
+ jwt: {
224
+ key: 'integration-test-secret-32chars!!',
225
+ session: { refreshGraceSeconds: 30 },
226
+ ...(mode === undefined ? {} : { validateRefreshFingerprint: mode }),
227
+ },
228
+ database,
229
+ });
230
+ await configured.auth.createUser({
231
+ email: `fingerprint-${String(mode)}@example.com`,
232
+ name: 'Fingerprint',
233
+ password: 'password-123456',
234
+ });
235
+ const login = await configured.auth.login(
236
+ `fingerprint-${String(mode)}@example.com`,
237
+ 'password-123456',
238
+ { userAgent: 'browser-a' },
239
+ );
240
+ assertSuccess(login);
241
+ const successor = await configured.auth.refresh(login.refreshToken as string, { userAgent: 'browser-a' });
242
+ const retry = configured.auth.refresh(login.refreshToken as string, { userAgent: 'browser-b' });
243
+
244
+ if (mode === true) {
245
+ await expect(retry).rejects.toMatchObject({ code: 'TOKEN_REUSE' });
246
+ await expect(configured.auth.refresh(successor.refreshToken)).rejects.toMatchObject({ code: 'TOKEN_REUSE' });
247
+ }
248
+ else {
249
+ await expect(retry).resolves.toMatchObject({ refreshToken: successor.refreshToken });
250
+ }
251
+ }
252
+ });
253
+
254
+ it('enforces idle and absolute session caps with distinct errors', async () => {
255
+ const database = createTestAdapter();
256
+ const capped = createFortress({
257
+ jwt: {
258
+ key: 'integration-test-secret-32chars!!',
259
+ session: { idleTimeoutSeconds: 60, absoluteTimeoutSeconds: 120 },
260
+ },
261
+ database,
262
+ });
263
+ const events: string[] = [];
264
+ capped.auth.addAuthObserver(event => void events.push(event.eventType));
265
+ const user = await capped.auth.createUser({
266
+ email: 'session-caps@example.com',
267
+ name: 'Session Caps',
268
+ password: 'password-123456',
269
+ });
270
+
271
+ const idleLogin = await capped.auth.login('session-caps@example.com', 'password-123456');
272
+ assertSuccess(idleLogin);
273
+ await database.update({
274
+ model: 'refresh_token',
275
+ where: [{ field: 'userId', operator: '=', value: user.id }],
276
+ data: { lastActiveAt: new Date(Date.now() - 61_000) },
277
+ });
278
+ await expect(capped.auth.refresh(idleLogin.refreshToken as string)).rejects.toMatchObject({
279
+ code: 'SESSION_IDLE_TIMEOUT',
280
+ });
281
+ expect(events).toContain('SESSION_EXPIRED_IDLE');
282
+
283
+ const absoluteLogin = await capped.auth.login('session-caps@example.com', 'password-123456');
284
+ assertSuccess(absoluteLogin);
285
+ await database.update({
286
+ model: 'refresh_token',
287
+ where: [
288
+ { field: 'userId', operator: '=', value: user.id },
289
+ { field: 'isRevoked', operator: '=', value: false },
290
+ ],
291
+ data: {
292
+ familyCreatedAt: new Date(Date.now() - 121_000),
293
+ lastActiveAt: new Date(),
294
+ },
295
+ });
296
+ await expect(capped.auth.refresh(absoluteLogin.refreshToken as string)).rejects.toMatchObject({
297
+ code: 'SESSION_ABSOLUTE_TIMEOUT',
298
+ });
299
+ expect(events).toContain('SESSION_EXPIRED_ABSOLUTE');
300
+ });
301
+
302
+ it('enforces session caps on grace-window retries', async () => {
303
+ const database = createTestAdapter();
304
+ const capped = createFortress({
305
+ jwt: {
306
+ key: 'integration-test-secret-32chars!!',
307
+ session: {
308
+ refreshGraceSeconds: 30,
309
+ idleTimeoutSeconds: 60,
310
+ absoluteTimeoutSeconds: 120,
311
+ },
312
+ },
313
+ database,
314
+ });
315
+ const user = await capped.auth.createUser({
316
+ email: 'grace-caps@example.com',
317
+ name: 'Grace Caps',
318
+ password: 'password-123456',
319
+ });
320
+
321
+ const idleLogin = await capped.auth.login('grace-caps@example.com', 'password-123456');
322
+ assertSuccess(idleLogin);
323
+ await capped.auth.refresh(idleLogin.refreshToken as string);
324
+ await database.update({
325
+ model: 'refresh_token',
326
+ where: [
327
+ { field: 'userId', operator: '=', value: user.id },
328
+ { field: 'isRevoked', operator: '=', value: false },
329
+ ],
330
+ data: { lastActiveAt: new Date(Date.now() - 61_000) },
331
+ });
332
+ await expect(capped.auth.refresh(idleLogin.refreshToken as string)).rejects.toMatchObject({
333
+ code: 'SESSION_IDLE_TIMEOUT',
334
+ });
335
+
336
+ const absoluteLogin = await capped.auth.login('grace-caps@example.com', 'password-123456');
337
+ assertSuccess(absoluteLogin);
338
+ await capped.auth.refresh(absoluteLogin.refreshToken as string);
339
+ await database.update({
340
+ model: 'refresh_token',
341
+ where: [
342
+ { field: 'userId', operator: '=', value: user.id },
343
+ { field: 'isRevoked', operator: '=', value: false },
344
+ ],
345
+ data: { familyCreatedAt: new Date(Date.now() - 121_000) },
346
+ });
347
+ await expect(capped.auth.refresh(absoluteLogin.refreshToken as string)).rejects.toMatchObject({
348
+ code: 'SESSION_ABSOLUTE_TIMEOUT',
349
+ });
350
+ });
351
+
352
+ it('revokes the oldest session when maxSessionsPerUser is reached', async () => {
353
+ const database = createTestAdapter();
354
+ const limited = createFortress({
355
+ jwt: {
356
+ key: 'integration-test-secret-32chars!!',
357
+ session: { maxSessionsPerUser: 1 },
358
+ },
359
+ database,
360
+ });
361
+ await limited.auth.createUser({
362
+ email: 'session-limit@example.com',
363
+ name: 'Session Limit',
364
+ password: 'password-123456',
365
+ });
366
+ const first = await limited.auth.login('session-limit@example.com', 'password-123456');
367
+ assertSuccess(first);
368
+ const second = await limited.auth.login('session-limit@example.com', 'password-123456');
369
+ assertSuccess(second);
370
+
371
+ await expect(limited.auth.refresh(first.refreshToken as string)).rejects.toMatchObject({ code: 'TOKEN_REUSE' });
372
+ await expect(limited.auth.refresh(second.refreshToken as string)).resolves.toMatchObject({
373
+ refreshToken: expect.any(String),
374
+ });
375
+ });
376
+
377
+ it('enforces maxSessionsPerUser under concurrent login and by family age', async () => {
378
+ const database = createTestAdapter();
379
+ const limited = createFortress({
380
+ jwt: {
381
+ key: 'integration-test-secret-32chars!!',
382
+ session: { maxSessionsPerUser: 2 },
383
+ },
384
+ database,
385
+ });
386
+ const user = await limited.auth.createUser({
387
+ email: 'session-race@example.com',
388
+ name: 'Session Race',
389
+ password: 'password-123456',
390
+ });
391
+
392
+ const concurrent = await Promise.all([
393
+ limited.auth.login('session-race@example.com', 'password-123456'),
394
+ limited.auth.login('session-race@example.com', 'password-123456'),
395
+ limited.auth.login('session-race@example.com', 'password-123456'),
396
+ ]);
397
+ expect(await database.count({
398
+ model: 'refresh_token',
399
+ where: [{ field: 'isRevoked', operator: '=', value: false }],
400
+ })).toBe(2);
401
+
402
+ const activeRows = await database.findMany<{ tokenHash: string }>({
403
+ model: 'refresh_token',
404
+ where: [{ field: 'isRevoked', operator: '=', value: false }],
405
+ });
406
+ const activeHashes = new Set(activeRows.map(row => row.tokenHash));
407
+ let oldest: AuthSuccess | undefined;
408
+ let oldestHash: string | undefined;
409
+ for (const candidate of concurrent) {
410
+ assertSuccess(candidate);
411
+ const candidateHash = await hashToken(candidate.refreshToken);
412
+ if (activeHashes.has(candidateHash)) {
413
+ oldest = candidate;
414
+ oldestHash = candidateHash;
415
+ break;
416
+ }
417
+ }
418
+ if (!oldest || !oldestHash)
419
+ throw new Error('Expected an active concurrent session');
420
+ await database.update({
421
+ model: 'refresh_token',
422
+ where: [{ field: 'tokenHash', operator: '=', value: oldestHash }],
423
+ data: { familyCreatedAt: new Date(Date.now() - 60_000) },
424
+ });
425
+ const rotatedOldest = await limited.auth.refresh(oldest.refreshToken);
426
+ const newest = await limited.auth.login('session-race@example.com', 'password-123456');
427
+ assertSuccess(newest);
428
+
429
+ await expect(limited.auth.refresh(rotatedOldest.refreshToken)).rejects.toMatchObject({ code: 'TOKEN_REUSE' });
430
+ await expect(limited.auth.refresh(newest.refreshToken as string)).resolves.toBeDefined();
431
+ expect(user.id).toBeTruthy();
432
+ });
433
+
434
+ it('detects refresh token reuse', async () => {
435
+ await fortress.auth.createUser({
436
+ email: 'eve@example.com',
437
+ name: 'Eve',
438
+ password: 'password-123456',
439
+ });
440
+
441
+ const login = await fortress.auth.login('eve@example.com', 'password-123456');
442
+ assertSuccess(login);
443
+ const oldRefreshToken = login.refreshToken as string;
444
+
445
+ // Use the refresh token (rotates it)
446
+ await fortress.auth.refresh(oldRefreshToken);
447
+
448
+ // Try to reuse the old token — should detect reuse
449
+ await expect(fortress.auth.refresh(oldRefreshToken)).rejects.toThrow('Token reuse detected');
450
+ });
451
+
452
+ it('logout invalidates refresh token', async () => {
453
+ await fortress.auth.createUser({
454
+ email: 'frank@example.com',
455
+ name: 'Frank',
456
+ password: 'password-123456',
457
+ });
458
+
459
+ const login = await fortress.auth.login('frank@example.com', 'password-123456');
460
+ assertSuccess(login);
461
+ await fortress.auth.logout(login.refreshToken as string);
462
+
463
+ // Trying to refresh with logged out token should fail
464
+ await expect(fortress.auth.refresh(login.refreshToken as string)).rejects.toThrow('Token reuse detected');
465
+ });
466
+
467
+ it('me() returns user by id', async () => {
468
+ const created = await fortress.auth.createUser({
469
+ email: 'grace@example.com',
470
+ name: 'Grace',
471
+ password: 'password-123456',
472
+ });
473
+
474
+ const user = await fortress.auth.me(created.id);
475
+ expect(user.name).toBe('Grace');
476
+ expect(user.email).toBe('grace@example.com');
477
+ expect((user as any).passwordHash).toBeUndefined();
478
+ });
479
+ });
480
+
481
+ describe('multi-key login', () => {
482
+ it('auto-creates email login identifier on user creation', async () => {
483
+ const user = await fortress.auth.createUser({
484
+ email: 'multi@example.com',
485
+ name: 'Multi User',
486
+ password: 'password-123456',
487
+ });
488
+
489
+ const identifiers = await fortress.auth.getLoginIdentifiers(user.id);
490
+ expect(identifiers).toHaveLength(1);
491
+ expect(identifiers[0].type).toBe('email');
492
+ expect(identifiers[0].value).toBe('multi@example.com');
493
+ });
494
+
495
+ it('allows login with phone after adding phone identifier', async () => {
496
+ const user = await fortress.auth.createUser({
497
+ email: 'phone-user@example.com',
498
+ name: 'Phone User',
499
+ password: 'password-123456',
500
+ });
501
+
502
+ await fortress.auth.addLoginIdentifier(user.id, 'phone', '+250788123456');
503
+
504
+ // Login with phone
505
+ const result = await fortress.auth.login('+250788123456', 'password-123456');
506
+ assertSuccess(result);
507
+ expect(result.user.name).toBe('Phone User');
508
+ expect(result.accessToken).toBeTruthy();
509
+ });
510
+
511
+ it('allows login with username after adding username identifier', async () => {
512
+ const user = await fortress.auth.createUser({
513
+ email: 'username-user@example.com',
514
+ name: 'Username User',
515
+ password: 'password-123456',
516
+ });
517
+
518
+ await fortress.auth.addLoginIdentifier(user.id, 'username', 'alice');
519
+
520
+ // Login with username
521
+ const result = await fortress.auth.login('alice', 'password-123456');
522
+ assertSuccess(result);
523
+ expect(result.user.name).toBe('Username User');
524
+ });
525
+
526
+ it('still allows login with email', async () => {
527
+ await fortress.auth.createUser({
528
+ email: 'email-login@example.com',
529
+ name: 'Email User',
530
+ password: 'password-123456',
531
+ });
532
+
533
+ const result = await fortress.auth.login('email-login@example.com', 'password-123456');
534
+ assertSuccess(result);
535
+ expect(result.user.name).toBe('Email User');
536
+ });
537
+
538
+ it('can remove a login identifier', async () => {
539
+ const user = await fortress.auth.createUser({
540
+ email: 'remove-id@example.com',
541
+ name: 'Remove ID',
542
+ password: 'password-123456',
543
+ });
544
+
545
+ await fortress.auth.addLoginIdentifier(user.id, 'phone', '+250788999999');
546
+ await fortress.auth.removeLoginIdentifier(user.id, 'phone', '+250788999999');
547
+
548
+ // Phone login should fail now (falls back to email lookup, which won't match a phone)
549
+ await expect(
550
+ fortress.auth.login('+250788999999', 'password-123456'),
551
+ ).rejects.toThrow('Invalid credentials');
552
+ });
553
+
554
+ it('multiple identifiers all share the same password', async () => {
555
+ const user = await fortress.auth.createUser({
556
+ email: 'shared@example.com',
557
+ name: 'Shared Password',
558
+ password: 'same-password-123',
559
+ });
560
+
561
+ await fortress.auth.addLoginIdentifier(user.id, 'phone', '+250781111111');
562
+ await fortress.auth.addLoginIdentifier(user.id, 'username', 'shared_user');
563
+
564
+ // All three work with the same password
565
+ const r1 = await fortress.auth.login('shared@example.com', 'same-password-123');
566
+ assertSuccess(r1);
567
+ const r2 = await fortress.auth.login('+250781111111', 'same-password-123');
568
+ assertSuccess(r2);
569
+ const r3 = await fortress.auth.login('shared_user', 'same-password-123');
570
+ assertSuccess(r3);
571
+
572
+ expect(r1.user.id).toBe(user.id);
573
+ expect(r2.user.id).toBe(user.id);
574
+ expect(r3.user.id).toBe(user.id);
575
+ });
576
+ });
577
+
578
+ describe('iAM integration', () => {
579
+ it('creates groups and adds users', async () => {
580
+ const user = await fortress.auth.createUser({
581
+ email: 'ian@example.com',
582
+ name: 'Ian',
583
+ password: 'password-123456',
584
+ });
585
+
586
+ const group = await fortress.iam.createGroup('editors', 'Content editors');
587
+ expect(group.name).toBe('editors');
588
+
589
+ await fortress.iam.addUserToGroup(group.id, user.id);
590
+ });
591
+
592
+ it('creates roles with permissions and checks access', async () => {
593
+ // Create a user
594
+ const user = await fortress.auth.createUser({
595
+ email: 'jane@example.com',
596
+ name: 'Jane',
597
+ password: 'password-123456',
598
+ });
599
+
600
+ // Create a role with permissions
601
+ const role = await fortress.iam.createRole('editor', [
602
+ { resource: 'post', action: 'create' },
603
+ { resource: 'post', action: 'read' },
604
+ { resource: 'post', action: 'update' },
605
+ ]);
606
+
607
+ // Bind role directly to user
608
+ await fortress.iam.bindRoleToUser(user.id, role.id);
609
+
610
+ // Check permissions
611
+ const canCreate = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'create');
612
+ const canRead = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'read');
613
+ const canDelete = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'delete');
614
+
615
+ expect(canCreate).toBe(true);
616
+ expect(canRead).toBe(true);
617
+ expect(canDelete).toBe(false); // not in the role
618
+ });
619
+
620
+ it('permissions work through group membership', async () => {
621
+ const user = await fortress.auth.createUser({
622
+ email: 'kate@example.com',
623
+ name: 'Kate',
624
+ password: 'password-123456',
625
+ });
626
+
627
+ const group = await fortress.iam.createGroup('admins');
628
+ await fortress.iam.addUserToGroup(group.id, user.id);
629
+
630
+ const role = await fortress.iam.createRole('admin-role', [
631
+ { resource: 'user', action: 'create' },
632
+ { resource: 'user', action: 'delete' },
633
+ ]);
634
+
635
+ await fortress.iam.bindRoleToGroup(group.id, role.id);
636
+
637
+ // User should have permissions via group
638
+ const canCreate = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'user', 'create');
639
+ const canDelete = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'user', 'delete');
640
+ const canUpdate = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'user', 'update');
641
+
642
+ expect(canCreate).toBe(true);
643
+ expect(canDelete).toBe(true);
644
+ expect(canUpdate).toBe(false);
645
+ });
646
+
647
+ it('user with no roles has no permissions', async () => {
648
+ const user = await fortress.auth.createUser({
649
+ email: 'larry@example.com',
650
+ name: 'Larry',
651
+ password: 'password-123456',
652
+ });
653
+
654
+ const allowed = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'anything', 'read');
655
+ expect(allowed).toBe(false);
656
+ });
657
+ });
658
+
659
+ describe('plugin integration', () => {
660
+ it('runs afterLogin hooks', async () => {
661
+ let hookCalled = false;
662
+
663
+ const f = createFortress({
664
+ jwt: { key: 'integration-test-secret-32chars!!' },
665
+ database: createTestAdapter(),
666
+ plugins: [
667
+ {
668
+ name: 'test-hook',
669
+ hooks: {
670
+ async afterLogin(_ctx, result) {
671
+ hookCalled = true;
672
+ return { ...result, pluginData: { customField: 'from-plugin' } };
673
+ },
674
+ },
675
+ },
676
+ ],
677
+ });
678
+
679
+ await f.auth.createUser({
680
+ email: 'plugin@example.com',
681
+ name: 'Plugin Test',
682
+ password: 'password-123456',
683
+ });
684
+
685
+ const result = await f.auth.login('plugin@example.com', 'password-123456');
686
+ assertSuccess(result);
687
+
688
+ expect(hookCalled).toBe(true);
689
+ expect(result.pluginData?.customField).toBe('from-plugin');
690
+ });
691
+
692
+ it('beforeLogin hook can block login', async () => {
693
+ const f = createFortress({
694
+ jwt: { key: 'integration-test-secret-32chars!!' },
695
+ database: createTestAdapter(),
696
+ plugins: [
697
+ {
698
+ name: 'blocker',
699
+ hooks: {
700
+ async beforeLogin() {
701
+ return { stop: true, response: { blocked: true, reason: 'maintenance' } };
702
+ },
703
+ },
704
+ },
705
+ ],
706
+ });
707
+
708
+ await f.auth.createUser({
709
+ email: 'blocked@example.com',
710
+ name: 'Blocked',
711
+ password: 'password-123456',
712
+ });
713
+
714
+ const result = await f.auth.login('blocked@example.com', 'password-123456');
715
+ expect((result as any).blocked).toBe(true);
716
+ expect((result as any).reason).toBe('maintenance');
717
+ });
718
+ });