@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,354 @@
1
+ /**
2
+ * Framework-agnostic HTTP entry point: `fortress.handleRequest(request)`.
3
+ *
4
+ * Composes route matching → plugin `before-auth` → token verification →
5
+ * plugin `after-auth` → fortress-RBAC default-deny → plugin `after-rbac` →
6
+ * validation → endpoint dispatch → cookie attachment → JSON response.
7
+ *
8
+ * Adapters (Hono / Express / SvelteKit) detect Fortress-managed paths and
9
+ * delegate to this function with the raw `Request`. The returned `Response`
10
+ * is web-standard and can be returned directly from any framework that
11
+ * supports `Response`.
12
+ */
13
+
14
+ import type { Fortress } from '../fortress';
15
+ import type { Subject, TokenClaims } from '../types';
16
+ import type { RouteEntry } from './match';
17
+ import { resolveCookieConfig } from '../config';
18
+ import { Errors, FortressError } from '../errors';
19
+ import { coerceBySchema, validateRequest } from '../validation';
20
+ import { clearAuthCookies, serializeAuthCookies } from './cookie-serialize';
21
+ import { enforceCsrf, resolveCsrfConfig } from './csrf';
22
+ import { dispatchEndpoint } from './dispatch';
23
+ import { errorToResponse, withCookies } from './error-response';
24
+ import {
25
+ enforceFortressPermission,
26
+ getPluginPathPrefixes,
27
+ isFortressPath,
28
+ } from './fortress-rbac';
29
+ import { buildRouteTable, matchRoute } from './match';
30
+ import { runPluginMiddleware } from './plugin-middleware';
31
+ import { tryPluginPrincipal } from './principal';
32
+ import { extractAccessToken } from './token-extraction';
33
+
34
+ /**
35
+ * Build the request handler closure for a Fortress instance. Called once
36
+ * during `createFortress` and exposed as `fortress.handleRequest`.
37
+ *
38
+ * The returned function is `async (request: Request) => Promise<Response>`.
39
+ * It owns the route table, the resolved cookie config, and the cached
40
+ * plugin path prefixes — building these once at startup avoids per-request
41
+ * recomputation.
42
+ */
43
+ export function buildHandleRequest(
44
+ fortress: Fortress,
45
+ ): (request: Request) => Promise<Response> {
46
+ const mountedRoutes = new Set(
47
+ fortress.manifest
48
+ .filter(route => route.mounted)
49
+ .map(route => `${route.method.toUpperCase()} ${route.path}`),
50
+ );
51
+ const routeTable = buildRouteTable(
52
+ fortress.endpoints.filter(endpoint => mountedRoutes.has(`${endpoint.method.toUpperCase()} ${endpoint.path}`)),
53
+ );
54
+ const cookieConfig = resolveCookieConfig(fortress.config.cookies);
55
+ // H5: pipeline CSRF check resolved once at startup so per-request cost
56
+ // is just header / cookie inspection.
57
+ const csrfConfig = resolveCsrfConfig(fortress.config.csrf);
58
+ const plugins = fortress.config.plugins ?? [];
59
+ const pluginPathPrefixes = getPluginPathPrefixes(plugins);
60
+ const startActiveSpan = fortress.telemetry.tracer.startActiveSpan?.bind(fortress.telemetry.tracer)
61
+ ?? (async (name, attributes, callback) => callback(
62
+ fortress.telemetry.tracer.startSpan(name, attributes),
63
+ ));
64
+
65
+ return async function handleRequest(request: Request): Promise<Response> {
66
+ // Keep the request span active for the entire async pipeline so database,
67
+ // IAM, and plugin spans inherit it through the configured telemetry
68
+ // provider. The no-op provider invokes this callback without allocation.
69
+ return startActiveSpan(
70
+ 'fortress.handleRequest',
71
+ { 'http.method': request.method },
72
+ async (span) => {
73
+ let response: Response | undefined;
74
+ try {
75
+ const url = new URL(request.url);
76
+ const pathname = url.pathname;
77
+
78
+ // 1. Plugin before-auth middleware (rate limit, audit log, etc.)
79
+ await runPluginMiddleware(plugins, fortress.config, 'before-auth', { request });
80
+
81
+ // 1a. H5 — CSRF check. Runs before token verification so a malicious
82
+ // cross-site POST can't trigger any auth lookup work either. The
83
+ // check is a no-op for safe methods, bearer/API-key flows, and
84
+ // deployments that opt out.
85
+ enforceCsrf(request, pathname, csrfConfig, cookieConfig);
86
+
87
+ // 2. Match path + method to an endpoint
88
+ const matched = matchRoute(routeTable, request.method, pathname);
89
+ if (!matched) {
90
+ // Only return 404 for fortress-owned paths; otherwise the caller
91
+ // (an adapter) might want to fall through to its own router.
92
+ if (isFortressPath(pathname, pluginPathPrefixes)) {
93
+ throw Errors.notFound(`No endpoint matches ${request.method} ${pathname}`);
94
+ }
95
+ // Non-fortress path with no match — also a 404 from this entry point
96
+ throw Errors.notFound(`No endpoint matches ${request.method} ${pathname}`);
97
+ }
98
+ const { endpoint, params } = matched;
99
+ // Upgrade span attributes now that we know the matched route.
100
+ // `endpoint.path` is parameterized (e.g. `/iam/roles/:id`) so it's
101
+ // low-cardinality and safe to put on a span/metric attribute.
102
+ span.setAttribute('http.route', endpoint.path);
103
+ span.setAttribute('fortress.handler', endpoint.handler);
104
+
105
+ // 3. Principal resolution. Three paths, tried in order:
106
+ //
107
+ // 3a. Plugin-backed credential resolvers (api-key, future
108
+ // OAuth client_credentials, future mTLS). First plugin to
109
+ // return non-null wins — its subject + optional claims
110
+ // become the request principal.
111
+ // 3b. JWT bearer fallback. Used when no plugin resolves the
112
+ // request and the endpoint declared `security: 'bearer'`.
113
+ // 3c. Routes that declare `meta.bearerKind: 'oauth'` (the OAuth
114
+ // protocol endpoints — token, userinfo, introspect, revoke,
115
+ // authorize, jwks, discovery) self-parse their bearer
116
+ // (which is an OAuth access token, not a Fortress JWT) so
117
+ // we skip both the resolver chain and the JWT check here.
118
+ // Other `/oauth/*` routes — e.g. the consent-flow endpoints
119
+ // `/oauth/flows/:flowId{,/approve,/deny}` — default to
120
+ // `bearerKind: 'jwt'` and go through the normal auth
121
+ // pipeline so a host app SPA can call them with a Fortress
122
+ // JWT and have RBAC honoured.
123
+ let subject: Subject | undefined;
124
+ let userId: string | undefined;
125
+ let claims: TokenClaims | undefined;
126
+ let scopes: string[] | null | undefined;
127
+ const selfManagedBearer = endpoint.meta?.bearerKind === 'oauth';
128
+
129
+ if (!selfManagedBearer) {
130
+ const resolved = await tryPluginPrincipal(fortress, request);
131
+ if (resolved) {
132
+ subject = resolved.subject;
133
+ claims = resolved.claims;
134
+ scopes = resolved.scopes;
135
+ }
136
+ }
137
+
138
+ const requiresBearer = !selfManagedBearer
139
+ && ((endpoint.meta?.security?.includes('bearer') ?? false) || !!endpoint.meta?.permission);
140
+ if (!subject && requiresBearer) {
141
+ const token = extractAccessToken(request, cookieConfig);
142
+ if (!token)
143
+ throw Errors.unauthorized('Missing access token');
144
+ try {
145
+ claims = await fortress.auth.verifyToken(token);
146
+ subject = { type: claims.subjectType, id: claims.sub };
147
+ }
148
+ catch (err) {
149
+ if (err instanceof FortressError)
150
+ throw err;
151
+ throw Errors.unauthorized('Invalid access token');
152
+ }
153
+ }
154
+
155
+ // Convenience alias: downstream code that only needs `userId` (adapters,
156
+ // plugin middleware) still works for USER subjects. Non-user principals
157
+ // leave `userId` undefined, which is correct — they'd be hitting routes
158
+ // that RBAC-check via `subject` now.
159
+ if (subject?.type === 'USER')
160
+ userId = subject.id;
161
+
162
+ if (subject) {
163
+ span.setAttribute('fortress.subject_type', subject.type);
164
+ }
165
+
166
+ // 4. Plugin after-auth middleware
167
+ await runPluginMiddleware(plugins, fortress.config, 'after-auth', {
168
+ request,
169
+ fortressSubject: subject,
170
+ fortressUserId: userId,
171
+ fortressClaims: claims,
172
+ fortressScopes: scopes,
173
+ });
174
+
175
+ // 5. Fortress-managed default-deny RBAC. Routes flagged
176
+ // `bearerKind: 'oauth'` self-authenticate inside their handlers
177
+ // (the bearer is an OAuth token, not a JWT) so they're exempt
178
+ // from the IAM check too.
179
+ if (!selfManagedBearer) {
180
+ await enforceFortressPermission(endpoint, subject, {
181
+ checkPermission: (subj, resource, action, credentialScopes): Promise<boolean> =>
182
+ fortress.iam.checkPermission(subj, resource, action, { credentialScopes }),
183
+ }, scopes);
184
+ }
185
+
186
+ // 6. Plugin after-rbac middleware
187
+ await runPluginMiddleware(plugins, fortress.config, 'after-rbac', {
188
+ request,
189
+ fortressSubject: subject,
190
+ fortressUserId: userId,
191
+ fortressClaims: claims,
192
+ fortressScopes: scopes,
193
+ });
194
+
195
+ // 7. Body parse + validation. Validation reads the body via clone()
196
+ // so dispatch can re-read it. We sniff the content-type to know
197
+ // whether validateRequest can parse JSON. Skipped for routes
198
+ // flagged `bearerKind: 'oauth'` — their bodies are
199
+ // `application/x-www-form-urlencoded` and the OAuth dispatcher
200
+ // does its own parsing/validation per RFC 6749.
201
+ if (!selfManagedBearer) {
202
+ let parsedBody: unknown;
203
+ if (
204
+ request.method !== 'GET'
205
+ && request.method !== 'HEAD'
206
+ && (request.headers.get('content-type') ?? '').includes('json')
207
+ ) {
208
+ parsedBody = await request.clone().json().catch(() => undefined);
209
+ }
210
+ const query = Object.fromEntries(url.searchParams);
211
+ // URL-sourced data is always strings. Coerce query/params to the
212
+ // types declared in the endpoint's JSON Schema before validation
213
+ // so `:id`/`?limit=2` don't trip integer/number/boolean checks.
214
+ const coercedQuery = coerceBySchema(endpoint.input?.query, query);
215
+ const coercedParams = coerceBySchema(endpoint.input?.params, params);
216
+ await validateRequest(endpoint.input, { body: parsedBody, query: coercedQuery, params: coercedParams });
217
+ }
218
+
219
+ // 8. Dispatch + serialize. Pass IP/UA from headers as RequestMeta so
220
+ // auth handlers can stamp refresh tokens with their origin.
221
+ const meta = {
222
+ ipAddress:
223
+ request.headers.get('x-forwarded-for')
224
+ ?? request.headers.get('x-real-ip')
225
+ ?? undefined,
226
+ userAgent: request.headers.get('user-agent') ?? undefined,
227
+ };
228
+ const dispatched = await dispatchEndpoint(fortress, request, endpoint, params, {
229
+ subject,
230
+ userId,
231
+ claims,
232
+ scopes,
233
+ meta,
234
+ });
235
+
236
+ // 9. If the endpoint emitted an auth result, serialize cookies.
237
+ // Detected by checking the response body for accessToken/refreshToken
238
+ // fields. We avoid touching streamed/HTML responses.
239
+ const cookies = await maybeBuildAuthCookies(
240
+ endpoint.handler,
241
+ dispatched,
242
+ fortress,
243
+ request,
244
+ );
245
+ response = cookies.length > 0
246
+ ? withCookies(cookies.response, cookies.setCookies)
247
+ : dispatched;
248
+ return response;
249
+ }
250
+ catch (err) {
251
+ response = errorToResponse(err, fortress.logger);
252
+ span.recordException(err);
253
+ span.setStatus({
254
+ code: 'error',
255
+ message: err instanceof Error ? err.message : String(err),
256
+ });
257
+ return response;
258
+ }
259
+ finally {
260
+ if (response) {
261
+ span.setAttribute('http.status_code', response.status);
262
+ if (response.status >= 200 && response.status < 400) {
263
+ span.setStatus({ code: 'ok' });
264
+ }
265
+ else if (response.status >= 400) {
266
+ // 4xx/5xx without an exception (e.g. a FortressError already
267
+ // serialized). Mark the span as error so traces highlight it.
268
+ span.setStatus({
269
+ code: 'error',
270
+ message: `HTTP ${response.status}`,
271
+ });
272
+ }
273
+ }
274
+ span.end();
275
+ }
276
+ },
277
+ );
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Inspect the dispatched response and, if it's an auth-issuing endpoint
283
+ * (`login`, `refresh`, `register-and-login`, etc.), build the matching
284
+ * `Set-Cookie` headers using the resolved cookie config.
285
+ *
286
+ * Returns the (possibly clone-replaced) response so callers can use
287
+ * `withCookies` against the body that was actually consumed.
288
+ */
289
+ async function maybeBuildAuthCookies(
290
+ handler: string,
291
+ response: Response,
292
+ fortress: Fortress,
293
+ _request: Request,
294
+ ): Promise<{ setCookies: string[]; response: Response; length: number }> {
295
+ // Logout succeeds only after dispatch revokes the supplied refresh token;
296
+ // clear both browser credentials on that successful response.
297
+ if (handler === 'logout') {
298
+ if (response.status >= 400)
299
+ return { setCookies: [], response, length: 0 };
300
+ const setCookies = clearAuthCookies(resolveCookieConfig(fortress.config.cookies));
301
+ return { setCookies, response, length: setCookies.length };
302
+ }
303
+
304
+ // Only inspect successful auth-related handlers.
305
+ const isAuthIssuing = handler === 'login'
306
+ || handler === 'refresh'
307
+ || handler === 'impersonate'
308
+ || handler === 'verifyTwoFactor'
309
+ || handler === 'verifyMagicLink';
310
+ if (!isAuthIssuing)
311
+ return { setCookies: [], response, length: 0 };
312
+ if (response.status >= 400)
313
+ return { setCookies: [], response, length: 0 };
314
+ const contentType = response.headers.get('content-type') ?? '';
315
+ if (!contentType.includes('json'))
316
+ return { setCookies: [], response, length: 0 };
317
+
318
+ // Read the JSON body once and reconstruct the response.
319
+ const bodyText = await response.clone().text();
320
+ let parsed: unknown;
321
+ try {
322
+ parsed = JSON.parse(bodyText);
323
+ }
324
+ catch {
325
+ return { setCookies: [], response, length: 0 };
326
+ }
327
+ const obj = parsed as { accessToken?: unknown; refreshToken?: unknown };
328
+ if (typeof obj?.accessToken !== 'string') {
329
+ return { setCookies: [], response, length: 0 };
330
+ }
331
+
332
+ const cookieConfig = resolveCookieConfig(fortress.config.cookies);
333
+ const accessExpiry = fortress.config.jwt.accessTokenExpirySeconds ?? 900;
334
+ const refreshExpiry = fortress.config.jwt.refreshTokenExpirySeconds ?? 604_800;
335
+ const setCookies = serializeAuthCookies(
336
+ {
337
+ accessToken: obj.accessToken,
338
+ refreshToken: typeof obj.refreshToken === 'string' ? obj.refreshToken : null,
339
+ },
340
+ cookieConfig,
341
+ { access: accessExpiry, refresh: refreshExpiry },
342
+ );
343
+
344
+ // Reconstruct the response from the consumed body so the caller can stream it.
345
+ const replaced = new Response(bodyText, {
346
+ status: response.status,
347
+ statusText: response.statusText,
348
+ headers: response.headers,
349
+ });
350
+ return { setCookies, response: replaced, length: setCookies.length };
351
+ }
352
+
353
+ // Re-export for adapter use
354
+ export type { RouteEntry };
@@ -0,0 +1,122 @@
1
+ import type { EndpointDefinition } from '../endpoint';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildRouteTable, matchRoute } from './match';
4
+
5
+ function ep(method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', path: string, handler: string): EndpointDefinition {
6
+ return {
7
+ method,
8
+ path,
9
+ handler,
10
+ };
11
+ }
12
+
13
+ describe('matchRoute', () => {
14
+ const table = buildRouteTable([
15
+ ep('GET', '/auth/me', 'me'),
16
+ ep('POST', '/auth/login', 'login'),
17
+ ep('GET', '/iam/roles', 'getRoles'),
18
+ ep('POST', '/iam/roles', 'createRole'),
19
+ ep('DELETE', '/iam/roles/:id', 'deleteRole'),
20
+ ep('GET', '/iam/users/:id/permissions', 'getUserPermissions'),
21
+ ep('DELETE', '/iam/groups/:id/users/:userId', 'removeUserFromGroup'),
22
+ ]);
23
+
24
+ it('matches an exact static route', () => {
25
+ const m = matchRoute(table, 'GET', '/auth/me');
26
+ expect(m?.endpoint.handler).toBe('me');
27
+ expect(m?.params).toEqual({});
28
+ });
29
+
30
+ it('respects HTTP method', () => {
31
+ expect(matchRoute(table, 'POST', '/auth/me')).toBeNull();
32
+ expect(matchRoute(table, 'GET', '/iam/roles')?.endpoint.handler).toBe('getRoles');
33
+ expect(matchRoute(table, 'POST', '/iam/roles')?.endpoint.handler).toBe('createRole');
34
+ });
35
+
36
+ it('extracts a single path param', () => {
37
+ const m = matchRoute(table, 'DELETE', '/iam/roles/42');
38
+ expect(m?.endpoint.handler).toBe('deleteRole');
39
+ expect(m?.params).toEqual({ id: '42' });
40
+ });
41
+
42
+ it('extracts multiple path params', () => {
43
+ const m = matchRoute(table, 'DELETE', '/iam/groups/3/users/7');
44
+ expect(m?.endpoint.handler).toBe('removeUserFromGroup');
45
+ expect(m?.params).toEqual({ id: '3', userId: '7' });
46
+ });
47
+
48
+ it('returns null when segment counts differ', () => {
49
+ expect(matchRoute(table, 'GET', '/iam/roles/extra')).toBeNull();
50
+ });
51
+
52
+ it('returns null when no route matches', () => {
53
+ expect(matchRoute(table, 'GET', '/nope')).toBeNull();
54
+ });
55
+
56
+ it('case-insensitive on method', () => {
57
+ expect(matchRoute(table, 'get', '/auth/me')?.endpoint.handler).toBe('me');
58
+ });
59
+
60
+ it('url-decodes path param values', () => {
61
+ const m = matchRoute(table, 'GET', '/iam/users/hello%20world/permissions');
62
+ expect(m?.params.id).toBe('hello world');
63
+ });
64
+
65
+ it.each([
66
+ [
67
+ [
68
+ ep('GET', '/users/:id', 'param'),
69
+ ep('GET', '/users/me', 'static'),
70
+ ],
71
+ 'static',
72
+ ],
73
+ [
74
+ [
75
+ ep('GET', '/users/me', 'static'),
76
+ ep('GET', '/users/:id', 'param'),
77
+ ],
78
+ 'static',
79
+ ],
80
+ ] as const)('prefers /users/me over /users/:id regardless of registration order', (routes, handler) => {
81
+ const m = matchRoute(buildRouteTable(routes), 'GET', '/users/me');
82
+ expect(m?.endpoint.handler).toBe(handler);
83
+ expect(m?.params).toEqual({});
84
+ });
85
+
86
+ it.each([
87
+ [
88
+ [
89
+ ep('GET', '/users/*', 'wildcard'),
90
+ ep('GET', '/users/:id', 'param'),
91
+ ep('GET', '/users/me', 'static'),
92
+ ],
93
+ 'static',
94
+ '/users/me',
95
+ {},
96
+ ],
97
+ [
98
+ [
99
+ ep('GET', '/users/me', 'static'),
100
+ ep('GET', '/users/*', 'wildcard'),
101
+ ep('GET', '/users/:id', 'param'),
102
+ ],
103
+ 'param',
104
+ '/users/alice',
105
+ { id: 'alice' },
106
+ ],
107
+ [
108
+ [
109
+ ep('GET', '/accounts/:id', 'param'),
110
+ ep('GET', '/users/me', 'static'),
111
+ ep('GET', '/users/*', 'wildcard'),
112
+ ],
113
+ 'wildcard',
114
+ '/users/alice',
115
+ {},
116
+ ],
117
+ ] as const)('uses static, param, then wildcard precedence deterministically', (routes, handler, path, params) => {
118
+ const m = matchRoute(buildRouteTable(routes), 'GET', path);
119
+ expect(m?.endpoint.handler).toBe(handler);
120
+ expect(m?.params).toEqual(params);
121
+ });
122
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Endpoint route matching shared by dispatch and fortress-RBAC.
3
+ *
4
+ * Builds a path-segment table from {@link EndpointDefinition}s and matches
5
+ * incoming `(method, pathname)` pairs against it. Supports `:param` segments
6
+ * and returns extracted path parameters alongside the matched endpoint.
7
+ */
8
+
9
+ import type { EndpointDefinition } from '../endpoint';
10
+
11
+ /** Minimal route shape accepted by the matcher. */
12
+ export interface RouteLike {
13
+ method: string;
14
+ path: string;
15
+ }
16
+
17
+ /** Route table entry built from an {@link EndpointDefinition} or manifest entry. */
18
+ export interface RouteEntry<T extends RouteLike = EndpointDefinition> {
19
+ endpoint: T;
20
+ method: string;
21
+ segments: string[];
22
+ paramNames: string[];
23
+ }
24
+
25
+ /** Higher values are more specific when two routes have the same shape. */
26
+ function segmentSpecificity(segment: string): number {
27
+ if (segment === '*')
28
+ return 0;
29
+ if (segment.startsWith(':'))
30
+ return 1;
31
+ return 2;
32
+ }
33
+
34
+ /** Successful match: the endpoint/route plus extracted path parameters. */
35
+ export interface RouteMatch<T extends RouteLike = EndpointDefinition> {
36
+ endpoint: T;
37
+ params: Record<string, string>;
38
+ }
39
+
40
+ /** Normalize path separators so route and middleware matching share one form. */
41
+ export function canonicalizePath(pathname: string): string {
42
+ const segments = pathname.split('/').filter(Boolean);
43
+ return segments.length === 0 ? '/' : `/${segments.join('/')}`;
44
+ }
45
+
46
+ /**
47
+ * Pre-build a route table from endpoint definitions. Adapters call this once
48
+ * at startup and hand the result to {@link matchRoute} on every request.
49
+ */
50
+ export function buildRouteTable<T extends RouteLike>(endpoints: readonly T[]): RouteEntry<T>[] {
51
+ const table = endpoints.map((ep) => {
52
+ const segments = canonicalizePath(ep.path).split('/').filter(Boolean);
53
+ const paramNames: string[] = [];
54
+ for (const seg of segments) {
55
+ if (seg.startsWith(':'))
56
+ paramNames.push(seg.slice(1));
57
+ }
58
+ return {
59
+ endpoint: ep,
60
+ method: ep.method.toUpperCase(),
61
+ segments,
62
+ paramNames,
63
+ };
64
+ });
65
+
66
+ // Match specificity lexicographically so a static segment always wins over
67
+ // a parameter (and a parameter over a wildcard), independent of registration
68
+ // order. Stable sorting retains registration order for duplicate patterns.
69
+ return table
70
+ .map((route, index) => ({ route, index }))
71
+ .sort((a, b) => {
72
+ const length = Math.max(a.route.segments.length, b.route.segments.length);
73
+ for (let i = 0; i < length; i++) {
74
+ const difference = segmentSpecificity(b.route.segments[i] ?? '')
75
+ - segmentSpecificity(a.route.segments[i] ?? '');
76
+ if (difference !== 0)
77
+ return difference;
78
+ }
79
+ return a.index - b.index;
80
+ })
81
+ .map(({ route }) => route);
82
+ }
83
+
84
+ /**
85
+ * Find the endpoint that matches a `(method, pathname)` pair. Returns the
86
+ * matched endpoint and any extracted `:param` values, or `null` if nothing
87
+ * matches.
88
+ *
89
+ * Matching is segment-by-segment with literal segments compared verbatim,
90
+ * `:param` segments capturing into `params`, and `*` matching any one segment.
91
+ * Static segments take priority over params, which take priority over
92
+ * wildcards, independent of registration order.
93
+ */
94
+ export function matchRoute<T extends RouteLike>(
95
+ table: RouteEntry<T>[],
96
+ method: string,
97
+ pathname: string,
98
+ ): RouteMatch<T> | null {
99
+ const upperMethod = method.toUpperCase();
100
+ const pathSegments = canonicalizePath(pathname).split('/').filter(Boolean);
101
+
102
+ for (const route of table) {
103
+ if (route.method !== upperMethod)
104
+ continue;
105
+ if (route.segments.length !== pathSegments.length)
106
+ continue;
107
+
108
+ const params: Record<string, string> = {};
109
+ let matched = true;
110
+ for (let i = 0; i < route.segments.length; i++) {
111
+ const routeSeg = route.segments[i];
112
+ const pathSeg = pathSegments[i];
113
+ if (routeSeg.startsWith(':')) {
114
+ params[routeSeg.slice(1)] = decodeURIComponent(pathSeg);
115
+ }
116
+ else if (routeSeg !== '*' && routeSeg !== pathSeg) {
117
+ matched = false;
118
+ break;
119
+ }
120
+ }
121
+ if (matched)
122
+ return { endpoint: route.endpoint, params };
123
+ }
124
+
125
+ return null;
126
+ }
@@ -0,0 +1,34 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createOutboundClient } from './outbound';
3
+
4
+ describe('outbound full-exchange timeout', () => {
5
+ afterEach(() => vi.restoreAllMocks());
6
+
7
+ it('keeps the deadline active while the response body is consumed', async () => {
8
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
9
+ const request = input as Request;
10
+ const body = new ReadableStream<Uint8Array>({
11
+ start(controller) {
12
+ request.signal.addEventListener('abort', () => {
13
+ controller.error(request.signal.reason);
14
+ }, { once: true });
15
+ },
16
+ });
17
+ return new Response(body, { status: 200 });
18
+ });
19
+
20
+ const client = createOutboundClient(20);
21
+ const response = await client.get('https://slow.example.test/data');
22
+
23
+ await expect(response.text()).rejects.toThrow(/timed out/i);
24
+ });
25
+
26
+ it('clears the deadline after a complete body is read', async () => {
27
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
28
+ const client = createOutboundClient(100);
29
+
30
+ const response = await client.get('https://example.test/data');
31
+
32
+ await expect(response.json()).resolves.toEqual({ ok: true });
33
+ });
34
+ });