@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,336 @@
1
+ /**
2
+ * Pure key operations for the api-key plugin.
3
+ *
4
+ * Extracted so both the api-key plugin (self-service) and the admin plugin
5
+ * (admin-over-users) can share one implementation without cross-plugin
6
+ * runtime coupling. Functions take a {@link DatabaseAdapter} plus args and
7
+ * throw {@link FortressError} via the existing `Errors` factory on failure.
8
+ *
9
+ * Keys are owned by a {@link Subject} — either a USER or a SERVICE_ACCOUNT
10
+ * (and extensible to future subject types). The storage schema uses a
11
+ * polymorphic `(subject_type, subject_id)` pair instead of a hard FK to
12
+ * `users.id`, mirroring `role_binding` / `direct_permission_binding`.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import type { DatabaseAdapter } from '../../adapters/database';
18
+ import type { FortressUser, ServiceAccount, Subject, SubjectType } from '../../core/types';
19
+ import { hashToken } from '../../core/auth/refresh-token';
20
+ import { Errors } from '../../core/errors';
21
+
22
+ export interface ApiKeyInfo {
23
+ id: string;
24
+ name: string;
25
+ keyPrefix: string;
26
+ scopes: string[] | null;
27
+ expiresAt: Date | null;
28
+ lastUsedAt: Date | null;
29
+ createdAt: Date;
30
+ }
31
+
32
+ export interface ApiKeyRecord {
33
+ id: string;
34
+ subjectType: SubjectType;
35
+ subjectId: string;
36
+ name: string;
37
+ keyHash: string;
38
+ keyPrefix: string;
39
+ scopes: string | null;
40
+ expiresAt: Date | null;
41
+ lastUsedAt: Date | null;
42
+ isRevoked: boolean;
43
+ createdAt: Date;
44
+ }
45
+
46
+ export interface ApiKeyKnobs {
47
+ prefix: string;
48
+ defaultExpirySeconds: number | null;
49
+ maxKeysPerSubject: number;
50
+ }
51
+
52
+ export interface CreateKeyOptions {
53
+ name: string;
54
+ scopes?: string[];
55
+ expiresAt?: Date;
56
+ }
57
+
58
+ /** Generate a new random API key and its hash. */
59
+ export async function generateApiKey(
60
+ prefix: string,
61
+ ): Promise<{ raw: string; hash: string; keyPrefix: string }> {
62
+ const bytes = new Uint8Array(32);
63
+ crypto.getRandomValues(bytes);
64
+ const random = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
65
+ const raw = `${prefix}_sk_${random}`;
66
+ const hash = await hashToken(raw);
67
+ // Include random material even when the configured textual prefix is long
68
+ // (the default `fortress_sk_` alone already occupies 12 characters).
69
+ const keyPrefix = `${prefix}_sk_${random.slice(0, 8)}`;
70
+ return { raw, hash, keyPrefix };
71
+ }
72
+
73
+ function toInfo(r: ApiKeyRecord): ApiKeyInfo {
74
+ return {
75
+ id: r.id,
76
+ name: r.name,
77
+ keyPrefix: r.keyPrefix,
78
+ scopes: r.scopes ? JSON.parse(r.scopes) as string[] : null,
79
+ expiresAt: r.expiresAt,
80
+ lastUsedAt: r.lastUsedAt,
81
+ createdAt: r.createdAt,
82
+ };
83
+ }
84
+
85
+ function subjectWhere(subject: Subject): Array<{ field: 'subjectType' | 'subjectId'; operator: '='; value: string | number }> {
86
+ return [
87
+ { field: 'subjectType', operator: '=', value: subject.type },
88
+ { field: 'subjectId', operator: '=', value: subject.id },
89
+ ];
90
+ }
91
+
92
+ /**
93
+ * Create a new key for a subject. Enforces `maxKeysPerSubject`. Caller-neutral —
94
+ * the admin plugin calls this on behalf of a target subject, self-service
95
+ * calls with the authenticated caller's subject.
96
+ */
97
+ export async function createKeyForSubject(
98
+ db: DatabaseAdapter,
99
+ subject: Subject,
100
+ options: CreateKeyOptions,
101
+ knobs: ApiKeyKnobs,
102
+ ): Promise<{ key: string; id: string }> {
103
+ return db.transaction(async (tx) => {
104
+ // SQLite transactions take the writer lock; PostgreSQL needs an explicit
105
+ // subject-scoped advisory lock so count+insert is one atomic quota check
106
+ // across processes.
107
+ if (tx.dialect === 'pg' && tx.rawQuery) {
108
+ await tx.rawQuery('SELECT pg_advisory_xact_lock(hashtext(?), hashtext(?))', [subject.type, subject.id]);
109
+ }
110
+
111
+ const activeCount = await tx.count({
112
+ model: 'api_key',
113
+ where: [
114
+ ...subjectWhere(subject),
115
+ { field: 'isRevoked', operator: '=', value: false },
116
+ ],
117
+ });
118
+
119
+ if (activeCount >= knobs.maxKeysPerSubject) {
120
+ throw Errors.badRequest(`Maximum of ${knobs.maxKeysPerSubject} active API keys per subject`);
121
+ }
122
+
123
+ const { raw, hash, keyPrefix } = await generateApiKey(knobs.prefix);
124
+
125
+ let expiresAt: Date | null = null;
126
+ if (options.expiresAt) {
127
+ expiresAt = options.expiresAt;
128
+ }
129
+ else if (knobs.defaultExpirySeconds) {
130
+ expiresAt = new Date(Date.now() + knobs.defaultExpirySeconds * 1000);
131
+ }
132
+
133
+ const record = await tx.create<ApiKeyRecord>({
134
+ model: 'api_key',
135
+ data: {
136
+ subjectType: subject.type,
137
+ subjectId: subject.id,
138
+ name: options.name,
139
+ keyHash: hash,
140
+ keyPrefix,
141
+ scopes: options.scopes ? JSON.stringify(options.scopes) : null,
142
+ expiresAt,
143
+ lastUsedAt: null,
144
+ isRevoked: false,
145
+ },
146
+ });
147
+
148
+ return { key: raw, id: record.id };
149
+ });
150
+ }
151
+
152
+ /** List non-revoked keys owned by a subject. Caller-neutral. */
153
+ export async function listKeysForSubject(
154
+ db: DatabaseAdapter,
155
+ subject: Subject,
156
+ ): Promise<ApiKeyInfo[]> {
157
+ const records = await db.findMany<ApiKeyRecord>({
158
+ model: 'api_key',
159
+ where: [
160
+ ...subjectWhere(subject),
161
+ { field: 'isRevoked', operator: '=', value: false },
162
+ ],
163
+ });
164
+ return records.map(toInfo);
165
+ }
166
+
167
+ /**
168
+ * Revoke a key owned by the given subject. Throws `notFound` if the key is
169
+ * missing or belongs to a different subject — the ownership check enforces
170
+ * self-service semantics.
171
+ */
172
+ export async function revokeKeyForSubject(
173
+ db: DatabaseAdapter,
174
+ subject: Subject,
175
+ keyId: string,
176
+ ): Promise<void> {
177
+ const record = await db.findOne<ApiKeyRecord>({
178
+ model: 'api_key',
179
+ where: [{ field: 'id', operator: '=', value: keyId }],
180
+ });
181
+ if (!record || record.subjectType !== subject.type || record.subjectId !== subject.id) {
182
+ throw Errors.notFound('API key not found');
183
+ }
184
+ await db.update({
185
+ model: 'api_key',
186
+ where: [{ field: 'id', operator: '=', value: keyId }],
187
+ data: { isRevoked: true },
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Rotate a key owned by the given subject — revokes the old one and issues
193
+ * a new one with the same name, scopes, and expiry. Same ownership check as
194
+ * {@link revokeKeyForSubject}.
195
+ */
196
+ export async function rotateKeyForSubject(
197
+ db: DatabaseAdapter,
198
+ subject: Subject,
199
+ keyId: string,
200
+ knobs: { prefix: string },
201
+ ): Promise<{ key: string; id: string }> {
202
+ const record = await db.findOne<ApiKeyRecord>({
203
+ model: 'api_key',
204
+ where: [{ field: 'id', operator: '=', value: keyId }],
205
+ });
206
+ if (!record || record.subjectType !== subject.type || record.subjectId !== subject.id) {
207
+ throw Errors.notFound('API key not found');
208
+ }
209
+
210
+ await db.update({
211
+ model: 'api_key',
212
+ where: [{ field: 'id', operator: '=', value: keyId }],
213
+ data: { isRevoked: true },
214
+ });
215
+
216
+ const { raw, hash, keyPrefix } = await generateApiKey(knobs.prefix);
217
+ const newRecord = await db.create<ApiKeyRecord>({
218
+ model: 'api_key',
219
+ data: {
220
+ subjectType: record.subjectType,
221
+ subjectId: record.subjectId,
222
+ name: record.name,
223
+ keyHash: hash,
224
+ keyPrefix,
225
+ scopes: record.scopes,
226
+ expiresAt: record.expiresAt,
227
+ lastUsedAt: null,
228
+ isRevoked: false,
229
+ },
230
+ });
231
+
232
+ return { key: raw, id: newRecord.id };
233
+ }
234
+
235
+ /**
236
+ * Resolve a raw API key to its owning subject + scopes. Returns `null` for
237
+ * unknown, revoked, expired, or inactive keys. Side-effect: updates
238
+ * `lastUsedAt` on successful resolution.
239
+ *
240
+ * USER and SERVICE_ACCOUNT owners are additionally checked for existence and
241
+ * `isActive` — this is the first line of defense before the permission
242
+ * resolver enforces the same gate in `internal-adapter.getSubjectPermissions`.
243
+ */
244
+ export async function resolveApiKey(
245
+ db: DatabaseAdapter,
246
+ rawKey: string,
247
+ ): Promise<{ subject: Subject; scopes: string[] | null } | null> {
248
+ const hash = await hashToken(rawKey);
249
+ const record = await db.findOne<ApiKeyRecord>({
250
+ model: 'api_key',
251
+ where: [{ field: 'keyHash', operator: '=', value: hash }],
252
+ });
253
+
254
+ if (!record || record.isRevoked)
255
+ return null;
256
+ if (record.expiresAt && record.expiresAt < new Date())
257
+ return null;
258
+
259
+ // A credential cannot outlive or bypass deactivation of its owner.
260
+ if (record.subjectType === 'USER') {
261
+ const user = await db.findOne<FortressUser>({
262
+ model: 'user',
263
+ where: [{ field: 'id', operator: '=', value: record.subjectId }],
264
+ });
265
+ if (!user || !user.isActive)
266
+ return null;
267
+ }
268
+ else if (record.subjectType === 'SERVICE_ACCOUNT') {
269
+ const sa = await db.findOne<ServiceAccount>({
270
+ model: 'service_account',
271
+ where: [{ field: 'id', operator: '=', value: record.subjectId }],
272
+ });
273
+ if (!sa || !sa.isActive)
274
+ return null;
275
+ }
276
+
277
+ await db.update({
278
+ model: 'api_key',
279
+ where: [{ field: 'id', operator: '=', value: record.id }],
280
+ data: { lastUsedAt: new Date() },
281
+ });
282
+
283
+ return {
284
+ subject: { type: record.subjectType, id: record.subjectId },
285
+ scopes: record.scopes ? JSON.parse(record.scopes) as string[] : null,
286
+ };
287
+ }
288
+
289
+ // ── Admin-only helpers ───────────────────────────────────────────────
290
+
291
+ /**
292
+ * Admin variant of {@link revokeKeyForSubject} — revokes any key by id
293
+ * without an ownership check. Use only behind an admin-permission gate.
294
+ */
295
+ export async function revokeKeyAsAdmin(
296
+ db: DatabaseAdapter,
297
+ keyId: string,
298
+ ): Promise<void> {
299
+ const record = await db.findOne<ApiKeyRecord>({
300
+ model: 'api_key',
301
+ where: [{ field: 'id', operator: '=', value: keyId }],
302
+ });
303
+ if (!record)
304
+ throw Errors.notFound('API key not found');
305
+ await db.update({
306
+ model: 'api_key',
307
+ where: [{ field: 'id', operator: '=', value: keyId }],
308
+ data: { isRevoked: true },
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Admin variant of {@link listKeysForSubject} — lists all keys for any
314
+ * subject without an ownership check. Used by the admin plugin's
315
+ * `adminListUserApiKeys` convenience helper.
316
+ */
317
+ export async function listKeysForAnySubject(
318
+ db: DatabaseAdapter,
319
+ subject: Subject,
320
+ ): Promise<ApiKeyInfo[]> {
321
+ return listKeysForSubject(db, subject);
322
+ }
323
+
324
+ /**
325
+ * Hard-delete every api key owned by a subject. Used by the api-key plugin's
326
+ * IAM observer to cascade-delete keys when a service account is deleted.
327
+ */
328
+ export async function deleteAllKeysForSubject(
329
+ db: DatabaseAdapter,
330
+ subject: Subject,
331
+ ): Promise<void> {
332
+ await db.delete({
333
+ model: 'api_key',
334
+ where: subjectWhere(subject),
335
+ });
336
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * API key plugin for fortress.
3
+ *
4
+ * Issues scoped, hashed API keys for users and service accounts, with
5
+ * optional expiry, revocation, and per-key permission scopes. Authenticates
6
+ * incoming requests via `Authorization: ApiKey <key>` or `X-API-Key: <key>`
7
+ * headers and exposes management methods on the fortress instance.
8
+ *
9
+ * HTTP endpoints are opt-in: pass `apiKey({ routes: true })` to mount the
10
+ * self-service routes under `/api-key/keys/*`. The programmatic methods on
11
+ * `fortress.plugins['api-key']` are always available regardless of the flag.
12
+ *
13
+ * Keys are owned by a {@link Subject} — either a USER or a SERVICE_ACCOUNT.
14
+ * When a service account is deleted via core IAM, this plugin's IAM
15
+ * observer hard-deletes every key it owns.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ import type { IamEvent, IamEventListener } from '../../core/iam/iam-service';
21
+ import type { FortressPlugin, PluginContext, PluginRouteContext } from '../../core/plugin';
22
+ import type { Subject } from '../../core/types';
23
+ import type { ApiKeyInfo, ApiKeyKnobs, CreateKeyOptions } from './core';
24
+ import { Errors } from '../../core/errors';
25
+ import { arr, bool, endpoint, id, int, obj, ref, str } from '../../core/schema-builder';
26
+ import {
27
+ createKeyForSubject,
28
+ deleteAllKeysForSubject,
29
+ listKeysForSubject,
30
+ resolveApiKey,
31
+ revokeKeyForSubject,
32
+ rotateKeyForSubject,
33
+ } from './core';
34
+
35
+ export type { ApiKeyInfo, ApiKeyRecord } from './core';
36
+
37
+ export interface ApiKeyConfig {
38
+ /** Prefix for generated keys (default: 'fortress') */
39
+ prefix?: string;
40
+ /** Default expiry in seconds. null = never expires (default: null) */
41
+ defaultExpirySeconds?: number | null;
42
+ /** Maximum active (non-revoked) keys per subject (default: 10) */
43
+ maxKeysPerSubject?: number;
44
+ /**
45
+ * Mount self-service HTTP routes under `/api-key/keys/*`. Default `false`.
46
+ * The programmatic methods on `fortress.plugins['api-key']` are always
47
+ * available; this flag only controls HTTP mounting.
48
+ */
49
+ routes?: boolean;
50
+ }
51
+
52
+ export interface ApiKeyMethods {
53
+ createKey: (
54
+ input: { subject?: Subject; name: string; scopes?: string[]; expiresAt?: Date | string },
55
+ routeCtx?: PluginRouteContext,
56
+ ) => Promise<{ key: string; id: string }>;
57
+ listKeys: (
58
+ input: { subject?: Subject },
59
+ routeCtx?: PluginRouteContext,
60
+ ) => Promise<ApiKeyInfo[]>;
61
+ revokeKey: (
62
+ input: { subject?: Subject; id: string },
63
+ routeCtx?: PluginRouteContext,
64
+ ) => Promise<{ ok: true }>;
65
+ rotateKey: (
66
+ input: { subject?: Subject; id: string },
67
+ routeCtx?: PluginRouteContext,
68
+ ) => Promise<{ key: string; id: string }>;
69
+ resolveKey: (
70
+ rawKey: string,
71
+ ) => Promise<{ subject: Subject; scopes: string[] | null } | null>;
72
+ }
73
+
74
+ // ── Routes ──────────────────────────────────────────────────────────
75
+
76
+ const errorRef = ref('ErrorResponse');
77
+
78
+ const apiKeySelfServiceRoutes = {
79
+ createKey: endpoint('POST', '/api-key/keys')
80
+ .summary('Create an API key')
81
+ .description('Create a new API key for the authenticated caller. The raw key is returned exactly once — it cannot be retrieved later.')
82
+ .tags('API Keys')
83
+ .security('bearer')
84
+ .body(obj({
85
+ name: str('Human-readable key label'),
86
+ scopes: arr(str(), 'Optional permission scopes attached to the key'),
87
+ expiresAt: str('Optional expiry (ISO 8601 string)'),
88
+ }, 'name'))
89
+ .response(201, 'Key created', obj({
90
+ key: str('Raw API key — shown exactly once, store it immediately'),
91
+ id: int('Database id of the key'),
92
+ }, 'key', 'id'))
93
+ .response(400, 'Bad request', errorRef)
94
+ .response(401, 'Not authenticated', errorRef)
95
+ .handler('createKey')
96
+ .build(),
97
+
98
+ listKeys: endpoint('GET', '/api-key/keys')
99
+ .summary('List the caller\'s API keys')
100
+ .description('Return the active (non-revoked) API keys belonging to the authenticated caller. Raw keys and hashes are never returned.')
101
+ .tags('API Keys')
102
+ .security('bearer')
103
+ .response(200, 'Keys', obj({
104
+ keys: arr(obj({
105
+ id: id('Database id'),
106
+ name: str('Key label'),
107
+ keyPrefix: str('First 12 characters of the key, for identification'),
108
+ scopes: arr(str()),
109
+ expiresAt: str('ISO 8601 expiry, if any'),
110
+ lastUsedAt: str('ISO 8601 timestamp of last successful resolve'),
111
+ createdAt: str('ISO 8601 creation timestamp'),
112
+ }, 'id', 'name', 'keyPrefix', 'createdAt')),
113
+ }, 'keys'))
114
+ .response(401, 'Not authenticated', errorRef)
115
+ .handler('listKeys')
116
+ .build(),
117
+
118
+ revokeKey: endpoint('DELETE', '/api-key/keys/:id')
119
+ .summary('Revoke an API key')
120
+ .description('Revoke one of the authenticated caller\'s own API keys. Returns 404 if the id does not belong to the caller.')
121
+ .tags('API Keys')
122
+ .security('bearer')
123
+ .params(obj({ id: str('Key id') }, 'id'))
124
+ .response(200, 'Revoked', obj({ ok: bool() }, 'ok'))
125
+ .response(401, 'Not authenticated', errorRef)
126
+ .response(404, 'Not found', errorRef)
127
+ .handler('revokeKey')
128
+ .build(),
129
+
130
+ rotateKey: endpoint('POST', '/api-key/keys/:id/rotate')
131
+ .summary('Rotate an API key')
132
+ .description('Revoke an existing key and issue a new one with the same name, scopes, and expiry. The new raw key is returned exactly once.')
133
+ .tags('API Keys')
134
+ .security('bearer')
135
+ .params(obj({ id: str('Key id to rotate') }, 'id'))
136
+ .response(200, 'Rotated', obj({
137
+ key: str('New raw API key — shown exactly once'),
138
+ id: int('Database id of the new key'),
139
+ }, 'key', 'id'))
140
+ .response(401, 'Not authenticated', errorRef)
141
+ .response(404, 'Not found', errorRef)
142
+ .handler('rotateKey')
143
+ .build(),
144
+ } as const;
145
+
146
+ // ── Plugin Factory ──────────────────────────────────────────────────
147
+
148
+ /**
149
+ * API key plugin factory. Returns a {@link FortressPlugin} that issues
150
+ * scoped, hashed API keys, resolves them into request principals via the
151
+ * `Authorization: ApiKey`/`X-API-Key` headers, and exposes management
152
+ * methods on the fortress instance. Pass `{ routes: true }` to mount the
153
+ * self-service HTTP routes under `/api-key/keys/*`.
154
+ */
155
+ export function apiKey(config: ApiKeyConfig = {}): FortressPlugin & { readonly name: 'api-key' } {
156
+ const knobs: ApiKeyKnobs = {
157
+ prefix: config.prefix ?? 'fortress',
158
+ defaultExpirySeconds: config.defaultExpirySeconds ?? null,
159
+ maxKeysPerSubject: config.maxKeysPerSubject ?? 10,
160
+ };
161
+ const mountRoutes = config.routes === true;
162
+ let observerRegistered = false;
163
+
164
+ return {
165
+ name: 'api-key',
166
+
167
+ models: [{
168
+ name: 'api_key',
169
+ fields: {
170
+ id: { type: 'number', required: true },
171
+ subjectType: { type: 'string', required: true },
172
+ subjectId: { type: 'number', required: true },
173
+ name: { type: 'string', required: true },
174
+ keyHash: { type: 'string', required: true, unique: true },
175
+ keyPrefix: { type: 'string', required: true },
176
+ scopes: { type: 'string' },
177
+ expiresAt: { type: 'date' },
178
+ lastUsedAt: { type: 'date' },
179
+ isRevoked: { type: 'boolean', required: true },
180
+ createdAt: { type: 'date', required: true },
181
+ },
182
+ }],
183
+
184
+ ...(mountRoutes ? { routes: apiKeySelfServiceRoutes } : {}),
185
+
186
+ async resolvePrincipal(request: Request, ctx: PluginContext): Promise<{ subject: Subject; scopes?: string[] | null } | null> {
187
+ const auth = request.headers.get('authorization') ?? '';
188
+ const fromAuth = auth.startsWith('ApiKey ') ? auth.slice(7).trim() : null;
189
+ const fromHeader = !fromAuth ? request.headers.get('x-api-key') : null;
190
+ const key = fromAuth ?? fromHeader;
191
+ if (!key)
192
+ return null;
193
+ const resolved = await resolveApiKey(ctx.db, key);
194
+ if (!resolved)
195
+ return null;
196
+ return { subject: resolved.subject, scopes: resolved.scopes };
197
+ },
198
+
199
+ methods: (ctx: PluginContext) => {
200
+ // Register the IAM cascade observer exactly once per plugin instance.
201
+ // Done inside `methods` (rather than at factory time) because `ctx.iam`
202
+ // is not wired at factory construction — the Fortress boot order
203
+ // constructs services first, then calls `processPlugins` which invokes
204
+ // this `methods` factory with a fully-populated ctx.
205
+ if (!observerRegistered && ctx.iam) {
206
+ observerRegistered = true;
207
+ const cascade: IamEventListener = async (event: IamEvent) => {
208
+ if (event.eventType === 'SERVICE_ACCOUNT_DELETED' && event.targetId != null) {
209
+ await deleteAllKeysForSubject(ctx.db, {
210
+ type: 'SERVICE_ACCOUNT',
211
+ id: event.targetId,
212
+ });
213
+ }
214
+ };
215
+ ctx.iam.addIamObserver(cascade);
216
+ }
217
+
218
+ return {
219
+ async createKey(
220
+ input: { subject?: Subject; name: string; scopes?: string[]; expiresAt?: Date | string },
221
+ routeCtx?: PluginRouteContext,
222
+ ): Promise<{ key: string; id: string }> {
223
+ const subject = resolveCallerSubject(input, routeCtx);
224
+ const options: CreateKeyOptions = {
225
+ name: String(input.name ?? ''),
226
+ scopes: Array.isArray(input.scopes) ? input.scopes.map(String) : undefined,
227
+ expiresAt: coerceDate(input.expiresAt),
228
+ };
229
+ if (!options.name)
230
+ throw Errors.badRequest('name is required');
231
+ return createKeyForSubject(ctx.db, subject, options, knobs);
232
+ },
233
+
234
+ async listKeys(
235
+ input: { subject?: Subject },
236
+ routeCtx?: PluginRouteContext,
237
+ ): Promise<ApiKeyInfo[]> {
238
+ const subject = resolveCallerSubject(input, routeCtx);
239
+ return listKeysForSubject(ctx.db, subject);
240
+ },
241
+
242
+ async revokeKey(
243
+ input: { subject?: Subject; id: string },
244
+ routeCtx?: PluginRouteContext,
245
+ ): Promise<{ ok: true }> {
246
+ const subject = resolveCallerSubject(input, routeCtx);
247
+ const keyId = String(input.id ?? '');
248
+ if (!keyId)
249
+ throw Errors.badRequest('id is required');
250
+ await revokeKeyForSubject(ctx.db, subject, keyId);
251
+ return { ok: true };
252
+ },
253
+
254
+ async rotateKey(
255
+ input: { subject?: Subject; id: string },
256
+ routeCtx?: PluginRouteContext,
257
+ ): Promise<{ key: string; id: string }> {
258
+ const subject = resolveCallerSubject(input, routeCtx);
259
+ const keyId = String(input.id ?? '');
260
+ if (!keyId)
261
+ throw Errors.badRequest('id is required');
262
+ return rotateKeyForSubject(ctx.db, subject, keyId, { prefix: knobs.prefix });
263
+ },
264
+
265
+ async resolveKey(
266
+ rawKey: string,
267
+ ): Promise<{ subject: Subject; scopes: string[] | null } | null> {
268
+ return resolveApiKey(ctx.db, rawKey);
269
+ },
270
+ };
271
+ },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Resolve the caller's subject. Two call paths:
277
+ *
278
+ * 1. HTTP: the dispatcher passes `routeCtx` with the verified principal
279
+ * (resolved from a JWT or an api-key `resolvePrincipal` pass). Use it
280
+ * and ignore any `subject` in the body — the client doesn't get to pick
281
+ * which subject a key is created for.
282
+ * 2. Programmatic (seed scripts, custom server code): no `routeCtx`, so
283
+ * fall back to `input.subject`. These callers are trusted because they
284
+ * already hold a reference to `fortress.plugins['api-key']`.
285
+ */
286
+ function resolveCallerSubject(
287
+ input: { subject?: Subject },
288
+ routeCtx?: PluginRouteContext,
289
+ ): Subject {
290
+ if (routeCtx) {
291
+ if (!routeCtx.subject)
292
+ throw Errors.unauthorized('Not authenticated');
293
+ // Do not allow an API key credential to self-manage API keys. A scoped
294
+ // key could otherwise mint/rotate into a broader or unscoped key via the
295
+ // bearer-only self-service routes. Browser/JWT sessions have no
296
+ // credential scopes, so they remain allowed to manage their own keys.
297
+ if (routeCtx.scopes !== undefined)
298
+ throw Errors.forbidden('API keys cannot manage API keys');
299
+ return routeCtx.subject;
300
+ }
301
+ if (!input.subject)
302
+ throw Errors.badRequest('subject is required for programmatic calls');
303
+ return input.subject;
304
+ }
305
+
306
+ function coerceDate(value: Date | string | undefined): Date | undefined {
307
+ if (value == null)
308
+ return undefined;
309
+ if (value instanceof Date)
310
+ return value;
311
+ const parsed = new Date(value);
312
+ if (Number.isNaN(parsed.getTime()))
313
+ throw Errors.badRequest('expiresAt must be a valid date');
314
+ return parsed;
315
+ }