@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,138 @@
1
+ /**
2
+ * Public types for the webhook plugin v1 (custom events + Bring-Your-Own-Queue).
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type { FetchFn } from '@bajustone/fetcher';
8
+ import type { StandardSchemaV1 } from '../../core/standard-schema';
9
+ import type { RetryMode } from './delivery';
10
+ import type { WebhookQueue } from './queue/types';
11
+
12
+ export type { RetryMode } from './delivery';
13
+ export type { WebhookQueue, WebhookQueueContext, WebhookQueueJob } from './queue/types';
14
+
15
+ /** A fortress lifecycle hook a built-in event auto-emits from. */
16
+ export type BuiltinEventSource = 'afterLogin' | 'onLoginFailure' | 'beforeLogout' | 'afterRegister' | 'afterTokenRefresh';
17
+
18
+ /**
19
+ * Declares an event consumers (or the built-ins) can emit. Built-in auth events
20
+ * and user-defined events share this one shape and the same emit path.
21
+ */
22
+ export interface WebhookEventDeclaration<S extends StandardSchemaV1 = StandardSchemaV1> {
23
+ /** Dot-cased event name, e.g. `auth.login.success` or `order.paid`. */
24
+ name: string;
25
+ description?: string;
26
+ /** Standard Schema V1 validator (fortress builder, fetcher builder, Zod, …); the payload is validated at `emit()` time. */
27
+ schema?: S;
28
+ /** If set, the plugin auto-emits this event from the named fortress hook. */
29
+ source?: BuiltinEventSource;
30
+ }
31
+
32
+ /** Stable delivery failure taxonomy persisted in `webhook_delivery.error_kind`. */
33
+ export type WebhookErrorKind = 'http' | 'network';
34
+
35
+ /** Stable endpoint deactivation reason taxonomy. */
36
+ export type WebhookDeactivatedReason = `permanent_${number}` | 'too_many_failures';
37
+
38
+ /** A registered delivery endpoint. `secret` is redacted from `listEndpoints()`. */
39
+ export interface WebhookEndpoint {
40
+ id: string;
41
+ url: string;
42
+ events: string; // JSON array of event names
43
+ secret: string;
44
+ isActive: boolean;
45
+ deactivatedReason: WebhookDeactivatedReason | null;
46
+ consecutiveFailures: number;
47
+ createdAt: Date;
48
+ }
49
+
50
+ /** A persisted delivery attempt row — the transactional outbox record. */
51
+ export interface WebhookDelivery {
52
+ id: string;
53
+ endpointId: string;
54
+ eventType: string;
55
+ payload: string; // JSON
56
+ status: 'pending' | 'success' | 'failed';
57
+ attempts: number;
58
+ idempotencyKey: string | null;
59
+ lastAttemptAt: Date | null;
60
+ nextRetryAt: Date | null;
61
+ responseStatus: number | null;
62
+ responseBody: string | null;
63
+ errorKind: WebhookErrorKind | null;
64
+ createdAt: Date;
65
+ }
66
+
67
+ /** Delivery tuning + observability seams. */
68
+ export interface WebhookDeliveryConfig {
69
+ /** Per-attempt timeout (ms). Default 10_000. */
70
+ timeoutMs?: number;
71
+ /** Who owns retries. Default `'pluginScheduled'`. */
72
+ retry?: RetryMode;
73
+ /** Statuses that permanently deactivate an endpoint. Default `[404, 410, 421]`. */
74
+ permanentStatuses?: number[];
75
+ /** Consecutive failures before the circuit breaker deactivates an endpoint. Default 15. */
76
+ maxConsecutiveFailures?: number;
77
+ /** Called on a terminal `failed` delivery — the DLQ/alert seam. */
78
+ onDeliveryFailed?: (delivery: WebhookDelivery) => void | Promise<void>;
79
+ /** Called when an endpoint is auto-deactivated (permanent status or circuit breaker). */
80
+ onEndpointDeactivated?: (endpoint: WebhookEndpoint, reason: WebhookDeactivatedReason) => void | Promise<void>;
81
+ /**
82
+ * Override the delivery transport (a fetcher `FetchFn`). Defaults to the
83
+ * SSRF-guarded `ssrfSafeFetch()`. Use for custom transports or to intercept
84
+ * delivery in tests — **bypasses the SSRF guard**, so only override when you
85
+ * control the targets.
86
+ */
87
+ fetch?: FetchFn;
88
+ }
89
+
90
+ /** Options for `emit()`. */
91
+ export interface EmitOptions {
92
+ /** Dedup key — a second `emit` with the same `(endpoint, idempotencyKey)` is a no-op. */
93
+ idempotencyKey?: string;
94
+ }
95
+
96
+ /** Options for `registerEndpoint()`. */
97
+ export interface RegisterEndpointOptions {
98
+ /** Signing secret. If omitted, a CSPRNG secret is generated and returned once. */
99
+ secret?: string;
100
+ }
101
+
102
+ /** Patch accepted by `updateEndpoint()`. */
103
+ export interface UpdateEndpointPatch {
104
+ url?: string;
105
+ events?: string[];
106
+ isActive?: boolean;
107
+ }
108
+
109
+ /** An endpoint with `secret` removed — the shape returned by `listEndpoints()`/`updateEndpoint()`. */
110
+ export type RedactedWebhookEndpoint = Omit<WebhookEndpoint, 'secret'>;
111
+
112
+ export interface WebhookConfig {
113
+ /** Event registry. Defaults to {@link import('./builtin-events').builtinEvents}(). */
114
+ events?: WebhookEventDeclaration[];
115
+ /** Delivery queue (BYOQ). Defaults to the dev-only `inMemoryQueue()`. */
116
+ queue?: WebhookQueue;
117
+ /** Maximum delivery retries. Default 5. */
118
+ maxRetries?: number;
119
+ /** Reject `emit()` payloads larger than this many bytes. Default 262144 (256 KB). */
120
+ maxPayloadBytes?: number;
121
+ delivery?: WebhookDeliveryConfig;
122
+ }
123
+
124
+ /** Distinct, catchable failure codes thrown by `emit()`. */
125
+ export type WebhookEmitErrorCode = 'unknown_event' | 'invalid_payload' | 'payload_too_large';
126
+
127
+ /** Thrown by `emit()` so callers can branch on `code` instead of string-matching. */
128
+ export class WebhookEmitError extends Error {
129
+ readonly code: WebhookEmitErrorCode;
130
+ constructor(code: WebhookEmitErrorCode, message: string) {
131
+ super(message);
132
+ this.name = 'WebhookEmitError';
133
+ this.code = code;
134
+ }
135
+ }
136
+
137
+ // `RetryMode` re-exported above is used by WebhookDeliveryConfig.
138
+ export type { StandardSchemaV1 };
@@ -0,0 +1,324 @@
1
+ import type { FetchFn } from '@bajustone/fetcher';
2
+ import type { Fortress } from '../../core/fortress';
3
+ import type { RedactedWebhookEndpoint, WebhookDelivery, WebhookDeliveryConfig, WebhookEndpoint, WebhookEventDeclaration } from './index';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { createFortress } from '../../core/fortress';
6
+ import { obj, str } from '../../core/schema-builder';
7
+ import { createTestAdapter } from '../../testing';
8
+ import { assertSafeWebhookUrl, builtinEvents, webhook } from './index';
9
+
10
+ const SECRET = 'webhook-test-secret-32chars!!xxx';
11
+
12
+ interface WebhookMethods {
13
+ emit: (name: string, payload: Record<string, unknown>, opts?: { idempotencyKey?: string }) => Promise<void>;
14
+ registerEndpoint: (url: string, events: string[], opts?: { secret?: string }) => Promise<WebhookEndpoint>;
15
+ updateEndpoint: (id: string, patch: { url?: string; events?: string[]; isActive?: boolean }) => Promise<RedactedWebhookEndpoint | null>;
16
+ rotateSecret: (id: string) => Promise<{ id: string; secret: string }>;
17
+ listEndpoints: () => Promise<RedactedWebhookEndpoint[]>;
18
+ removeEndpoint: (id: string) => Promise<void>;
19
+ listEventTypes: () => { name: string; description?: string }[];
20
+ stop: () => Promise<void>;
21
+ }
22
+
23
+ interface RecordedCall { url: string; body: string; headers: Record<string, string> }
24
+
25
+ function recordingTransport(respond: () => Response | Promise<Response> = () => new Response(null, { status: 200 })): { fetch: FetchFn; calls: RecordedCall[] } {
26
+ const calls: RecordedCall[] = [];
27
+ const fetch: FetchFn = async (req) => {
28
+ const body = await req.text();
29
+ const headers: Record<string, string> = {};
30
+ req.headers.forEach((value, key) => {
31
+ headers[key] = value;
32
+ });
33
+ calls.push({ url: req.url, body, headers });
34
+ return respond();
35
+ };
36
+ return { fetch, calls };
37
+ }
38
+
39
+ const open: WebhookMethods[] = [];
40
+
41
+ interface SetupOpts {
42
+ respond?: () => Response | Promise<Response>;
43
+ events?: WebhookEventDeclaration[];
44
+ maxRetries?: number;
45
+ maxPayloadBytes?: number;
46
+ delivery?: Partial<Omit<WebhookDeliveryConfig, 'fetch'>>;
47
+ }
48
+
49
+ function setup(opts: SetupOpts = {}): { fortress: Fortress; wh: WebhookMethods; calls: RecordedCall[] } {
50
+ const transport = recordingTransport(opts.respond);
51
+ const fortress = createFortress({
52
+ jwt: { key: SECRET },
53
+ database: createTestAdapter(),
54
+ plugins: [webhook({
55
+ events: opts.events,
56
+ maxRetries: opts.maxRetries,
57
+ maxPayloadBytes: opts.maxPayloadBytes,
58
+ delivery: { fetch: transport.fetch, ...opts.delivery },
59
+ })],
60
+ });
61
+ const wh = fortress.plugins.webhook as unknown as WebhookMethods;
62
+ open.push(wh);
63
+ return { fortress, wh, calls: transport.calls };
64
+ }
65
+
66
+ async function waitFor(predicate: () => boolean, tries = 100): Promise<void> {
67
+ for (let i = 0; i < tries && !predicate(); i++)
68
+ await new Promise(resolve => setTimeout(resolve, 10));
69
+ }
70
+
71
+ async function seedLogin(fortress: Fortress, email: string): Promise<void> {
72
+ await fortress.auth.createUser({ email, name: 'Test', password: 'password-123456' });
73
+ await fortress.auth.login(email, 'password-123456');
74
+ }
75
+
76
+ afterEach(async () => {
77
+ for (const wh of open)
78
+ await wh.stop().catch(() => {});
79
+ open.length = 0;
80
+ });
81
+
82
+ describe('webhook plugin — built-in events', () => {
83
+ it('delivers the built-in auth.login.success event', async () => {
84
+ const { fortress, wh, calls } = setup();
85
+ await wh.registerEndpoint('https://example.com/hook', ['auth.login.success']);
86
+ await seedLogin(fortress, 'alice@example.com');
87
+ await waitFor(() => calls.length >= 1);
88
+
89
+ expect(calls.length).toBe(1);
90
+ const body = JSON.parse(calls[0].body);
91
+ expect(body.event).toBe('auth.login.success');
92
+ expect(body.email).toBe('alice@example.com');
93
+ });
94
+
95
+ it('signs with stable Standard Webhooks headers (webhook-id is msg_<deliveryId>)', async () => {
96
+ const { fortress, wh, calls } = setup();
97
+ await wh.registerEndpoint('https://example.com/hook', ['auth.login.success']);
98
+ await seedLogin(fortress, 'bob@example.com');
99
+ await waitFor(() => calls.length >= 1);
100
+
101
+ const headers = calls[0].headers;
102
+ expect(headers['webhook-id']).toMatch(/^msg_/);
103
+ expect(Number(headers['webhook-timestamp'])).toBeGreaterThan(0);
104
+ expect(headers['webhook-signature']).toMatch(/^v1,/);
105
+ });
106
+
107
+ it('only delivers to endpoints subscribed to the event', async () => {
108
+ const { fortress, wh, calls } = setup();
109
+ await wh.registerEndpoint('https://example.com/login-hook', ['auth.login.success']);
110
+ await wh.registerEndpoint('https://example.com/register-hook', ['auth.user.registered']);
111
+ await seedLogin(fortress, 'carol@example.com');
112
+ await waitFor(() => calls.length >= 2);
113
+
114
+ const login = calls.filter(c => c.url === 'https://example.com/login-hook');
115
+ const register = calls.filter(c => c.url === 'https://example.com/register-hook');
116
+ expect(login).toHaveLength(1);
117
+ expect(JSON.parse(login[0].body).event).toBe('auth.login.success');
118
+ expect(register).toHaveLength(1);
119
+ expect(JSON.parse(register[0].body).event).toBe('auth.user.registered');
120
+ });
121
+
122
+ it('excluding a built-in event makes its hook a no-op', async () => {
123
+ const { fortress, wh, calls } = setup({ events: builtinEvents({ exclude: ['auth.login.success'] }) });
124
+ await wh.registerEndpoint('https://example.com/hook', ['auth.login.success']);
125
+ await seedLogin(fortress, 'dave@example.com');
126
+ await new Promise(resolve => setTimeout(resolve, 50));
127
+ expect(calls.length).toBe(0);
128
+ });
129
+ });
130
+
131
+ describe('webhook plugin — endpoint management', () => {
132
+ it('generates a CSPRNG secret when omitted and returns it once', async () => {
133
+ const { wh } = setup();
134
+ const endpoint = await wh.registerEndpoint('https://example.com/hook', ['auth.logout']);
135
+ expect(endpoint.secret).toMatch(/^whsec_/);
136
+ expect(endpoint.isActive).toBe(true);
137
+ expect(JSON.parse(endpoint.events)).toEqual(['auth.logout']);
138
+ });
139
+
140
+ it('redacts the secret from listEndpoints', async () => {
141
+ const { wh } = setup();
142
+ await wh.registerEndpoint('https://a.com/hook', ['auth.logout'], { secret: 'my-secret' });
143
+ const list = await wh.listEndpoints();
144
+ expect(list).toHaveLength(1);
145
+ expect((list[0] as { secret?: string }).secret).toBeUndefined();
146
+ expect(list[0].url).toBe('https://a.com/hook');
147
+ });
148
+
149
+ it('updateEndpoint patches (redacted) and rotateSecret returns a fresh secret', async () => {
150
+ const { wh } = setup();
151
+ const endpoint = await wh.registerEndpoint('https://a.com/hook', ['auth.logout'], { secret: 's1' });
152
+
153
+ const updated = await wh.updateEndpoint(endpoint.id, { url: 'https://b.com/hook', isActive: false });
154
+ expect(updated?.url).toBe('https://b.com/hook');
155
+ expect(updated?.isActive).toBe(false);
156
+ expect((updated as { secret?: string } | null)?.secret).toBeUndefined();
157
+
158
+ const rotated = await wh.rotateSecret(endpoint.id);
159
+ expect(rotated.secret).toMatch(/^whsec_/);
160
+ expect(rotated.secret).not.toBe('s1');
161
+ });
162
+
163
+ it('removeEndpoint deletes the endpoint', async () => {
164
+ const { wh } = setup();
165
+ const endpoint = await wh.registerEndpoint('https://example.com/hook', ['auth.logout']);
166
+ await wh.removeEndpoint(endpoint.id);
167
+ expect(await wh.listEndpoints()).toHaveLength(0);
168
+ });
169
+
170
+ it('listEventTypes returns the registered events', async () => {
171
+ const { wh } = setup({ events: [...builtinEvents(), { name: 'order.paid', description: 'An order was paid' }] });
172
+ const names = wh.listEventTypes().map(e => e.name);
173
+ expect(names).toContain('auth.login.success');
174
+ expect(names).toContain('order.paid');
175
+ });
176
+ });
177
+
178
+ describe('webhook plugin — custom events + emit()', () => {
179
+ const OrderPaid = obj({ orderId: str() }, 'orderId');
180
+
181
+ it('emit() throws WebhookEmitError for unknown events and invalid payloads', async () => {
182
+ const { wh } = setup({ events: [...builtinEvents(), { name: 'order.paid', schema: OrderPaid }] });
183
+ await wh.registerEndpoint('https://example.com/hook', ['order.paid']);
184
+
185
+ await expect(wh.emit('nope.event', {})).rejects.toMatchObject({ code: 'unknown_event' });
186
+ await expect(wh.emit('order.paid', { wrong: 1 })).rejects.toMatchObject({ code: 'invalid_payload' });
187
+ });
188
+
189
+ it('emit() delivers a valid custom event', async () => {
190
+ const { wh, calls } = setup({ events: [...builtinEvents(), { name: 'order.paid', schema: OrderPaid }] });
191
+ await wh.registerEndpoint('https://example.com/hook', ['order.paid']);
192
+ await wh.emit('order.paid', { orderId: 'o1' });
193
+ await waitFor(() => calls.length >= 1);
194
+ expect(JSON.parse(calls[0].body).orderId).toBe('o1');
195
+ });
196
+
197
+ it('emit() rejects an oversized payload', async () => {
198
+ const { wh } = setup({ maxPayloadBytes: 100, events: [...builtinEvents(), { name: 'big.event' }] });
199
+ await expect(wh.emit('big.event', { blob: 'x'.repeat(500) })).rejects.toMatchObject({ code: 'payload_too_large' });
200
+ });
201
+
202
+ it('skips malformed persisted endpoint event JSON without aborting emit', async () => {
203
+ const { fortress, wh, calls } = setup({ events: [{ name: 'test.event' }] });
204
+ const endpoint = await wh.registerEndpoint('https://example.com/hook', ['test.event']);
205
+ await fortress.config.database.update({
206
+ model: 'webhook_endpoint',
207
+ where: [{ field: 'id', operator: '=', value: endpoint.id }],
208
+ data: { events: '{malformed' },
209
+ });
210
+
211
+ await expect(wh.emit('test.event', { ok: true })).resolves.toBeUndefined();
212
+ expect(calls).toHaveLength(0);
213
+ await expect(fortress.config.database.count({ model: 'webhook_delivery' })).resolves.toBe(0);
214
+ });
215
+
216
+ it('emit() is idempotent per (endpoint, idempotencyKey)', async () => {
217
+ const { wh, calls } = setup({ events: [...builtinEvents(), { name: 'order.paid' }] });
218
+ await wh.registerEndpoint('https://example.com/hook', ['order.paid']);
219
+ await wh.emit('order.paid', { orderId: 'o1' }, { idempotencyKey: 'k1' });
220
+ await wh.emit('order.paid', { orderId: 'o1' }, { idempotencyKey: 'k1' });
221
+ await waitFor(() => calls.length >= 1);
222
+ await new Promise(resolve => setTimeout(resolve, 40));
223
+ expect(calls.length).toBe(1);
224
+ });
225
+
226
+ it('emit() dedup is concurrency-safe via the unique-index backstop', async () => {
227
+ const { wh, calls } = setup({ events: [...builtinEvents(), { name: 'order.paid' }] });
228
+ await wh.registerEndpoint('https://example.com/hook', ['order.paid']);
229
+ // Two concurrent emits with the same key race the findOne pre-check; the DB
230
+ // unique index must keep it to a single delivery row.
231
+ await Promise.all([
232
+ wh.emit('order.paid', { orderId: 'o1' }, { idempotencyKey: 'race-key' }),
233
+ wh.emit('order.paid', { orderId: 'o1' }, { idempotencyKey: 'race-key' }),
234
+ ]);
235
+ await waitFor(() => calls.length >= 1);
236
+ await new Promise(resolve => setTimeout(resolve, 40));
237
+ expect(calls.length).toBe(1);
238
+ });
239
+ });
240
+
241
+ describe('webhook plugin — failure handling', () => {
242
+ const events: WebhookEventDeclaration[] = [...builtinEvents(), { name: 'order.paid' }];
243
+
244
+ it('deactivates an endpoint on a permanent status (410) and fires onEndpointDeactivated', async () => {
245
+ let reason: string | null = null;
246
+ const { wh } = setup({
247
+ respond: () => new Response(null, { status: 410 }),
248
+ events,
249
+ delivery: { onEndpointDeactivated: (_e, r) => { reason = r; } },
250
+ });
251
+ await wh.registerEndpoint('https://example.com/hook', ['order.paid']);
252
+ await wh.emit('order.paid', { orderId: 'o1' });
253
+ await waitFor(() => reason !== null);
254
+
255
+ expect(reason).toBe('permanent_410');
256
+ const list = await wh.listEndpoints();
257
+ expect(list[0].isActive).toBe(false);
258
+ expect(list[0].deactivatedReason).toBe('permanent_410');
259
+ });
260
+
261
+ it('fails fast on a non-retriable 4xx (422) and fires onDeliveryFailed', async () => {
262
+ let failed: WebhookDelivery | null = null;
263
+ const { wh } = setup({
264
+ respond: () => new Response('bad request', { status: 422 }),
265
+ events,
266
+ delivery: { onDeliveryFailed: (d) => { failed = d; } },
267
+ });
268
+ await wh.registerEndpoint('https://example.com/hook', ['order.paid']);
269
+ await wh.emit('order.paid', { orderId: 'o1' });
270
+ await waitFor(() => failed !== null);
271
+
272
+ expect(failed!.status).toBe('failed');
273
+ expect(failed!.responseStatus).toBe(422);
274
+ expect(failed!.errorKind).toBe('http');
275
+ });
276
+
277
+ it('opens the circuit breaker after maxConsecutiveFailures and marks the row failed (no zombie pending)', async () => {
278
+ let reason: string | null = null;
279
+ let failed: WebhookDelivery | null = null;
280
+ const { wh } = setup({
281
+ respond: () => new Response(null, { status: 500 }),
282
+ events,
283
+ maxRetries: 100,
284
+ delivery: {
285
+ maxConsecutiveFailures: 1,
286
+ onEndpointDeactivated: (_e, r) => { reason = r; },
287
+ onDeliveryFailed: (d) => { failed = d; },
288
+ },
289
+ });
290
+ await wh.registerEndpoint('https://example.com/hook', ['order.paid']);
291
+ await wh.emit('order.paid', { orderId: 'o1' });
292
+ await waitFor(() => reason !== null && failed !== null);
293
+ expect(reason).toBe('too_many_failures');
294
+ // M1: a tripped breaker marks the delivery failed (DLQ seam sees it) instead
295
+ // of leaving a permanent zombie `pending` row.
296
+ expect(failed!.status).toBe('failed');
297
+ });
298
+
299
+ it('counts consecutive failures correctly under concurrent same-endpoint deliveries', async () => {
300
+ const { fortress, wh } = setup({
301
+ respond: () => new Response(null, { status: 500 }),
302
+ events,
303
+ maxRetries: 100,
304
+ delivery: { maxConsecutiveFailures: 100 }, // just count, don't deactivate
305
+ });
306
+ // One endpoint subscribed to two built-in events that both fire on signup+login.
307
+ await wh.registerEndpoint('https://example.com/hook', ['auth.user.registered', 'auth.login.success']);
308
+ await seedLogin(fortress, 'race@example.com');
309
+ // Poll until both failing deliveries have updated the counter (serialized → 2).
310
+ let cf = 0;
311
+ for (let i = 0; i < 100 && cf < 2; i++) {
312
+ await new Promise(resolve => setTimeout(resolve, 10));
313
+ cf = (await wh.listEndpoints())[0]?.consecutiveFailures ?? 0;
314
+ }
315
+ expect(cf).toBe(2);
316
+ });
317
+ });
318
+
319
+ describe('webhook plugin — SSRF guard', () => {
320
+ it('assertSafeWebhookUrl rejects non-https and private targets', async () => {
321
+ await expect(assertSafeWebhookUrl('http://example.com/hook')).rejects.toThrow();
322
+ await expect(assertSafeWebhookUrl('https://[::ffff:169.254.169.254]/hook')).rejects.toThrow();
323
+ });
324
+ });