@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,861 @@
1
+ import type { Fortress } from '../core/fortress';
2
+ import type { FortressEnv } from './middleware/auth';
3
+ import { Hono } from 'hono';
4
+ import { beforeEach, describe, expect, it } from 'vitest';
5
+ import { FortressError } from '../core/errors';
6
+ import { createFortress } from '../core/fortress';
7
+ import { oauth } from '../plugins/oauth';
8
+ import { openapi } from '../plugins/openapi';
9
+ import { createTestAdapter } from '../testing';
10
+ import { mountFortress } from './handle';
11
+ import { getClaims, getDb, getUserId } from './helpers';
12
+ import { createHonoMiddleware } from './index';
13
+ import { createCsrfMiddleware } from './middleware/csrf';
14
+
15
+ const SECRET = 'hono-handler-test-secret-at-least-32!!';
16
+
17
+ // --- Helper ---
18
+
19
+ function createApp(fortress: Fortress, options?: Parameters<typeof createHonoMiddleware>[1]): Hono<FortressEnv> {
20
+ const { authMiddleware, rbacMiddleware, errorHandler } = createHonoMiddleware(fortress, options);
21
+ const app = new Hono<FortressEnv>();
22
+ app.onError(errorHandler);
23
+
24
+ app.post('/auth/login', async (c) => {
25
+ const { identifier, password } = await c.req.json();
26
+ const result = await fortress.auth.login(identifier, password);
27
+ return c.json(result);
28
+ });
29
+
30
+ app.post('/auth/register', async (c) => {
31
+ const data = await c.req.json();
32
+ const user = await fortress.auth.createUser(data);
33
+ return c.json(user);
34
+ });
35
+
36
+ app.use('/api/*', authMiddleware);
37
+ if (options?.routeMap) {
38
+ app.use('/api/*', rbacMiddleware);
39
+ }
40
+
41
+ return app;
42
+ }
43
+
44
+ async function seedAndLogin(fortress: Fortress, app: Hono<FortressEnv>): Promise<{ token: string; userId: string }> {
45
+ const user = await fortress.auth.createUser({
46
+ email: 'test@example.com',
47
+ name: 'Test User',
48
+ password: 'password-123456',
49
+ });
50
+
51
+ const res = await app.request('/auth/login', {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ identifier: 'test@example.com', password: 'password-123456' }),
55
+ });
56
+ const data = await res.json() as any;
57
+ return { token: data.accessToken, userId: user.id };
58
+ }
59
+
60
+ // =====================
61
+ // Error Handler
62
+ // =====================
63
+
64
+ describe('error handler', () => {
65
+ let app: Hono<FortressEnv>;
66
+
67
+ beforeEach(() => {
68
+ const fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
69
+ app = createApp(fortress);
70
+ });
71
+
72
+ it('returns structured JSON for FortressError', async () => {
73
+ const res = await app.request('/api/anything');
74
+ expect(res.status).toBe(401);
75
+ const body = await res.json() as any;
76
+ expect(body.code).toBe('UNAUTHORIZED');
77
+ expect(body.message).toBeDefined();
78
+ expect(body.statusCode).toBe(401);
79
+ });
80
+
81
+ it('returns 500 for unknown errors without stack trace', async () => {
82
+ const fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
83
+ const { errorHandler } = createHonoMiddleware(fortress);
84
+ const testApp = new Hono();
85
+ testApp.onError(errorHandler);
86
+ testApp.get('/blow-up', () => {
87
+ throw new Error('internal failure');
88
+ });
89
+
90
+ const res = await testApp.request('/blow-up');
91
+ expect(res.status).toBe(500);
92
+ const body = await res.json() as any;
93
+ expect(body.code).toBe('INTERNAL_ERROR');
94
+ expect(body.message).toBe('Internal server error');
95
+ expect(body).not.toHaveProperty('stack');
96
+ });
97
+
98
+ it('adds Retry-After header for RATE_LIMITED errors', async () => {
99
+ const fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
100
+ const { errorHandler } = createHonoMiddleware(fortress);
101
+ const testApp = new Hono();
102
+ testApp.onError(errorHandler);
103
+ testApp.get('/rate-limited', () => {
104
+ throw new FortressError('RATE_LIMITED', 'Too many requests', 429, { retryAfter: 60 });
105
+ });
106
+
107
+ const res = await testApp.request('/rate-limited');
108
+ expect(res.status).toBe(429);
109
+ expect(res.headers.get('Retry-After')).toBe('60');
110
+ });
111
+
112
+ it('maps various error codes to correct status codes', async () => {
113
+ const fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
114
+ const { errorHandler } = createHonoMiddleware(fortress);
115
+ const testApp = new Hono();
116
+ testApp.onError(errorHandler);
117
+
118
+ testApp.get('/forbidden', () => {
119
+ throw new FortressError('FORBIDDEN', 'Nope', 403);
120
+ });
121
+ testApp.get('/not-found', () => {
122
+ throw new FortressError('NOT_FOUND', 'Gone', 404);
123
+ });
124
+ testApp.get('/conflict', () => {
125
+ throw new FortressError('CONFLICT', 'Dupe', 409);
126
+ });
127
+
128
+ expect((await testApp.request('/forbidden')).status).toBe(403);
129
+ expect((await testApp.request('/not-found')).status).toBe(404);
130
+ expect((await testApp.request('/conflict')).status).toBe(409);
131
+ });
132
+ });
133
+
134
+ // =====================
135
+ // Auth Middleware
136
+ // =====================
137
+
138
+ describe('auth middleware', () => {
139
+ let fortress: Fortress;
140
+ let app: Hono<FortressEnv>;
141
+
142
+ beforeEach(async () => {
143
+ fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
144
+ app = createApp(fortress);
145
+
146
+ app.get('/api/me', (c) => {
147
+ const userId = getUserId(c);
148
+ const claims = getClaims(c);
149
+ return c.json({ userId, name: claims.name, groups: claims.groups });
150
+ });
151
+ });
152
+
153
+ it('extracts user ID and claims from valid token', async () => {
154
+ const { token, userId } = await seedAndLogin(fortress, app);
155
+
156
+ const res = await app.request('/api/me', {
157
+ headers: { Authorization: `Bearer ${token}` },
158
+ });
159
+ expect(res.status).toBe(200);
160
+ const body = await res.json() as any;
161
+ expect(body.userId).toBe(userId);
162
+ expect(body.name).toBe('Test User');
163
+ expect(body.groups).toEqual([]);
164
+ });
165
+
166
+ it('rejects requests without Authorization header', async () => {
167
+ const res = await app.request('/api/me');
168
+ expect(res.status).toBe(401);
169
+ });
170
+
171
+ it('rejects malformed Authorization header', async () => {
172
+ const res = await app.request('/api/me', {
173
+ headers: { Authorization: 'NotBearer token' },
174
+ });
175
+ expect(res.status).toBe(401);
176
+ });
177
+
178
+ it('rejects expired/invalid tokens', async () => {
179
+ const res = await app.request('/api/me', {
180
+ headers: { Authorization: 'Bearer totally.invalid.token' },
181
+ });
182
+ expect(res.status).toBe(401);
183
+ });
184
+
185
+ it('provides database adapter via getDb()', async () => {
186
+ app.get('/api/db-test', (c) => {
187
+ const db = getDb(c);
188
+ return c.json({ hasCreate: typeof db.create === 'function' });
189
+ });
190
+
191
+ const { token } = await seedAndLogin(fortress, app);
192
+ const res = await app.request('/api/db-test', {
193
+ headers: { Authorization: `Bearer ${token}` },
194
+ });
195
+ expect(res.status).toBe(200);
196
+ const body = await res.json() as any;
197
+ expect(body.hasCreate).toBe(true);
198
+ });
199
+ });
200
+
201
+ // =====================
202
+ // RBAC Middleware
203
+ // =====================
204
+
205
+ describe('rbac middleware', () => {
206
+ let fortress: Fortress;
207
+ let app: Hono<FortressEnv>;
208
+
209
+ beforeEach(async () => {
210
+ fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
211
+ app = createApp(fortress, {
212
+ routeMap: {
213
+ 'GET /api/posts': { resource: 'post', action: 'list' },
214
+ 'POST /api/posts': { resource: 'post', action: 'create' },
215
+ 'GET /api/posts/:id': { resource: 'post', action: 'read' },
216
+ 'PUT /api/posts/:id': { resource: 'post', action: 'update' },
217
+ 'DELETE /api/posts/:id': { resource: 'post', action: 'delete' },
218
+ },
219
+ skipPaths: ['/health', '/public/*'],
220
+ });
221
+
222
+ app.get('/api/posts', c => c.json({ posts: [] }));
223
+ app.post('/api/posts', c => c.json({ created: true }));
224
+ app.get('/api/posts/:id', c => c.json({ id: c.req.param('id') }));
225
+ app.put('/api/posts/:id', c => c.json({ updated: true }));
226
+ app.delete('/api/posts/:id', c => c.json({ deleted: true }));
227
+ app.get('/api/unmapped', c => c.json({ ok: true }));
228
+ });
229
+
230
+ it('denies when user has no permissions', async () => {
231
+ const { token } = await seedAndLogin(fortress, app);
232
+
233
+ const res = await app.request('/api/posts', {
234
+ headers: { Authorization: `Bearer ${token}` },
235
+ });
236
+ expect(res.status).toBe(403);
237
+ const body = await res.json() as any;
238
+ expect(body.code).toBe('FORBIDDEN');
239
+ });
240
+
241
+ it('allows when user has matching permission', async () => {
242
+ const { token, userId } = await seedAndLogin(fortress, app);
243
+ const role = await fortress.iam.createRole('reader', [{ resource: 'post', action: 'list' }]);
244
+ await fortress.iam.bindRoleToUser(userId, role.id);
245
+
246
+ const res = await app.request('/api/posts', {
247
+ headers: { Authorization: `Bearer ${token}` },
248
+ });
249
+ expect(res.status).toBe(200);
250
+ });
251
+
252
+ it('enforces per-action permission: allow read, deny create', async () => {
253
+ const { token, userId } = await seedAndLogin(fortress, app);
254
+ const role = await fortress.iam.createRole('reader', [{ resource: 'post', action: 'list' }]);
255
+ await fortress.iam.bindRoleToUser(userId, role.id);
256
+
257
+ const getRes = await app.request('/api/posts', {
258
+ headers: { Authorization: `Bearer ${token}` },
259
+ });
260
+ expect(getRes.status).toBe(200);
261
+
262
+ const postRes = await app.request('/api/posts', {
263
+ method: 'POST',
264
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({}),
266
+ });
267
+ expect(postRes.status).toBe(403);
268
+ });
269
+
270
+ it('matches PUT and DELETE on parameterized routes', async () => {
271
+ const { token, userId } = await seedAndLogin(fortress, app);
272
+ const role = await fortress.iam.createRole('editor', [
273
+ { resource: 'post', action: 'update' },
274
+ { resource: 'post', action: 'delete' },
275
+ ]);
276
+ await fortress.iam.bindRoleToUser(userId, role.id);
277
+
278
+ const putRes = await app.request('/api/posts/42', {
279
+ method: 'PUT',
280
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({}),
282
+ });
283
+ expect(putRes.status).toBe(200);
284
+
285
+ const deleteRes = await app.request('/api/posts/42', {
286
+ method: 'DELETE',
287
+ headers: { Authorization: `Bearer ${token}` },
288
+ });
289
+ expect(deleteRes.status).toBe(200);
290
+ });
291
+
292
+ it('passes through unmapped routes without permission check', async () => {
293
+ const { token } = await seedAndLogin(fortress, app);
294
+
295
+ const res = await app.request('/api/unmapped', {
296
+ headers: { Authorization: `Bearer ${token}` },
297
+ });
298
+ expect(res.status).toBe(200);
299
+ });
300
+
301
+ it('supports dynamic mapRequest fallback', async () => {
302
+ const mapFortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
303
+ // Pass a routeMap (even empty) so rbacMiddleware is applied by createApp
304
+ const mapApp = createApp(mapFortress, {
305
+ routeMap: {},
306
+ mapRequest: (method, path) => {
307
+ if (method === 'GET' && path.startsWith('/api/dynamic/'))
308
+ return { resource: 'dynamic', action: 'read' };
309
+ return null;
310
+ },
311
+ });
312
+
313
+ mapApp.get('/api/dynamic/:id', c => c.json({ id: c.req.param('id') }));
314
+
315
+ const { token, userId } = await seedAndLogin(mapFortress, mapApp);
316
+
317
+ // No permission → denied
318
+ const denied = await mapApp.request('/api/dynamic/1', {
319
+ headers: { Authorization: `Bearer ${token}` },
320
+ });
321
+ expect(denied.status).toBe(403);
322
+
323
+ // Grant permission → allowed
324
+ const role = await mapFortress.iam.createRole('dyn-reader', [{ resource: 'dynamic', action: 'read' }]);
325
+ await mapFortress.iam.bindRoleToUser(userId, role.id);
326
+
327
+ // Need a fresh token since claims are cached
328
+ const loginRes = await mapApp.request('/auth/login', {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({ identifier: 'test@example.com', password: 'password-123456' }),
332
+ });
333
+ const newToken = ((await loginRes.json()) as any).accessToken;
334
+
335
+ const allowed = await mapApp.request('/api/dynamic/1', {
336
+ headers: { Authorization: `Bearer ${newToken}` },
337
+ });
338
+ expect(allowed.status).toBe(200);
339
+ });
340
+ });
341
+
342
+ // =====================
343
+ // CSRF Middleware
344
+ // =====================
345
+
346
+ describe('csrf middleware', () => {
347
+ let app: Hono;
348
+
349
+ beforeEach(() => {
350
+ app = new Hono();
351
+ app.use('*', createCsrfMiddleware());
352
+ app.get('/data', c => c.json({ ok: true }));
353
+ app.post('/data', c => c.json({ ok: true }));
354
+ app.put('/data', c => c.json({ ok: true }));
355
+ app.patch('/data', c => c.json({ ok: true }));
356
+ app.delete('/data', c => c.json({ ok: true }));
357
+ });
358
+
359
+ it('allows GET without CSRF header', async () => {
360
+ const res = await app.request('/data');
361
+ expect(res.status).toBe(200);
362
+ });
363
+
364
+ it('allows POST with CSRF header', async () => {
365
+ const res = await app.request('/data', {
366
+ method: 'POST',
367
+ headers: { 'X-Fortress-CSRF': '1', 'Content-Type': 'application/json' },
368
+ body: '{}',
369
+ });
370
+ expect(res.status).toBe(200);
371
+ });
372
+
373
+ it('rejects POST without CSRF header', async () => {
374
+ const res = await app.request('/data', {
375
+ method: 'POST',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: '{}',
378
+ });
379
+ expect(res.status).toBe(403);
380
+ const body = await res.json() as any;
381
+ expect(body.error).toBe('CSRF_MISSING');
382
+ });
383
+
384
+ it('rejects PUT without CSRF header', async () => {
385
+ const res = await app.request('/data', {
386
+ method: 'PUT',
387
+ headers: { 'Content-Type': 'application/json' },
388
+ body: '{}',
389
+ });
390
+ expect(res.status).toBe(403);
391
+ });
392
+
393
+ it('rejects PATCH without CSRF header', async () => {
394
+ const res = await app.request('/data', {
395
+ method: 'PATCH',
396
+ headers: { 'Content-Type': 'application/json' },
397
+ body: '{}',
398
+ });
399
+ expect(res.status).toBe(403);
400
+ });
401
+
402
+ it('rejects DELETE without CSRF header', async () => {
403
+ const res = await app.request('/data', { method: 'DELETE' });
404
+ expect(res.status).toBe(403);
405
+ });
406
+
407
+ it('allows PUT/PATCH/DELETE with CSRF header', async () => {
408
+ for (const method of ['PUT', 'PATCH', 'DELETE']) {
409
+ const res = await app.request('/data', {
410
+ method,
411
+ headers: { 'X-Fortress-CSRF': '1', 'Content-Type': 'application/json' },
412
+ body: method !== 'DELETE' ? '{}' : undefined,
413
+ });
414
+ expect(res.status).toBe(200);
415
+ }
416
+ });
417
+
418
+ it('rejects cross-site requests via Sec-Fetch-Site', async () => {
419
+ const res = await app.request('/data', {
420
+ method: 'POST',
421
+ headers: {
422
+ 'X-Fortress-CSRF': '1',
423
+ 'Sec-Fetch-Site': 'cross-site',
424
+ 'Content-Type': 'application/json',
425
+ },
426
+ body: '{}',
427
+ });
428
+ expect(res.status).toBe(403);
429
+ const body = await res.json() as any;
430
+ expect(body.error).toBe('CSRF_REJECTED');
431
+ });
432
+
433
+ it('allows same-origin requests via Sec-Fetch-Site', async () => {
434
+ const res = await app.request('/data', {
435
+ method: 'POST',
436
+ headers: {
437
+ 'X-Fortress-CSRF': '1',
438
+ 'Sec-Fetch-Site': 'same-origin',
439
+ 'Content-Type': 'application/json',
440
+ },
441
+ body: '{}',
442
+ });
443
+ expect(res.status).toBe(200);
444
+ });
445
+
446
+ it('supports custom header name', async () => {
447
+ const customApp = new Hono();
448
+ customApp.use('*', createCsrfMiddleware({ headerName: 'X-Custom-CSRF' }));
449
+ customApp.post('/data', c => c.json({ ok: true }));
450
+
451
+ // Wrong header name
452
+ const bad = await customApp.request('/data', {
453
+ method: 'POST',
454
+ headers: { 'X-Fortress-CSRF': '1', 'Content-Type': 'application/json' },
455
+ body: '{}',
456
+ });
457
+ expect(bad.status).toBe(403);
458
+
459
+ // Correct header name
460
+ const good = await customApp.request('/data', {
461
+ method: 'POST',
462
+ headers: { 'X-Custom-CSRF': '1', 'Content-Type': 'application/json' },
463
+ body: '{}',
464
+ });
465
+ expect(good.status).toBe(200);
466
+ });
467
+
468
+ it('supports skip paths', async () => {
469
+ const skipApp = new Hono();
470
+ skipApp.use('*', createCsrfMiddleware({ skipPaths: ['/webhook/'] }));
471
+ skipApp.post('/webhook/github', c => c.json({ ok: true }));
472
+ skipApp.post('/api/data', c => c.json({ ok: true }));
473
+
474
+ const skipped = await skipApp.request('/webhook/github', {
475
+ method: 'POST',
476
+ headers: { 'Content-Type': 'application/json' },
477
+ body: '{}',
478
+ });
479
+ expect(skipped.status).toBe(200);
480
+
481
+ const enforced = await skipApp.request('/api/data', {
482
+ method: 'POST',
483
+ headers: { 'Content-Type': 'application/json' },
484
+ body: '{}',
485
+ });
486
+ expect(enforced.status).toBe(403);
487
+ });
488
+ });
489
+
490
+ // =====================
491
+ // Plugin Route Mounting
492
+ // =====================
493
+
494
+ describe('plugin route mounting: OAuth', () => {
495
+ let fortress: Fortress<any>;
496
+ let app: Hono<FortressEnv>;
497
+ let oauthClient: { clientId: string; clientSecret: string };
498
+
499
+ beforeEach(async () => {
500
+ fortress = createFortress({
501
+ jwt: { key: SECRET },
502
+ database: createTestAdapter(),
503
+ plugins: [oauth({ issuerUrl: 'https://auth.example.com' })],
504
+ });
505
+
506
+ app = new Hono<FortressEnv>();
507
+ const { errorHandler } = createHonoMiddleware(fortress);
508
+ app.onError(errorHandler);
509
+
510
+ mountFortress(app, fortress);
511
+
512
+ // Create an OAuth client
513
+ const methods = fortress.plugins.oauth as any;
514
+ oauthClient = await methods.createClient({
515
+ name: 'Test App',
516
+ redirectUris: ['https://app.example.com/callback'],
517
+ grantTypes: ['authorization_code', 'client_credentials'],
518
+ });
519
+ });
520
+
521
+ it('gET /.well-known/openid-configuration returns discovery document', async () => {
522
+ const res = await app.request('/oauth/.well-known/openid-configuration');
523
+ expect(res.status).toBe(200);
524
+ const body = await res.json() as any;
525
+ expect(body.issuer).toBe('https://auth.example.com');
526
+ expect(body.token_endpoint).toContain('/oauth/token');
527
+ expect(body.grant_types_supported).toContain('authorization_code');
528
+ expect(body.grant_types_supported).toContain('client_credentials');
529
+ });
530
+
531
+ it('pOST /oauth/token with client_credentials grant', async () => {
532
+ const basicAuth = btoa(`${oauthClient.clientId}:${oauthClient.clientSecret}`);
533
+
534
+ const res = await app.request('/oauth/token', {
535
+ method: 'POST',
536
+ headers: {
537
+ 'Authorization': `Basic ${basicAuth}`,
538
+ 'Content-Type': 'application/x-www-form-urlencoded',
539
+ },
540
+ body: 'grant_type=client_credentials&scope=read',
541
+ });
542
+
543
+ expect(res.status).toBe(200);
544
+ const body = await res.json() as any;
545
+ expect(body.access_token).toBeTruthy();
546
+ expect(body.token_type).toBe('Bearer');
547
+ expect(body.expires_in).toBeDefined();
548
+ });
549
+
550
+ it('pOST /oauth/token rejects invalid client credentials', async () => {
551
+ const basicAuth = btoa(`${oauthClient.clientId}:wrong-secret`);
552
+
553
+ const res = await app.request('/oauth/token', {
554
+ method: 'POST',
555
+ headers: {
556
+ 'Authorization': `Basic ${basicAuth}`,
557
+ 'Content-Type': 'application/x-www-form-urlencoded',
558
+ },
559
+ body: 'grant_type=client_credentials',
560
+ });
561
+
562
+ expect(res.status).toBe(401);
563
+ });
564
+
565
+ it('pOST /oauth/token rejects unsupported grant_type', async () => {
566
+ const basicAuth = btoa(`${oauthClient.clientId}:${oauthClient.clientSecret}`);
567
+
568
+ const res = await app.request('/oauth/token', {
569
+ method: 'POST',
570
+ headers: {
571
+ 'Authorization': `Basic ${basicAuth}`,
572
+ 'Content-Type': 'application/x-www-form-urlencoded',
573
+ },
574
+ body: 'grant_type=password',
575
+ });
576
+
577
+ expect(res.status).toBe(400);
578
+ });
579
+
580
+ it('pOST /oauth/introspect validates token', async () => {
581
+ const basicAuth = btoa(`${oauthClient.clientId}:${oauthClient.clientSecret}`);
582
+
583
+ // First get a token
584
+ const tokenRes = await app.request('/oauth/token', {
585
+ method: 'POST',
586
+ headers: {
587
+ 'Authorization': `Basic ${basicAuth}`,
588
+ 'Content-Type': 'application/x-www-form-urlencoded',
589
+ },
590
+ body: 'grant_type=client_credentials',
591
+ });
592
+ const tokenBody = await tokenRes.json() as any;
593
+
594
+ // Introspect it
595
+ const introspectRes = await app.request('/oauth/introspect', {
596
+ method: 'POST',
597
+ headers: {
598
+ 'Authorization': `Basic ${basicAuth}`,
599
+ 'Content-Type': 'application/x-www-form-urlencoded',
600
+ },
601
+ body: `token=${tokenBody.access_token}`,
602
+ });
603
+
604
+ expect(introspectRes.status).toBe(200);
605
+ const body = await introspectRes.json() as any;
606
+ expect(body.active).toBe(true);
607
+ expect(body.client_id).toBe(oauthClient.clientId);
608
+ expect(body.token_type).toBe('Bearer');
609
+ });
610
+
611
+ it('pOST /oauth/introspect rejects without client auth', async () => {
612
+ const res = await app.request('/oauth/introspect', {
613
+ method: 'POST',
614
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
615
+ body: 'token=some-token',
616
+ });
617
+
618
+ expect(res.status).toBe(401);
619
+ const body = await res.json() as any;
620
+ expect(body.error).toBe('invalid_client');
621
+ });
622
+
623
+ it('pOST /oauth/introspect returns inactive for invalid token', async () => {
624
+ const basicAuth = btoa(`${oauthClient.clientId}:${oauthClient.clientSecret}`);
625
+
626
+ const res = await app.request('/oauth/introspect', {
627
+ method: 'POST',
628
+ headers: {
629
+ 'Authorization': `Basic ${basicAuth}`,
630
+ 'Content-Type': 'application/x-www-form-urlencoded',
631
+ },
632
+ body: 'token=nonexistent-token',
633
+ });
634
+
635
+ expect(res.status).toBe(200);
636
+ const body = await res.json() as any;
637
+ expect(body.active).toBe(false);
638
+ });
639
+
640
+ it('pOST /oauth/revoke succeeds for valid token', async () => {
641
+ const basicAuth = btoa(`${oauthClient.clientId}:${oauthClient.clientSecret}`);
642
+
643
+ // Get token
644
+ const tokenRes = await app.request('/oauth/token', {
645
+ method: 'POST',
646
+ headers: {
647
+ 'Authorization': `Basic ${basicAuth}`,
648
+ 'Content-Type': 'application/x-www-form-urlencoded',
649
+ },
650
+ body: 'grant_type=client_credentials',
651
+ });
652
+ const { access_token } = await tokenRes.json() as any;
653
+
654
+ // Revoke
655
+ const revokeRes = await app.request('/oauth/revoke', {
656
+ method: 'POST',
657
+ headers: {
658
+ 'Authorization': `Basic ${basicAuth}`,
659
+ 'Content-Type': 'application/x-www-form-urlencoded',
660
+ },
661
+ body: `token=${access_token}`,
662
+ });
663
+ expect(revokeRes.status).toBe(200);
664
+
665
+ // Introspect should show inactive
666
+ const introspectRes = await app.request('/oauth/introspect', {
667
+ method: 'POST',
668
+ headers: {
669
+ 'Authorization': `Basic ${basicAuth}`,
670
+ 'Content-Type': 'application/x-www-form-urlencoded',
671
+ },
672
+ body: `token=${access_token}`,
673
+ });
674
+ const body = await introspectRes.json() as any;
675
+ expect(body.active).toBe(false);
676
+ });
677
+
678
+ it('gET /oauth/userinfo requires Bearer token', async () => {
679
+ const res = await app.request('/oauth/userinfo');
680
+ expect(res.status).toBe(401);
681
+ const body = await res.json() as any;
682
+ expect(body.error).toBe('invalid_token');
683
+ });
684
+
685
+ it('gET /oauth/userinfo scope-gates identity claims for a valid token', async () => {
686
+ // Create user and auth code
687
+ const user = await fortress.auth.createUser({
688
+ email: 'oauth-user@test.com',
689
+ name: 'OAuth User',
690
+ password: 'password-123456',
691
+ });
692
+
693
+ const methods = fortress.plugins.oauth as any;
694
+ const { code } = await methods.createAuthorizationCode({
695
+ clientId: oauthClient.clientId,
696
+ userId: user.id,
697
+ redirectUri: 'https://app.example.com/callback',
698
+ });
699
+
700
+ const tokenResult = await methods.exchangeCode({
701
+ code,
702
+ clientId: oauthClient.clientId,
703
+ clientSecret: oauthClient.clientSecret,
704
+ redirectUri: 'https://app.example.com/callback',
705
+ });
706
+
707
+ const res = await app.request('/oauth/userinfo', {
708
+ headers: { Authorization: `Bearer ${tokenResult.accessToken}` },
709
+ });
710
+ expect(res.status).toBe(200);
711
+ const body = await res.json() as any;
712
+ expect(body.sub).toBe(String(user.id));
713
+ expect(body.email).toBeUndefined();
714
+ expect(body.name).toBeUndefined();
715
+ });
716
+ });
717
+
718
+ // =====================
719
+ // Plugin Route Mounting: OpenAPI with prefix
720
+ // =====================
721
+
722
+ describe('plugin route mounting: OpenAPI with prefix', () => {
723
+ it('scalar UI data-url resolves to prefixed spec path', async () => {
724
+ const fortress = createFortress({
725
+ jwt: { key: SECRET },
726
+ database: createTestAdapter(),
727
+ plugins: [openapi()],
728
+ });
729
+
730
+ const app = new Hono<FortressEnv>();
731
+ const { errorHandler } = createHonoMiddleware(fortress);
732
+ app.onError(errorHandler);
733
+
734
+ mountFortress(app, fortress, { prefix: '/api/v1' });
735
+
736
+ // Spec should be served at the prefixed path
737
+ const specRes = await app.request('/api/v1/openapi.json');
738
+ expect(specRes.status).toBe(200);
739
+ const spec = await specRes.json() as any;
740
+ expect(spec.openapi).toBe('3.1.0');
741
+
742
+ // UI should be served at the prefixed path
743
+ const uiRes = await app.request('/api/v1/openapi');
744
+ expect(uiRes.status).toBe(200);
745
+ const html = await uiRes.text();
746
+ expect(html).toContain('data-url="./openapi.json"');
747
+ // Must NOT contain the absolute unprefixed path
748
+ expect(html).not.toContain('data-url="/openapi.json"');
749
+ });
750
+
751
+ it('unprefixed spec path returns 404', async () => {
752
+ const fortress = createFortress({
753
+ jwt: { key: SECRET },
754
+ database: createTestAdapter(),
755
+ plugins: [openapi()],
756
+ });
757
+
758
+ const app = new Hono<FortressEnv>();
759
+ mountFortress(app, fortress, { prefix: '/api/v1' });
760
+
761
+ const res = await app.request('/openapi.json');
762
+ expect(res.status).toBe(404);
763
+ });
764
+ });
765
+
766
+ // =====================
767
+ // Full auth flow via Hono
768
+ // =====================
769
+
770
+ describe('full auth flow via Hono', () => {
771
+ let fortress: Fortress;
772
+ let app: Hono<FortressEnv>;
773
+
774
+ beforeEach(async () => {
775
+ fortress = createFortress({
776
+ jwt: { key: SECRET },
777
+ database: createTestAdapter(),
778
+ });
779
+
780
+ app = createApp(fortress);
781
+
782
+ app.post('/auth/refresh', async (c) => {
783
+ const { refreshToken } = await c.req.json();
784
+ const result = await fortress.auth.refresh(refreshToken);
785
+ return c.json(result);
786
+ });
787
+
788
+ app.post('/auth/logout', async (c) => {
789
+ const { refreshToken } = await c.req.json();
790
+ await fortress.auth.logout(refreshToken);
791
+ return c.json({ ok: true });
792
+ });
793
+
794
+ app.get('/api/me', (c) => {
795
+ const userId = getUserId(c);
796
+ return c.json({ userId });
797
+ });
798
+ });
799
+
800
+ it('register → login → access protected → refresh → access again → logout', async () => {
801
+ // Register
802
+ const regRes = await app.request('/auth/register', {
803
+ method: 'POST',
804
+ headers: { 'Content-Type': 'application/json' },
805
+ body: JSON.stringify({ email: 'flow@test.com', name: 'Flow', password: 'password-123456' }),
806
+ });
807
+ expect(regRes.status).toBe(200);
808
+
809
+ // Login
810
+ const loginRes = await app.request('/auth/login', {
811
+ method: 'POST',
812
+ headers: { 'Content-Type': 'application/json' },
813
+ body: JSON.stringify({ identifier: 'flow@test.com', password: 'password-123456' }),
814
+ });
815
+ expect(loginRes.status).toBe(200);
816
+ const loginData = await loginRes.json() as any;
817
+ expect(loginData.accessToken).toBeTruthy();
818
+ expect(loginData.refreshToken).toBeTruthy();
819
+
820
+ // Access protected route
821
+ const meRes = await app.request('/api/me', {
822
+ headers: { Authorization: `Bearer ${loginData.accessToken}` },
823
+ });
824
+ expect(meRes.status).toBe(200);
825
+ const meData = await meRes.json() as any;
826
+ expect(meData.userId).toBe(loginData.user.id);
827
+
828
+ // Refresh
829
+ const refreshRes = await app.request('/auth/refresh', {
830
+ method: 'POST',
831
+ headers: { 'Content-Type': 'application/json' },
832
+ body: JSON.stringify({ refreshToken: loginData.refreshToken }),
833
+ });
834
+ expect(refreshRes.status).toBe(200);
835
+ const refreshData = await refreshRes.json() as any;
836
+ expect(refreshData.accessToken).toBeTruthy();
837
+ expect(refreshData.refreshToken).toBeTruthy();
838
+
839
+ // Access with new token
840
+ const meRes2 = await app.request('/api/me', {
841
+ headers: { Authorization: `Bearer ${refreshData.accessToken}` },
842
+ });
843
+ expect(meRes2.status).toBe(200);
844
+
845
+ // Logout
846
+ const logoutRes = await app.request('/auth/logout', {
847
+ method: 'POST',
848
+ headers: { 'Content-Type': 'application/json' },
849
+ body: JSON.stringify({ refreshToken: refreshData.refreshToken }),
850
+ });
851
+ expect(logoutRes.status).toBe(200);
852
+
853
+ // Refresh after logout should fail
854
+ const failedRefresh = await app.request('/auth/refresh', {
855
+ method: 'POST',
856
+ headers: { 'Content-Type': 'application/json' },
857
+ body: JSON.stringify({ refreshToken: refreshData.refreshToken }),
858
+ });
859
+ expect(failedRefresh.status).toBe(401);
860
+ });
861
+ });