@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,551 @@
1
+ import type { DatabaseAdapter } from '../adapters/database';
2
+ import type { Fortress } from '../core/fortress';
3
+ import type { PluginRequestContext } from '../core/http/plugin-middleware';
4
+ import type { MiddlewareDefinition, PluginContext } from '../core/plugin';
5
+ import type { Subject, TokenClaims } from '../core/types';
6
+ import { FortressError } from '../core/errors';
7
+ import {
8
+ chainAdapterWrappers,
9
+ collectScopeRules,
10
+ executePluginMiddleware,
11
+ wrapAdapterWithScopeRules,
12
+ } from '../core/plugin-runner';
13
+
14
+ // Minimal Express-compatible types so users bring their own express version
15
+
16
+ /**
17
+ * Fortress-specific fields attached to the Express `Request` by
18
+ * {@link createAuthMiddleware}. Exported so host apps can declaration-merge
19
+ * them into express's native `Request` type for typed access without casts.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * // src/types/express.d.ts
24
+ * import type { FortressExpressFields } from '@bajustone/fortress/express';
25
+ *
26
+ * declare module 'express-serve-static-core' {
27
+ * interface Request extends FortressExpressFields {}
28
+ * }
29
+ * ```
30
+ */
31
+ export interface FortressExpressFields {
32
+ /**
33
+ * The resolved principal — set for every authenticated request regardless
34
+ * of credential type (JWT, api-key, future OAuth client_credentials, mTLS).
35
+ */
36
+ fortressSubject?: Subject;
37
+ /**
38
+ * Convenience alias — populated **only** when the subject is a `USER`.
39
+ * Non-USER subjects (e.g. `SERVICE_ACCOUNT` via api-key) leave this
40
+ * undefined; fall back to {@link fortressSubject}.
41
+ */
42
+ fortressUserId?: string;
43
+ fortressClaims?: TokenClaims;
44
+ fortressScopes?: string[] | null;
45
+ fortressDb?: DatabaseAdapter;
46
+ fortressGetScopedDb?: (model: string) => Promise<DatabaseAdapter>;
47
+ }
48
+
49
+ /** Minimal Express request shape fortress reads from. Compatible with any modern Express version. */
50
+ export interface ExpressRequest extends FortressExpressFields {
51
+ headers: Record<string, string | string[] | undefined>;
52
+ method: string;
53
+ /** Express's route path without the query string. */
54
+ path: string;
55
+ /** Original path including query string, when supplied by Express. */
56
+ originalUrl?: string;
57
+ /** Node/Express request URL fallback, usually including the query string. */
58
+ url?: string;
59
+ /** Resolved request protocol (`http`/`https`) when supplied by Express. */
60
+ protocol?: string;
61
+ /** Parsed request body, if body middleware ran before this slot. */
62
+ body?: unknown;
63
+ }
64
+
65
+ /** Minimal Express response shape fortress writes to. */
66
+ export interface ExpressResponse {
67
+ status: (code: number) => ExpressResponse;
68
+ json: (body: unknown) => void;
69
+ setHeader: (name: string, value: string) => void;
70
+ }
71
+
72
+ /** The `next(err?)` callback Express middleware signs off with. */
73
+ export type ExpressNextFunction = (err?: unknown) => void;
74
+ /** Express middleware signature fortress's adapter exports use. */
75
+ export type ExpressMiddleware = (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => void;
76
+
77
+ // --- Route mapping ---
78
+
79
+ /** A `(resource, action)` IAM mapping for an HTTP route. */
80
+ export interface RouteMapping {
81
+ resource: string;
82
+ action: string;
83
+ }
84
+
85
+ /** Options accepted by {@link createRbacMiddleware} (and the express adapter factory). */
86
+ export interface RbacOptions {
87
+ routeMap?: Record<string, RouteMapping>;
88
+ mapRequest?: (method: string, path: string) => RouteMapping | null;
89
+ skipPaths?: string[];
90
+ /**
91
+ * Behavior when a non-skipped route matches no `routeMap`/`mapRequest`
92
+ * entry. `'allow'` (default) treats it as public; `'deny'` fails closed
93
+ * with a 403 so a forgotten mapping can't silently expose a user route.
94
+ * List genuinely public routes in {@link RbacOptions.skipPaths} when using
95
+ * `'deny'`.
96
+ */
97
+ unmappedRoutes?: 'allow' | 'deny';
98
+ }
99
+
100
+ // --- Auth middleware ---
101
+
102
+ /**
103
+ * Build the Express auth middleware. Resolves the request principal via
104
+ * `fortress.resolvePrincipal`, which tries plugin `resolvePrincipal` hooks
105
+ * (api-key, future OAuth client_credentials, mTLS) first and then falls
106
+ * back to the JWT bearer token (cookie-first, `Authorization: Bearer`
107
+ * second). Populates `fortressSubject`, `fortressUserId` (USER alias),
108
+ * `fortressClaims`, `fortressDb`, and `fortressGetScopedDb` on the request.
109
+ */
110
+ export function createAuthMiddleware(fortress: Fortress): ExpressMiddleware {
111
+ return async (req, _res, next) => {
112
+ try {
113
+ // Adapt the Express request into a minimal web Request so
114
+ // fortress.resolvePrincipal can read headers / cookies uniformly.
115
+ const headerJar = new Headers();
116
+ for (const [k, v] of Object.entries(req.headers)) {
117
+ if (Array.isArray(v)) {
118
+ for (const item of v) headerJar.append(k, item);
119
+ }
120
+ else if (typeof v === 'string') {
121
+ headerJar.set(k, v);
122
+ }
123
+ }
124
+ const requestPath = expressRequestPath(req);
125
+ const host = expressHeader(req, 'host') ?? 'localhost';
126
+ const protocol = req.protocol ?? expressHeader(req, 'x-forwarded-proto') ?? 'http';
127
+ const probe = new Request(`${protocol}://${host}${requestPath}`, {
128
+ method: req.method,
129
+ headers: headerJar,
130
+ });
131
+
132
+ const resolved = await fortress.resolvePrincipal(probe);
133
+ if (!resolved) {
134
+ throw new FortressError('UNAUTHORIZED', 'Missing or invalid credentials', 401);
135
+ }
136
+
137
+ const { subject, claims, scopes } = resolved;
138
+ req.fortressSubject = subject;
139
+ if (subject.type === 'USER')
140
+ req.fortressUserId = subject.id;
141
+ if (claims)
142
+ req.fortressClaims = claims;
143
+ if (scopes !== undefined)
144
+ req.fortressScopes = scopes;
145
+
146
+ const plugins = fortress.config.plugins ?? [];
147
+ // Tenant comes from the verified JWT claim, never a client header.
148
+ const requestContext: Record<string, unknown> = {
149
+ tenantId: claims?.customClaims?.tenantId,
150
+ ipAddress: req.headers['x-forwarded-for'] ?? req.headers['x-real-ip'],
151
+ userAgent: req.headers['user-agent'],
152
+ };
153
+
154
+ const wrappedAdapter = chainAdapterWrappers(plugins, fortress.config.database, requestContext);
155
+ req.fortressDb = wrappedAdapter;
156
+
157
+ const pluginCtx: PluginContext = { db: wrappedAdapter, config: fortress.config };
158
+ req.fortressGetScopedDb = async (model: string): Promise<DatabaseAdapter> => {
159
+ const scopeRule = await collectScopeRules(plugins, subject.id, model, pluginCtx);
160
+ if (!scopeRule)
161
+ return wrappedAdapter;
162
+ return wrapAdapterWithScopeRules(wrappedAdapter, scopeRule);
163
+ };
164
+
165
+ next();
166
+ }
167
+ catch (err) {
168
+ next(err);
169
+ }
170
+ };
171
+ }
172
+
173
+ // --- CSRF middleware (user routes only) ---
174
+
175
+ /** Options accepted by {@link createCsrfMiddleware}. */
176
+ export interface CsrfConfig {
177
+ /** Header name required on unsafe methods. Default: `X-Fortress-CSRF`. */
178
+ headerName?: string;
179
+ /** Paths/subtrees exempt from CSRF checking. A trailing `/*` is accepted. */
180
+ skipPaths?: string[];
181
+ /** Methods exempt from CSRF checking. Default: GET, HEAD, OPTIONS. */
182
+ safeMethods?: string[];
183
+ }
184
+
185
+ /**
186
+ * Build standalone Express CSRF middleware for host-owned routes. Fortress-
187
+ * managed routes already enforce cookie-aware CSRF inside `handleRequest`.
188
+ * This middleware uses the custom-header strategy and rejects cross-site
189
+ * browser requests on unsafe methods.
190
+ */
191
+ export function createCsrfMiddleware(config?: CsrfConfig): ExpressMiddleware {
192
+ const headerName = (config?.headerName ?? 'X-Fortress-CSRF').toLowerCase();
193
+ const skipPaths = config?.skipPaths ?? [];
194
+ const safeMethods = new Set((config?.safeMethods ?? ['GET', 'HEAD', 'OPTIONS']).map(method => method.toUpperCase()));
195
+
196
+ return (req, _res, next) => {
197
+ try {
198
+ if (safeMethods.has(req.method.toUpperCase()) || matchesCsrfSkipPath(req.path, skipPaths)) {
199
+ next();
200
+ return;
201
+ }
202
+
203
+ const fetchSite = expressHeader(req, 'sec-fetch-site');
204
+ if (fetchSite === 'cross-site')
205
+ throw new FortressError('FORBIDDEN', 'CSRF: cross-site request rejected', 403);
206
+ if (!expressHeader(req, headerName))
207
+ throw new FortressError('FORBIDDEN', `CSRF: missing ${config?.headerName ?? 'X-Fortress-CSRF'} header`, 403);
208
+
209
+ next();
210
+ }
211
+ catch (error) {
212
+ next(error);
213
+ }
214
+ };
215
+ }
216
+
217
+ // --- RBAC middleware (user routes only) ---
218
+
219
+ /**
220
+ * Build the Express RBAC middleware for **user-owned routes**. Resolves a
221
+ * `(resource, action)` mapping via `routeMap` / `mapRequest` and enforces
222
+ * the IAM permission check. Fortress-managed routes (`/auth/*`, `/iam/*`,
223
+ * plugin paths) are protected automatically inside `fortress.handleRequest`
224
+ * — this middleware only handles routes the caller registered themselves.
225
+ */
226
+ export function createRbacMiddleware(fortress: Fortress, options?: RbacOptions): ExpressMiddleware {
227
+ // Express's default router is case-insensitive and ignores one trailing slash.
228
+ // Canonicalize configured paths once so exact and parameterized lookups share
229
+ // those semantics with the request path below.
230
+ const routeMap = normalizeRouteMap(options?.routeMap ?? {});
231
+ const skipPaths = options?.skipPaths ?? [];
232
+ const skipPatterns = skipPaths.map(p => pathToRegex(p));
233
+ const unmappedRoutes = options?.unmappedRoutes ?? 'allow';
234
+
235
+ return async (req, _res, next) => {
236
+ try {
237
+ // Express strips an `app.use('/mount', ...)` prefix from req.path/url.
238
+ // Match against originalUrl so route maps use the same full path as
239
+ // Hono/SvelteKit and as the host wrote in configuration.
240
+ const path = normalizeExpressPath(new URL(expressRequestPath(req), 'http://localhost').pathname);
241
+ const method = req.method;
242
+
243
+ if (skipPatterns.some(pattern => pattern.test(path))) {
244
+ next();
245
+ return;
246
+ }
247
+
248
+ const key = `${method} ${path}`;
249
+ let mapping: RouteMapping | null = routeMap[key] ?? null;
250
+
251
+ if (!mapping) {
252
+ mapping = findRouteMapMatch(method, path, routeMap);
253
+ }
254
+
255
+ if (!mapping && options?.mapRequest) {
256
+ mapping = options.mapRequest(method, path);
257
+ }
258
+
259
+ if (!mapping) {
260
+ if (unmappedRoutes === 'deny') {
261
+ // Fail closed: an unmapped route under deny mode is refused rather
262
+ // than treated as public. Authenticating won't help — the route
263
+ // needs an explicit mapping or a skipPaths entry.
264
+ throw new FortressError('FORBIDDEN', 'No permission mapping for this route', 403);
265
+ }
266
+ // No declarative mapping — caller treats this as a public route.
267
+ next();
268
+ return;
269
+ }
270
+
271
+ if (!req.fortressSubject) {
272
+ throw new FortressError('UNAUTHORIZED', 'Not authenticated', 401);
273
+ }
274
+
275
+ const allowed = await fortress.iam.checkPermission(req.fortressSubject, mapping.resource, mapping.action, {
276
+ credentialScopes: req.fortressScopes,
277
+ });
278
+ if (!allowed) {
279
+ throw new FortressError('FORBIDDEN', 'Insufficient permissions', 403);
280
+ }
281
+
282
+ next();
283
+ }
284
+ catch (err) {
285
+ next(err);
286
+ }
287
+ };
288
+ }
289
+
290
+ // --- Error handler ---
291
+
292
+ /**
293
+ * Build the Express error handler. Translates {@link FortressError} into the
294
+ * appropriate HTTP response with `Retry-After` for rate-limited responses,
295
+ * and returns a generic 500 for everything else.
296
+ *
297
+ * Stays synchronous so it composes naturally with Express's `(err, req,
298
+ * res, next)` signature. Shares the response *shape* with core's
299
+ * `errorToResponse` via {@link FortressError.toJSON}. When a `Fortress`
300
+ * instance is provided, unhandled errors are routed to its configured
301
+ * {@link FortressLogger}; otherwise they're silently swallowed (matching
302
+ * the silent-by-default posture of the library).
303
+ */
304
+ export function createErrorHandler(fortress?: Fortress): (err: unknown, req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => void {
305
+ return (err, _req, res, _next) => {
306
+ if (err instanceof FortressError) {
307
+ if (err.code === 'RATE_LIMITED' && err.retryAfter) {
308
+ res.setHeader('Retry-After', String(err.retryAfter));
309
+ }
310
+ res.status(err.statusCode).json(err.toJSON());
311
+ return;
312
+ }
313
+
314
+ fortress?.logger.error(
315
+ { err, message: err instanceof Error ? err.message : 'Unknown error' },
316
+ 'unhandled error in express middleware',
317
+ );
318
+ res.status(500).json({ code: 'INTERNAL_ERROR', message: 'Internal server error', statusCode: 500 });
319
+ };
320
+ }
321
+
322
+ // --- Plugin middleware ---
323
+
324
+ /**
325
+ * Express middleware that executes plugin-defined middleware for a given
326
+ * position (`before-auth`, `after-auth`, `after-rbac`). Used internally by
327
+ * {@link createExpressMiddleware}; expose if you want to mount the plugin
328
+ * middleware slots manually.
329
+ */
330
+ export function createExpressPluginMiddleware(
331
+ fortress: Fortress,
332
+ position: MiddlewareDefinition['position'],
333
+ ): ExpressMiddleware {
334
+ const plugins = fortress.config.plugins ?? [];
335
+
336
+ return async (req, _res, next) => {
337
+ try {
338
+ const ctx: PluginContext = { db: fortress.config.database, config: fortress.config };
339
+ const headers = new Headers();
340
+ for (const [name, value] of Object.entries(req.headers)) {
341
+ if (Array.isArray(value)) {
342
+ for (const item of value) headers.append(name, item);
343
+ }
344
+ else if (typeof value === 'string') {
345
+ headers.set(name, value);
346
+ }
347
+ }
348
+ const requestPath = expressRequestPath(req);
349
+ const forwardedProtocol = req.headers['x-forwarded-proto'];
350
+ const protocol = req.protocol
351
+ ?? (Array.isArray(forwardedProtocol) ? forwardedProtocol[0] : forwardedProtocol)
352
+ ?? 'http';
353
+ const hostHeader = req.headers.host;
354
+ const host = (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader) ?? 'localhost';
355
+ const method = req.method.toUpperCase();
356
+ let body: BodyInit | undefined;
357
+ if (method !== 'GET' && method !== 'HEAD' && req.body !== undefined) {
358
+ if (typeof req.body === 'string' || req.body instanceof URLSearchParams)
359
+ body = req.body;
360
+ else if (req.body instanceof ArrayBuffer || ArrayBuffer.isView(req.body))
361
+ body = req.body as BodyInit;
362
+ else if (headers.get('content-type')?.toLowerCase().startsWith('application/x-www-form-urlencoded'))
363
+ body = new URLSearchParams(Object.entries(req.body as Record<string, unknown>).map(([key, value]) => [key, String(value)]));
364
+ else
365
+ body = JSON.stringify(req.body);
366
+ }
367
+ const requestContext: PluginRequestContext = {
368
+ request: new Request(`${protocol}://${host}${requestPath}`, {
369
+ method,
370
+ headers,
371
+ body,
372
+ }),
373
+ fortressSubject: req.fortressSubject,
374
+ fortressUserId: req.fortressUserId,
375
+ fortressClaims: req.fortressClaims,
376
+ fortressScopes: req.fortressScopes,
377
+ };
378
+ await executePluginMiddleware(
379
+ plugins,
380
+ position,
381
+ new URL(requestPath, 'http://localhost').pathname,
382
+ ctx,
383
+ requestContext,
384
+ );
385
+ next();
386
+ }
387
+ catch (err) {
388
+ next(err);
389
+ }
390
+ };
391
+ }
392
+
393
+ // --- Factory ---
394
+
395
+ /** Options for {@link createExpressMiddleware}. Extends {@link RbacOptions}. */
396
+ export interface ExpressAdapterOptions extends RbacOptions {
397
+ /** Standalone CSRF middleware options for host-owned Express routes. */
398
+ csrf?: CsrfConfig;
399
+ }
400
+
401
+ /**
402
+ * Build the full set of fortress Express middleware: auth, CSRF, RBAC, error
403
+ * handler, and the three plugin middleware slots (`beforeAuth`, `afterAuth`,
404
+ * `afterRbac`). Mount each in the corresponding place in your Express app.
405
+ */
406
+ export function createExpressMiddleware(fortress: Fortress, options?: ExpressAdapterOptions): {
407
+ authMiddleware: ExpressMiddleware;
408
+ csrfMiddleware: ExpressMiddleware;
409
+ rbacMiddleware: ExpressMiddleware;
410
+ errorHandler: ReturnType<typeof createErrorHandler>;
411
+ pluginMiddleware: {
412
+ beforeAuth: ExpressMiddleware;
413
+ afterAuth: ExpressMiddleware;
414
+ afterRbac: ExpressMiddleware;
415
+ };
416
+ } {
417
+ return {
418
+ authMiddleware: createAuthMiddleware(fortress),
419
+ csrfMiddleware: createCsrfMiddleware(options?.csrf),
420
+ rbacMiddleware: createRbacMiddleware(fortress, options),
421
+ errorHandler: createErrorHandler(fortress),
422
+ pluginMiddleware: {
423
+ beforeAuth: createExpressPluginMiddleware(fortress, 'before-auth'),
424
+ afterAuth: createExpressPluginMiddleware(fortress, 'after-auth'),
425
+ afterRbac: createExpressPluginMiddleware(fortress, 'after-rbac'),
426
+ },
427
+ };
428
+ }
429
+
430
+ // --- Helpers ---
431
+
432
+ /**
433
+ * Read the resolved request principal. Works for every subject kind
434
+ * (`USER`, `SERVICE_ACCOUNT`, ...). Throws 401 if the auth middleware did
435
+ * not run or no credential was present.
436
+ */
437
+ export function getSubject(req: ExpressRequest): Subject {
438
+ if (!req.fortressSubject) {
439
+ throw new FortressError('UNAUTHORIZED', 'Not authenticated', 401);
440
+ }
441
+ return req.fortressSubject;
442
+ }
443
+
444
+ /**
445
+ * Read the authenticated user ID. Throws 401 if the request was
446
+ * authenticated by a non-USER subject (e.g. a service account via
447
+ * api-key) — use {@link getSubject} for handlers that accept any
448
+ * principal.
449
+ */
450
+ export function getUserId(req: ExpressRequest): string {
451
+ if (!req.fortressSubject || req.fortressSubject.type !== 'USER') {
452
+ throw new FortressError('UNAUTHORIZED', 'User not authenticated', 401);
453
+ }
454
+ return req.fortressSubject.id;
455
+ }
456
+
457
+ /**
458
+ * Read the verified JWT claims. Only populated when the request was
459
+ * authenticated via a JWT — api-key principals have no JWT claims and
460
+ * this helper throws 401.
461
+ */
462
+ export function getClaims(req: ExpressRequest): TokenClaims {
463
+ if (!req.fortressClaims) {
464
+ throw new FortressError('UNAUTHORIZED', 'No JWT claims on this request', 401);
465
+ }
466
+ return req.fortressClaims;
467
+ }
468
+
469
+ /** Read the per-request fortress database adapter (with plugin wrappers applied). */
470
+ export function getDb(req: ExpressRequest): DatabaseAdapter {
471
+ if (!req.fortressDb) {
472
+ throw new FortressError('UNAUTHORIZED', 'User not authenticated', 401);
473
+ }
474
+ return req.fortressDb;
475
+ }
476
+
477
+ /** Read the per-request fortress database adapter scoped to a specific model (applies any active row-level scope rules). */
478
+ export function getScopedDb(req: ExpressRequest, model: string): Promise<DatabaseAdapter> {
479
+ if (!req.fortressGetScopedDb) {
480
+ throw new FortressError('UNAUTHORIZED', 'User not authenticated', 401);
481
+ }
482
+ return req.fortressGetScopedDb(model);
483
+ }
484
+
485
+ // --- Internal route matching ---
486
+
487
+ function findRouteMapMatch(method: string, path: string, routeMap: Record<string, RouteMapping>): RouteMapping | null {
488
+ for (const [pattern, mapping] of Object.entries(routeMap)) {
489
+ const [patternMethod, patternPath] = pattern.split(' ', 2);
490
+ if (patternMethod !== method)
491
+ continue;
492
+ if (pathToRegex(patternPath).test(path))
493
+ return mapping;
494
+ }
495
+ return null;
496
+ }
497
+
498
+ function expressRequestPath(req: ExpressRequest): string {
499
+ const path = req.originalUrl ?? req.url ?? req.path;
500
+ return path.startsWith('/') ? path : `/${path}`;
501
+ }
502
+
503
+ function expressHeader(req: ExpressRequest, name: string): string | undefined {
504
+ const target = name.toLowerCase();
505
+ for (const [headerName, value] of Object.entries(req.headers)) {
506
+ if (headerName.toLowerCase() !== target)
507
+ continue;
508
+ return Array.isArray(value) ? value[0] : value;
509
+ }
510
+ return undefined;
511
+ }
512
+
513
+ function matchesCsrfSkipPath(path: string, skipPaths: string[]): boolean {
514
+ return skipPaths.some((configured) => {
515
+ const withoutWildcard = configured.endsWith('/*') ? configured.slice(0, -2) : configured;
516
+ const base = withoutWildcard.length > 1 && withoutWildcard.endsWith('/')
517
+ ? withoutWildcard.slice(0, -1)
518
+ : withoutWildcard;
519
+ return path === base || path.startsWith(`${base}/`);
520
+ });
521
+ }
522
+
523
+ function normalizeRouteMap(routeMap: Record<string, RouteMapping>): Record<string, RouteMapping> {
524
+ return Object.fromEntries(Object.entries(routeMap).map(([pattern, mapping]) => {
525
+ const separator = pattern.indexOf(' ');
526
+ if (separator < 0)
527
+ return [pattern, mapping];
528
+ const method = pattern.slice(0, separator);
529
+ const path = normalizeExpressPath(pattern.slice(separator + 1));
530
+ return [`${method} ${path}`, mapping];
531
+ }));
532
+ }
533
+
534
+ function normalizeExpressPath(path: string): string {
535
+ const lowerCased = path.toLowerCase();
536
+ return lowerCased.length > 1 && lowerCased.endsWith('/')
537
+ ? lowerCased.slice(0, -1)
538
+ : lowerCased;
539
+ }
540
+
541
+ function pathToRegex(pattern: string): RegExp {
542
+ // Escape literals first, then add back only the route syntax Fortress
543
+ // supports. This prevents dots (and other regex metacharacters) in a
544
+ // configured path from matching unintended requests.
545
+ const regexStr = normalizeExpressPath(pattern)
546
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
547
+ .replace(/:[^/]+/g, '[^/]+')
548
+ .replace(/\\\*/g, '.*')
549
+ .replace(/\//g, '\\/');
550
+ return new RegExp(`^${regexStr}$`, 'i');
551
+ }
@@ -0,0 +1,154 @@
1
+ import type { EndpointDefinition } from '../core/endpoint';
2
+ import type { Fortress } from '../core/fortress';
3
+ import type {
4
+ ProtectedRouteContext,
5
+ ProtectedRouteHandler,
6
+ ProtectedRouteTarget,
7
+ ProtectOptions,
8
+ } from '../core/http/protect';
9
+ import type { ExpressMiddleware, ExpressRequest, ExpressResponse } from './middleware';
10
+ import { protect } from '../core/http/protect';
11
+
12
+ /**
13
+ * Express-flavoured host callback. `E` flows in from the endpoint passed to
14
+ * `protectedRoute()`, so `ctx.body` / `ctx.query` / `ctx.params` / `ctx.input`
15
+ * are typed from the endpoint's phantom generics. String targets degrade to
16
+ * the loose default.
17
+ */
18
+ export type ExpressProtectedRouteHandler<
19
+
20
+ E extends EndpointDefinition<any, any, any, any> = EndpointDefinition,
21
+ TResult = unknown,
22
+ > = (
23
+ req: ExpressRequest,
24
+ res: ExpressResponse,
25
+ ctx: ProtectedRouteContext<E>,
26
+ ) => TResult | Response | Promise<TResult | Response>;
27
+
28
+ /**
29
+ * Wrap a host-owned Express route in Fortress's protection pipeline.
30
+ *
31
+ * Two overloads, mirroring `protect()`:
32
+ *
33
+ * - **Typed target** — passing an `EndpointDefinition` flows its phantom
34
+ * generics through `ctx`.
35
+ * - **String target** — passing a unique `handler` name keeps the loose
36
+ * `Record<string, unknown>` / `unknown` typing.
37
+ */
38
+ export function protectedRoute<
39
+
40
+ E extends EndpointDefinition<any, any, any, any>,
41
+ TResult = unknown,
42
+ >(
43
+ fortress: Fortress,
44
+ target: E,
45
+ handler: ExpressProtectedRouteHandler<E, TResult>,
46
+ options?: ProtectOptions,
47
+ ): ExpressMiddleware;
48
+ export function protectedRoute<TResult = unknown>(
49
+ fortress: Fortress,
50
+ target: string,
51
+ handler: ExpressProtectedRouteHandler<EndpointDefinition, TResult>,
52
+ options?: ProtectOptions,
53
+ ): ExpressMiddleware;
54
+ export function protectedRoute(
55
+ fortress: Fortress,
56
+ target: ProtectedRouteTarget,
57
+
58
+ handler: ExpressProtectedRouteHandler<any, unknown>,
59
+ options: ProtectOptions = {},
60
+ ): ExpressMiddleware {
61
+ return async (req, res, next) => {
62
+ try {
63
+ const request = expressToWebRequest(req);
64
+ // Cast: core `protect` overloads require a concrete branch; impl is loose.
65
+ const protectedHandler = (protect as (
66
+ f: Fortress,
67
+ t: ProtectedRouteTarget,
68
+ h: ProtectedRouteHandler,
69
+ o?: ProtectOptions,
70
+ ) => (request: Request) => Promise<Response>)(
71
+ fortress,
72
+ target,
73
+ ctx => handler(req, res, ctx),
74
+ { ...options, method: options.method ?? req.method },
75
+ );
76
+ const response = await protectedHandler(request);
77
+ await sendWebResponseToExpress(response, res);
78
+ }
79
+ catch (err) {
80
+ next(err);
81
+ }
82
+ };
83
+ }
84
+
85
+ function expressToWebRequest(req: ExpressRequest): Request {
86
+ const host = (req.headers.host as string | undefined) ?? 'localhost';
87
+ const protocol = ((req as { protocol?: string }).protocol) ?? 'http';
88
+ const originalUrl = ((req as { originalUrl?: string }).originalUrl) ?? req.path;
89
+ const search = originalUrl.includes('?') ? `?${originalUrl.split('?')[1]}` : '';
90
+ const url = `${protocol}://${host}${req.path}${search}`;
91
+
92
+ const headers = new Headers();
93
+ for (const [k, v] of Object.entries(req.headers)) {
94
+ if (Array.isArray(v)) {
95
+ for (const item of v) headers.append(k, item);
96
+ }
97
+ else if (typeof v === 'string') {
98
+ headers.set(k, v);
99
+ }
100
+ }
101
+
102
+ const init: RequestInit = { method: req.method, headers };
103
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
104
+ const body = (req as { body?: unknown }).body;
105
+ if (body !== undefined && body !== null) {
106
+ if (typeof body === 'string') {
107
+ init.body = body;
108
+ }
109
+ else if (body instanceof Uint8Array) {
110
+ init.body = body as BodyInit;
111
+ }
112
+ else {
113
+ init.body = JSON.stringify(body);
114
+ if (!headers.has('content-type'))
115
+ headers.set('content-type', 'application/json');
116
+ }
117
+ }
118
+ }
119
+
120
+ return new Request(url, init);
121
+ }
122
+
123
+ async function sendWebResponseToExpress(response: Response, res: ExpressResponse): Promise<void> {
124
+ res.status(response.status);
125
+ for (const [k, v] of response.headers) {
126
+ if (k.toLowerCase() !== 'set-cookie')
127
+ res.setHeader(k, v);
128
+ }
129
+ const setCookies = response.headers.getSetCookie();
130
+ if (setCookies.length > 0) {
131
+ (res as { setHeader: (name: string, value: string | string[]) => void })
132
+ .setHeader('set-cookie', setCookies);
133
+ }
134
+
135
+ const text = await response.text();
136
+ const isJson = (response.headers.get('content-type') ?? '').includes('json');
137
+ if (isJson) {
138
+ try {
139
+ res.json(JSON.parse(text));
140
+ }
141
+ catch {
142
+ res.json({ raw: text });
143
+ }
144
+ }
145
+ else {
146
+ const resWithSend = res as unknown as { send?: (body: string) => void };
147
+ if (resWithSend.send)
148
+ resWithSend.send(text);
149
+ else
150
+ res.json(text);
151
+ }
152
+ }
153
+
154
+ export type { ProtectedRouteContext, ProtectedRouteTarget, ProtectOptions };