@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,67 @@
1
+ /**
2
+ * Crash-safe bundled queue: polls `webhook_delivery` for due `pending` rows and
3
+ * drives delivery. The `webhook_delivery` table is the transactional outbox, so
4
+ * `enqueue` is a no-op — the row already exists and the poller picks it up.
5
+ * This is the only bundled queue that survives a process restart.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { WebhookDelivery } from '../types';
11
+ import type { WebhookQueue } from './types';
12
+
13
+ export function databaseQueue(opts: { pollMs?: number } = {}): WebhookQueue {
14
+ const pollMs = opts.pollMs ?? 10_000;
15
+
16
+ return {
17
+ async enqueue() {
18
+ // No-op: the webhook_delivery row IS the queue entry; the poller delivers it.
19
+ },
20
+
21
+ async start(handler, ctx) {
22
+ let stopped = false;
23
+ let timer: ReturnType<typeof setTimeout> | null = null;
24
+
25
+ const poll = async (): Promise<void> => {
26
+ if (stopped)
27
+ return;
28
+ try {
29
+ const due = await ctx.db.findMany<WebhookDelivery>({
30
+ model: 'webhook_delivery',
31
+ where: [
32
+ { field: 'status', operator: '=', value: 'pending' },
33
+ { field: 'nextRetryAt', operator: 'lte', value: new Date() },
34
+ ],
35
+ sortBy: { field: 'nextRetryAt', direction: 'asc' },
36
+ });
37
+ for (const delivery of due) {
38
+ if (stopped)
39
+ break;
40
+ // Sequential, so a slow delivery doesn't get double-picked next tick.
41
+ await handler({
42
+ deliveryId: delivery.id,
43
+ endpointId: delivery.endpointId,
44
+ eventType: delivery.eventType,
45
+ attempt: delivery.attempts + 1,
46
+ scheduledFor: delivery.nextRetryAt ?? undefined,
47
+ }).catch(() => {});
48
+ }
49
+ }
50
+ catch {
51
+ // Swallow poll errors (e.g. transient DB hiccup); retry next tick.
52
+ }
53
+ if (!stopped)
54
+ timer = setTimeout(() => void poll(), pollMs);
55
+ };
56
+
57
+ // First poll runs immediately and doubles as the startup recovery sweep.
58
+ timer = setTimeout(() => void poll(), 0);
59
+
60
+ return async () => {
61
+ stopped = true;
62
+ if (timer)
63
+ clearTimeout(timer);
64
+ };
65
+ },
66
+ } satisfies WebhookQueue;
67
+ }
@@ -0,0 +1,48 @@
1
+ import type { DatabaseAdapter } from '../../../adapters/database';
2
+ import type { WebhookQueueContext, WebhookQueueJob } from './types';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { inMemoryQueue } from './in-memory';
5
+
6
+ function ctxWithPending(pending: unknown[] = []): WebhookQueueContext {
7
+ return { db: { findMany: async () => pending } as unknown as DatabaseAdapter };
8
+ }
9
+ const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 5));
10
+
11
+ describe('inMemoryQueue', () => {
12
+ it('delivers an enqueued job to the handler', async () => {
13
+ const jobs: WebhookQueueJob[] = [];
14
+ const queue = inMemoryQueue();
15
+ const stop = await queue.start!(async job => void jobs.push(job), ctxWithPending());
16
+
17
+ await queue.enqueue({ deliveryId: 'd1', endpointId: 'e1', eventType: 'order.paid', attempt: 1 });
18
+ await tick();
19
+
20
+ expect(jobs.map(j => j.deliveryId)).toEqual(['d1']);
21
+ await stop();
22
+ });
23
+
24
+ it('runs a startup recovery sweep over pending rows', async () => {
25
+ const jobs: WebhookQueueJob[] = [];
26
+ const queue = inMemoryQueue();
27
+ const stop = await queue.start!(
28
+ async job => void jobs.push(job),
29
+ ctxWithPending([{ id: 'd9', endpointId: 'e9', eventType: 'auth.login.success', attempts: 2, nextRetryAt: null }]),
30
+ );
31
+ await tick();
32
+
33
+ expect(jobs[0]).toMatchObject({ deliveryId: 'd9', attempt: 3 });
34
+ await stop();
35
+ });
36
+
37
+ it('stops delivering after teardown', async () => {
38
+ const jobs: WebhookQueueJob[] = [];
39
+ const queue = inMemoryQueue();
40
+ const stop = await queue.start!(async job => void jobs.push(job), ctxWithPending());
41
+ await stop();
42
+
43
+ await queue.enqueue({ deliveryId: 'd2', endpointId: 'e2', eventType: 'order.paid', attempt: 1 });
44
+ await tick();
45
+
46
+ expect(jobs).toHaveLength(0);
47
+ });
48
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `setTimeout`-driven, single-process queue. The **default** when no queue is
3
+ * configured.
4
+ *
5
+ * **Dev-only.** Scheduled retries live in in-process timers, so a restart
6
+ * **loses every pending retry** — the `webhook_delivery` row stays `pending`
7
+ * but nothing re-attempts it until the next process runs its startup recovery
8
+ * sweep (see {@link inMemoryQueue}'s `start`). Use {@link import('./database').databaseQueue}
9
+ * (or a real broker) in production.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import type { WebhookDelivery } from '../types';
15
+ import type { WebhookQueue, WebhookQueueJob } from './types';
16
+
17
+ export function inMemoryQueue(): WebhookQueue {
18
+ let handler: ((job: WebhookQueueJob) => Promise<void>) | null = null;
19
+ const timers = new Set<ReturnType<typeof setTimeout>>();
20
+ // Serialize handler execution: jobs run one at a time so two deliveries to the
21
+ // same endpoint never overlap (concurrent runs would race the circuit-breaker
22
+ // `consecutiveFailures` read-modify-write).
23
+ let chain: Promise<void> = Promise.resolve();
24
+
25
+ const enqueue = async (job: WebhookQueueJob): Promise<void> => {
26
+ const delay = job.scheduledFor ? Math.max(0, job.scheduledFor.getTime() - Date.now()) : 0;
27
+ const dispatch = (): void => {
28
+ const current = handler;
29
+ if (!current)
30
+ return;
31
+ chain = chain.then(() => current(job)).catch(() => {});
32
+ };
33
+ if (delay === 0) {
34
+ queueMicrotask(dispatch);
35
+ }
36
+ else {
37
+ const timer = setTimeout(() => {
38
+ timers.delete(timer);
39
+ dispatch();
40
+ }, delay);
41
+ timers.add(timer);
42
+ }
43
+ };
44
+
45
+ return {
46
+ enqueue,
47
+ async start(h, ctx) {
48
+ handler = h;
49
+ // Startup recovery sweep: re-enqueue rows a prior process left pending.
50
+ const pending = await ctx.db.findMany<WebhookDelivery>({
51
+ model: 'webhook_delivery',
52
+ where: [{ field: 'status', operator: '=', value: 'pending' }],
53
+ });
54
+ for (const delivery of pending) {
55
+ void enqueue({
56
+ deliveryId: delivery.id,
57
+ endpointId: delivery.endpointId,
58
+ eventType: delivery.eventType,
59
+ attempt: delivery.attempts + 1,
60
+ scheduledFor: delivery.nextRetryAt ?? undefined,
61
+ });
62
+ }
63
+ return async () => {
64
+ for (const timer of timers)
65
+ clearTimeout(timer);
66
+ timers.clear();
67
+ handler = null;
68
+ };
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Bring-Your-Own-Queue interface. Delivery is always queued; consumers can
3
+ * back it with any system (in-memory, DB, BullMQ, SQS, Cloudflare Queues,
4
+ * Inngest, …) by implementing this small surface.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ import type { DatabaseAdapter } from '../../../adapters/database';
10
+
11
+ /** One unit of delivery work — a single attempt of a single delivery row. */
12
+ export interface WebhookQueueJob {
13
+ deliveryId: string;
14
+ endpointId: string;
15
+ eventType: string;
16
+ /** 1-based attempt number. */
17
+ attempt: number;
18
+ /** When this attempt should run (retries); absent means "now". */
19
+ scheduledFor?: Date;
20
+ }
21
+
22
+ /** Context handed to {@link WebhookQueue.start} so fortress-aware queues can reach the DB. */
23
+ export interface WebhookQueueContext {
24
+ /** The fortress database adapter — used by the database queue to poll, and for the startup recovery sweep. */
25
+ db: DatabaseAdapter;
26
+ }
27
+
28
+ export interface WebhookQueue {
29
+ /**
30
+ * If true, the plugin re-throws on failed delivery and the queue is expected
31
+ * to retry per its own policy (BullMQ/SQS/Inngest). If false/absent, the
32
+ * plugin schedules retries itself.
33
+ */
34
+ handlesRetries?: boolean;
35
+
36
+ /** Enqueue a delivery job. (A no-op for the database queue — the table IS the queue.) */
37
+ enqueue: (job: WebhookQueueJob) => Promise<void>;
38
+
39
+ /**
40
+ * Optional worker registration. Pull queues (in-memory, database) implement
41
+ * this; push/serverless queues (Cloudflare Queues with a separate consumer,
42
+ * Inngest, …) leave it undefined. Returns a teardown the consumer calls on
43
+ * shutdown. Implementations SHOULD run a startup recovery sweep here —
44
+ * re-enqueue rows left `pending` by a prior process — so at-least-once holds
45
+ * across restarts.
46
+ */
47
+ start?: (
48
+ handler: (job: WebhookQueueJob) => Promise<void>,
49
+ ctx: WebhookQueueContext,
50
+ ) => Promise<() => Promise<void>>;
51
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { BUILTIN_EVENT_NAMES, builtinEvents } from './builtin-events';
3
+ import { assertEventRegistry, createEventRegistry } from './registry';
4
+
5
+ describe('webhook event registry', () => {
6
+ it('builtinEvents returns the five auth events in order', () => {
7
+ expect(builtinEvents().map(e => e.name)).toEqual([
8
+ 'auth.login.success',
9
+ 'auth.login.failure',
10
+ 'auth.logout',
11
+ 'auth.user.registered',
12
+ 'auth.token.refreshed',
13
+ ]);
14
+ expect(BUILTIN_EVENT_NAMES.has('auth.login.success')).toBe(true);
15
+ });
16
+
17
+ it('builtinEvents({ exclude }) drops only the excluded events', () => {
18
+ const events = builtinEvents({ exclude: ['auth.logout'] });
19
+ expect(events).toHaveLength(4);
20
+ expect(events.find(e => e.name === 'auth.logout')).toBeUndefined();
21
+ });
22
+
23
+ it('assertEventRegistry rejects duplicate names', () => {
24
+ expect(() => assertEventRegistry([{ name: 'order.paid' }, { name: 'order.paid' }])).toThrow(/Duplicate/);
25
+ });
26
+
27
+ it('assertEventRegistry rejects an empty name', () => {
28
+ expect(() => assertEventRegistry([{ name: '' }])).toThrow();
29
+ });
30
+
31
+ it('assertEventRegistry rejects an unknown source', () => {
32
+ expect(() => assertEventRegistry([{ name: 'x', source: 'nope' as never }])).toThrow(/source/);
33
+ });
34
+
35
+ it('createEventRegistry exposes lookup + the source→name map', () => {
36
+ const reg = createEventRegistry([
37
+ ...builtinEvents({ exclude: ['auth.logout'] }),
38
+ { name: 'order.paid' },
39
+ ]);
40
+ expect(reg.has('order.paid')).toBe(true);
41
+ expect(reg.get('auth.login.success')?.source).toBe('afterLogin');
42
+ expect(reg.names()).toContain('order.paid');
43
+
44
+ const sources = reg.sources();
45
+ expect(sources.get('afterLogin')).toBe('auth.login.success');
46
+ expect(sources.has('beforeLogout')).toBe(false); // excluded above
47
+ });
48
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Event registry — the single source of truth for which events may be emitted.
3
+ * Validates declarations at construction and provides name lookup.
4
+ *
5
+ * @module
6
+ */
7
+
8
+ import type { BuiltinEventSource, WebhookEventDeclaration } from './types';
9
+
10
+ export interface EventRegistry {
11
+ /** All declarations, in registration order. */
12
+ list: () => WebhookEventDeclaration[];
13
+ /** All event names, in registration order. */
14
+ names: () => string[];
15
+ get: (name: string) => WebhookEventDeclaration | undefined;
16
+ has: (name: string) => boolean;
17
+ /** Map of built-in hook source → event name, for hook binding. */
18
+ sources: () => Map<BuiltinEventSource, string>;
19
+ }
20
+
21
+ const VALID_SOURCES: ReadonlySet<string> = new Set<BuiltinEventSource>([
22
+ 'afterLogin',
23
+ 'onLoginFailure',
24
+ 'beforeLogout',
25
+ 'afterRegister',
26
+ 'afterTokenRefresh',
27
+ ]);
28
+
29
+ /**
30
+ * Validate an event registry: every declaration needs a non-empty string name,
31
+ * names must be unique, and any `source` must name a real hook. Throws on the
32
+ * first violation (a construction-time bug, not a runtime condition).
33
+ */
34
+ export function assertEventRegistry(events: WebhookEventDeclaration[]): void {
35
+ const seen = new Set<string>();
36
+ for (const event of events) {
37
+ if (typeof event.name !== 'string' || event.name.length === 0)
38
+ throw new TypeError('Webhook event declaration requires a non-empty string `name`');
39
+ if (seen.has(event.name))
40
+ throw new Error(`Duplicate webhook event name: ${event.name}`);
41
+ seen.add(event.name);
42
+ if (event.source !== undefined && !VALID_SOURCES.has(event.source))
43
+ throw new Error(`Webhook event '${event.name}' has an unknown source: ${event.source}`);
44
+ }
45
+ }
46
+
47
+ /** Build a validated {@link EventRegistry} from a list of declarations. */
48
+ export function createEventRegistry(events: WebhookEventDeclaration[]): EventRegistry {
49
+ assertEventRegistry(events);
50
+ const ordered = [...events];
51
+ const byName = new Map(ordered.map(e => [e.name, e]));
52
+ const sources = new Map<BuiltinEventSource, string>();
53
+ for (const event of ordered) {
54
+ if (event.source && !sources.has(event.source))
55
+ sources.set(event.source, event.name);
56
+ }
57
+ return {
58
+ list: () => ordered.map(e => ({ ...e })),
59
+ names: () => ordered.map(e => e.name),
60
+ get: name => byName.get(name),
61
+ has: name => byName.has(name),
62
+ sources: () => new Map(sources),
63
+ };
64
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Standard Webhooks signing — HMAC-SHA256 over `{id}.{timestamp}.{body}`.
3
+ *
4
+ * @see https://www.standardwebhooks.com
5
+ * @module
6
+ */
7
+
8
+ /**
9
+ * Sign a webhook payload per the Standard Webhooks spec: HMAC-SHA256 over
10
+ * `{webhookId}.{timestamp}.{body}`, base64-encoded. Uses Web Crypto so it runs
11
+ * on every fortress runtime.
12
+ */
13
+ export async function signPayload(secret: string, webhookId: string, timestamp: number, body: string): Promise<string> {
14
+ const content = `${webhookId}.${timestamp}.${body}`;
15
+ const key = await crypto.subtle.importKey(
16
+ 'raw',
17
+ new TextEncoder().encode(secret),
18
+ { name: 'HMAC', hash: 'SHA-256' },
19
+ false,
20
+ ['sign'],
21
+ );
22
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(content));
23
+ return btoa(String.fromCharCode(...new Uint8Array(sig)));
24
+ }
25
+
26
+ /**
27
+ * Build the Standard Webhooks delivery headers for one attempt.
28
+ *
29
+ * `webhookId` must be **stable across retries of the same delivery** (receivers
30
+ * use it as their idempotency key); only `timestamp` changes per attempt. The
31
+ * caller is responsible for passing a stable id (e.g. `msg_<deliveryId>`).
32
+ */
33
+ export async function signatureHeaders(
34
+ secret: string,
35
+ webhookId: string,
36
+ timestamp: number,
37
+ body: string,
38
+ ): Promise<Record<string, string>> {
39
+ const signature = await signPayload(secret, webhookId, timestamp, body);
40
+ return {
41
+ 'webhook-id': webhookId,
42
+ 'webhook-timestamp': String(timestamp),
43
+ 'webhook-signature': `v1,${signature}`,
44
+ };
45
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * SSRF protection for webhook delivery.
3
+ *
4
+ * Webhook URLs are consumer-supplied, so delivery is a live SSRF surface
5
+ * (cloud metadata endpoints, internal services). This module resolves the
6
+ * target host, refuses private/loopback/link-local/CGNAT addresses (IPv4,
7
+ * IPv6, `::ffff:`-mapped, and `64:ff9b::/96` NAT64 forms), and returns the *resolved IP* so the
8
+ * caller can pin the connection to it — closing the DNS-rebinding window
9
+ * between validation and connect.
10
+ *
11
+ * Lifted verbatim from the original `index.ts` (logic unchanged); the delivery
12
+ * transport (`delivery.ts`) pins {@link SafeWebhookTarget.address} via a custom
13
+ * `lookup`, and {@link assertSafeWebhookUrl} is exported for custom transports.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import { lookup } from 'node:dns/promises';
19
+ import { isIP } from 'node:net';
20
+
21
+ /** A validated webhook target plus the exact resolved IP to pin the connection to. */
22
+ export interface SafeWebhookTarget {
23
+ url: URL;
24
+ address: string;
25
+ family: 4 | 6;
26
+ }
27
+
28
+ function stripIpv6Brackets(host: string): string {
29
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
30
+ }
31
+
32
+ function parseIpv4(address: string): number[] | null {
33
+ const parts = address.split('.');
34
+ if (parts.length !== 4)
35
+ return null;
36
+ const octets = parts.map(part => Number(part));
37
+ if (octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255))
38
+ return null;
39
+ return octets;
40
+ }
41
+
42
+ function extractMappedIpv4(address: string): string | null {
43
+ const lower = stripIpv6Brackets(address).toLowerCase();
44
+ if (!lower.startsWith('::ffff:'))
45
+ return null;
46
+ const tail = lower.slice('::ffff:'.length);
47
+ if (parseIpv4(tail))
48
+ return tail;
49
+ const words = tail.split(':');
50
+ if (words.length !== 2)
51
+ return null;
52
+ const hi = Number.parseInt(words[0], 16);
53
+ const lo = Number.parseInt(words[1], 16);
54
+ if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi < 0 || hi > 0xFFFF || lo < 0 || lo > 0xFFFF)
55
+ return null;
56
+ return `${hi >> 8}.${hi & 0xFF}.${lo >> 8}.${lo & 0xFF}`;
57
+ }
58
+
59
+ function isPrivateIpv4(address: string): boolean {
60
+ const octets = parseIpv4(address);
61
+ if (!octets)
62
+ return false;
63
+ const [a, b] = octets;
64
+ return a === 0
65
+ || a === 10
66
+ || a === 127
67
+ || (a === 100 && b >= 64 && b <= 127)
68
+ || (a === 169 && b === 254)
69
+ || (a === 172 && b >= 16 && b <= 31)
70
+ || (a === 192 && b === 168)
71
+ || (a === 198 && (b === 18 || b === 19))
72
+ || a >= 224;
73
+ }
74
+
75
+ /**
76
+ * Expand an IPv6 literal to its 8 16-bit groups, handling `::` compression and
77
+ * a trailing embedded dotted-quad. Returns null for anything not well-formed.
78
+ */
79
+ function ipv6Groups(address: string): number[] | null {
80
+ let host = stripIpv6Brackets(address).toLowerCase();
81
+ const zone = host.indexOf('%');
82
+ if (zone !== -1)
83
+ host = host.slice(0, zone);
84
+ if (!host.includes(':'))
85
+ return null;
86
+
87
+ // Fold a trailing dotted-quad (e.g. `64:ff9b::1.2.3.4`) into two hex groups.
88
+ if (host.includes('.')) {
89
+ const lastColon = host.lastIndexOf(':');
90
+ const v4 = parseIpv4(host.slice(lastColon + 1));
91
+ if (!v4)
92
+ return null;
93
+ const hi = ((v4[0] << 8) | v4[1]).toString(16);
94
+ const lo = ((v4[2] << 8) | v4[3]).toString(16);
95
+ host = `${host.slice(0, lastColon + 1)}${hi}:${lo}`;
96
+ }
97
+
98
+ const halves = host.split('::');
99
+ if (halves.length > 2)
100
+ return null;
101
+ const head = halves[0] === '' ? [] : halves[0].split(':');
102
+ const tail = halves.length === 2 ? (halves[1] === '' ? [] : halves[1].split(':')) : null;
103
+
104
+ let parts: string[];
105
+ if (tail === null) {
106
+ parts = head; // no `::` — must already be 8 groups
107
+ }
108
+ else {
109
+ const missing = 8 - head.length - tail.length;
110
+ if (missing < 1)
111
+ return null;
112
+ parts = [...head, ...Array.from<string>({ length: missing }).fill('0'), ...tail];
113
+ }
114
+ if (parts.length !== 8)
115
+ return null;
116
+
117
+ const groups = parts.map(part => Number.parseInt(part, 16));
118
+ if (groups.some(g => !Number.isInteger(g) || g < 0 || g > 0xFFFF))
119
+ return null;
120
+ return groups;
121
+ }
122
+
123
+ /**
124
+ * Decode the IPv4 embedded in an RFC 6052 NAT64 well-known-prefix (`64:ff9b::/96`)
125
+ * address, or null if `address` isn't in that prefix. On a NAT64/DNS64 network
126
+ * the gateway connects to this translated IPv4, so it must face the same
127
+ * private-range checks — `64:ff9b::a9fe:a9fe` is the cloud metadata IP.
128
+ */
129
+ function extractNat64Ipv4(address: string): string | null {
130
+ const groups = ipv6Groups(address);
131
+ if (!groups)
132
+ return null;
133
+ if (groups[0] === 0x0064 && groups[1] === 0xFF9B
134
+ && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 0) {
135
+ const g6 = groups[6];
136
+ const g7 = groups[7];
137
+ return `${(g6 >> 8) & 0xFF}.${g6 & 0xFF}.${(g7 >> 8) & 0xFF}.${g7 & 0xFF}`;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /** `true` if `address` is a loopback/private/link-local/CGNAT/multicast IP (v4, v6, `::ffff:` mapped, or `64:ff9b::/96` NAT64). */
143
+ export function isPrivateIp(address: string): boolean {
144
+ const host = stripIpv6Brackets(address).toLowerCase();
145
+ const mapped = extractMappedIpv4(host);
146
+ if (mapped)
147
+ return isPrivateIpv4(mapped);
148
+ const nat64 = extractNat64Ipv4(host);
149
+ if (nat64)
150
+ return isPrivateIpv4(nat64);
151
+ if (isPrivateIpv4(host))
152
+ return true;
153
+ if (host === '::' || host === '::1')
154
+ return true;
155
+ return host.startsWith('fc')
156
+ || host.startsWith('fd')
157
+ || host.startsWith('fe80:')
158
+ || host.startsWith('ff');
159
+ }
160
+
161
+ /**
162
+ * Resolve `url` to a {@link SafeWebhookTarget}, throwing when it is not https
163
+ * or resolves to a loopback/private/link-local/CGNAT address (including
164
+ * IPv4-mapped IPv6 forms like `::ffff:169.254.169.254`) or a `localhost`/
165
+ * `.local`/`.localhost` host. The returned `address`/`family` is the exact IP
166
+ * the caller must pin the connection to.
167
+ */
168
+ export async function resolveSafeWebhookTarget(url: string): Promise<SafeWebhookTarget> {
169
+ const parsed = new URL(url);
170
+ if (parsed.protocol !== 'https:')
171
+ throw new Error('Webhook URL must use https');
172
+ const host = stripIpv6Brackets(parsed.hostname.toLowerCase());
173
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local'))
174
+ throw new Error('Webhook URL host is not allowed');
175
+
176
+ const literalFamily = isIP(host);
177
+ const records = literalFamily === 0
178
+ ? await lookup(host, { all: true, verbatim: true })
179
+ : [{ address: host, family: literalFamily }];
180
+ if (records.length === 0)
181
+ throw new Error('Webhook URL host did not resolve');
182
+ if (records.some(record => isPrivateIp(record.address)))
183
+ throw new Error('Webhook URL resolves to a private address');
184
+
185
+ const selected = records[0];
186
+ return { url: parsed, address: selected.address, family: selected.family as 4 | 6 };
187
+ }
188
+
189
+ /**
190
+ * Resolve `url` and throw when it points at a loopback, link-local, RFC1918,
191
+ * or otherwise non-public address. Exported for tests and for custom delivery
192
+ * transports that want to reuse fortress's SSRF guard. The built-in transport
193
+ * (`delivery.ts`) already calls {@link resolveSafeWebhookTarget} and pins the
194
+ * resolved IP, so callers using it do not need to invoke this themselves.
195
+ */
196
+ export async function assertSafeWebhookUrl(url: string): Promise<void> {
197
+ await resolveSafeWebhookTarget(url);
198
+ }