@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,652 @@
1
+ /**
2
+ * Endpoint dispatch — body parsing and handler invocation.
3
+ *
4
+ * Given a matched {@link EndpointDefinition}, this module:
5
+ *
6
+ * 1. Parses the request body (JSON, or `application/x-www-form-urlencoded`
7
+ * for OAuth endpoints).
8
+ * 2. Invokes the right service or plugin method by name. Auth + IAM
9
+ * endpoints dispatch through hardcoded switches that mirror the
10
+ * positional argument shapes of `fortress.auth.*` / `fortress.iam.*`.
11
+ * Plugin endpoints dispatch via `fortress.plugins[name][handler]` with
12
+ * the merged `{ ...body, ...params }` shape used by the existing
13
+ * adapter route mounts.
14
+ * 3. Serializes the result into a `Response` (JSON by default; HTML for
15
+ * the OpenAPI Scalar UI plugin).
16
+ *
17
+ * OAuth has special-case handling because RFC 6749 requires
18
+ * form-encoded bodies and Basic-auth client authentication.
19
+ */
20
+
21
+ import type { ClientAuth } from '../../plugins/oauth';
22
+ import type { EndpointDefinition } from '../endpoint';
23
+ import type { Fortress } from '../fortress';
24
+ import type { FortressPlugin, PluginRouteContext } from '../plugin';
25
+ import type { RequestMeta, Subject, TokenClaims } from '../types';
26
+ import { Errors, FortressError } from '../errors';
27
+
28
+ /** Auth context resolved by `handleRequest` before dispatch. */
29
+ export interface DispatchAuth {
30
+ /** Resolved request principal — USER or SERVICE_ACCOUNT. Present iff authenticated. */
31
+ subject?: Subject;
32
+ /** Convenience alias for `subject?.id` when the subject is a USER. */
33
+ userId?: string;
34
+ claims?: TokenClaims;
35
+ /** Credential-level narrowing scopes (e.g. API-key scopes). */
36
+ scopes?: string[] | null;
37
+ meta?: RequestMeta;
38
+ }
39
+
40
+ /** Result of dispatching a request — a fully-formed `Response`. */
41
+ export type DispatchResult = Response;
42
+
43
+ /**
44
+ * Dispatch a matched endpoint to the right service or plugin method and
45
+ * return a `Response`. Body parsing is performed here so the right
46
+ * content-type strategy is picked per endpoint family (JSON for most,
47
+ * `application/x-www-form-urlencoded` for OAuth).
48
+ *
49
+ * The caller is expected to have already run plugin middleware, token
50
+ * verification, validation, and {@link enforceFortressPermission}.
51
+ */
52
+ export async function dispatchEndpoint(
53
+ fortress: Fortress,
54
+ request: Request,
55
+ endpoint: EndpointDefinition,
56
+ pathParams: Record<string, string>,
57
+ auth: DispatchAuth,
58
+ ): Promise<DispatchResult> {
59
+ // OAuth endpoints get form-encoded body parsing + Basic auth handling.
60
+ const owningPlugin = findOwningPlugin(fortress, endpoint);
61
+ if (owningPlugin?.name === 'oauth') {
62
+ return dispatchOAuth(fortress, request, endpoint, auth);
63
+ }
64
+
65
+ // Parse body / query into a generic object.
66
+ const body = await parseBodyOrQuery(request);
67
+
68
+ // Plugin route (non-oauth)
69
+ if (owningPlugin) {
70
+ return dispatchPlugin(fortress, owningPlugin, endpoint, body, pathParams, request, auth);
71
+ }
72
+
73
+ // Core auth / IAM dispatch
74
+ if (endpoint.path.startsWith('/auth/')) {
75
+ const result = await invokeAuthHandler(fortress, endpoint.handler, body, pathParams, auth);
76
+ return jsonResponse(result, successStatus(endpoint));
77
+ }
78
+ if (endpoint.path.startsWith('/iam/')) {
79
+ const result = await invokeIamHandler(fortress, endpoint.handler, body, pathParams);
80
+ return jsonResponse(result, successStatus(endpoint));
81
+ }
82
+
83
+ // Top-level host routes are metadata-only and have no Fortress handler.
84
+ // Framework adapters exclude them from their mounted route table so the
85
+ // host handler runs; direct core dispatch fails closed instead of inventing
86
+ // a successful response.
87
+ throw Errors.notFound(`No Fortress handler is mounted for ${endpoint.method} ${endpoint.path}`);
88
+ }
89
+
90
+ // ── Body parsing ─────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Parse the request body as JSON for write methods, or fall back to query
94
+ * params for GET. Returns an empty object if parsing fails or there is no
95
+ * body — handlers tolerate missing fields.
96
+ */
97
+ async function parseBodyOrQuery(request: Request): Promise<Record<string, unknown>> {
98
+ if (request.method === 'GET' || request.method === 'HEAD') {
99
+ return Object.fromEntries(new URL(request.url).searchParams);
100
+ }
101
+ // Some requests have no body at all (e.g. DELETE without payload).
102
+ const contentType = request.headers.get('content-type') ?? '';
103
+ if (!contentType.includes('json')) {
104
+ // No body or non-JSON body for write methods — try query params.
105
+ return Object.fromEntries(new URL(request.url).searchParams);
106
+ }
107
+ try {
108
+ return (await request.json()) as Record<string, unknown>;
109
+ }
110
+ catch {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ async function parseFormBody(request: Request): Promise<Record<string, string>> {
116
+ const text = await request.text();
117
+ const params = new URLSearchParams(text);
118
+ const out: Record<string, string> = {};
119
+ for (const [k, v] of params) out[k] = v;
120
+ return out;
121
+ }
122
+
123
+ function parseBasicAuth(header: string | null): ClientAuth | undefined {
124
+ if (!header?.startsWith('Basic '))
125
+ return undefined;
126
+ let decoded: string;
127
+ try {
128
+ decoded = atob(header.slice(6));
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ const colon = decoded.indexOf(':');
134
+ if (colon === -1)
135
+ return undefined;
136
+ return {
137
+ clientId: decoded.slice(0, colon),
138
+ clientSecret: decoded.slice(colon + 1),
139
+ };
140
+ }
141
+
142
+ function parseBearerToken(header: string | null): string | undefined {
143
+ if (!header?.startsWith('Bearer '))
144
+ return undefined;
145
+ return header.slice(7);
146
+ }
147
+
148
+ // ── Plugin dispatch (non-oauth) ─────────────────────────────────────
149
+
150
+ /** Find the plugin (if any) that owns the given endpoint. */
151
+ function findOwningPlugin(fortress: Fortress, endpoint: EndpointDefinition): FortressPlugin | undefined {
152
+ const plugins = fortress.config.plugins ?? [];
153
+ for (const plugin of plugins) {
154
+ if (!plugin.routes)
155
+ continue;
156
+ const routes = Object.values(plugin.routes) as EndpointDefinition[];
157
+ if (routes.some(r => r.method === endpoint.method && r.path === endpoint.path)) {
158
+ return plugin;
159
+ }
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ async function dispatchPlugin(
165
+ fortress: Fortress,
166
+ plugin: FortressPlugin,
167
+ endpoint: EndpointDefinition,
168
+ body: Record<string, unknown>,
169
+ pathParams: Record<string, string>,
170
+ request: Request,
171
+ auth: DispatchAuth,
172
+ ): Promise<Response> {
173
+ const methods = (fortress.plugins as Record<string, Record<string, (...args: unknown[]) => unknown>>)[plugin.name];
174
+ if (!methods?.[endpoint.handler]) {
175
+ throw Errors.notFound(`Plugin handler '${plugin.name}.${endpoint.handler}' not found`);
176
+ }
177
+
178
+ // Call as `methods.<handler>(...)` so the `this` binding is preserved —
179
+ // some plugin methods reference sibling helpers via `this`.
180
+ // OpenAPI Scalar UI returns HTML, not JSON.
181
+ if (plugin.name === 'openapi' && endpoint.handler === 'getUI') {
182
+ const html = methods.getUI() as string;
183
+ return new Response(html, {
184
+ status: 200,
185
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
186
+ });
187
+ }
188
+
189
+ const ctx: PluginRouteContext = {
190
+ subject: auth.subject,
191
+ userId: auth.subject?.type === 'USER' ? auth.subject.id : undefined,
192
+ claims: auth.claims,
193
+ scopes: auth.scopes,
194
+ meta: auth.meta,
195
+ request,
196
+ };
197
+ const result = await methods[endpoint.handler]({ ...body, ...pathParams }, ctx);
198
+ // Allow handlers to opt into HTML by returning a string starting with `<!`.
199
+ if (typeof result === 'string' && result.trimStart().startsWith('<!')) {
200
+ return new Response(result, {
201
+ status: 200,
202
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
203
+ });
204
+ }
205
+ return jsonResponse(result ?? { ok: true }, successStatus(endpoint));
206
+ }
207
+
208
+ // ── OAuth special case ──────────────────────────────────────────────
209
+
210
+ async function dispatchOAuth(
211
+ fortress: Fortress,
212
+ request: Request,
213
+ endpoint: EndpointDefinition,
214
+ auth: DispatchAuth,
215
+ ): Promise<Response> {
216
+ const methods = (fortress.plugins as Record<string, Record<string, (...args: unknown[]) => unknown>>).oauth;
217
+ if (!methods)
218
+ throw Errors.notFound(`OAuth plugin not registered`);
219
+ const handlerName = endpoint.handler;
220
+ if (!methods[handlerName])
221
+ throw Errors.notFound(`OAuth handler '${handlerName}' not found`);
222
+
223
+ // IMPORTANT: invoke as `methods.<name>(...)` so the `this` binding is the
224
+ // methods object — the OAuth plugin's `handleTokenRequest` calls
225
+ // `this.clientCredentialsGrant(...)` and friends, which would otherwise
226
+ // throw "Cannot read properties of undefined" if called bare.
227
+ const m = methods as Record<string, (...args: unknown[]) => Promise<unknown> | unknown>;
228
+ const authHeader = request.headers.get('authorization');
229
+
230
+ switch (handlerName) {
231
+ case 'handleTokenRequest': {
232
+ const body = await parseFormBody(request);
233
+ const clientAuth = parseBasicAuth(authHeader);
234
+ const result = await m.handleTokenRequest(body, clientAuth);
235
+ return jsonResponse(result, 200);
236
+ }
237
+ case 'handleIntrospectRequest': {
238
+ const body = await parseFormBody(request);
239
+ const clientAuth = parseBasicAuth(authHeader);
240
+ if (!clientAuth) {
241
+ return jsonResponse(
242
+ { error: 'invalid_client', error_description: 'Client authentication required' },
243
+ 401,
244
+ );
245
+ }
246
+ const result = await m.handleIntrospectRequest({ token: body.token }, clientAuth);
247
+ return jsonResponse(result, 200);
248
+ }
249
+ case 'handleRevokeRequest': {
250
+ const body = await parseFormBody(request);
251
+ const clientAuth = parseBasicAuth(authHeader);
252
+ if (!clientAuth) {
253
+ return jsonResponse(
254
+ { error: 'invalid_client', error_description: 'Client authentication required' },
255
+ 401,
256
+ );
257
+ }
258
+ await m.handleRevokeRequest({ token: body.token }, clientAuth);
259
+ return jsonResponse({}, 200);
260
+ }
261
+ case 'handleUserInfoRequest': {
262
+ // RFC 6750 §2.1: bearer token from Authorization header.
263
+ // The plugin's `handleUserInfoRequest` now returns OIDC-Core-§5.3
264
+ // shaped claims directly (sub-as-string, scope-gated standard
265
+ // claims, optional userinfoClaims hook output) and throws 401 for
266
+ // an invalid / expired token — which the standard error mapper
267
+ // serialises to a `{ code, message }` body. We just pass the result
268
+ // through.
269
+ const bearer = parseBearerToken(authHeader);
270
+ if (!bearer) {
271
+ return jsonResponse(
272
+ { error: 'invalid_token', error_description: 'Bearer token required' },
273
+ 401,
274
+ );
275
+ }
276
+ const claims = await m.handleUserInfoRequest(bearer) as Record<string, unknown>;
277
+ return jsonResponse(claims, 200);
278
+ }
279
+ case 'handleDiscovery': {
280
+ const result = m.handleDiscovery();
281
+ return jsonResponse(result, 200);
282
+ }
283
+ case 'handleJwksRequest': {
284
+ // RFC 7517 / OIDC Discovery: public JWKS for id_token verification.
285
+ // Cacheable for a short window — long enough that key rotation
286
+ // doesn't immediately bust every RP, short enough that a rotated
287
+ // key reaches them within the grace period.
288
+ const result = await m.handleJwksRequest();
289
+ return new Response(JSON.stringify(result), {
290
+ status: 200,
291
+ headers: {
292
+ 'Content-Type': 'application/json',
293
+ 'Cache-Control': 'public, max-age=300',
294
+ },
295
+ });
296
+ }
297
+ case 'handleAuthorizeRequest': {
298
+ // GET /oauth/authorize — front door for the auth-code flow.
299
+ // Reads query params, optionally identifies the user, then 302s to
300
+ // either the configured loginUrl or consentUrl with `?flow=<id>`.
301
+ const query = Object.fromEntries(new URL(request.url).searchParams);
302
+ const result = (await m.handleAuthorizeRequest(query, { userId: auth.userId })) as {
303
+ redirectUrl: string;
304
+ };
305
+ return new Response(null, {
306
+ status: 302,
307
+ headers: { Location: result.redirectUrl },
308
+ });
309
+ }
310
+ case 'handleGetFlow': {
311
+ // H6 fix — every consent-flow endpoint requires authentication so the
312
+ // owner check has someone to compare against. Without this guard, an
313
+ // unauthenticated caller could read another user's pending flow.
314
+ if (auth.userId === undefined) {
315
+ return jsonResponse(
316
+ { error: 'unauthorized', error_description: 'Authentication required' },
317
+ 401,
318
+ );
319
+ }
320
+ const flowId = consentFlowIdFromUrl(request.url);
321
+ const result = await m.handleGetFlow(flowId, { userId: auth.userId });
322
+ return jsonResponse(result, 200);
323
+ }
324
+ case 'handleApproveFlow': {
325
+ if (auth.userId === undefined) {
326
+ return jsonResponse(
327
+ { error: 'unauthorized', error_description: 'Authentication required' },
328
+ 401,
329
+ );
330
+ }
331
+ const flowId = consentFlowIdFromUrl(request.url);
332
+ const result = await m.handleApproveFlow(flowId, { userId: auth.userId });
333
+ return jsonResponse(result, 200);
334
+ }
335
+ case 'handleDenyFlow': {
336
+ if (auth.userId === undefined) {
337
+ return jsonResponse(
338
+ { error: 'unauthorized', error_description: 'Authentication required' },
339
+ 401,
340
+ );
341
+ }
342
+ const flowId = consentFlowIdFromUrl(request.url);
343
+ const result = await m.handleDenyFlow(flowId, { userId: auth.userId });
344
+ return jsonResponse(result, 200);
345
+ }
346
+ default: {
347
+ // Authorize endpoint and friends — JSON body. Call through `m` so the
348
+ // `this` binding survives.
349
+ const body = (await request.json().catch(() => ({}))) as unknown;
350
+ const result = await m[handlerName](body);
351
+ return jsonResponse(result ?? { ok: true }, successStatus(endpoint));
352
+ }
353
+ }
354
+ }
355
+
356
+ const FLOW_ID_PATTERN = /\/oauth\/flows\/([^/]+)/;
357
+
358
+ /**
359
+ * Extract the opaque `:flowId` path segment from a
360
+ * `/oauth/flows/:flowId[/...]` URL. Throws if the segment is missing.
361
+ */
362
+ function consentFlowIdFromUrl(url: string): string {
363
+ const path = new URL(url).pathname;
364
+ const match = FLOW_ID_PATTERN.exec(path);
365
+ if (!match)
366
+ throw Errors.badRequest('Invalid flow id');
367
+ return decodeURIComponent(match[1]);
368
+ }
369
+
370
+ // ── Auth / IAM hardcoded dispatch ────────────────────────────────────
371
+
372
+ async function invokeAuthHandler(
373
+ fortress: Fortress,
374
+ handler: string,
375
+ body: Record<string, unknown>,
376
+ params: Record<string, string>,
377
+ auth: DispatchAuth,
378
+ ): Promise<unknown> {
379
+ const meta = auth.meta;
380
+ switch (handler) {
381
+ case 'login':
382
+ return fortress.auth.login(String(body.identifier ?? ''), String(body.password ?? ''), {
383
+ ...meta,
384
+ ...(typeof body.trustedDeviceToken === 'string' ? { trustedDeviceToken: body.trustedDeviceToken } : {}),
385
+ });
386
+ case 'verifyTwoFactor': {
387
+ const methods = (fortress.plugins as Record<string, Record<string, (...args: unknown[]) => unknown>>)['two-factor'];
388
+ if (!methods?.verify)
389
+ throw Errors.badRequest('Two-factor plugin is not configured');
390
+ return methods.verify(String(body.continuationToken ?? ''), String(body.code ?? ''), {
391
+ ...meta,
392
+ ...(body.rememberDevice === true ? { rememberDevice: true } : {}),
393
+ });
394
+ }
395
+ case 'verifyMagicLink': {
396
+ const methods = (fortress.plugins as Record<string, Record<string, (...args: unknown[]) => unknown>>)['magic-link'];
397
+ if (!methods?.verify)
398
+ throw Errors.badRequest('Magic-link plugin is not configured');
399
+ return methods.verify(String(body.token ?? ''), meta);
400
+ }
401
+ case 'createUser':
402
+ return fortress.auth.createUser(body as never);
403
+ case 'refresh':
404
+ return fortress.auth.refresh(String(body.refreshToken ?? ''), meta);
405
+ case 'logout':
406
+ await fortress.auth.logout(String(body.refreshToken ?? ''));
407
+ return { ok: true };
408
+ case 'me':
409
+ return fortress.auth.me(requireUserId(auth));
410
+ case 'listSessions':
411
+ return fortress.auth.listSessions(requireUserId(auth));
412
+ case 'revokeSession':
413
+ await fortress.auth.revokeSession(requireUserId(auth), params.id);
414
+ return { ok: true };
415
+ case 'revokeAllOtherSessions':
416
+ await fortress.auth.revokeAllOtherSessions(requireUserId(auth), String(body.currentTokenId ?? ''));
417
+ return { ok: true };
418
+ case 'addLoginIdentifier':
419
+ await fortress.auth.addLoginIdentifier(
420
+ requireUserId(auth),
421
+ body.type as never,
422
+ String(body.value ?? ''),
423
+ );
424
+ return { ok: true };
425
+ case 'removeLoginIdentifier':
426
+ await fortress.auth.removeLoginIdentifier(
427
+ requireUserId(auth),
428
+ body.type as never,
429
+ String(body.value ?? ''),
430
+ );
431
+ return { ok: true };
432
+ case 'getLoginIdentifiers':
433
+ return fortress.auth.getLoginIdentifiers(requireUserId(auth));
434
+ case 'impersonate':
435
+ return fortress.auth.impersonate(requireUserId(auth), String(body.targetUserId ?? ''), {
436
+ reason: body.reason as string | undefined,
437
+ expirySeconds: body.expirySeconds as number | undefined,
438
+ });
439
+ default:
440
+ throw Errors.notFound(`Auth handler '${handler}' not found`);
441
+ }
442
+ }
443
+
444
+ async function invokeIamHandler(
445
+ fortress: Fortress,
446
+ handler: string,
447
+ body: Record<string, unknown>,
448
+ params: Record<string, string>,
449
+ ): Promise<unknown> {
450
+ switch (handler) {
451
+ case 'getResources':
452
+ return fortress.iam.getResources();
453
+ case 'getRoles':
454
+ return fortress.iam.getRoles();
455
+ case 'createRole':
456
+ return fortress.iam.createRole(
457
+ String(body.name ?? ''),
458
+ body.permissions as never,
459
+ body.description as string | undefined,
460
+ );
461
+ case 'deleteRole':
462
+ await fortress.iam.deleteRole(params.id);
463
+ return { ok: true };
464
+ case 'bindRoleToUser':
465
+ await fortress.iam.bindRoleToUser(
466
+ String(body.userId ?? ''),
467
+ params.id,
468
+ body.tenantId as string | undefined,
469
+ );
470
+ return { ok: true };
471
+ case 'bindRoleToGroup':
472
+ await fortress.iam.bindRoleToGroup(
473
+ String(body.groupId ?? ''),
474
+ params.id,
475
+ body.tenantId as string | undefined,
476
+ );
477
+ return { ok: true };
478
+ case 'unbindRole':
479
+ await fortress.iam.unbindRole(
480
+ body.subjectType as never,
481
+ String(body.subjectId ?? ''),
482
+ params.id,
483
+ body.tenantId as string | undefined,
484
+ );
485
+ return { ok: true };
486
+ case 'createGroup':
487
+ return fortress.iam.createGroup(
488
+ String(body.name ?? ''),
489
+ body.description as string | undefined,
490
+ );
491
+ case 'addUserToGroup':
492
+ await fortress.iam.addUserToGroup(params.id, String(body.userId ?? ''));
493
+ return { ok: true };
494
+ case 'removeUserFromGroup':
495
+ await fortress.iam.removeUserFromGroup(params.id, params.userId);
496
+ return { ok: true };
497
+ case 'getUserPermissions':
498
+ return fortress.iam.getPermissionsForSubject(
499
+ { type: 'USER', id: params.id },
500
+ body.tenantId as string | undefined,
501
+ );
502
+ case 'checkPermission': {
503
+ // Accept either the new `{ subject: { type, id } }` shape or the
504
+ // legacy `{ userId }` shape. Defaulting to USER keeps this backwards
505
+ // compatible for callers that haven't migrated yet — step 10 updates
506
+ // the endpoint body schema to the new shape.
507
+ const subjectIn = body.subject as { type?: string; id?: string } | undefined;
508
+ const subject: Subject = subjectIn?.type && subjectIn?.id != null
509
+ ? { type: subjectIn.type as 'USER' | 'GROUP' | 'SERVICE_ACCOUNT', id: String(subjectIn.id) }
510
+ : { type: 'USER' as const, id: String(body.userId ?? '') };
511
+ // M7 fix: sanitize caller-supplied context before forwarding to the
512
+ // evaluator. `/iam/check` is an admin diagnostic; we must NOT let a
513
+ // caller widen credentialScopes (which would let any caller answer
514
+ // "yes" by passing scopes:['*']) nor satisfy ownership conditions by
515
+ // forging `resource.ownerId === user.id`. The narrowed shape allows
516
+ // only `request.*` (truly request-scoped data the caller could have
517
+ // supplied in a real request anyway).
518
+ const rawCtx = body.context as Record<string, unknown> | undefined;
519
+ const safeCtx: { tenantId?: string; request?: Record<string, unknown> } = {};
520
+ if (rawCtx?.tenantId !== undefined && typeof rawCtx.tenantId === 'string')
521
+ safeCtx.tenantId = rawCtx.tenantId;
522
+ if (rawCtx?.request && typeof rawCtx.request === 'object')
523
+ safeCtx.request = rawCtx.request as Record<string, unknown>;
524
+ const allowed = await fortress.iam.checkPermission(
525
+ subject,
526
+ String(body.resource ?? ''),
527
+ String(body.action ?? ''),
528
+ safeCtx as never,
529
+ );
530
+ return { allowed };
531
+ }
532
+ case 'bindPermissionToUser':
533
+ await fortress.iam.bindPermissionToUser(
534
+ String(body.userId ?? ''),
535
+ body.permission as never,
536
+ body.tenantId as string | undefined,
537
+ );
538
+ return { ok: true };
539
+ case 'bindPermissionToGroup':
540
+ await fortress.iam.bindPermissionToGroup(
541
+ String(body.groupId ?? ''),
542
+ body.permission as never,
543
+ body.tenantId as string | undefined,
544
+ );
545
+ return { ok: true };
546
+ case 'unbindPermissionFromUser':
547
+ await fortress.iam.unbindPermissionFromUser(
548
+ String(body.userId ?? ''),
549
+ String(body.permissionId ?? ''),
550
+ body.tenantId as string | undefined,
551
+ );
552
+ return { ok: true };
553
+ case 'unbindPermissionFromGroup':
554
+ await fortress.iam.unbindPermissionFromGroup(
555
+ String(body.groupId ?? ''),
556
+ String(body.permissionId ?? ''),
557
+ body.tenantId as string | undefined,
558
+ );
559
+ return { ok: true };
560
+
561
+ // ── Service Accounts ──────────────────────────────────────────
562
+ case 'createServiceAccount':
563
+ return fortress.iam.createServiceAccount({
564
+ name: String(body.name ?? ''),
565
+ displayName: body.displayName as string | undefined,
566
+ description: body.description as string | undefined,
567
+ });
568
+ case 'listServiceAccounts':
569
+ return fortress.iam.listServiceAccounts({
570
+ limit: body.limit != null ? Number(body.limit) : undefined,
571
+ offset: body.offset != null ? Number(body.offset) : undefined,
572
+ });
573
+ case 'getServiceAccount':
574
+ return fortress.iam.getServiceAccount(params.id);
575
+ case 'updateServiceAccount':
576
+ return fortress.iam.updateServiceAccount(params.id, {
577
+ displayName: body.displayName as string | null | undefined,
578
+ description: body.description as string | null | undefined,
579
+ isActive: body.isActive as boolean | undefined,
580
+ });
581
+ case 'deleteServiceAccount':
582
+ await fortress.iam.deleteServiceAccount(params.id);
583
+ return { ok: true };
584
+ case 'getServiceAccountPermissions':
585
+ return fortress.iam.getPermissionsForSubject(
586
+ { type: 'SERVICE_ACCOUNT', id: params.id },
587
+ body.tenantId as string | undefined,
588
+ );
589
+ case 'bindRoleToServiceAccount':
590
+ await fortress.iam.bindRoleToServiceAccount(
591
+ String(body.serviceAccountId ?? ''),
592
+ params.id,
593
+ body.tenantId as string | undefined,
594
+ );
595
+ return { ok: true };
596
+ case 'unbindRoleFromServiceAccount':
597
+ await fortress.iam.unbindRoleFromServiceAccount(
598
+ String(body.serviceAccountId ?? ''),
599
+ params.id,
600
+ body.tenantId as string | undefined,
601
+ );
602
+ return { ok: true };
603
+ case 'bindPermissionToServiceAccount':
604
+ await fortress.iam.bindPermissionToServiceAccount(
605
+ String(body.serviceAccountId ?? ''),
606
+ body.permission as never,
607
+ body.tenantId as string | undefined,
608
+ );
609
+ return { ok: true };
610
+ case 'unbindPermissionFromServiceAccount':
611
+ await fortress.iam.unbindPermissionFromServiceAccount(
612
+ String(body.serviceAccountId ?? ''),
613
+ String(body.permissionId ?? ''),
614
+ body.tenantId as string | undefined,
615
+ );
616
+ return { ok: true };
617
+
618
+ default:
619
+ throw Errors.notFound(`IAM handler '${handler}' not found`);
620
+ }
621
+ }
622
+
623
+ function requireUserId(auth: DispatchAuth): string {
624
+ if (!auth.userId)
625
+ throw new FortressError('UNAUTHORIZED', 'User not authenticated', 401);
626
+ return auth.userId;
627
+ }
628
+
629
+ // ── Response helpers ────────────────────────────────────────────────
630
+
631
+ /**
632
+ * Pick the first 2xx status declared in `endpoint.responses` (e.g. `201`
633
+ * for `POST /auth/register`), defaulting to `200`.
634
+ */
635
+ function successStatus(endpoint: EndpointDefinition): number {
636
+ if (!endpoint.responses)
637
+ return 200;
638
+ for (const code of Object.keys(endpoint.responses)) {
639
+ const num = Number(code);
640
+ if (num >= 200 && num < 300)
641
+ return num;
642
+ }
643
+ return 200;
644
+ }
645
+
646
+ /** Build a JSON `Response` with the right `Content-Type`. */
647
+ export function jsonResponse(body: unknown, status: number): Response {
648
+ return new Response(JSON.stringify(body ?? { ok: true }), {
649
+ status,
650
+ headers: { 'Content-Type': 'application/json' },
651
+ });
652
+ }