@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,105 @@
1
+ /**
2
+ * Shared client for fortress's *outbound* HTTP — OAuth token exchange, OIDC
3
+ * discovery, provider userinfo, GitHub profile, and the HIBP breach check.
4
+ *
5
+ * Built on `@bajustone/fetcher` so every outbound call gets:
6
+ * - a **timeout** (native `fetch` has none, so a hung upstream would block a
7
+ * login/registration indefinitely);
8
+ * - the **never-throws** contract — transport failures (network/timeout)
9
+ * resolve to a `Response` with `ok === false` rather than rejecting, so
10
+ * call sites branch on `.ok`/`.result()` instead of try/catch;
11
+ * - optional **schema-validated parsing** via a per-call `responseSchema`.
12
+ *
13
+ * Always invoked with absolute URLs, so `baseUrl` is unused (an absolute path
14
+ * overrides it in fetcher).
15
+ */
16
+ import type { Middleware } from '@bajustone/fetcher';
17
+ import { createFetch } from '@bajustone/fetcher';
18
+
19
+ /** Default outbound timeout (ms), covering headers and full body consumption. */
20
+ export const OUTBOUND_TIMEOUT_MS = 10_000;
21
+
22
+ function requestWithSignal(request: Request, signal: AbortSignal): Request {
23
+ return new Request(request, request.body
24
+ ? { signal, body: request.body, duplex: 'half' } as RequestInit
25
+ : { signal });
26
+ }
27
+
28
+ /**
29
+ * Keep the deadline alive until the response body is consumed. A fetch-only
30
+ * timeout stops at response headers, allowing a peer to dribble the body
31
+ * forever after returning an immediate 200 response.
32
+ */
33
+ function fullExchangeTimeout(ms: number): Middleware {
34
+ return async (request, next) => {
35
+ const controller = new AbortController();
36
+ const userSignal = request.signal;
37
+ const abortFromUser = (): void => controller.abort(userSignal.reason);
38
+ if (userSignal.aborted)
39
+ abortFromUser();
40
+ else
41
+ userSignal.addEventListener('abort', abortFromUser, { once: true });
42
+
43
+ const timer = setTimeout(() => {
44
+ controller.abort(new DOMException(`Request timed out after ${ms}ms`, 'TimeoutError'));
45
+ }, ms);
46
+ let cleaned = false;
47
+ const cleanup = (): void => {
48
+ if (cleaned)
49
+ return;
50
+ cleaned = true;
51
+ clearTimeout(timer);
52
+ userSignal.removeEventListener('abort', abortFromUser);
53
+ };
54
+
55
+ try {
56
+ const response = await next(requestWithSignal(request, controller.signal));
57
+ if (!response.body) {
58
+ cleanup();
59
+ return response;
60
+ }
61
+
62
+ const reader = response.body.getReader();
63
+ const body = new ReadableStream<Uint8Array>({
64
+ async pull(streamController) {
65
+ try {
66
+ const chunk = await reader.read();
67
+ if (chunk.done) {
68
+ cleanup();
69
+ streamController.close();
70
+ }
71
+ else {
72
+ streamController.enqueue(chunk.value);
73
+ }
74
+ }
75
+ catch (error) {
76
+ cleanup();
77
+ streamController.error(error);
78
+ }
79
+ },
80
+ async cancel(reason) {
81
+ cleanup();
82
+ await reader.cancel(reason);
83
+ },
84
+ });
85
+
86
+ return new Response(body, {
87
+ headers: response.headers,
88
+ status: response.status,
89
+ statusText: response.statusText,
90
+ });
91
+ }
92
+ catch (error) {
93
+ cleanup();
94
+ throw error;
95
+ }
96
+ };
97
+ }
98
+
99
+ /** Create an outbound client whose deadline covers headers and response body. */
100
+ export function createOutboundClient(timeoutMs: number = OUTBOUND_TIMEOUT_MS): ReturnType<typeof createFetch> {
101
+ return createFetch({ baseUrl: '', middleware: [fullExchangeTimeout(timeoutMs)] });
102
+ }
103
+
104
+ /** Shared fetcher client for Fortress outbound HTTP. */
105
+ export const outboundClient = createOutboundClient();
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Framework-agnostic plugin middleware execution for `fortress.handleRequest`
3
+ * and adapter handles.
4
+ *
5
+ * Wraps {@link executePluginMiddleware} from `plugin-runner.ts` so the
6
+ * caller can hand it a {@link PluginRequestContext} (a duck-typed object with
7
+ * `fortressUserId` and friends) instead of a framework-specific Hono context
8
+ * or Express request.
9
+ *
10
+ * Plugins that short-circuit do so by throwing a {@link FortressError}; the
11
+ * caller is expected to wrap this in a try/catch and translate via
12
+ * `errorToResponse`.
13
+ */
14
+
15
+ import type { FortressConfig } from '../config';
16
+ import type { FortressPlugin, MiddlewareDefinition, PluginContext } from '../plugin';
17
+ import type { Subject, TokenClaims } from '../types';
18
+ import { executePluginMiddleware } from '../plugin-runner';
19
+
20
+ /**
21
+ * Object passed to plugin middleware as the "request" argument when running
22
+ * inside `fortress.handleRequest` or any framework adapter that delegates to
23
+ * core. Carries the web-standard `Request` plus the resolved auth context.
24
+ *
25
+ * The shape is intentionally compatible with the existing admin-plugin
26
+ * `extractUserId` helper, which checks `'fortressUserId' in request`.
27
+ */
28
+ export interface PluginRequestContext {
29
+ request: Request;
30
+ /**
31
+ * The resolved principal for this request, if any. Populated from a
32
+ * plugin `resolvePrincipal` hit or the JWT fallback. Plugins preferring
33
+ * subject-level identity (non-USER principals) should read this instead
34
+ * of `fortressUserId`.
35
+ */
36
+ fortressSubject?: Subject;
37
+ /**
38
+ * Convenience alias — set only when the resolved subject is a `USER`.
39
+ * Non-USER principals (e.g. `SERVICE_ACCOUNT` via api-key) leave this
40
+ * field undefined; consumers should fall back to `fortressSubject`.
41
+ */
42
+ fortressUserId?: string;
43
+ fortressClaims?: TokenClaims;
44
+ /** Credential-level narrowing scopes (for example API-key scopes), if any. */
45
+ fortressScopes?: string[] | null;
46
+ }
47
+
48
+ /**
49
+ * Run all plugin middleware for the given phase against a request path.
50
+ *
51
+ * The path is extracted from `ctx.request.url.pathname` so plugins can
52
+ * filter by path pattern. Errors thrown by plugin handlers propagate to the
53
+ * caller.
54
+ */
55
+ export async function runPluginMiddleware(
56
+ plugins: readonly FortressPlugin[],
57
+ config: FortressConfig,
58
+ phase: MiddlewareDefinition['position'],
59
+ ctx: PluginRequestContext,
60
+ ): Promise<void> {
61
+ if (plugins.length === 0)
62
+ return;
63
+
64
+ const pluginCtx: PluginContext = { db: config.database, config };
65
+ const path = new URL(ctx.request.url).pathname;
66
+ await executePluginMiddleware(plugins, phase, path, pluginCtx, ctx);
67
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Unit tests for the shared request-principal resolver
3
+ * (`src/core/http/principal.ts`). Exercises the plugin-chain + JWT
4
+ * fallback pipeline that both `handleRequest` and the adapter user-route
5
+ * middleware delegate to.
6
+ */
7
+
8
+ import type { Fortress } from '../fortress';
9
+ import type { FortressPlugin } from '../plugin';
10
+ import type { Subject } from '../types';
11
+ import { beforeEach, describe, expect, it } from 'vitest';
12
+ import { apiKey } from '../../plugins/api-key';
13
+ import { createTestAdapter } from '../../testing';
14
+ import { createFortress } from '../fortress';
15
+ import { resolveRequestPrincipal, tryPluginPrincipal } from './principal';
16
+
17
+ const SECRET = 'principal-resolver-test-secret-32char!';
18
+
19
+ async function makeFortressWithApiKey() {
20
+ const fortress: Fortress<any> = createFortress({
21
+ jwt: { key: SECRET },
22
+ database: createTestAdapter(),
23
+ plugins: [apiKey({ prefix: 'test' })],
24
+ });
25
+ const user = await fortress.auth.createUser({
26
+ email: 'u@example.com',
27
+ name: 'User',
28
+ password: 'password-123456',
29
+ });
30
+ const sa = await fortress.iam.createServiceAccount({ name: 'ci-bot' });
31
+ const userKey = await fortress.plugins['api-key'].createKey({
32
+ subject: { type: 'USER', id: user.id },
33
+ name: 'user-key',
34
+ });
35
+ const saKey = await fortress.plugins['api-key'].createKey({
36
+ subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
37
+ name: 'sa-key',
38
+ });
39
+ return { fortress, user, sa, userKey: userKey.key, saKey: saKey.key };
40
+ }
41
+
42
+ describe('tryPluginPrincipal', () => {
43
+ it('returns null when no plugin is registered', async () => {
44
+ const fortress = createFortress({
45
+ jwt: { key: SECRET },
46
+ database: createTestAdapter(),
47
+ });
48
+ const result = await tryPluginPrincipal(
49
+ fortress,
50
+ new Request('http://localhost/api/anything'),
51
+ );
52
+ expect(result).toBeNull();
53
+ });
54
+
55
+ it('returns null when a plugin is registered but no credential is present', async () => {
56
+ const { fortress } = await makeFortressWithApiKey();
57
+ const result = await tryPluginPrincipal(
58
+ fortress,
59
+ new Request('http://localhost/api/anything'),
60
+ );
61
+ expect(result).toBeNull();
62
+ });
63
+
64
+ it('resolves a USER subject from Authorization: ApiKey', async () => {
65
+ const { fortress, user, userKey } = await makeFortressWithApiKey();
66
+ const result = await tryPluginPrincipal(
67
+ fortress,
68
+ new Request('http://localhost/api/anything', {
69
+ headers: { authorization: `ApiKey ${userKey}` },
70
+ }),
71
+ );
72
+ expect(result).not.toBeNull();
73
+ expect(result?.subject).toEqual({ type: 'USER', id: user.id });
74
+ });
75
+
76
+ it('resolves a SERVICE_ACCOUNT subject from X-API-Key', async () => {
77
+ const { fortress, sa, saKey } = await makeFortressWithApiKey();
78
+ const result = await tryPluginPrincipal(
79
+ fortress,
80
+ new Request('http://localhost/api/anything', {
81
+ headers: { 'x-api-key': saKey },
82
+ }),
83
+ );
84
+ expect(result).not.toBeNull();
85
+ expect(result?.subject).toEqual({ type: 'SERVICE_ACCOUNT', id: sa.id });
86
+ });
87
+
88
+ it('returns null for an unknown api-key', async () => {
89
+ const { fortress } = await makeFortressWithApiKey();
90
+ const result = await tryPluginPrincipal(
91
+ fortress,
92
+ new Request('http://localhost/api/anything', {
93
+ headers: { authorization: 'ApiKey test_sk_not-a-real-key' },
94
+ }),
95
+ );
96
+ expect(result).toBeNull();
97
+ });
98
+
99
+ it('walks plugins in registration order — first non-null wins', async () => {
100
+ // Two synthetic resolving plugins: only the second would return non-null
101
+ // if the first deferred. Assert the first-registered plugin wins when
102
+ // both would match.
103
+ const calls: string[] = [];
104
+ const firstHit: FortressPlugin = {
105
+ name: 'first',
106
+ resolvePrincipal: async () => {
107
+ calls.push('first');
108
+ return { subject: { type: 'USER', id: '100' } };
109
+ },
110
+ };
111
+ const secondHit: FortressPlugin = {
112
+ name: 'second',
113
+ resolvePrincipal: async () => {
114
+ calls.push('second');
115
+ return { subject: { type: 'USER', id: '200' } };
116
+ },
117
+ };
118
+ const fortress = createFortress({
119
+ jwt: { key: SECRET },
120
+ database: createTestAdapter(),
121
+ plugins: [firstHit, secondHit],
122
+ });
123
+ const result = await tryPluginPrincipal(
124
+ fortress,
125
+ new Request('http://localhost/api/anything'),
126
+ );
127
+ expect(result?.subject).toEqual({ type: 'USER', id: '100' });
128
+ expect(calls).toEqual(['first']); // second never ran
129
+ });
130
+
131
+ it('defers to the next plugin when a resolver returns null', async () => {
132
+ const calls: string[] = [];
133
+ const firstDefers: FortressPlugin = {
134
+ name: 'first',
135
+ resolvePrincipal: async () => {
136
+ calls.push('first');
137
+ return null;
138
+ },
139
+ };
140
+ const secondHit: FortressPlugin = {
141
+ name: 'second',
142
+ resolvePrincipal: async () => {
143
+ calls.push('second');
144
+ return { subject: { type: 'SERVICE_ACCOUNT', id: '42' } };
145
+ },
146
+ };
147
+ const fortress = createFortress({
148
+ jwt: { key: SECRET },
149
+ database: createTestAdapter(),
150
+ plugins: [firstDefers, secondHit],
151
+ });
152
+ const result = await tryPluginPrincipal(
153
+ fortress,
154
+ new Request('http://localhost/api/anything'),
155
+ );
156
+ expect(result?.subject).toEqual({ type: 'SERVICE_ACCOUNT', id: '42' });
157
+ expect(calls).toEqual(['first', 'second']);
158
+ });
159
+
160
+ it('skips plugins that do not implement resolvePrincipal', async () => {
161
+ const unrelated: FortressPlugin = { name: 'unrelated' };
162
+ const hit: FortressPlugin = {
163
+ name: 'hit',
164
+ resolvePrincipal: async () => ({ subject: { type: 'USER', id: '7' } }),
165
+ };
166
+ const fortress = createFortress({
167
+ jwt: { key: SECRET },
168
+ database: createTestAdapter(),
169
+ plugins: [unrelated, hit],
170
+ });
171
+ const result = await tryPluginPrincipal(
172
+ fortress,
173
+ new Request('http://localhost/'),
174
+ );
175
+ expect(result?.subject).toEqual({ type: 'USER', id: '7' });
176
+ });
177
+ });
178
+
179
+ describe('resolveRequestPrincipal', () => {
180
+ let fortress: Awaited<ReturnType<typeof makeFortressWithApiKey>>['fortress'];
181
+ let user: Awaited<ReturnType<typeof makeFortressWithApiKey>>['user'];
182
+ let sa: Awaited<ReturnType<typeof makeFortressWithApiKey>>['sa'];
183
+ let userKey: string;
184
+ let saKey: string;
185
+ let validAccessToken: string;
186
+
187
+ beforeEach(async () => {
188
+ const setup = await makeFortressWithApiKey();
189
+ fortress = setup.fortress;
190
+ user = setup.user;
191
+ sa = setup.sa;
192
+ userKey = setup.userKey;
193
+ saKey = setup.saKey;
194
+
195
+ const login = await fortress.auth.login('u@example.com', 'password-123456');
196
+ if (login.status !== 'success')
197
+ throw new Error('login should succeed');
198
+ validAccessToken = login.accessToken;
199
+ });
200
+
201
+ it('returns null when no credential is present', async () => {
202
+ const result = await resolveRequestPrincipal(
203
+ fortress,
204
+ new Request('http://localhost/api'),
205
+ );
206
+ expect(result).toBeNull();
207
+ });
208
+
209
+ it('resolves via the plugin chain BEFORE trying the JWT', async () => {
210
+ // Present BOTH a valid JWT and an api-key. The plugin-resolved principal
211
+ // should win (this matches handle-request semantics and is the whole
212
+ // reason we wired the chain into user-route middleware).
213
+ const result = await resolveRequestPrincipal(
214
+ fortress,
215
+ new Request('http://localhost/api', {
216
+ headers: {
217
+ 'authorization': `Bearer ${validAccessToken}`,
218
+ 'x-api-key': saKey,
219
+ },
220
+ }),
221
+ );
222
+ expect(result?.subject).toEqual({ type: 'SERVICE_ACCOUNT', id: sa.id });
223
+ });
224
+
225
+ it('falls back to JWT bearer when no plugin matches', async () => {
226
+ const result = await resolveRequestPrincipal(
227
+ fortress,
228
+ new Request('http://localhost/api', {
229
+ headers: { authorization: `Bearer ${validAccessToken}` },
230
+ }),
231
+ );
232
+ expect(result?.subject).toEqual({ type: 'USER', id: user.id });
233
+ expect(result?.claims).toBeDefined();
234
+ expect(result?.claims?.sub).toBe(user.id);
235
+ });
236
+
237
+ it('resolves a USER api-key via the plugin chain', async () => {
238
+ const result = await resolveRequestPrincipal(
239
+ fortress,
240
+ new Request('http://localhost/api', {
241
+ headers: { authorization: `ApiKey ${userKey}` },
242
+ }),
243
+ );
244
+ expect(result?.subject).toEqual({ type: 'USER', id: user.id });
245
+ // api-key path doesn't carry JWT claims
246
+ expect(result?.claims).toBeUndefined();
247
+ });
248
+
249
+ it('returns null (does NOT throw) for an invalid JWT', async () => {
250
+ const result = await resolveRequestPrincipal(
251
+ fortress,
252
+ new Request('http://localhost/api', {
253
+ headers: { authorization: 'Bearer definitely-not-a-jwt' },
254
+ }),
255
+ );
256
+ expect(result).toBeNull();
257
+ });
258
+
259
+ it('returns null (does NOT throw) for a malformed JWT with tampered signature', async () => {
260
+ // Take a real JWT and flip the FIRST signature char. Mangling the last
261
+ // base64url char is non-deterministic — the trailing char only carries a
262
+ // few significant bits, so a different char can decode to the same
263
+ // signature bytes (and occasionally the original char already is the
264
+ // replacement). A mid-segment char carries full bits, so flipping it
265
+ // always invalidates the signature.
266
+ const parts = validAccessToken.split('.');
267
+ const sig = parts[2];
268
+ parts[2] = (sig[0] === 'A' ? 'B' : 'A') + sig.slice(1);
269
+ const bad = parts.join('.');
270
+ const result = await resolveRequestPrincipal(
271
+ fortress,
272
+ new Request('http://localhost/api', {
273
+ headers: { authorization: `Bearer ${bad}` },
274
+ }),
275
+ );
276
+ expect(result).toBeNull();
277
+ });
278
+
279
+ it('reads the access token from the configured cookie as JWT fallback', async () => {
280
+ const result = await resolveRequestPrincipal(
281
+ fortress,
282
+ new Request('http://localhost/api', {
283
+ headers: {
284
+ cookie: `${fortress.cookies.accessName}=${validAccessToken}`,
285
+ },
286
+ }),
287
+ );
288
+ expect(result?.subject).toEqual({ type: 'USER', id: user.id });
289
+ });
290
+
291
+ it('resolves a SERVICE_ACCOUNT principal even though it has no USER analogue', async () => {
292
+ const result = await resolveRequestPrincipal(
293
+ fortress,
294
+ new Request('http://localhost/api', {
295
+ headers: { 'x-api-key': saKey },
296
+ }),
297
+ );
298
+ expect(result?.subject.type).toBe('SERVICE_ACCOUNT');
299
+ expect(result?.subject.id).toBe(sa.id);
300
+ });
301
+ });
302
+
303
+ describe('fortress.resolvePrincipal (instance method)', () => {
304
+ it('exposes the same resolver as the module-level helper', async () => {
305
+ const { fortress, userKey } = await makeFortressWithApiKey();
306
+ const result = await fortress.resolvePrincipal(
307
+ new Request('http://localhost/', {
308
+ headers: { authorization: `ApiKey ${userKey}` },
309
+ }),
310
+ );
311
+ expect(result?.subject.type).toBe('USER');
312
+ });
313
+
314
+ it('returns null when no credential is present', async () => {
315
+ const { fortress } = await makeFortressWithApiKey();
316
+ const result = await fortress.resolvePrincipal(
317
+ new Request('http://localhost/'),
318
+ );
319
+ expect(result).toBeNull();
320
+ });
321
+ });
322
+
323
+ // Guard against the regression where plugins list is passed via `unknown` and
324
+ // silently loses its type. The resolver accepts any Subject shape back from a
325
+ // plugin and threads it through unchanged.
326
+ describe('resolveRequestPrincipal — subject shape pass-through', () => {
327
+ it('preserves arbitrary SubjectType values from plugins', async () => {
328
+ const custom: FortressPlugin = {
329
+ name: 'group-resolver',
330
+ resolvePrincipal: async (): Promise<{ subject: Subject }> => ({
331
+ subject: { type: 'GROUP', id: '99' },
332
+ }),
333
+ };
334
+ const fortress = createFortress({
335
+ jwt: { key: SECRET },
336
+ database: createTestAdapter(),
337
+ plugins: [custom],
338
+ });
339
+ const result = await resolveRequestPrincipal(
340
+ fortress,
341
+ new Request('http://localhost/'),
342
+ );
343
+ expect(result?.subject).toEqual({ type: 'GROUP', id: '99' });
344
+ });
345
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Shared principal resolution for `fortress.handleRequest` and user-route
3
+ * adapters (Hono / Express / SvelteKit).
4
+ *
5
+ * Plugins that register a `resolvePrincipal` capability (e.g. `api-key`) are
6
+ * tried in registration order; the first non-null result wins. If no plugin
7
+ * resolves the request, a JWT bearer token is attempted as a fallback.
8
+ *
9
+ * Two variants are exported:
10
+ *
11
+ * - {@link tryPluginPrincipal} — only walks the plugin chain. Used by
12
+ * `handle-request.ts`, which needs to decide whether to enforce the JWT
13
+ * fallback based on the endpoint's `security` metadata.
14
+ * - {@link resolveRequestPrincipal} — plugin chain + non-throwing JWT
15
+ * fallback. Used by adapter user-route middleware so that API keys and
16
+ * cookies/bearer tokens authenticate uniformly on *any* route, not just
17
+ * Fortress-owned routes.
18
+ */
19
+
20
+ import type { Fortress } from '../fortress';
21
+ import type { Subject, TokenClaims } from '../types';
22
+
23
+ /** Result of a successful principal resolution. */
24
+ export interface ResolvedPrincipal {
25
+ subject: Subject;
26
+ claims?: TokenClaims;
27
+ /** Credential-level narrowing scopes (e.g. API-key scopes). null/undefined = unscoped. */
28
+ scopes?: string[] | null;
29
+ }
30
+
31
+ /**
32
+ * Walk the `resolvePrincipal` plugin chain. Returns the first non-null
33
+ * result, or null if every plugin deferred.
34
+ */
35
+ export async function tryPluginPrincipal(
36
+ fortress: Fortress,
37
+ request: Request,
38
+ ): Promise<ResolvedPrincipal | null> {
39
+ const plugins = fortress.config.plugins ?? [];
40
+ for (const plugin of plugins) {
41
+ if (!plugin.resolvePrincipal)
42
+ continue;
43
+ const resolved = await plugin.resolvePrincipal(request, {
44
+ db: fortress.config.database,
45
+ config: fortress.config,
46
+ auth: fortress.auth,
47
+ iam: fortress.iam,
48
+ });
49
+ if (resolved)
50
+ return { subject: resolved.subject, claims: resolved.claims, scopes: resolved.scopes };
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Resolve a request principal by trying the plugin chain first, then falling
57
+ * back to the configured JWT bearer token (cookie-first, `Authorization:
58
+ * Bearer` second).
59
+ *
60
+ * Non-throwing: returns `null` if no credential is present **or** the JWT
61
+ * fails verification. Adapter user-route middleware calls this so RBAC can
62
+ * decide how to handle the missing principal (401 for protected routes,
63
+ * anonymous pass for public ones).
64
+ */
65
+ export async function resolveRequestPrincipal(
66
+ fortress: Fortress,
67
+ request: Request,
68
+ ): Promise<ResolvedPrincipal | null> {
69
+ const plugin = await tryPluginPrincipal(fortress, request);
70
+ if (plugin)
71
+ return plugin;
72
+
73
+ const token = fortress.extractAccessToken(request);
74
+ if (!token)
75
+ return null;
76
+ try {
77
+ const claims = await fortress.auth.verifyToken(token);
78
+ return {
79
+ subject: { type: claims.subjectType, id: claims.sub },
80
+ claims,
81
+ };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }