@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,154 @@
1
+ /**
2
+ * Webhook delivery over `@bajustone/fetcher`.
3
+ *
4
+ * fetcher is 100% native `fetch` and does **no** IP pinning of its own, so the
5
+ * SSRF guard must live *inside* the transport: {@link ssrfSafeFetch} is a
6
+ * fetcher `FetchFn` that resolves + validates the target and issues the request
7
+ * through `node:https` with a custom `lookup` that pins the connection to the
8
+ * resolved IP — closing the DNS-rebinding window. A plain fetch (or a
9
+ * pre-flight check followed by a normal fetch) would re-resolve DNS and reopen
10
+ * that window, so the guard MUST be the transport.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import type { FetchFn } from '@bajustone/fetcher';
16
+ import { Buffer } from 'node:buffer';
17
+ import { request as httpsRequest } from 'node:https';
18
+ import { createFetch } from '@bajustone/fetcher';
19
+ import { resolveSafeWebhookTarget } from './ssrf';
20
+
21
+ /** How retries are handled for a delivery client. See the webhook config docs. */
22
+ export type RetryMode = 'pluginScheduled' | 'inProcess' | 'queue';
23
+
24
+ /**
25
+ * A fetcher `FetchFn` that delivers through `node:https` with the SSRF guard
26
+ * and connect-time IP pinning applied. Resolution + validation + connection
27
+ * all use the same resolved IP, so there is no TOCTOU gap.
28
+ */
29
+ export function ssrfSafeFetch(): FetchFn {
30
+ return async (req: Request): Promise<Response> => {
31
+ // Throws on non-https / private / unresolved — fetcher turns the throw
32
+ // into a `{ ok: false }` result via its never-throws contract.
33
+ const target = await resolveSafeWebhookTarget(req.url);
34
+ const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.text();
35
+
36
+ const headers: Record<string, string> = {};
37
+ req.headers.forEach((value, key) => {
38
+ headers[key] = value;
39
+ });
40
+
41
+ return new Promise<Response>((resolve, reject) => {
42
+ const clientReq = httpsRequest(
43
+ {
44
+ protocol: target.url.protocol,
45
+ hostname: target.url.hostname,
46
+ port: target.url.port || 443,
47
+ path: `${target.url.pathname}${target.url.search}`,
48
+ method: req.method,
49
+ headers,
50
+ // Pin the connection to the exact validated IP (DNS-rebind defense).
51
+ lookup: (_hostname, _options, cb) => cb(null, target.address, target.family),
52
+ },
53
+ (res) => {
54
+ const chunks: Buffer[] = [];
55
+ res.on('data', chunk => chunks.push(chunk as Buffer));
56
+ res.on('end', () => {
57
+ const resHeaders = new Headers();
58
+ for (const [key, value] of Object.entries(res.headers)) {
59
+ if (typeof value === 'string')
60
+ resHeaders.set(key, value);
61
+ else if (Array.isArray(value))
62
+ resHeaders.set(key, value.join(', '));
63
+ }
64
+ const status = res.statusCode && res.statusCode >= 200 ? res.statusCode : 502;
65
+ resolve(new Response(Buffer.concat(chunks), { status, headers: resHeaders }));
66
+ });
67
+ },
68
+ );
69
+
70
+ // Honor the fetcher timeout middleware's AbortSignal.
71
+ if (req.signal) {
72
+ if (req.signal.aborted)
73
+ clientReq.destroy(new Error('aborted'));
74
+ else
75
+ req.signal.addEventListener('abort', () => clientReq.destroy(new Error('aborted')), { once: true });
76
+ }
77
+
78
+ clientReq.on('error', reject);
79
+ clientReq.end(body);
80
+ });
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Build a delivery client. Always wires the {@link ssrfSafeFetch} transport
86
+ * (never a plain fetch) and a per-attempt timeout. For `inProcess` retries it
87
+ * also configures fetcher's retry middleware — POST must be opted in
88
+ * explicitly (fetcher does not retry POST by default), which is safe because
89
+ * every delivery carries a stable `webhook-id` for receiver-side dedup.
90
+ */
91
+ export function createDeliveryClient(opts: { timeoutMs?: number; retryMode: RetryMode; fetch?: FetchFn }): ReturnType<typeof createFetch> {
92
+ return createFetch({
93
+ baseUrl: '',
94
+ fetch: opts.fetch ?? ssrfSafeFetch(),
95
+ timeout: opts.timeoutMs ?? 10_000,
96
+ ...(opts.retryMode === 'inProcess'
97
+ ? {
98
+ retry: {
99
+ attempts: 5,
100
+ methods: ['POST'],
101
+ backoff: 5_000,
102
+ factor: 2,
103
+ maxBackoff: 5 * 60_000,
104
+ },
105
+ }
106
+ : {}),
107
+ });
108
+ }
109
+
110
+ /** Outcome of a single delivery attempt (or one `inProcess` retry sequence). */
111
+ export type DeliveryResult
112
+ = | { ok: true; status: number }
113
+ | { ok: false; kind: 'http' | 'network'; status: number | null; responseBody: string | null; message: string };
114
+
115
+ async function safeReadBody(response: Response): Promise<string | null> {
116
+ try {
117
+ const text = await response.text();
118
+ return text.slice(0, 2048); // first ~2KB, for debugging
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Deliver `body` to `url` with `headers`. Never throws: fetcher's transport
127
+ * resolves network/timeout failures to a `Response` with `ok === false`
128
+ * (`status === 0`), so the result is always a classified {@link DeliveryResult}.
129
+ */
130
+ export async function deliver(
131
+ client: ReturnType<typeof createDeliveryClient>,
132
+ url: string,
133
+ body: string,
134
+ headers: Record<string, string>,
135
+ ): Promise<DeliveryResult> {
136
+ const response = await client.post(url, {
137
+ body,
138
+ headers: { 'Content-Type': 'application/json', ...headers },
139
+ });
140
+
141
+ if (response.ok)
142
+ return { ok: true, status: response.status };
143
+
144
+ // status === 0 marks a transport failure (network/timeout/SSRF-rejected);
145
+ // anything else is a real HTTP status from the receiver.
146
+ const transport = response.status === 0;
147
+ return {
148
+ ok: false,
149
+ kind: transport ? 'network' : 'http',
150
+ status: transport ? null : response.status,
151
+ responseBody: transport ? null : await safeReadBody(response),
152
+ message: transport ? 'Transport error (network, timeout, or blocked target)' : `HTTP ${response.status}`,
153
+ };
154
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Binds fortress's auth lifecycle hooks to the webhook `emit` path, one hook
3
+ * per declared built-in `source`. Built-in events fire-and-forget — login and
4
+ * registration are never blocked on webhook persistence/delivery; user code
5
+ * uses the awaitable `emit()` method for at-least-once semantics.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { DatabaseAdapter } from '../../adapters/database';
11
+ import type { PluginHooks } from '../../core/plugin';
12
+ import type { EventRegistry } from './registry';
13
+
14
+ /** Persist + enqueue an event for delivery. */
15
+ export type EmitFn = (db: DatabaseAdapter, eventName: string, payload: Record<string, unknown>) => Promise<void>;
16
+
17
+ /**
18
+ * Wire the built-in auth hooks to `emit`. Only sources present in the registry
19
+ * are bound — excluding a built-in via `builtinEvents({ exclude })` leaves its
20
+ * hook unbound (a no-op for that event).
21
+ */
22
+ export function bindBuiltinHooks(registry: EventRegistry, emit: EmitFn): PluginHooks {
23
+ const sources = registry.sources();
24
+ const hooks: PluginHooks = {};
25
+
26
+ const loginName = sources.get('afterLogin');
27
+ if (loginName) {
28
+ hooks.afterLogin = async (ctx, result) => {
29
+ void emit(ctx.db, loginName, {
30
+ event: loginName,
31
+ userId: result.user.id,
32
+ email: result.user.email,
33
+ timestamp: new Date().toISOString(),
34
+ ip: ctx.meta?.ipAddress ?? null,
35
+ }).catch(() => {});
36
+ return result;
37
+ };
38
+ }
39
+
40
+ const failureName = sources.get('onLoginFailure');
41
+ if (failureName) {
42
+ hooks.onLoginFailure = async (ctx) => {
43
+ void emit(ctx.db, failureName, {
44
+ event: failureName,
45
+ identifier: ctx.identifier,
46
+ error: ctx.error.message,
47
+ timestamp: new Date().toISOString(),
48
+ }).catch(() => {});
49
+ };
50
+ }
51
+
52
+ const logoutName = sources.get('beforeLogout');
53
+ if (logoutName) {
54
+ hooks.beforeLogout = async (ctx) => {
55
+ void emit(ctx.db, logoutName, {
56
+ event: logoutName,
57
+ timestamp: new Date().toISOString(),
58
+ ip: ctx.meta?.ipAddress ?? null,
59
+ }).catch(() => {});
60
+ };
61
+ }
62
+
63
+ const registerName = sources.get('afterRegister');
64
+ if (registerName) {
65
+ hooks.afterRegister = async (ctx, user) => {
66
+ void emit(ctx.db, registerName, {
67
+ event: registerName,
68
+ userId: user.id,
69
+ email: user.email,
70
+ timestamp: new Date().toISOString(),
71
+ ip: ctx.meta?.ipAddress ?? null,
72
+ }).catch(() => {});
73
+ };
74
+ }
75
+
76
+ const refreshName = sources.get('afterTokenRefresh');
77
+ if (refreshName) {
78
+ hooks.afterTokenRefresh = async (ctx, result) => {
79
+ void emit(ctx.db, refreshName, {
80
+ event: refreshName,
81
+ timestamp: new Date().toISOString(),
82
+ ip: ctx.meta?.ipAddress ?? null,
83
+ }).catch(() => {});
84
+ return result;
85
+ };
86
+ }
87
+
88
+ return hooks;
89
+ }
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Webhook delivery plugin for fortress (v1).
3
+ *
4
+ * - **Custom events** — declare and `emit()` your own events through the same
5
+ * path the built-in auth events use.
6
+ * - **Bring-Your-Own-Queue** — delivery is always queued; plug in any backend
7
+ * via {@link import('./queue/types').WebhookQueue}. Defaults to the dev-only
8
+ * {@link inMemoryQueue}; use {@link databaseQueue} (crash-safe) or a broker in prod.
9
+ * - **Standard Webhooks** signing (HMAC-SHA256), with a stable per-delivery
10
+ * `webhook-id` for receiver-side idempotency.
11
+ * - **SSRF-safe delivery** over `@bajustone/fetcher` with connect-time IP pinning.
12
+ * - Failure classification, jittered backoff, circuit-breaker auto-deactivation,
13
+ * and DLQ observability hooks.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import type { DatabaseAdapter, WhereClause } from '../../adapters/database';
19
+ import type { FortressPlugin } from '../../core/plugin';
20
+ import type { DeliveryResult } from './delivery';
21
+ import type { WebhookQueueJob } from './queue/types';
22
+ import type {
23
+ EmitOptions,
24
+ RedactedWebhookEndpoint,
25
+ RegisterEndpointOptions,
26
+ UpdateEndpointPatch,
27
+ WebhookConfig,
28
+ WebhookDeactivatedReason,
29
+ WebhookDelivery,
30
+ WebhookEndpoint,
31
+ } from './types';
32
+ import { Buffer } from 'node:buffer';
33
+ import { builtinEvents } from './builtin-events';
34
+ import { createDeliveryClient, deliver } from './delivery';
35
+ import { bindBuiltinHooks } from './hooks';
36
+ import { inMemoryQueue } from './queue/in-memory';
37
+ import { createEventRegistry } from './registry';
38
+ import { signatureHeaders } from './signing';
39
+ import { WebhookEmitError } from './types';
40
+
41
+ export { builtinEvents } from './builtin-events';
42
+ export { databaseQueue } from './queue/database';
43
+ export { inMemoryQueue } from './queue/in-memory';
44
+ export type { WebhookQueue, WebhookQueueContext, WebhookQueueJob } from './queue/types';
45
+ export { assertSafeWebhookUrl } from './ssrf';
46
+ export type {
47
+ EmitOptions,
48
+ RedactedWebhookEndpoint,
49
+ RegisterEndpointOptions,
50
+ UpdateEndpointPatch,
51
+ WebhookConfig,
52
+ WebhookDeactivatedReason,
53
+ WebhookDelivery,
54
+ WebhookDeliveryConfig,
55
+ WebhookEmitErrorCode,
56
+ WebhookEndpoint,
57
+ WebhookErrorKind,
58
+ WebhookEventDeclaration,
59
+ } from './types';
60
+ export { WebhookEmitError } from './types';
61
+
62
+ /** Plugin-scheduled retry backoff ladder (ms); jittered ±25% per attempt. */
63
+ const RETRY_INTERVALS_MS = [5_000, 5 * 60_000, 30 * 60_000, 2 * 60 * 60_000, 5 * 60 * 60_000];
64
+
65
+ /** 4xx statuses that are transient and worth retrying (matches the fetcher transport's retry set). */
66
+ const TRANSIENT_4XX = new Set([408, 425, 429]);
67
+
68
+ function withJitter(ms: number): number {
69
+ // Full ±25% jitter so an endpoint recovering doesn't get a synchronized spike.
70
+ return Math.max(0, Math.round(ms * (1 + (Math.random() - 0.5) * 0.5)));
71
+ }
72
+
73
+ /** Generate a Standard-Webhooks-style signing secret (`whsec_<hex>`), CSPRNG-backed. */
74
+ function generateSecret(): string {
75
+ const bytes = new Uint8Array(32);
76
+ crypto.getRandomValues(bytes);
77
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
78
+ return `whsec_${hex}`;
79
+ }
80
+
81
+ function idWhere(id: string): WhereClause[] {
82
+ return [{ field: 'id', operator: '=', value: id }];
83
+ }
84
+
85
+ function redact(endpoint: WebhookEndpoint): RedactedWebhookEndpoint {
86
+ const { secret: _secret, ...rest } = endpoint;
87
+ return rest;
88
+ }
89
+
90
+ /**
91
+ * Webhook plugin factory. See the module docs for the full feature set.
92
+ */
93
+ export function webhook(config: WebhookConfig = {}): FortressPlugin {
94
+ const registry = createEventRegistry(config.events ?? builtinEvents());
95
+ const queue = config.queue ?? inMemoryQueue();
96
+ const retryMode = config.delivery?.retry ?? 'pluginScheduled';
97
+ const maxRetries = config.maxRetries ?? 5;
98
+ const maxPayloadBytes = config.maxPayloadBytes ?? 256 * 1024;
99
+ const timeoutMs = config.delivery?.timeoutMs ?? 10_000;
100
+ const permanentStatuses = new Set(config.delivery?.permanentStatuses ?? [404, 410, 421]);
101
+ const maxConsecutiveFailures = config.delivery?.maxConsecutiveFailures ?? 15;
102
+ const onDeliveryFailed = config.delivery?.onDeliveryFailed;
103
+ const onEndpointDeactivated = config.delivery?.onEndpointDeactivated;
104
+
105
+ if (retryMode === 'queue' && !queue.handlesRetries) {
106
+ throw new Error('delivery.retry: "queue" requires a queue with handlesRetries: true');
107
+ }
108
+
109
+ const client = createDeliveryClient({ timeoutMs, retryMode, fetch: config.delivery?.fetch });
110
+ const deliverFn = (url: string, body: string, headers: Record<string, string>): Promise<DeliveryResult> =>
111
+ deliver(client, url, body, headers);
112
+
113
+ // ── Failure classification ────────────────────────────────────────
114
+ function classify(result: DeliveryResult): 'deactivate' | 'permanent_fail' | 'retry' {
115
+ if (!result.ok && result.kind === 'http' && result.status !== null) {
116
+ if (permanentStatuses.has(result.status))
117
+ return 'deactivate';
118
+ if (TRANSIENT_4XX.has(result.status))
119
+ return 'retry'; // 408 / 425 / 429 are transient
120
+ if (result.status >= 400 && result.status < 500)
121
+ return 'permanent_fail'; // other 4xx — retrying just burns attempts
122
+ return 'retry'; // 5xx
123
+ }
124
+ return 'retry'; // network / timeout
125
+ }
126
+
127
+ async function deactivateEndpoint(db: DatabaseAdapter, endpoint: WebhookEndpoint, reason: WebhookDeactivatedReason): Promise<void> {
128
+ if (!endpoint.isActive)
129
+ return;
130
+ await db.update({ model: 'webhook_endpoint', where: idWhere(endpoint.id), data: { isActive: false, deactivatedReason: reason } });
131
+ if (onEndpointDeactivated)
132
+ await onEndpointDeactivated({ ...endpoint, isActive: false, deactivatedReason: reason }, reason);
133
+ }
134
+
135
+ // ── Delivery (one queue job = one attempt) ────────────────────────
136
+ async function processJob(db: DatabaseAdapter, job: WebhookQueueJob): Promise<void> {
137
+ const delivery = await db.findOne<WebhookDelivery>({ model: 'webhook_delivery', where: idWhere(job.deliveryId) });
138
+ if (!delivery || delivery.status !== 'pending')
139
+ return;
140
+ const endpoint = await db.findOne<WebhookEndpoint>({ model: 'webhook_endpoint', where: idWhere(delivery.endpointId) });
141
+ if (!endpoint || !endpoint.isActive)
142
+ return;
143
+
144
+ // Stable per-delivery id (receiver idempotency key); only the timestamp changes per attempt.
145
+ const webhookId = `msg_${delivery.id}`;
146
+ const timestamp = Math.floor(Date.now() / 1000);
147
+ const headers = await signatureHeaders(endpoint.secret, webhookId, timestamp, delivery.payload);
148
+
149
+ const result = await deliverFn(endpoint.url, delivery.payload, headers);
150
+ const now = new Date();
151
+ const attempts = delivery.attempts + 1;
152
+
153
+ if (result.ok) {
154
+ await db.update({
155
+ model: 'webhook_delivery',
156
+ where: idWhere(delivery.id),
157
+ data: { status: 'success', attempts, lastAttemptAt: now, nextRetryAt: null, responseStatus: result.status, errorKind: null },
158
+ });
159
+ if (endpoint.consecutiveFailures > 0)
160
+ await db.update({ model: 'webhook_endpoint', where: idWhere(endpoint.id), data: { consecutiveFailures: 0 } });
161
+ return;
162
+ }
163
+
164
+ // Failure path.
165
+ const consecutiveFailures = endpoint.consecutiveFailures + 1;
166
+ await db.update({ model: 'webhook_endpoint', where: idWhere(endpoint.id), data: { consecutiveFailures } });
167
+
168
+ const responseStatus = result.status;
169
+ const responseBody = result.responseBody;
170
+ const errorKind = result.kind;
171
+ const action = classify(result);
172
+ const exhausted = attempts >= maxRetries;
173
+ const breakerTripped = consecutiveFailures >= maxConsecutiveFailures;
174
+ // `inProcess` mode already retried inside fetcher, so a failure here is terminal.
175
+ const willRetry = action === 'retry' && !exhausted && retryMode !== 'inProcess';
176
+
177
+ const markFailed = async (): Promise<void> => {
178
+ await db.update({
179
+ model: 'webhook_delivery',
180
+ where: idWhere(delivery.id),
181
+ data: { status: 'failed', attempts, lastAttemptAt: now, nextRetryAt: null, responseStatus, responseBody, errorKind },
182
+ });
183
+ if (onDeliveryFailed) {
184
+ const row = await db.findOne<WebhookDelivery>({ model: 'webhook_delivery', where: idWhere(delivery.id) });
185
+ if (row)
186
+ await onDeliveryFailed(row);
187
+ }
188
+ };
189
+
190
+ // Permanent status → fail + deactivate.
191
+ if (action === 'deactivate') {
192
+ await markFailed();
193
+ await deactivateEndpoint(db, endpoint, `permanent_${responseStatus}` as WebhookDeactivatedReason);
194
+ return;
195
+ }
196
+
197
+ // Terminal: no further attempt will run — non-retriable, retries exhausted,
198
+ // `inProcess` (fetcher already retried), or the circuit breaker has tripped.
199
+ // Mark failed FIRST so a tripped-breaker delivery never lingers as a zombie
200
+ // `pending` row that nothing re-attempts and `onDeliveryFailed` never sees.
201
+ if (!willRetry || breakerTripped) {
202
+ await markFailed();
203
+ if (breakerTripped)
204
+ await deactivateEndpoint(db, endpoint, 'too_many_failures');
205
+ return;
206
+ }
207
+
208
+ // Retry is warranted and the breaker has NOT tripped.
209
+ if (retryMode === 'queue') {
210
+ // The external queue owns retries: keep the row pending and re-throw.
211
+ await db.update({
212
+ model: 'webhook_delivery',
213
+ where: idWhere(delivery.id),
214
+ data: { status: 'pending', attempts, lastAttemptAt: now, responseStatus, responseBody, errorKind },
215
+ });
216
+ throw new Error(`Webhook delivery failed (${errorKind}${responseStatus !== null ? ` ${responseStatus}` : ''}); queue will retry`);
217
+ }
218
+
219
+ // pluginScheduled: schedule the next attempt with jitter and re-enqueue.
220
+ const retryMs = withJitter(RETRY_INTERVALS_MS[Math.min(attempts - 1, RETRY_INTERVALS_MS.length - 1)]!);
221
+ const nextRetryAt = new Date(Date.now() + retryMs);
222
+ await db.update({
223
+ model: 'webhook_delivery',
224
+ where: idWhere(delivery.id),
225
+ data: { status: 'pending', attempts, lastAttemptAt: now, nextRetryAt, responseStatus, responseBody, errorKind },
226
+ });
227
+ await queue.enqueue({
228
+ deliveryId: delivery.id,
229
+ endpointId: endpoint.id,
230
+ eventType: delivery.eventType,
231
+ attempt: attempts + 1,
232
+ scheduledFor: nextRetryAt,
233
+ });
234
+ }
235
+
236
+ // ── Emit (persist outbox row + enqueue) ───────────────────────────
237
+ async function emitEvent(db: DatabaseAdapter, eventName: string, payload: Record<string, unknown>, opts?: EmitOptions): Promise<void> {
238
+ const declaration = registry.get(eventName);
239
+ if (!declaration)
240
+ throw new WebhookEmitError('unknown_event', `Unknown webhook event: ${eventName}`);
241
+
242
+ if (declaration.schema) {
243
+ const result = await declaration.schema['~standard'].validate(payload);
244
+ if (result.issues)
245
+ throw new WebhookEmitError('invalid_payload', `Invalid payload for webhook event '${eventName}'`);
246
+ }
247
+
248
+ const payloadJson = JSON.stringify(payload);
249
+ if (Buffer.byteLength(payloadJson, 'utf8') > maxPayloadBytes)
250
+ throw new WebhookEmitError('payload_too_large', `Webhook payload for '${eventName}' exceeds ${maxPayloadBytes} bytes`);
251
+
252
+ const endpoints = await db.findMany<WebhookEndpoint>({ model: 'webhook_endpoint', where: [{ field: 'isActive', operator: '=', value: true }] });
253
+ const now = new Date();
254
+
255
+ for (const endpoint of endpoints) {
256
+ let names: string[];
257
+ try {
258
+ names = JSON.parse(endpoint.events) as string[];
259
+ }
260
+ catch {
261
+ continue;
262
+ }
263
+ if (!names.includes(eventName))
264
+ continue;
265
+
266
+ if (opts?.idempotencyKey) {
267
+ const existing = await db.findOne<WebhookDelivery>({
268
+ model: 'webhook_delivery',
269
+ where: [
270
+ { field: 'endpointId', operator: '=', value: endpoint.id },
271
+ { field: 'idempotencyKey', operator: '=', value: opts.idempotencyKey },
272
+ ],
273
+ });
274
+ if (existing)
275
+ continue; // already emitted to this endpoint
276
+ }
277
+
278
+ // Outbox: persist the row first, then signal the queue. The findOne above
279
+ // is the fast path; the unique index on (endpoint_id, idempotency_key) is
280
+ // the race backstop — a concurrent emit with the same key loses the insert,
281
+ // and we treat that as "already emitted" rather than enqueuing a duplicate.
282
+ let delivery: WebhookDelivery;
283
+ try {
284
+ delivery = await db.create<WebhookDelivery>({
285
+ model: 'webhook_delivery',
286
+ data: {
287
+ endpointId: endpoint.id,
288
+ eventType: eventName,
289
+ payload: payloadJson,
290
+ status: 'pending',
291
+ attempts: 0,
292
+ idempotencyKey: opts?.idempotencyKey ?? null,
293
+ nextRetryAt: now, // due immediately (the database queue selects on this)
294
+ },
295
+ });
296
+ }
297
+ catch (err) {
298
+ if (opts?.idempotencyKey) {
299
+ const winner = await db.findOne<WebhookDelivery>({
300
+ model: 'webhook_delivery',
301
+ where: [
302
+ { field: 'endpointId', operator: '=', value: endpoint.id },
303
+ { field: 'idempotencyKey', operator: '=', value: opts.idempotencyKey },
304
+ ],
305
+ });
306
+ if (winner)
307
+ continue; // a concurrent emit with the same key won the insert
308
+ }
309
+ throw err;
310
+ }
311
+ await queue.enqueue({ deliveryId: delivery.id, endpointId: endpoint.id, eventType: eventName, attempt: 1, scheduledFor: now });
312
+ }
313
+ }
314
+
315
+ // ── Queue lifecycle ───────────────────────────────────────────────
316
+ let queueStarted = false;
317
+ let queueTeardown: (() => Promise<void>) | null = null;
318
+ function ensureQueueStarted(db: DatabaseAdapter): void {
319
+ if (queueStarted || !queue.start)
320
+ return;
321
+ queueStarted = true;
322
+ // start() sets its handler synchronously before its first await, so jobs
323
+ // enqueued right after this call are not dropped.
324
+ void queue.start(job => processJob(db, job), { db })
325
+ .then((teardown) => {
326
+ queueTeardown = teardown;
327
+ })
328
+ .catch(() => {
329
+ queueStarted = false;
330
+ });
331
+ }
332
+
333
+ return {
334
+ name: 'webhook',
335
+
336
+ models: [
337
+ {
338
+ name: 'webhook_endpoint',
339
+ fields: {
340
+ id: { type: 'string', required: true },
341
+ url: { type: 'string', required: true },
342
+ events: { type: 'string', required: true },
343
+ secret: { type: 'string', required: true },
344
+ isActive: { type: 'boolean', required: true },
345
+ deactivatedReason: { type: 'string' },
346
+ consecutiveFailures: { type: 'number', required: true },
347
+ createdAt: { type: 'date', required: true },
348
+ },
349
+ },
350
+ {
351
+ name: 'webhook_delivery',
352
+ fields: {
353
+ id: { type: 'string', required: true },
354
+ endpointId: { type: 'string', required: true, references: { model: 'webhook_endpoint', field: 'id' } },
355
+ eventType: { type: 'string', required: true },
356
+ payload: { type: 'string', required: true },
357
+ status: { type: 'string', required: true },
358
+ attempts: { type: 'number', required: true },
359
+ idempotencyKey: { type: 'string' },
360
+ lastAttemptAt: { type: 'date' },
361
+ nextRetryAt: { type: 'date' },
362
+ responseStatus: { type: 'number' },
363
+ responseBody: { type: 'string' },
364
+ errorKind: { type: 'string' },
365
+ createdAt: { type: 'date', required: true },
366
+ },
367
+ },
368
+ ],
369
+
370
+ hooks: bindBuiltinHooks(registry, emitEvent),
371
+
372
+ methods: (ctx) => {
373
+ ensureQueueStarted(ctx.db);
374
+
375
+ return {
376
+ /** Emit an event — validates against the declared schema and the payload cap, then queues delivery to every subscribed endpoint. */
377
+ emit(eventName: string, payload: Record<string, unknown>, opts?: EmitOptions): Promise<void> {
378
+ return emitEvent(ctx.db, eventName, payload, opts);
379
+ },
380
+
381
+ /** Register a delivery endpoint. A CSPRNG `secret` is generated when omitted and returned once on the result. */
382
+ async registerEndpoint(url: string, events: string[], opts?: RegisterEndpointOptions): Promise<WebhookEndpoint> {
383
+ const secret = opts?.secret ?? generateSecret();
384
+ return ctx.db.create<WebhookEndpoint>({
385
+ model: 'webhook_endpoint',
386
+ data: { url, events: JSON.stringify(events), secret, isActive: true, consecutiveFailures: 0 },
387
+ });
388
+ },
389
+
390
+ /** Patch an endpoint's url / events / active flag. Re-activating clears the deactivation reason and resets the failure counter. Secret is redacted. */
391
+ async updateEndpoint(id: string, patch: UpdateEndpointPatch): Promise<RedactedWebhookEndpoint | null> {
392
+ const data: Record<string, unknown> = {};
393
+ if (patch.url !== undefined)
394
+ data.url = patch.url;
395
+ if (patch.events !== undefined)
396
+ data.events = JSON.stringify(patch.events);
397
+ if (patch.isActive !== undefined) {
398
+ data.isActive = patch.isActive;
399
+ if (patch.isActive) {
400
+ data.deactivatedReason = null;
401
+ data.consecutiveFailures = 0;
402
+ }
403
+ }
404
+ const updated = await ctx.db.update<WebhookEndpoint>({ model: 'webhook_endpoint', where: idWhere(id), data });
405
+ return updated ? redact(updated) : null;
406
+ },
407
+
408
+ /** Rotate an endpoint's signing secret. Returns the new secret once. */
409
+ async rotateSecret(id: string): Promise<{ id: string; secret: string }> {
410
+ const secret = generateSecret();
411
+ const updated = await ctx.db.update<WebhookEndpoint>({ model: 'webhook_endpoint', where: idWhere(id), data: { secret } });
412
+ if (!updated)
413
+ throw new Error(`Webhook endpoint not found: ${id}`);
414
+ return { id, secret };
415
+ },
416
+
417
+ /** List all endpoints with `secret` redacted. */
418
+ async listEndpoints(): Promise<RedactedWebhookEndpoint[]> {
419
+ const rows = await ctx.db.findMany<WebhookEndpoint>({ model: 'webhook_endpoint' });
420
+ return rows.map(redact);
421
+ },
422
+
423
+ /** Delete an endpoint and its delivery rows. */
424
+ async removeEndpoint(id: string): Promise<void> {
425
+ await ctx.db.delete({ model: 'webhook_delivery', where: [{ field: 'endpointId', operator: '=', value: id }] });
426
+ await ctx.db.delete({ model: 'webhook_endpoint', where: idWhere(id) });
427
+ },
428
+
429
+ /** The registered event types (name + description). */
430
+ listEventTypes(): { name: string; description?: string }[] {
431
+ return registry.list().map(e => ({ name: e.name, description: e.description }));
432
+ },
433
+
434
+ /** Stop the queue worker (timers/poller) — call on shutdown. */
435
+ async stop(): Promise<void> {
436
+ if (queueTeardown) {
437
+ await queueTeardown();
438
+ queueTeardown = null;
439
+ }
440
+ queueStarted = false;
441
+ },
442
+ };
443
+ },
444
+ };
445
+ }