@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,188 @@
1
+ # WebAuthn Plugin
2
+
3
+ ## Overview
4
+
5
+ The `webauthn` plugin adds passkey and WebAuthn support to Fortress, enabling passwordless authentication using platform authenticators (Touch ID, Face ID, Windows Hello) and security keys. It is built on [@simplewebauthn/server](https://simplewebauthn.dev/).
6
+
7
+ The plugin supports both registration (adding a passkey to an existing account) and authentication (signing in with a passkey). In passwordless mode, successful authentication returns JWT tokens directly.
8
+
9
+ ## Installation
10
+
11
+ Import the `webauthn` factory and pass it in the `plugins` array when creating a Fortress instance:
12
+
13
+ ```ts
14
+ import { createFortress } from '@bajustone/fortress';
15
+ import { webauthn } from '@bajustone/fortress/plugins/webauthn';
16
+
17
+ const fortress = createFortress({
18
+ jwt: { key: 'your-secret-at-least-32-bytes!!' },
19
+ database: adapter,
20
+ plugins: [
21
+ webauthn({
22
+ rpName: 'My App',
23
+ rpID: 'example.com',
24
+ origin: 'https://example.com',
25
+ }),
26
+ ],
27
+ });
28
+ ```
29
+
30
+ Once registered, methods are available at `fortress.plugins['webauthn']` with full type safety.
31
+
32
+ ## Configuration
33
+
34
+ | Option | Type | Default | Required | Description |
35
+ |---|---|---|---|---|
36
+ | `rpName` | `string` | -- | Yes | Human-readable relying party name shown to users (e.g., "My App"). |
37
+ | `rpID` | `string` | -- | Yes | Relying party identifier, typically the domain (e.g., "example.com"). |
38
+ | `origin` | `string \| string[]` | -- | Yes | Expected origin(s) for credentials (e.g., "https://example.com"). |
39
+ | `attestation` | `'none' \| 'indirect' \| 'direct' \| 'enterprise'` | `'none'` | No | Attestation preference. |
40
+ | `authenticatorSelection.authenticatorAttachment` | `'platform' \| 'cross-platform'` | -- | No | Restrict to platform or roaming authenticators. |
41
+ | `authenticatorSelection.residentKey` | `'discouraged' \| 'preferred' \| 'required'` | `'preferred'` | No | Resident key (discoverable credential) preference. |
42
+ | `authenticatorSelection.userVerification` | `'discouraged' \| 'preferred' \| 'required'` | `'preferred'` | No | User verification preference. |
43
+ | `timeout` | `number` | `60000` (60s) | No | Timeout for WebAuthn ceremonies in milliseconds. |
44
+ | `challengeTTLSeconds` | `number` | `300` (5 min) | No | How long a challenge remains valid, in seconds. |
45
+ | `supportPasswordless` | `boolean` | `true` | No | When `true`, `verifyAuthentication` returns `AuthResult` for direct passwordless login. When `false`, the plugin registers a pre-token `postAuthGate` and challenges password logins. |
46
+
47
+ ## HTTP Routes
48
+
49
+ The plugin defines four routes that are auto-mounted via `mountFortress`:
50
+
51
+ | Method | Path | Auth Required | Description |
52
+ |---|---|---|---|
53
+ | POST | `/webauthn/register/options` | Yes (bearer) | Generate registration options for a new passkey against the authenticated caller. Body: `{}`. |
54
+ | POST | `/webauthn/register/verify` | Yes (bearer) | Verify the registration response and store the credential for the authenticated caller. Body: `{ response }`. |
55
+ | POST | `/webauthn/authenticate/options` | No | Generate authentication options. Optionally pass `userId` for non-discoverable flow. |
56
+ | POST | `/webauthn/authenticate/verify` | No | Verify the authentication assertion and return tokens (if passwordless). |
57
+
58
+ ## API Reference
59
+
60
+ | Method | Signature | Returns |
61
+ |---|---|---|
62
+ | `generateRegistrationOptions` | `(input: Record<string, unknown>, ctx: PluginRouteContext)` | `Promise<{ options: PublicKeyCredentialCreationOptionsJSON }>` |
63
+ | `verifyRegistration` | `(input: { response: RegistrationResponseJSON }, ctx: PluginRouteContext)` | `Promise<{ verified, credentialId, credentialDeviceType, credentialBackedUp }>` |
64
+ | `generateAuthenticationOptions` | `(input: { userId?: string })` | `Promise<{ options: PublicKeyCredentialRequestOptionsJSON }>` |
65
+ | `completeAuthentication` | `(continuationToken, response, meta?)` | `Promise<AuthResult>` |
66
+ | `verifyAuthentication` | `(input: { response: AuthenticationResponseJSON }, meta?)` | `Promise<AuthResult>` |
67
+
68
+ > **Registration methods require `PluginRouteContext`.** The target user is always the verified caller (`ctx.userId`) — there is no body-supplied `userId` field. When a browser hits `POST /webauthn/register/options` or `/webauthn/register/verify`, `fortress.handleRequest` constructs the `ctx` automatically from the bearer token. Programmatic callers must build one by hand (see below).
69
+
70
+ ### generateRegistrationOptions
71
+
72
+ Generates options for `navigator.credentials.create()`. Existing credentials for the authenticated caller are included in `excludeCredentials` to prevent duplicate registration:
73
+
74
+ ```ts
75
+ // Programmatic (server-side, no HTTP request)
76
+ const { options } = await fortress.plugins['webauthn'].generateRegistrationOptions(
77
+ {},
78
+ { userId, request: new Request('http://localhost') },
79
+ );
80
+ // Pass options to the browser: navigator.credentials.create({ publicKey: options })
81
+ ```
82
+
83
+ Throws `Unauthorized` if `ctx.userId` is missing, or `NotFound` if the user does not exist.
84
+
85
+ ### verifyRegistration
86
+
87
+ Verifies the authenticator's response and stores the new credential against the authenticated caller:
88
+
89
+ ```ts
90
+ const result = await fortress.plugins['webauthn'].verifyRegistration(
91
+ { response: registrationResponseFromBrowser },
92
+ { userId, request: new Request('http://localhost') },
93
+ );
94
+ // result.verified, result.credentialId, result.credentialDeviceType, result.credentialBackedUp
95
+ ```
96
+
97
+ Throws:
98
+ - `Unauthorized` -- `ctx.userId` is missing.
99
+ - `BadRequest` -- No pending challenge, challenge expired, or verification failed.
100
+
101
+ ### generateAuthenticationOptions
102
+
103
+ Generates options for `navigator.credentials.get()`. Pass `userId` to restrict to that user's credentials, or omit for discoverable credential (passkey) flow:
104
+
105
+ ```ts
106
+ // Discoverable flow (user selects passkey)
107
+ const { options } = await fortress.plugins['webauthn'].generateAuthenticationOptions({});
108
+
109
+ // User-specific flow
110
+ const { options } = await fortress.plugins['webauthn'].generateAuthenticationOptions({ userId });
111
+ ```
112
+
113
+ ### verifyAuthentication
114
+
115
+ Verifies the authentication assertion. In passwordless mode, returns the unified auth result and issues a full session:
116
+
117
+ ```ts
118
+ const result = await fortress.plugins['webauthn'].verifyAuthentication({
119
+ response: authenticationResponseFromBrowser,
120
+ });
121
+ if (result.status === 'success') {
122
+ // result.user, result.method === 'webauthn', result.accessToken, result.refreshToken
123
+ }
124
+ ```
125
+
126
+ For second-factor mode, pass the pending login's continuation token and browser assertion to `completeAuthentication(continuationToken, response, meta)`. Both paths validate the authenticator counter to detect credential cloning (skipped for synced passkeys where both counters are 0), and both rerun remaining gates before token issuance.
127
+
128
+ Throws:
129
+ - `Unauthorized` -- Unknown credential or verification failed.
130
+ - `BadRequest` -- No pending challenge or challenge expired.
131
+
132
+ ## Example
133
+
134
+ ```ts
135
+ import { createFortress } from '@bajustone/fortress';
136
+ import { webauthn } from '@bajustone/fortress/plugins/webauthn';
137
+
138
+ const fortress = createFortress({
139
+ jwt: { key: 'your-secret-at-least-32-bytes!!' },
140
+ database: adapter,
141
+ plugins: [
142
+ webauthn({
143
+ rpName: 'My App',
144
+ rpID: 'myapp.com',
145
+ origin: 'https://myapp.com',
146
+ supportPasswordless: true,
147
+ }),
148
+ ],
149
+ });
150
+
151
+ // --- Registration (browser + server) ---
152
+ // Most apps let the browser hit POST /webauthn/register/options and
153
+ // /webauthn/register/verify directly — the dispatcher constructs the
154
+ // PluginRouteContext from the bearer token so the passkey is always
155
+ // registered against the verified caller.
156
+ //
157
+ // Calling programmatically (e.g. from a custom Hono route or CLI tool)
158
+ // requires building a PluginRouteContext by hand:
159
+
160
+ // Server: generate options
161
+ const { options } = await fortress.plugins['webauthn'].generateRegistrationOptions(
162
+ {},
163
+ { userId, request: new Request('http://localhost') },
164
+ );
165
+
166
+ // Browser: create credential
167
+ // const credential = await navigator.credentials.create({ publicKey: options });
168
+
169
+ // Server: verify and store
170
+ const result = await fortress.plugins['webauthn'].verifyRegistration(
171
+ { response: credentialResponseFromBrowser },
172
+ { userId, request: new Request('http://localhost') },
173
+ );
174
+
175
+ // --- Authentication (browser + server) ---
176
+
177
+ // Server: generate options
178
+ const { options: authOptions } = await fortress.plugins['webauthn'].generateAuthenticationOptions({});
179
+
180
+ // Browser: get assertion
181
+ // const assertion = await navigator.credentials.get({ publicKey: authOptions });
182
+
183
+ // Server: verify and get tokens
184
+ const authResult = await fortress.plugins['webauthn'].verifyAuthentication({
185
+ response: assertionFromBrowser,
186
+ });
187
+ // authResult.accessToken -- use this to authenticate the user
188
+ ```
@@ -0,0 +1,242 @@
1
+ # Webhook Plugin
2
+
3
+ ## Overview
4
+
5
+ The `webhook` plugin delivers events to consumer-registered HTTPS endpoints following the [Standard Webhooks](https://www.standardwebhooks.com) specification (HMAC-SHA256 signing, `webhook-id`/`webhook-timestamp`/`webhook-signature` headers).
6
+
7
+ Highlights:
8
+
9
+ - **Custom events** — declare and `emit()` your own events through the same path the built-in auth events use.
10
+ - **Bring-Your-Own-Queue (BYOQ)** — delivery is always queued; plug in any backend (in-memory, database, BullMQ, SQS, Cloudflare Queues, …) via a small interface. Defaults to a dev-only in-memory queue.
11
+ - **SSRF-safe delivery** — webhook URLs are consumer-supplied, so delivery resolves + validates the target, blocks private/loopback/link-local/CGNAT/NAT64 addresses, and **pins the connection to the resolved IP** (closing the DNS-rebinding window).
12
+ - **Resilience** — failure classification, jittered exponential backoff, a per-endpoint circuit breaker, and DLQ/alert callbacks.
13
+ - **Per-delivery idempotency** — a stable `webhook-id` so receivers can dedup retries, plus an `idempotencyKey` so `emit()` is safe to call more than once.
14
+
15
+ Built-in auth events are dispatched automatically via lifecycle hooks; custom events you `emit()` yourself.
16
+
17
+ ## Installation
18
+
19
+ ```ts
20
+ import { createFortress } from '@bajustone/fortress';
21
+ import { webhook } from '@bajustone/fortress/plugins/webhook';
22
+
23
+ const fortress = createFortress({
24
+ jwt: { key: 'your-secret-at-least-32-bytes!!' },
25
+ database: adapter,
26
+ plugins: [webhook()], // defaults: all built-in events, in-memory queue
27
+ });
28
+ ```
29
+
30
+ Methods are available at `fortress.plugins.webhook`. The plugin is method-only — it ships **no** HTTP routes (mount your own management endpoints, as the example app does).
31
+
32
+ ## Events
33
+
34
+ Built-in and custom events share one declaration shape (`WebhookEventDeclaration`) and one emit path. The built-ins:
35
+
36
+ | Event | Source hook |
37
+ |---|---|
38
+ | `auth.login.success` | `afterLogin` |
39
+ | `auth.login.failure` | `onLoginFailure` |
40
+ | `auth.logout` | `beforeLogout` |
41
+ | `auth.user.registered` | `afterRegister` |
42
+ | `auth.token.refreshed` | `afterTokenRefresh` |
43
+
44
+ Pass `events` to declare custom events (and optionally exclude built-ins). Each event may carry a Standard Schema validator (`schema`) — the payload is validated at `emit()` time.
45
+
46
+ ```ts
47
+ import { builtinEvents, webhook } from '@bajustone/fortress/plugins/webhook';
48
+ import { obj, str } from '@bajustone/fortress';
49
+
50
+ webhook({
51
+ events: [
52
+ ...builtinEvents({ exclude: ['auth.logout'] }), // keep the built-ins you want
53
+ { name: 'order.paid', schema: obj({ orderId: str(), amount: str() }, 'orderId', 'amount') },
54
+ { name: 'order.refunded' },
55
+ ],
56
+ });
57
+ ```
58
+
59
+ Emit a custom event from your code:
60
+
61
+ ```ts
62
+ await fortress.plugins.webhook.emit('order.paid', { orderId: 'o_123', amount: '49.00' });
63
+ ```
64
+
65
+ `emit()` throws a `WebhookEmitError` (with `code: 'unknown_event' | 'invalid_payload' | 'payload_too_large'`) so you can branch on the failure.
66
+
67
+ ## Queue (Bring-Your-Own-Queue)
68
+
69
+ Delivery is always queued. Bundled queues (zero extra deps):
70
+
71
+ - **`inMemoryQueue()`** — the **default**. `setTimeout`-driven, single process. **Dev-only**: scheduled retries live in in-process timers, so a restart loses every pending retry until the next process runs its startup recovery sweep. Jobs are processed sequentially.
72
+ - **`databaseQueue({ pollMs })`** — the crash-safe bundled option. Polls `webhook_delivery` for due rows; the table itself is the transactional outbox, so it survives restarts. Single-worker (run one poller per deployment, or use a real broker for multi-worker).
73
+
74
+ ```ts
75
+ import { databaseQueue, webhook } from '@bajustone/fortress/plugins/webhook';
76
+
77
+ webhook({ queue: databaseQueue({ pollMs: 10_000 }) });
78
+ ```
79
+
80
+ Implement `WebhookQueue` to use any external broker. Set `handlesRetries: true` and `delivery.retry: 'queue'` to let the broker own retries (the plugin re-throws on failure so the broker re-delivers).
81
+
82
+ The queue worker starts automatically the first time the plugin methods are accessed; call `await fortress.plugins.webhook.stop()` on shutdown to tear down timers/pollers.
83
+
84
+ ## Configuration
85
+
86
+ All fields on `WebhookConfig` are optional:
87
+
88
+ | Option | Type | Default | Description |
89
+ |---|---|---|---|
90
+ | `events` | `WebhookEventDeclaration[]` | `builtinEvents()` | The event registry (built-in + custom). |
91
+ | `queue` | `WebhookQueue` | `inMemoryQueue()` | Delivery queue backend. |
92
+ | `maxRetries` | `number` | `5` | Attempts before a delivery is marked `failed`. |
93
+ | `maxPayloadBytes` | `number` | `262144` (256 KB) | `emit()` rejects larger payloads. |
94
+ | `delivery.timeoutMs` | `number` | `10000` | Per-attempt request timeout. |
95
+ | `delivery.retry` | `'pluginScheduled' \| 'inProcess' \| 'queue'` | `'pluginScheduled'` | Who owns retries (see below). |
96
+ | `delivery.permanentStatuses` | `number[]` | `[404, 410, 421]` | Statuses that permanently deactivate an endpoint. |
97
+ | `delivery.maxConsecutiveFailures` | `number` | `15` | Circuit breaker — deactivate after this many consecutive failures. |
98
+ | `delivery.onDeliveryFailed` | `(d: WebhookDelivery) => void \| Promise<void>` | — | Terminal-failure hook (the DLQ/alert seam). |
99
+ | `delivery.onEndpointDeactivated` | `(e: WebhookEndpoint, reason: WebhookDeactivatedReason) => void \| Promise<void>` | — | Fired on auto-deactivation. |
100
+ | `delivery.fetch` | `FetchFn` | `ssrfSafeFetch()` | Override the transport (custom transport or tests). **Bypasses the SSRF guard** — only override when you control the targets. |
101
+
102
+ ### Retry modes
103
+
104
+ | Mode | Behavior |
105
+ |---|---|
106
+ | `'pluginScheduled'` (default) | One attempt per job. On a retriable failure the plugin schedules `nextRetryAt` with jittered backoff and re-enqueues. Works with any queue. |
107
+ | `'inProcess'` | The fetcher transport retries within a single attempt (POST opted in). The queue sees one job per logical delivery. |
108
+ | `'queue'` | The plugin re-throws on a retriable failure so the queue re-delivers. Requires a queue with `handlesRetries: true`. |
109
+
110
+ ### Failure classification (plugin-scheduled)
111
+
112
+ | Result | Action |
113
+ |---|---|
114
+ | 2xx | success (resets the failure counter) |
115
+ | `404 / 410 / 421` | `failed` + **deactivate** the endpoint (`permanent_<status>`) |
116
+ | `408 / 425 / 429` | retry (transient) |
117
+ | other `4xx` | `failed`, **no retry** (permanent client error) |
118
+ | `5xx` / network / timeout | retry with jittered exponential backoff |
119
+
120
+ Backoff ladder (jittered ±25%): **5s → 5min → 30min → 2h → 5h**. Independently, the circuit breaker deactivates an endpoint after `maxConsecutiveFailures` consecutive failures (`reason: 'too_many_failures'`).
121
+
122
+ ## Endpoint management
123
+
124
+ | Method | Signature |
125
+ |---|---|
126
+ | `emit` | `(name: string, payload: object, opts?: { idempotencyKey?: string }) => Promise<void>` |
127
+ | `registerEndpoint` | `(url: string, events: string[], opts?: { secret?: string }) => Promise<WebhookEndpoint>` |
128
+ | `updateEndpoint` | `(id: string, patch: { url?, events?, isActive? }) => Promise<RedactedWebhookEndpoint \| null>` |
129
+ | `rotateSecret` | `(id: string) => Promise<{ id: string; secret: string }>` |
130
+ | `listEndpoints` | `() => Promise<RedactedWebhookEndpoint[]>` |
131
+ | `removeEndpoint` | `(id: string) => Promise<void>` |
132
+ | `listEventTypes` | `() => { name: string; description?: string }[]` |
133
+ | `stop` | `() => Promise<void>` |
134
+
135
+ ```ts
136
+ const wh = fortress.plugins.webhook;
137
+
138
+ // A CSPRNG secret is generated when omitted — returned ONCE on the result.
139
+ const endpoint = await wh.registerEndpoint('https://hooks.myapp.com/auth', ['auth.login.success', 'order.paid']);
140
+ console.log(endpoint.secret); // whsec_… — store it now; it is never returned again
141
+
142
+ await wh.updateEndpoint(endpoint.id, { events: ['order.paid'], isActive: true });
143
+ const { secret } = await wh.rotateSecret(endpoint.id); // new secret, returned once
144
+
145
+ const endpoints = await wh.listEndpoints(); // secret REDACTED
146
+ await wh.removeEndpoint(endpoint.id);
147
+ ```
148
+
149
+ > **Secrets are returned only at `registerEndpoint` and `rotateSecret`.** `listEndpoints()` and `updateEndpoint()` omit the `secret` field.
150
+
151
+ ## Signing
152
+
153
+ Each attempt sends:
154
+
155
+ ```
156
+ webhook-id: msg_<deliveryId> # STABLE across retries — use it as your idempotency key
157
+ webhook-timestamp: 1234567890 # changes per attempt
158
+ webhook-signature: v1,<base64-hmac-sha256>
159
+ ```
160
+
161
+ The signature is `HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{body}")`. Because `webhook-id` is stable per delivery, a receiver that dedups on it ignores retries automatically.
162
+
163
+ ## Idempotency
164
+
165
+ Pass `emit(name, payload, { idempotencyKey })` to make a logical event safe to emit more than once: a second `emit` with the same `(endpoint, idempotencyKey)` is a no-op, enforced by a unique index on `webhook_delivery (endpoint_id, idempotency_key)` (so it holds even under concurrent emits).
166
+
167
+ ## Types
168
+
169
+ ```ts
170
+ type WebhookDeactivatedReason = `permanent_${number}` | 'too_many_failures';
171
+ type WebhookErrorKind = 'http' | 'network';
172
+
173
+ interface WebhookEndpoint {
174
+ id: string;
175
+ url: string;
176
+ events: string; // JSON array of event names
177
+ secret: string; // redacted in listEndpoints()/updateEndpoint()
178
+ isActive: boolean;
179
+ deactivatedReason: WebhookDeactivatedReason | null;
180
+ consecutiveFailures: number;
181
+ createdAt: Date;
182
+ }
183
+
184
+ interface WebhookDelivery {
185
+ id: string;
186
+ endpointId: string;
187
+ eventType: string;
188
+ payload: string; // JSON
189
+ status: 'pending' | 'success' | 'failed';
190
+ attempts: number;
191
+ idempotencyKey: string | null;
192
+ lastAttemptAt: Date | null;
193
+ nextRetryAt: Date | null;
194
+ responseStatus: number | null;
195
+ responseBody: string | null; // first ~2 KB, for debugging
196
+ errorKind: WebhookErrorKind | null;
197
+ createdAt: Date;
198
+ }
199
+ ```
200
+
201
+ ## Security
202
+
203
+ - **SSRF.** Built-in delivery resolves the host, rejects non-https and private/loopback/link-local/CGNAT targets (IPv4, IPv6, `::ffff:`-mapped, and `64:ff9b::/96` NAT64 forms), and pins the connection to the validated IP. Overriding `delivery.fetch` bypasses this guard — only do so for targets you control. `assertSafeWebhookUrl(url)` is exported if a custom transport wants to reuse the guard.
204
+ - **Secrets.** Generated with a CSPRNG, returned only at creation/rotation, and redacted from list/update. Rotate with `rotateSecret`.
205
+ - **Payloads.** Signed but **not encrypted** — never put secrets/PII in a webhook payload you wouldn't want a receiver (or a misconfigured endpoint) to see.
206
+
207
+ ## Example
208
+
209
+ ```ts
210
+ import { createFortress, obj, str } from '@bajustone/fortress';
211
+ import { builtinEvents, databaseQueue, webhook } from '@bajustone/fortress/plugins/webhook';
212
+
213
+ const fortress = createFortress({
214
+ jwt: { key: 'your-secret-at-least-32-bytes!!' },
215
+ database: adapter,
216
+ plugins: [
217
+ webhook({
218
+ events: [
219
+ ...builtinEvents(),
220
+ { name: 'order.paid', schema: obj({ orderId: str() }, 'orderId') },
221
+ ],
222
+ queue: databaseQueue({ pollMs: 10_000 }), // crash-safe
223
+ delivery: {
224
+ maxConsecutiveFailures: 10,
225
+ onEndpointDeactivated: (e, reason) => log.warn('webhook endpoint disabled', { id: e.id, reason }),
226
+ onDeliveryFailed: d => deadLetter.push(d),
227
+ },
228
+ }),
229
+ ],
230
+ });
231
+
232
+ const wh = fortress.plugins.webhook;
233
+
234
+ // Built-in auth events deliver automatically on login/register/etc.
235
+ await wh.registerEndpoint('https://hooks.myapp.com/auth', ['auth.login.success', 'order.paid']);
236
+
237
+ // Emit a custom event
238
+ await fortress.plugins.webhook.emit('order.paid', { orderId: 'o_123' }, { idempotencyKey: 'o_123' });
239
+
240
+ // On shutdown
241
+ await wh.stop();
242
+ ```
@@ -0,0 +1,156 @@
1
+ # Policy as code
2
+
3
+ Declare roles, permissions, groups, and service accounts in JSON, then reconcile the file against the database with `diffPolicy()` and `applyPolicyPlan()`.
4
+
5
+ ## File format
6
+
7
+ Default file: `fortress.policy.json` in the working directory.
8
+ Environment-specific override:
9
+ `fortress.policy.<env>.json`, picked when `FORTRESS_ENV` is set (the env
10
+ file fully **replaces** the base — no implicit merging).
11
+
12
+ See [`examples/policy/fortress.policy.json`](../examples/policy/fortress.policy.json)
13
+ for a complete example. Minimum shape:
14
+
15
+ ```jsonc
16
+ {
17
+ "resources": [
18
+ { "name": "article", "actions": ["create", "read", "update", "delete"] }
19
+ ],
20
+ "roles": [
21
+ {
22
+ "name": "editor",
23
+ "description": "Authors and edits articles",
24
+ "permissions": [
25
+ { "resource": "article", "action": "create" },
26
+ { "resource": "article", "action": "update" }
27
+ ]
28
+ }
29
+ ],
30
+ "groups": [
31
+ { "name": "engineering", "description": "Engineering team" }
32
+ ],
33
+ "serviceAccounts": [
34
+ { "name": "ci-bot", "displayName": "CI", "isActive": true, "roles": ["editor"] }
35
+ ]
36
+ }
37
+ ```
38
+
39
+ ### What's covered
40
+
41
+ | Entity | Declared keys | Reconciled by `applyPolicyPlan` |
42
+ |---|---|---|
43
+ | Resources | `name`, `actions[]`, `description` | Creates missing resources; adds missing actions to existing resources |
44
+ | Roles | `name`, `description`, `permissions[]` | Creates missing roles; updates description; adds + removes permissions |
45
+ | Groups | `name`, `description` | Creates missing groups; updates description |
46
+ | Service accounts | `name`, `displayName`, `description`, `isActive`, `roles[]` | Creates missing SAs; updates fields; binds + unbinds roles |
47
+
48
+ ### What's intentionally NOT covered
49
+
50
+ - **OAuth clients.** They have secrets — manage via the admin endpoints.
51
+ - **User accounts / user → group memberships.** User data, not policy.
52
+ Provision users via signup or admin endpoints; manage memberships via
53
+ `fortress.iam.addUserToGroup(...)`.
54
+ - **Per-tenant role bindings.** Manage via the admin endpoints; the
55
+ global policy file is the wrong fit for per-tenant scale.
56
+ - **Resource deletions / action removals.** Dropping a resource cascades
57
+ to permissions and role bindings; require explicit operator action.
58
+
59
+ ## Diff + apply workflow
60
+
61
+ The same three-step loop you'd use for migrations or manifests:
62
+
63
+ ```ts
64
+ import { createFortress, loadPolicy, diffPolicy, applyPolicyPlan } from '@bajustone/fortress';
65
+ import config from './fortress.config';
66
+
67
+ const fortress = createFortress(config);
68
+ const { policy } = await loadPolicy(); // reads fortress.policy.json (or env override)
69
+ const plan = await diffPolicy(policy, fortress.iam);
70
+ if (!plan.inSync) {
71
+ console.log('Plan:');
72
+ for (const op of plan.ops) console.log(' -', op.description);
73
+
74
+ const result = await applyPolicyPlan(plan, fortress.iam);
75
+ if (result.errors.length) {
76
+ console.error('Some ops failed:', result.errors);
77
+ process.exit(1);
78
+ }
79
+ }
80
+ ```
81
+
82
+ ### Pruning (deletes)
83
+
84
+ `diffPolicy(policy, iam, { prune: true })` emits `delete-role`,
85
+ `delete-group`, `delete-service-account`, and `unbind-service-account-role`
86
+ ops for entities present in the database but absent from the policy
87
+ file. System roles (`isSystem === true`) are never deleted.
88
+
89
+ Run the un-pruned diff first to review, then add `prune: true` when
90
+ the diff matches your intent. An empty policy is rejected under prune by default because it would delete all managed IAM state; the destructive operation requires explicit `{ prune: true, allowEmptyPrune: true }` acknowledgement.
91
+
92
+ ### Resource ops apply without filesystem access
93
+
94
+ `applyPolicyPlan` reconciles resources/actions, roles, groups, service accounts, and their global role bindings directly through `IamService`. It never writes a temporary file, so it works in workerd/Deno-no-fs runtimes. The compatibility `applyResourceOps(plan, iam, legacyFilePath)` helper now ignores the retained path argument and applies only resource operations through the same in-memory database path.
95
+
96
+ ## CLI
97
+
98
+ The CLI exposes one offline command (no DB required) plus three
99
+ how-to printers because the diff/apply/check commands need your
100
+ configured `Fortress` instance:
101
+
102
+ ```sh
103
+ fortress policy:summary [--file <path>] [--env <name>] # offline; prints declared counts
104
+ fortress policy:diff # prints code snippet for in-app usage
105
+ fortress policy:apply # prints code snippet
106
+ fortress policy:check # prints code snippet
107
+ ```
108
+
109
+ `fortress policy:summary` is useful in CI to assert the file parses
110
+ and to print a quick inventory; the actual diff/apply runs from a
111
+ small script in your repo that knows your DB connection.
112
+
113
+ ## CI gate
114
+
115
+ Add a `policy:check` step to your CI workflow that runs against an
116
+ ephemeral DB seeded from production policy:
117
+
118
+ ```ts
119
+ // scripts/policy-check.ts
120
+ import { createFortress, loadPolicy, diffPolicy } from '@bajustone/fortress';
121
+ import { createTestAdapter } from '@bajustone/fortress/testing';
122
+ import { applyPolicyPlan } from '@bajustone/fortress';
123
+
124
+ const fortress = createFortress({
125
+ database: createTestAdapter(),
126
+ jwt: { key: process.env.FORTRESS_JWT_SECRET! },
127
+ });
128
+
129
+ const { policy } = await loadPolicy();
130
+ // Bootstrap from the policy itself so the diff result reflects only the
131
+ // drift between two consecutive policy revisions.
132
+ await applyPolicyPlan(await diffPolicy(policy, fortress.iam), fortress.iam);
133
+
134
+ const after = await diffPolicy(policy, fortress.iam);
135
+ if (!after.inSync) {
136
+ console.error('Policy reconciliation is not converging:', after.ops);
137
+ process.exit(1);
138
+ }
139
+ console.log(`Policy in sync (${policy.roles?.length ?? 0} role(s), ${policy.serviceAccounts?.length ?? 0} service account(s)).`);
140
+ ```
141
+
142
+ Run it as a step in `.github/workflows/fortress-ci.yml` alongside
143
+ `fortress manifest:check` and `fortress migrate:check`.
144
+
145
+ ## API reference
146
+
147
+ | Export | Kind | Purpose |
148
+ |---|---|---|
149
+ | `loadPolicy(options?)` | async function | Read, validate, and parse the policy file (env-aware) |
150
+ | `resolvePolicyPath(options?)` | async function | Resolve the file path that `loadPolicy` would use |
151
+ | `diffPolicy(policy, iam, options?)` | function | Compute the plan to reconcile live IAM with `policy` |
152
+ | `applyPolicyPlan(plan, iam)` | function | Apply every op in the plan; returns `applied` / `errors` |
153
+ | `applyResourceOps(plan, iam, legacyFilePath)` | function | Compatibility helper; applies resource-only ops directly without file I/O |
154
+ | `PolicyDocument`, `PolicyRole`, ... | types | Type-only exports for callers that read the file themselves |
155
+
156
+ All exported from the package root.
@@ -0,0 +1,46 @@
1
+ # Route security manifest
2
+
3
+ Fortress builds a canonical route-security manifest from endpoint metadata. The manifest is derived from the same `EndpointDefinition` records used by `fortress.handleRequest()`, adapter mounting, and OpenAPI generation.
4
+
5
+ ## Runtime API
6
+
7
+ ```ts
8
+ const manifest = fortress.manifest;
9
+ ```
10
+
11
+ Each entry contains:
12
+
13
+ - `method`, `path`, `handler` — the HTTP route and dispatcher handler.
14
+ - `plugin` — `auth`, `iam`, a registered plugin name, or `null` when the origin cannot be inferred.
15
+ - `classification` — one of:
16
+ - `public` — `meta.security` includes `none`.
17
+ - `authenticated` — bearer/basic/API-key route without an IAM permission.
18
+ - `rbac` — route has `meta.permission` and requires IAM authorization.
19
+ - `oauth-protocol` — route has `meta.bearerKind: 'oauth'` and self-authenticates as an OAuth protocol endpoint.
20
+ - `default-deny` — no usable security metadata; the request pipeline denies it.
21
+ - `permission`, `security`, `bearerKind` — direct endpoint security metadata.
22
+ - `csrfApplicable` — unsafe method and not skipped by `config.csrf`.
23
+ - `rateLimited` — route matches plugin rate-limit middleware, or a rate-limit hook protects the auth gate.
24
+ - `mounted` — `true` for routes present in the active `fortress.endpoints` union.
25
+
26
+ ## CLI
27
+
28
+ ```sh
29
+ fortress manifest --out route-manifest.json
30
+ fortress manifest:check
31
+ ```
32
+
33
+ The CLI currently emits/checks the core auth + IAM manifest. For app/plugin-aware checks, call the programmatic API against your configured `fortress` instance:
34
+
35
+ ```ts
36
+ import { detectRouteManifestDrift, hasRouteManifestDrift } from '@bajustone/fortress';
37
+
38
+ const drift = detectRouteManifestDrift(fortress, { openapi: yourGeneratedOpenApiSpec });
39
+ if (hasRouteManifestDrift(drift)) {
40
+ throw new Error(JSON.stringify(drift, null, 2));
41
+ }
42
+ ```
43
+
44
+ ## Adapter behavior
45
+
46
+ Hono, Express, and SvelteKit adapters use `fortress.manifest` to decide which paths are Fortress-managed before delegating to `fortress.handleRequest()`. This keeps route interception, OpenAPI, and security drift checks aligned with the same endpoint metadata.