@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,241 @@
1
+ /**
2
+ * Integration tests proving that api-key credentials authenticate user-owned
3
+ * Express routes. Mirror of the Hono api-key user-route tests — drives the
4
+ * middleware directly (no express runtime dependency) the same way
5
+ * `express.test.ts` does.
6
+ */
7
+
8
+ import type { Fortress } from '../core/fortress';
9
+ import type { Subject } from '../core/types';
10
+ import type { ExpressNextFunction, ExpressRequest, ExpressResponse } from './middleware';
11
+ import { beforeEach, describe, expect, it } from 'vitest';
12
+ import { createFortress } from '../core/fortress';
13
+ import { apiKey } from '../plugins/api-key';
14
+ import { createTestAdapter } from '../testing';
15
+ import { createAuthMiddleware, createRbacMiddleware, getSubject, getUserId } from './middleware';
16
+
17
+ const SECRET = 'express-api-key-user-routes-32-chars!';
18
+
19
+ function mockRes(): ExpressResponse {
20
+ return {
21
+ status() { return this; },
22
+ json() {},
23
+ setHeader() {},
24
+ } as ExpressResponse;
25
+ }
26
+
27
+ interface Ctx {
28
+ fortress: Fortress<any>;
29
+ userId: string;
30
+ userKey: string;
31
+ saId: string;
32
+ saKey: string;
33
+ saKeyNoPerm: string;
34
+ userAccessToken: string;
35
+ }
36
+
37
+ async function setup(): Promise<Ctx> {
38
+ const fortress = createFortress({
39
+ jwt: { key: SECRET },
40
+ database: createTestAdapter(),
41
+ plugins: [apiKey({ prefix: 'test' })],
42
+ });
43
+
44
+ const user = await fortress.auth.createUser({
45
+ email: 'human@example.com',
46
+ name: 'Human',
47
+ password: 'password-123456',
48
+ });
49
+ const userLogin = await fortress.auth.login('human@example.com', 'password-123456');
50
+ if (userLogin.status !== 'success')
51
+ throw new Error('login should succeed');
52
+
53
+ const deployRole = await fortress.iam.createRole('deployer', [
54
+ { resource: 'deploy', action: 'run' },
55
+ ]);
56
+ await fortress.iam.bindRoleToUser(user.id, deployRole.id);
57
+
58
+ const sa = await fortress.iam.createServiceAccount({ name: 'ci-deploy-bot' });
59
+ await fortress.iam.bindRoleToServiceAccount(sa.id, deployRole.id);
60
+ const saNoPerm = await fortress.iam.createServiceAccount({ name: 'observer-bot' });
61
+
62
+ const { key: userKey } = await fortress.plugins['api-key'].createKey({
63
+ subject: { type: 'USER', id: user.id },
64
+ name: 'user-key',
65
+ });
66
+ const { key: saKey } = await fortress.plugins['api-key'].createKey({
67
+ subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
68
+ name: 'sa-key',
69
+ });
70
+ const { key: saKeyNoPerm } = await fortress.plugins['api-key'].createKey({
71
+ subject: { type: 'SERVICE_ACCOUNT', id: saNoPerm.id },
72
+ name: 'observer-key',
73
+ });
74
+
75
+ return {
76
+ fortress,
77
+ userId: user.id,
78
+ userKey,
79
+ saId: sa.id,
80
+ saKey,
81
+ saKeyNoPerm,
82
+ userAccessToken: userLogin.accessToken,
83
+ };
84
+ }
85
+
86
+ /** Run an Express middleware against a fake request and return `{ ran, err }`. */
87
+ async function run(
88
+ middleware: ReturnType<typeof createAuthMiddleware>,
89
+ req: ExpressRequest,
90
+ ): Promise<{ ran: boolean; err: any }> {
91
+ let ran = false;
92
+ let err: any;
93
+ await middleware(req, mockRes(), ((e?: unknown) => {
94
+ if (e)
95
+ err = e;
96
+ else
97
+ ran = true;
98
+ }) as ExpressNextFunction);
99
+ return { ran, err };
100
+ }
101
+
102
+ describe('express user routes: api-key authentication', () => {
103
+ let ctx: Ctx;
104
+ beforeEach(async () => {
105
+ ctx = await setup();
106
+ });
107
+
108
+ it('authorization: ApiKey <key> authenticates a USER and populates fortressSubject', async () => {
109
+ const middleware = createAuthMiddleware(ctx.fortress);
110
+ const req: ExpressRequest = {
111
+ headers: { authorization: `ApiKey ${ctx.userKey}` },
112
+ method: 'GET',
113
+ path: '/me',
114
+ };
115
+ const { ran, err } = await run(middleware, req);
116
+ expect(err).toBeUndefined();
117
+ expect(ran).toBe(true);
118
+ expect(req.fortressSubject).toEqual({ type: 'USER', id: ctx.userId });
119
+ expect(req.fortressUserId).toBe(ctx.userId);
120
+ expect(getSubject(req)).toEqual({ type: 'USER', id: ctx.userId });
121
+ expect(getUserId(req)).toBe(ctx.userId);
122
+ });
123
+
124
+ it('x-API-Key authenticates a SERVICE_ACCOUNT; fortressUserId is undefined', async () => {
125
+ const middleware = createAuthMiddleware(ctx.fortress);
126
+ const req: ExpressRequest = {
127
+ headers: { 'x-api-key': ctx.saKey },
128
+ method: 'GET',
129
+ path: '/me',
130
+ };
131
+ const { ran, err } = await run(middleware, req);
132
+ expect(err).toBeUndefined();
133
+ expect(ran).toBe(true);
134
+ expect(req.fortressSubject).toEqual({ type: 'SERVICE_ACCOUNT', id: ctx.saId });
135
+ // fortressUserId is a USER-only alias
136
+ expect(req.fortressUserId).toBeUndefined();
137
+ expect(getSubject(req).type).toBe('SERVICE_ACCOUNT');
138
+ });
139
+
140
+ it('getUserId throws 401 for a SERVICE_ACCOUNT principal', async () => {
141
+ const middleware = createAuthMiddleware(ctx.fortress);
142
+ const req: ExpressRequest = {
143
+ headers: { 'x-api-key': ctx.saKey },
144
+ method: 'GET',
145
+ path: '/me',
146
+ };
147
+ await run(middleware, req);
148
+ expect(() => getUserId(req)).toThrow(/User not authenticated/);
149
+ });
150
+
151
+ it('falls back to JWT bearer when no api-key header is present', async () => {
152
+ const middleware = createAuthMiddleware(ctx.fortress);
153
+ const req: ExpressRequest = {
154
+ headers: { authorization: `Bearer ${ctx.userAccessToken}` },
155
+ method: 'GET',
156
+ path: '/me',
157
+ };
158
+ const { ran, err } = await run(middleware, req);
159
+ expect(err).toBeUndefined();
160
+ expect(ran).toBe(true);
161
+ expect(req.fortressSubject).toEqual({ type: 'USER', id: ctx.userId });
162
+ expect(req.fortressClaims).toBeDefined();
163
+ });
164
+
165
+ it('rejects requests with no credential', async () => {
166
+ const middleware = createAuthMiddleware(ctx.fortress);
167
+ const req: ExpressRequest = { headers: {}, method: 'GET', path: '/me' };
168
+ const { ran, err } = await run(middleware, req);
169
+ expect(ran).toBe(false);
170
+ expect(err?.code).toBe('UNAUTHORIZED');
171
+ });
172
+
173
+ it('rejects requests with an unknown api-key', async () => {
174
+ const middleware = createAuthMiddleware(ctx.fortress);
175
+ const req: ExpressRequest = {
176
+ headers: { 'x-api-key': 'test_sk_not-real' },
177
+ method: 'GET',
178
+ path: '/me',
179
+ };
180
+ const { ran, err } = await run(middleware, req);
181
+ expect(ran).toBe(false);
182
+ expect(err?.code).toBe('UNAUTHORIZED');
183
+ });
184
+ });
185
+
186
+ describe('express user routes: api-key + RBAC middleware', () => {
187
+ let ctx: Ctx;
188
+ beforeEach(async () => {
189
+ ctx = await setup();
190
+ });
191
+
192
+ it('allows a SERVICE_ACCOUNT api-key when the bound role grants the permission', async () => {
193
+ const authMw = createAuthMiddleware(ctx.fortress);
194
+ const rbacMw = createRbacMiddleware(ctx.fortress, {
195
+ routeMap: { 'POST /deploy/run': { resource: 'deploy', action: 'run' } },
196
+ });
197
+ const req: ExpressRequest = {
198
+ headers: { authorization: `ApiKey ${ctx.saKey}` },
199
+ method: 'POST',
200
+ path: '/deploy/run',
201
+ };
202
+ const auth = await run(authMw, req);
203
+ expect(auth.err).toBeUndefined();
204
+ const rbac = await run(rbacMw, req);
205
+ expect(rbac.err).toBeUndefined();
206
+ expect(rbac.ran).toBe(true);
207
+ expect((req.fortressSubject as Subject).type).toBe('SERVICE_ACCOUNT');
208
+ });
209
+
210
+ it('denies a SERVICE_ACCOUNT api-key that lacks the permission', async () => {
211
+ const authMw = createAuthMiddleware(ctx.fortress);
212
+ const rbacMw = createRbacMiddleware(ctx.fortress, {
213
+ routeMap: { 'POST /deploy/run': { resource: 'deploy', action: 'run' } },
214
+ });
215
+ const req: ExpressRequest = {
216
+ headers: { 'x-api-key': ctx.saKeyNoPerm },
217
+ method: 'POST',
218
+ path: '/deploy/run',
219
+ };
220
+ await run(authMw, req);
221
+ const rbac = await run(rbacMw, req);
222
+ expect(rbac.ran).toBe(false);
223
+ expect(rbac.err?.code).toBe('FORBIDDEN');
224
+ });
225
+
226
+ it('allows a USER api-key when the user has the permission', async () => {
227
+ const authMw = createAuthMiddleware(ctx.fortress);
228
+ const rbacMw = createRbacMiddleware(ctx.fortress, {
229
+ routeMap: { 'POST /deploy/run': { resource: 'deploy', action: 'run' } },
230
+ });
231
+ const req: ExpressRequest = {
232
+ headers: { authorization: `ApiKey ${ctx.userKey}` },
233
+ method: 'POST',
234
+ path: '/deploy/run',
235
+ };
236
+ await run(authMw, req);
237
+ const rbac = await run(rbacMw, req);
238
+ expect(rbac.err).toBeUndefined();
239
+ expect(rbac.ran).toBe(true);
240
+ });
241
+ });
@@ -0,0 +1,442 @@
1
+ import type { ErrorRequestHandler, Express, RequestHandler } from 'express';
2
+ import type { PluginRequestContext } from '../core/http/plugin-middleware';
3
+ import type { ExpressNextFunction, ExpressRequest, ExpressResponse } from './middleware';
4
+ import { once } from 'node:events';
5
+ import express from 'express';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { FortressError } from '../core/errors';
8
+ import { createFortress } from '../core/fortress';
9
+ import { assertSuccess } from '../core/types';
10
+ import { rateLimit } from '../plugins/rate-limit';
11
+ import { createTestAdapter } from '../testing';
12
+ import { expressToWebRequest } from './handle';
13
+ import { createAuthMiddleware, createCsrfMiddleware, createErrorHandler, createExpressMiddleware, createRbacMiddleware, getClaims, getDb, getUserId } from './middleware';
14
+
15
+ const SECRET = 'express-test-secret-32-chars!!!x';
16
+
17
+ function mockRes(): ExpressResponse {
18
+ let statusCode = 200;
19
+ let body: unknown;
20
+ return {
21
+ status(code: number) {
22
+ statusCode = code;
23
+ return this;
24
+ },
25
+ json(b: unknown) {
26
+ body = b;
27
+ },
28
+ setHeader() {},
29
+ get _statusCode() { return statusCode; },
30
+ get _body() { return body; },
31
+ } as any;
32
+ }
33
+
34
+ async function withExpressServer(app: Express, run: (baseUrl: string) => Promise<void>): Promise<void> {
35
+ const server = app.listen(0);
36
+ await once(server, 'listening');
37
+ const address = server.address();
38
+ if (!address || typeof address === 'string')
39
+ throw new Error('Express test server did not bind to a TCP port');
40
+
41
+ try {
42
+ await run(`http://127.0.0.1:${address.port}`);
43
+ }
44
+ finally {
45
+ await new Promise<void>((resolve, reject) => {
46
+ server.close(error => error ? reject(error) : resolve());
47
+ });
48
+ }
49
+ }
50
+
51
+ describe('express adapter', () => {
52
+ it('preserves parsed form-urlencoded bodies for OAuth dispatch', async () => {
53
+ const request = expressToWebRequest({
54
+ headers: {
55
+ 'host': 'example.test',
56
+ 'content-type': 'Application/X-WWW-Form-Urlencoded; Charset=UTF-8',
57
+ },
58
+ method: 'POST',
59
+ path: '/oauth/token',
60
+ originalUrl: '/oauth/token?trace=1',
61
+ protocol: 'https',
62
+ body: {
63
+ grant_type: 'client_credentials',
64
+ client_id: 'client id',
65
+ client_secret: 'secret+value',
66
+ scope: ['read', 'write'],
67
+ },
68
+ }, '/oauth/token');
69
+
70
+ expect(request.url).toBe('https://example.test/oauth/token?trace=1');
71
+ expect(await request.text()).toBe(
72
+ 'grant_type=client_credentials&client_id=client+id&client_secret=secret%2Bvalue&scope=read&scope=write',
73
+ );
74
+ });
75
+
76
+ it('createExpressMiddleware returns auth, rbac, csrf, and error handler', () => {
77
+ const fortress = createFortress({
78
+ jwt: { key: SECRET },
79
+ database: createTestAdapter(),
80
+ });
81
+
82
+ const { authMiddleware, csrfMiddleware, rbacMiddleware, errorHandler } = createExpressMiddleware(fortress);
83
+ expect(typeof authMiddleware).toBe('function');
84
+ expect(typeof csrfMiddleware).toBe('function');
85
+ expect(typeof rbacMiddleware).toBe('function');
86
+ expect(typeof errorHandler).toBe('function');
87
+ });
88
+
89
+ it('auth middleware rejects missing Authorization header', async () => {
90
+ const fortress = createFortress({
91
+ jwt: { key: SECRET },
92
+ database: createTestAdapter(),
93
+ });
94
+
95
+ const middleware = createAuthMiddleware(fortress);
96
+ const req: ExpressRequest = { headers: {}, method: 'GET', path: '/api/test' };
97
+ const res = mockRes();
98
+ let nextError: unknown;
99
+
100
+ await middleware(req, res, ((err?: unknown) => {
101
+ nextError = err;
102
+ }) as ExpressNextFunction);
103
+
104
+ expect(nextError).toBeDefined();
105
+ expect((nextError as any).code).toBe('UNAUTHORIZED');
106
+ });
107
+
108
+ it('auth middleware sets user context on valid token', async () => {
109
+ const fortress = createFortress({
110
+ jwt: { key: SECRET },
111
+ database: createTestAdapter(),
112
+ });
113
+
114
+ const user = await fortress.auth.createUser({
115
+ email: 'test@test.com',
116
+ name: 'Test',
117
+ password: 'password-123456',
118
+ });
119
+ const loginResult = await fortress.auth.login('test@test.com', 'password-123456');
120
+ assertSuccess(loginResult);
121
+
122
+ const middleware = createAuthMiddleware(fortress);
123
+ const req: ExpressRequest = {
124
+ headers: { authorization: `Bearer ${loginResult.accessToken}` },
125
+ method: 'GET',
126
+ path: '/api/test',
127
+ };
128
+ const res = mockRes();
129
+ let nextCalled = false;
130
+
131
+ await middleware(req, res, (() => {
132
+ nextCalled = true;
133
+ }) as ExpressNextFunction);
134
+
135
+ expect(nextCalled).toBe(true);
136
+ expect(req.fortressUserId).toBe(user.id);
137
+ expect(req.fortressClaims).toBeDefined();
138
+ expect(req.fortressDb).toBeDefined();
139
+ expect(getUserId(req)).toBe(user.id);
140
+ expect(getClaims(req)).toBeDefined();
141
+ expect(getDb(req)).toBeDefined();
142
+ });
143
+
144
+ it('passes a faithful authenticated PluginRequestContext after auth', async () => {
145
+ let captured: PluginRequestContext | undefined;
146
+ const fortress = createFortress({
147
+ jwt: { key: SECRET },
148
+ database: createTestAdapter(),
149
+ plugins: [{
150
+ name: 'context-spy',
151
+ middleware: [{
152
+ path: '/api/*',
153
+ position: 'after-auth',
154
+ handler: async (_ctx, request, next) => {
155
+ captured = request;
156
+ await next();
157
+ },
158
+ }],
159
+ }],
160
+ });
161
+ const user = await fortress.auth.createUser({
162
+ email: 'context@test.com',
163
+ name: 'Context',
164
+ password: 'password-123456',
165
+ });
166
+ const login = await fortress.auth.login('context@test.com', 'password-123456');
167
+ assertSuccess(login);
168
+ const middleware = createExpressMiddleware(fortress);
169
+ const req: ExpressRequest = {
170
+ headers: {
171
+ 'authorization': `Bearer ${login.accessToken}`,
172
+ 'host': 'example.test',
173
+ 'content-type': 'application/json',
174
+ },
175
+ method: 'POST',
176
+ path: '/api/items',
177
+ originalUrl: '/api/items?include=all',
178
+ protocol: 'https',
179
+ body: { name: 'item' },
180
+ };
181
+ await middleware.authMiddleware(req, mockRes(), (() => {}) as ExpressNextFunction);
182
+ await middleware.pluginMiddleware.afterAuth(req, mockRes(), (() => {}) as ExpressNextFunction);
183
+
184
+ expect(captured?.request.url).toBe('https://example.test/api/items?include=all');
185
+ expect(captured?.request.method).toBe('POST');
186
+ await expect(captured?.request.json()).resolves.toEqual({ name: 'item' });
187
+ expect(captured?.fortressSubject).toEqual({ type: 'USER', id: user.id });
188
+ expect(captured?.fortressUserId).toBe(user.id);
189
+ expect(captured?.fortressClaims?.sub).toBe(user.id);
190
+ });
191
+
192
+ it('normalizes plugin middleware to PluginRequestContext so path rate limits fire', async () => {
193
+ const fortress = createFortress({
194
+ jwt: { key: SECRET },
195
+ database: createTestAdapter(),
196
+ plugins: [rateLimit({
197
+ login: { disabled: true },
198
+ register: { disabled: true },
199
+ paths: [{ match: '/api/*', rule: { maxPerIp: 1, windowSeconds: 60 } }],
200
+ })],
201
+ });
202
+ const middleware = createExpressMiddleware(fortress).pluginMiddleware.beforeAuth;
203
+ const req: ExpressRequest = {
204
+ headers: { 'x-forwarded-for': '192.0.2.1' },
205
+ method: 'GET',
206
+ path: '/api/items',
207
+ };
208
+ const invoke = async (): Promise<unknown> => {
209
+ let nextError: unknown;
210
+ await middleware(req, mockRes(), ((error?: unknown) => {
211
+ nextError = error;
212
+ }) as ExpressNextFunction);
213
+ return nextError;
214
+ };
215
+
216
+ await expect(invoke()).resolves.toBeUndefined();
217
+ await expect(invoke()).resolves.toMatchObject({ code: 'RATE_LIMITED' });
218
+ });
219
+
220
+ it('standalone CSRF middleware rejects unsafe requests and matches skips at segment boundaries', async () => {
221
+ const middleware = createCsrfMiddleware({ skipPaths: ['/webhook'] });
222
+ const invoke = async (req: ExpressRequest): Promise<unknown> => {
223
+ let nextError: unknown;
224
+ await middleware(req, mockRes(), ((error?: unknown) => {
225
+ nextError = error;
226
+ }) as ExpressNextFunction);
227
+ return nextError;
228
+ };
229
+
230
+ await expect(invoke({ headers: {}, method: 'GET', path: '/api/items' })).resolves.toBeUndefined();
231
+ await expect(invoke({ headers: {}, method: 'POST', path: '/api/items' }))
232
+ .resolves
233
+ .toMatchObject({ code: 'FORBIDDEN', statusCode: 403 });
234
+ await expect(invoke({
235
+ headers: { 'x-fortress-csrf': '1' },
236
+ method: 'POST',
237
+ path: '/api/items',
238
+ })).resolves.toBeUndefined();
239
+ await expect(invoke({ headers: {}, method: 'POST', path: '/webhook/delivery' })).resolves.toBeUndefined();
240
+ await expect(invoke({ headers: {}, method: 'POST', path: '/webhook-evil' }))
241
+ .resolves
242
+ .toMatchObject({ code: 'FORBIDDEN' });
243
+ await expect(invoke({
244
+ headers: { 'x-fortress-csrf': '1', 'sec-fetch-site': 'cross-site' },
245
+ method: 'POST',
246
+ path: '/api/items',
247
+ })).resolves.toMatchObject({ code: 'FORBIDDEN' });
248
+ });
249
+
250
+ it('propagates custom CSRF factory options including safe methods and wildcard skips', async () => {
251
+ const fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
252
+ const middleware = createExpressMiddleware(fortress, {
253
+ csrf: {
254
+ headerName: 'X-Custom-CSRF',
255
+ safeMethods: ['get', 'post'],
256
+ skipPaths: ['/hooks/*'],
257
+ },
258
+ }).csrfMiddleware;
259
+ const invoke = async (req: ExpressRequest): Promise<unknown> => {
260
+ let nextError: unknown;
261
+ await middleware(req, mockRes(), ((error?: unknown) => {
262
+ nextError = error;
263
+ }) as ExpressNextFunction);
264
+ return nextError;
265
+ };
266
+
267
+ await expect(invoke({ headers: {}, method: 'POST', path: '/api/items' })).resolves.toBeUndefined();
268
+ await expect(invoke({ headers: {}, method: 'DELETE', path: '/hooks/delivery' })).resolves.toBeUndefined();
269
+ await expect(invoke({
270
+ headers: { 'X-Custom-CSRF': '1' },
271
+ method: 'PUT',
272
+ path: '/api/items',
273
+ })).resolves.toBeUndefined();
274
+ await expect(invoke({
275
+ headers: { 'x-fortress-csrf': '1' },
276
+ method: 'PUT',
277
+ path: '/api/items',
278
+ })).resolves.toMatchObject({ code: 'FORBIDDEN' });
279
+ });
280
+
281
+ it('rbac middleware skips when no route mapping matches', async () => {
282
+ const fortress = createFortress({
283
+ jwt: { key: SECRET },
284
+ database: createTestAdapter(),
285
+ });
286
+
287
+ const middleware = createRbacMiddleware(fortress, { routeMap: {} });
288
+ const req: ExpressRequest = {
289
+ headers: {},
290
+ method: 'GET',
291
+ path: '/api/unmapped',
292
+ fortressUserId: '1',
293
+ };
294
+ let nextCalled = false;
295
+
296
+ await middleware(req, mockRes(), (() => {
297
+ nextCalled = true;
298
+ }) as ExpressNextFunction);
299
+ expect(nextCalled).toBe(true);
300
+ });
301
+
302
+ it('matches routeMap against originalUrl when Express strips a mount path', async () => {
303
+ const fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
304
+ const middleware = createRbacMiddleware(fortress, {
305
+ routeMap: { 'GET /api/posts': { resource: 'post', action: 'list' } },
306
+ });
307
+ const req: ExpressRequest = {
308
+ headers: {},
309
+ method: 'GET',
310
+ path: '/posts',
311
+ url: '/posts',
312
+ originalUrl: '/api/posts?limit=10',
313
+ };
314
+ let nextError: unknown;
315
+
316
+ await middleware(req, mockRes(), ((error?: unknown) => {
317
+ nextError = error;
318
+ }) as ExpressNextFunction);
319
+ // If req.path were used, this would silently fall through as unmapped.
320
+ expect(nextError).toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
321
+ });
322
+
323
+ it('protects Express case and trailing-slash route variants and treats dots literally', async () => {
324
+ const fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
325
+ const app = express();
326
+ const router = express.Router();
327
+ const rbac = createRbacMiddleware(fortress, {
328
+ routeMap: {
329
+ 'GET /API/Admin/Users/': { resource: 'admin', action: 'list' },
330
+ 'GET /API/Report.JSON/': { resource: 'report', action: 'read' },
331
+ 'GET /API/Users/:id/': { resource: 'user', action: 'read' },
332
+ },
333
+ });
334
+
335
+ router.use(rbac as unknown as RequestHandler);
336
+ router.get('/admin/users', (_req, res) => res.json({ bypassed: true }));
337
+ router.get('/report.json', (_req, res) => res.json({ bypassed: true }));
338
+ router.get('/users/:id', (_req, res) => res.json({ bypassed: true }));
339
+ router.get('/reportXjson', (_req, res) => res.json({ public: true }));
340
+ app.use('/api', router);
341
+ app.use(createErrorHandler() as unknown as ErrorRequestHandler);
342
+
343
+ await withExpressServer(app, async (baseUrl) => {
344
+ for (const path of ['/api/Admin/users', '/api/admin/users/']) {
345
+ const response = await fetch(`${baseUrl}${path}`);
346
+ expect(response.status, path).toBe(401);
347
+ }
348
+
349
+ const protectedParam = await fetch(`${baseUrl}/api/Users/42/`);
350
+ expect(protectedParam.status).toBe(401);
351
+
352
+ const protectedDot = await fetch(`${baseUrl}/api/report.json`);
353
+ expect(protectedDot.status).toBe(401);
354
+
355
+ const literalDot = await fetch(`${baseUrl}/api/reportXjson`);
356
+ expect(literalDot.status).toBe(200);
357
+ expect(await literalDot.json()).toEqual({ public: true });
358
+ });
359
+ });
360
+
361
+ it('normalizes configured Express skip paths for case and trailing slashes', async () => {
362
+ const fortress = createFortress({ jwt: { key: SECRET }, database: createTestAdapter() });
363
+ const app = express();
364
+ const router = express.Router();
365
+ const rbac = createRbacMiddleware(fortress, {
366
+ routeMap: {},
367
+ skipPaths: ['/API/Health/', '/API/Public/*'],
368
+ unmappedRoutes: 'deny',
369
+ });
370
+
371
+ router.use(rbac as unknown as RequestHandler);
372
+ router.get('/health', (_req, res) => res.json({ ok: true }));
373
+ router.get('/public/info', (_req, res) => res.json({ ok: true }));
374
+ app.use('/api', router);
375
+ app.use(createErrorHandler() as unknown as ErrorRequestHandler);
376
+
377
+ await withExpressServer(app, async (baseUrl) => {
378
+ for (const path of ['/api/health', '/api/HEALTH/', '/api/Public/Info/']) {
379
+ const response = await fetch(`${baseUrl}${path}`);
380
+ expect(response.status, path).toBe(200);
381
+ }
382
+ });
383
+ });
384
+
385
+ it('rbac middleware denies unmapped routes when unmappedRoutes: deny', async () => {
386
+ const fortress = createFortress({
387
+ jwt: { key: SECRET },
388
+ database: createTestAdapter(),
389
+ });
390
+
391
+ const middleware = createRbacMiddleware(fortress, { routeMap: {}, unmappedRoutes: 'deny' });
392
+ const req: ExpressRequest = {
393
+ headers: {},
394
+ method: 'GET',
395
+ path: '/api/unmapped',
396
+ fortressUserId: '1',
397
+ };
398
+ let nextErr: unknown;
399
+
400
+ await middleware(req, mockRes(), ((err?: unknown) => {
401
+ nextErr = err;
402
+ }) as ExpressNextFunction);
403
+ expect(nextErr).toBeInstanceOf(FortressError);
404
+ expect((nextErr as FortressError).statusCode).toBe(403);
405
+ });
406
+
407
+ it('rbac middleware still allows skipPaths under unmappedRoutes: deny', async () => {
408
+ const fortress = createFortress({
409
+ jwt: { key: SECRET },
410
+ database: createTestAdapter(),
411
+ });
412
+
413
+ const middleware = createRbacMiddleware(fortress, {
414
+ routeMap: {},
415
+ unmappedRoutes: 'deny',
416
+ skipPaths: ['/api/health'],
417
+ });
418
+ const req: ExpressRequest = { headers: {}, method: 'GET', path: '/api/health' };
419
+ let nextErr: unknown;
420
+ let nextCalled = false;
421
+
422
+ await middleware(req, mockRes(), ((err?: unknown) => {
423
+ if (err)
424
+ nextErr = err;
425
+ else nextCalled = true;
426
+ }) as ExpressNextFunction);
427
+ expect(nextErr).toBeUndefined();
428
+ expect(nextCalled).toBe(true);
429
+ });
430
+
431
+ it('error handler formats FortressError correctly', () => {
432
+ const handler = createErrorHandler();
433
+ const res = mockRes() as any;
434
+
435
+ const err = new FortressError('FORBIDDEN', 'No access', 403);
436
+
437
+ handler(err, {} as any, res, (() => {}) as ExpressNextFunction);
438
+
439
+ expect(res._statusCode).toBe(403);
440
+ expect(res._body).toEqual({ code: 'FORBIDDEN', message: 'No access', statusCode: 403 });
441
+ });
442
+ });