@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,749 @@
1
+ import type { DatabaseAdapter } from '../../adapters/database';
2
+ import type { Fortress } from '../../core/fortress';
3
+ import type { AuditLogEntry, AuditLogMethods, AuditLogQueryOptions, ChainVerificationResult } from './index';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { createFortress } from '../../core/fortress';
6
+ import { SILENT_LOGGER } from '../../core/observability/logger';
7
+ import { assertSuccess } from '../../core/types';
8
+ import { createTestAdapter } from '../../testing';
9
+ import { auditLog } from './index';
10
+
11
+ const SECRET = 'audit-log-test-secret-32chars!!x';
12
+ const SHA256_HEX_RE = /^[a-f0-9]{64}$/;
13
+
14
+ describe('audit-log plugin', () => {
15
+ let fortress: Fortress<any>;
16
+ let getAuditLog: (options?: AuditLogQueryOptions) => Promise<AuditLogEntry[]>;
17
+
18
+ beforeEach(() => {
19
+ fortress = createFortress({
20
+ jwt: { key: SECRET },
21
+ database: createTestAdapter(),
22
+ plugins: [auditLog()],
23
+ });
24
+ getAuditLog = fortress.plugins['audit-log'].getAuditLog as typeof getAuditLog;
25
+ });
26
+
27
+ describe('afterLogin hook', () => {
28
+ it('logs LOGIN_SUCCESS after successful login', async () => {
29
+ await fortress.auth.createUser({
30
+ email: 'alice@example.com',
31
+ name: 'Alice',
32
+ password: 'password-123456',
33
+ });
34
+
35
+ await fortress.auth.login('alice@example.com', 'password-123456');
36
+
37
+ const entries = await getAuditLog();
38
+ const loginEntry = entries.find(e => e.eventType === 'LOGIN_SUCCESS');
39
+
40
+ expect(loginEntry).toBeDefined();
41
+ expect(loginEntry!.actorType).toBe('user');
42
+ expect(loginEntry!.outcome).toBe('success');
43
+ });
44
+ });
45
+
46
+ describe('onLoginFailure hook', () => {
47
+ it('logs LOGIN_FAILURE after failed login', async () => {
48
+ await fortress.auth.createUser({
49
+ email: 'bob@example.com',
50
+ name: 'Bob',
51
+ password: 'password-123456',
52
+ });
53
+
54
+ await expect(
55
+ fortress.auth.login('bob@example.com', 'wrong-password'),
56
+ ).rejects.toThrow();
57
+
58
+ const entries = await getAuditLog();
59
+ const failureEntry = entries.find(e => e.eventType === 'LOGIN_FAILURE');
60
+
61
+ expect(failureEntry).toBeDefined();
62
+ expect(failureEntry!.actorId).toBeNull();
63
+ expect(failureEntry!.actorType).toBe('anonymous');
64
+ expect(failureEntry!.outcome).toBe('failure');
65
+
66
+ const metadata = JSON.parse(failureEntry!.metadata!);
67
+ expect(metadata.identifier).toBe('bob@example.com');
68
+ });
69
+ });
70
+
71
+ describe('afterRegister hook', () => {
72
+ it('logs REGISTER after user creation', async () => {
73
+ const user = await fortress.auth.createUser({
74
+ email: 'carol@example.com',
75
+ name: 'Carol',
76
+ password: 'password-123456',
77
+ });
78
+
79
+ const entries = await getAuditLog();
80
+ const registerEntry = entries.find(e => e.eventType === 'REGISTER');
81
+
82
+ expect(registerEntry).toBeDefined();
83
+ expect(registerEntry!.actorId).toBe(user.id);
84
+ expect(registerEntry!.actorType).toBe('user');
85
+ expect(registerEntry!.targetId).toBe(user.id);
86
+ expect(registerEntry!.targetType).toBe('user');
87
+ expect(registerEntry!.outcome).toBe('success');
88
+ });
89
+ });
90
+
91
+ describe('beforeLogout hook', () => {
92
+ it('logs LOGOUT after logout', async () => {
93
+ await fortress.auth.createUser({
94
+ email: 'dave@example.com',
95
+ name: 'Dave',
96
+ password: 'password-123456',
97
+ });
98
+
99
+ const loginResult = await fortress.auth.login('dave@example.com', 'password-123456');
100
+ assertSuccess(loginResult);
101
+ await fortress.auth.logout(loginResult.refreshToken as string);
102
+
103
+ const entries = await getAuditLog();
104
+ const logoutEntry = entries.find(e => e.eventType === 'LOGOUT');
105
+
106
+ expect(logoutEntry).toBeDefined();
107
+ expect(logoutEntry!.eventType).toBe('LOGOUT');
108
+ expect(logoutEntry!.outcome).toBe('success');
109
+ });
110
+ });
111
+
112
+ describe('afterTokenRefresh hook', () => {
113
+ it('logs TOKEN_REFRESH after token refresh', async () => {
114
+ await fortress.auth.createUser({
115
+ email: 'eve@example.com',
116
+ name: 'Eve',
117
+ password: 'password-123456',
118
+ });
119
+
120
+ const loginResult = await fortress.auth.login('eve@example.com', 'password-123456');
121
+ assertSuccess(loginResult);
122
+ await fortress.auth.refresh(loginResult.refreshToken as string);
123
+
124
+ const entries = await getAuditLog();
125
+ const refreshEntry = entries.find(e => e.eventType === 'TOKEN_REFRESH');
126
+
127
+ expect(refreshEntry).toBeDefined();
128
+ expect(refreshEntry!.eventType).toBe('TOKEN_REFRESH');
129
+ expect(refreshEntry!.outcome).toBe('success');
130
+ });
131
+ });
132
+
133
+ describe('audit write failures', () => {
134
+ it('does not report committed register, login, refresh, or logout state as failed', async () => {
135
+ const db = createTestAdapter();
136
+ const legacy = createFortress({
137
+ jwt: { key: SECRET },
138
+ database: db,
139
+ plugins: [auditLog()],
140
+ }).plugins['audit-log'] as AuditLogMethods;
141
+ await legacy.logCustomEvent({ eventType: 'ROLE_CREATED' });
142
+
143
+ const logError = vi.fn();
144
+ const resilientFortress = createFortress({
145
+ jwt: { key: SECRET },
146
+ database: db,
147
+ logger: { ...SILENT_LOGGER, error: logError },
148
+ plugins: [auditLog({ hashChain: true })],
149
+ });
150
+
151
+ const user = await resilientFortress.auth.createUser({
152
+ email: 'resilient@example.com',
153
+ name: 'Resilient',
154
+ password: 'password-123456',
155
+ });
156
+ expect(user.email).toBe('resilient@example.com');
157
+ expect(await db.count({ model: 'user' })).toBe(1);
158
+
159
+ const login = await resilientFortress.auth.login('resilient@example.com', 'password-123456');
160
+ assertSuccess(login);
161
+ const refreshed = await resilientFortress.auth.refresh(login.refreshToken);
162
+ await expect(resilientFortress.auth.logout(refreshed.refreshToken)).resolves.toBeUndefined();
163
+ await expect(resilientFortress.auth.refresh(refreshed.refreshToken)).rejects.toThrow();
164
+ expect(logError).toHaveBeenCalledTimes(4);
165
+ });
166
+
167
+ it('isolates a throwing after-login hook so a completed login still succeeds', async () => {
168
+ // afterLogin hooks run after tokens are issued; a throwing side-effect
169
+ // hook must be logged and skipped, never fail (or orphan) the session.
170
+ const logError = vi.fn();
171
+ const hookFortress = createFortress({
172
+ jwt: { key: SECRET },
173
+ database: createTestAdapter(),
174
+ logger: { ...SILENT_LOGGER, error: logError },
175
+ plugins: [
176
+ auditLog(),
177
+ {
178
+ name: 'unrelated-failure',
179
+ hooks: {
180
+ async afterLogin() {
181
+ throw new Error('unrelated hook failed');
182
+ },
183
+ },
184
+ },
185
+ ],
186
+ });
187
+ await hookFortress.auth.createUser({
188
+ email: 'hook@example.com',
189
+ name: 'Hook',
190
+ password: 'password-123456',
191
+ });
192
+
193
+ const login = await hookFortress.auth.login('hook@example.com', 'password-123456');
194
+ assertSuccess(login);
195
+ expect(login.accessToken).toBeTruthy();
196
+ expect(logError).toHaveBeenCalledWith(
197
+ expect.objectContaining({ plugin: 'unrelated-failure' }),
198
+ 'afterLogin hook failed',
199
+ );
200
+ });
201
+
202
+ it('isolates throwing afterRegister and afterTokenRefresh hooks', async () => {
203
+ const logError = vi.fn();
204
+ const hookFortress = createFortress({
205
+ jwt: { key: SECRET },
206
+ database: createTestAdapter(),
207
+ logger: { ...SILENT_LOGGER, error: logError },
208
+ plugins: [{
209
+ name: 'throwing-side-effects',
210
+ hooks: {
211
+ async afterRegister() { throw new Error('register hook failed'); },
212
+ async afterTokenRefresh() { throw new Error('refresh hook failed'); },
213
+ },
214
+ }],
215
+ });
216
+
217
+ const user = await hookFortress.auth.createUser({
218
+ email: 'sideeffect@example.com',
219
+ name: 'Side Effect',
220
+ password: 'password-123456',
221
+ });
222
+ expect(user.email).toBe('sideeffect@example.com');
223
+
224
+ const login = await hookFortress.auth.login('sideeffect@example.com', 'password-123456');
225
+ assertSuccess(login);
226
+ const refreshed = await hookFortress.auth.refresh(login.refreshToken);
227
+ expect(refreshed.refreshToken).toBeTruthy();
228
+
229
+ expect(logError).toHaveBeenCalledWith(
230
+ expect.objectContaining({ plugin: 'throwing-side-effects' }),
231
+ 'afterRegister hook failed',
232
+ );
233
+ expect(logError).toHaveBeenCalledWith(
234
+ expect.objectContaining({ plugin: 'throwing-side-effects' }),
235
+ 'afterTokenRefresh hook failed',
236
+ );
237
+ });
238
+ });
239
+
240
+ describe('getAuditLog method', () => {
241
+ it('returns entries filtered by userId', async () => {
242
+ const alice = await fortress.auth.createUser({
243
+ email: 'alice2@example.com',
244
+ name: 'Alice',
245
+ password: 'password-123456',
246
+ });
247
+
248
+ await fortress.auth.createUser({
249
+ email: 'bob2@example.com',
250
+ name: 'Bob',
251
+ password: 'password-123456',
252
+ });
253
+
254
+ // Both users get REGISTER entries, filter by Alice's id
255
+ const entries = await getAuditLog({ userId: alice.id });
256
+
257
+ expect(entries.length).toBeGreaterThan(0);
258
+ for (const entry of entries) {
259
+ expect(entry.actorId).toBe(alice.id);
260
+ }
261
+ });
262
+
263
+ it('returns entries filtered by eventType', async () => {
264
+ await fortress.auth.createUser({
265
+ email: 'frank@example.com',
266
+ name: 'Frank',
267
+ password: 'password-123456',
268
+ });
269
+
270
+ await fortress.auth.login('frank@example.com', 'password-123456');
271
+
272
+ const entries = await getAuditLog({ eventType: 'LOGIN_SUCCESS' });
273
+
274
+ expect(entries.length).toBeGreaterThan(0);
275
+ for (const entry of entries) {
276
+ expect(entry.eventType).toBe('LOGIN_SUCCESS');
277
+ }
278
+ });
279
+ });
280
+
281
+ describe('hash chain', () => {
282
+ it('declares string identifiers consistently with the public entry type', () => {
283
+ const model = auditLog().models?.find(candidate => candidate.name === 'audit_log');
284
+ expect(model?.fields.id.type).toBe('string');
285
+ expect(model?.fields.actorId.type).toBe('string');
286
+ expect(model?.fields.targetId.type).toBe('string');
287
+ });
288
+
289
+ it('creates previousHash entries when enabled', async () => {
290
+ const chainFortress = createFortress({
291
+ jwt: { key: SECRET },
292
+ database: createTestAdapter(),
293
+ plugins: [auditLog({ hashChain: true })],
294
+ });
295
+
296
+ const chainGetAuditLog = chainFortress.plugins['audit-log'].getAuditLog as typeof getAuditLog;
297
+
298
+ await chainFortress.auth.createUser({
299
+ email: 'grace@example.com',
300
+ name: 'Grace',
301
+ password: 'password-123456',
302
+ });
303
+
304
+ await chainFortress.auth.login('grace@example.com', 'password-123456');
305
+
306
+ const entries = await chainGetAuditLog();
307
+
308
+ // First entry should have no previousHash
309
+ const sortedEntries = [...entries].sort((a, b) => a.id.localeCompare(b.id));
310
+ expect(sortedEntries[0].previousHash).toBeNull();
311
+
312
+ // Second entry should have a previousHash linking to the first
313
+ expect(sortedEntries[1].previousHash).toBeTruthy();
314
+ expect(typeof sortedEntries[1].previousHash).toBe('string');
315
+ expect(sortedEntries[1].previousHash!.length).toBe(64); // SHA-256 hex is 64 chars
316
+ });
317
+
318
+ it('serializes concurrent writes into one unbranched chain', async () => {
319
+ const chainFortress = createFortress({
320
+ jwt: { key: SECRET },
321
+ database: createTestAdapter(),
322
+ plugins: [auditLog({ hashChain: true })],
323
+ });
324
+ const methods = chainFortress.plugins['audit-log'] as AuditLogMethods;
325
+
326
+ await Promise.all(Array.from({ length: 20 }, (_, index) => methods.logCustomEvent({
327
+ eventType: 'ROLE_CREATED',
328
+ targetId: String(index),
329
+ metadata: { index },
330
+ })));
331
+
332
+ const entries = await methods.getAuditLog();
333
+ expect(entries).toHaveLength(20);
334
+ expect(entries.filter(entry => entry.previousHash == null)).toHaveLength(1);
335
+ expect(new Set(entries.flatMap(entry => entry.previousHash ?? [])).size).toBe(19);
336
+ const anchor = await chainFortress.config.database.findOne<{ entryCount: number; lastHash: string }>({
337
+ model: 'audit_chain_state',
338
+ where: [{ field: 'id', operator: '=', value: '1' }],
339
+ });
340
+ expect(anchor).toMatchObject({ entryCount: 20 });
341
+ expect(anchor?.lastHash).toMatch(SHA256_HEX_RE);
342
+ await expect(methods.verifyChain()).resolves.toMatchObject({ valid: true, totalEntries: 20 });
343
+ });
344
+
345
+ it('appends from the persisted anchor without reading historical entries', async () => {
346
+ const db = createTestAdapter();
347
+ const chainFortress = createFortress({
348
+ jwt: { key: SECRET },
349
+ database: db,
350
+ plugins: [auditLog({ hashChain: true })],
351
+ });
352
+ const methods = chainFortress.plugins['audit-log'] as AuditLogMethods;
353
+ await methods.logCustomEvent({ eventType: 'ROLE_CREATED' });
354
+
355
+ const originalTransaction = db.transaction.bind(db);
356
+ db.transaction = <T>(fn: (tx: DatabaseAdapter) => Promise<T>): Promise<T> => {
357
+ return originalTransaction(tx => fn(new Proxy(tx, {
358
+ get(target, property, receiver) {
359
+ if (property === 'findMany') {
360
+ return (params: { model: string }) => {
361
+ if (params.model === 'audit_log')
362
+ throw new Error('append performed a historical audit-log read');
363
+ return target.findMany(params);
364
+ };
365
+ }
366
+ return Reflect.get(target, property, receiver);
367
+ },
368
+ })));
369
+ };
370
+ await methods.logCustomEvent({ eventType: 'ROLE_UPDATED' });
371
+ await methods.logCustomEvent({ eventType: 'ROLE_DELETED' });
372
+
373
+ expect(await db.count({ model: 'audit_log' })).toBe(3);
374
+ });
375
+
376
+ it('does not serialize audit writes for unrelated database adapters', async () => {
377
+ const blockedDb = createTestAdapter();
378
+ const independentDb = createTestAdapter();
379
+ const originalTransaction = blockedDb.transaction.bind(blockedDb);
380
+ let release!: () => void;
381
+ const gate = new Promise<void>((resolve) => {
382
+ release = resolve;
383
+ });
384
+ let entered!: () => void;
385
+ const started = new Promise<void>((resolve) => {
386
+ entered = resolve;
387
+ });
388
+ blockedDb.transaction = async <T>(fn: (tx: DatabaseAdapter) => Promise<T>): Promise<T> => {
389
+ entered();
390
+ await gate;
391
+ return originalTransaction(fn);
392
+ };
393
+ const blocked = createFortress({
394
+ jwt: { key: SECRET },
395
+ database: blockedDb,
396
+ plugins: [auditLog({ hashChain: true })],
397
+ }).plugins['audit-log'] as AuditLogMethods;
398
+ const independent = createFortress({
399
+ jwt: { key: SECRET },
400
+ database: independentDb,
401
+ plugins: [auditLog({ hashChain: true })],
402
+ }).plugins['audit-log'] as AuditLogMethods;
403
+
404
+ const blockedWrite = blocked.logCustomEvent({ eventType: 'ROLE_CREATED' });
405
+ await started;
406
+ await independent.logCustomEvent({ eventType: 'ROLE_CREATED' });
407
+ expect(await independentDb.count({ model: 'audit_log' })).toBe(1);
408
+ release();
409
+ await blockedWrite;
410
+ });
411
+
412
+ it('transactionally rebaselines a non-empty unchained legacy log', async () => {
413
+ const db = createTestAdapter();
414
+ const legacy = createFortress({
415
+ jwt: { key: SECRET },
416
+ database: db,
417
+ plugins: [auditLog()],
418
+ }).plugins['audit-log'] as AuditLogMethods;
419
+ await legacy.logCustomEvent({ eventType: 'ROLE_CREATED', targetId: '1' });
420
+ await legacy.logCustomEvent({ eventType: 'ROLE_UPDATED', targetId: '2' });
421
+ await legacy.logCustomEvent({ eventType: 'ROLE_DELETED', targetId: '3' });
422
+
423
+ const chained = createFortress({
424
+ jwt: { key: SECRET },
425
+ database: db,
426
+ plugins: [auditLog({ hashChain: true })],
427
+ }).plugins['audit-log'] as AuditLogMethods;
428
+ await expect(chained.verifyChain()).resolves.toMatchObject({ valid: false, totalEntries: 3 });
429
+ await expect(
430
+ chained.logCustomEvent({ eventType: 'PERMISSION_CHANGED' }),
431
+ ).rejects.toThrow('call rebaselineChain() first');
432
+ await expect(chained.rebaselineChain()).resolves.toEqual({
433
+ valid: true,
434
+ totalEntries: 3,
435
+ brokenLinks: [],
436
+ });
437
+ await chained.logCustomEvent({ eventType: 'PERMISSION_CHANGED' });
438
+ await expect(chained.verifyChain()).resolves.toMatchObject({ valid: true, totalEntries: 4 });
439
+ await expect(chained.rebaselineChain()).rejects.toThrow('already been initialized');
440
+ });
441
+
442
+ it('verifies from the same locked snapshot while appends are in flight', async () => {
443
+ const chainFortress = createFortress({
444
+ jwt: { key: SECRET },
445
+ database: createTestAdapter(),
446
+ plugins: [auditLog({ hashChain: true })],
447
+ });
448
+ const methods = chainFortress.plugins['audit-log'] as AuditLogMethods;
449
+ const operations: Array<Promise<ChainVerificationResult | void>> = [];
450
+ for (let index = 0; index < 12; index++) {
451
+ operations.push(methods.logCustomEvent({ eventType: 'ROLE_CREATED', targetId: String(index) }));
452
+ operations.push(methods.verifyChain());
453
+ }
454
+
455
+ const results = await Promise.all(operations);
456
+ const verifications = results.filter((result): result is ChainVerificationResult => result != null);
457
+ expect(verifications).toHaveLength(12);
458
+ expect(verifications.every(result => result.valid)).toBe(true);
459
+ await expect(methods.verifyChain()).resolves.toMatchObject({ valid: true, totalEntries: 12 });
460
+ });
461
+
462
+ it('keeps historical tamper detection in verifyChain without scanning on append', async () => {
463
+ const db = createTestAdapter();
464
+ const chainFortress = createFortress({
465
+ jwt: { key: SECRET },
466
+ database: db,
467
+ plugins: [auditLog({ hashChain: true })],
468
+ });
469
+ const methods = chainFortress.plugins['audit-log'] as AuditLogMethods;
470
+ await methods.logCustomEvent({ eventType: 'ROLE_CREATED', metadata: { state: 'original' } });
471
+ await methods.logCustomEvent({ eventType: 'ROLE_UPDATED', metadata: { state: 'second' } });
472
+ const root = (await methods.getAuditLog()).find(entry => entry.previousHash == null)!;
473
+
474
+ await db.rawQuery!(
475
+ 'UPDATE fortress_audit_log SET metadata = ? WHERE id = ?',
476
+ [JSON.stringify({ state: 'tampered' }), root.id],
477
+ );
478
+
479
+ await expect(methods.verifyChain()).resolves.toMatchObject({ valid: false, totalEntries: 2 });
480
+ await expect(methods.logCustomEvent({ eventType: 'ROLE_DELETED' })).resolves.toBeUndefined();
481
+ await expect(methods.verifyChain()).resolves.toMatchObject({ valid: false, totalEntries: 3 });
482
+ });
483
+
484
+ it('detects deletion of the terminal entry through the persisted anchor', async () => {
485
+ const db = createTestAdapter();
486
+ const chainFortress = createFortress({
487
+ jwt: { key: SECRET },
488
+ database: db,
489
+ plugins: [auditLog({ hashChain: true })],
490
+ });
491
+ const methods = chainFortress.plugins['audit-log'] as AuditLogMethods;
492
+ for (let index = 0; index < 3; index++)
493
+ await methods.logCustomEvent({ eventType: 'ROLE_CREATED', targetId: String(index) });
494
+ const entries = await methods.getAuditLog();
495
+ const tail = entries.reduce((latest, entry) => Number(entry.id) > Number(latest.id) ? entry : latest);
496
+
497
+ await db.delete({
498
+ model: 'audit_log',
499
+ where: [{ field: 'id', operator: '=', value: tail.id }],
500
+ });
501
+
502
+ const verification = await methods.verifyChain();
503
+ expect(verification).toMatchObject({ valid: false, totalEntries: 2 });
504
+ expect(verification.brokenLinks.some(link => link.expected === 'anchor entry count 3')).toBe(true);
505
+ await expect(methods.logCustomEvent({ eventType: 'ROLE_DELETED' })).resolves.toBeUndefined();
506
+ await expect(methods.verifyChain()).resolves.toMatchObject({ valid: false, totalEntries: 3 });
507
+ });
508
+
509
+ it('does not treat deletion of both the chain and anchor as fresh bootstrap', async () => {
510
+ const db = createTestAdapter();
511
+ const chainFortress = createFortress({
512
+ jwt: { key: SECRET },
513
+ database: db,
514
+ plugins: [auditLog({ hashChain: true })],
515
+ });
516
+ const methods = chainFortress.plugins['audit-log'] as AuditLogMethods;
517
+ await methods.logCustomEvent({ eventType: 'ROLE_CREATED' });
518
+ await methods.logCustomEvent({ eventType: 'ROLE_UPDATED' });
519
+ await db.rawQuery!('DELETE FROM fortress_audit_log');
520
+ await db.rawQuery!('DELETE FROM fortress_audit_chain_state');
521
+
522
+ const verification = await methods.verifyChain();
523
+ expect(verification).toMatchObject({ valid: false, totalEntries: 0 });
524
+ expect(verification.brokenLinks).toContainEqual({
525
+ entryId: 'anchor',
526
+ expected: 'persistent zero-entry audit-chain anchor',
527
+ actual: null,
528
+ });
529
+ await expect(
530
+ methods.logCustomEvent({ eventType: 'ROLE_DELETED' }),
531
+ ).rejects.toThrow('valid audit hash-chain anchor');
532
+ expect(await db.count({ model: 'audit_log' })).toBe(0);
533
+ });
534
+ });
535
+
536
+ describe('logCustomEvent method', () => {
537
+ it('logs a custom event', async () => {
538
+ const logCustomEvent = fortress.plugins['audit-log'].logCustomEvent as (event: any) => Promise<void>;
539
+
540
+ await logCustomEvent({
541
+ eventType: 'ROLE_CREATED',
542
+ actorId: '1',
543
+ actorType: 'user',
544
+ targetId: '10',
545
+ targetType: 'role',
546
+ outcome: 'success',
547
+ metadata: { name: 'admin' },
548
+ });
549
+
550
+ const entries = await getAuditLog();
551
+ const entry = entries.find(e => e.eventType === 'ROLE_CREATED');
552
+
553
+ expect(entry).toBeDefined();
554
+ expect(entry!.actorId).toBe('1');
555
+ expect(entry!.targetId).toBe('10');
556
+ expect(entry!.targetType).toBe('role');
557
+ expect(JSON.parse(entry!.metadata!)).toEqual({ name: 'admin' });
558
+ });
559
+
560
+ it('defaults actorType to system when not provided', async () => {
561
+ const logCustomEvent = fortress.plugins['audit-log'].logCustomEvent as (event: any) => Promise<void>;
562
+
563
+ await logCustomEvent({ eventType: 'PERMISSION_CHANGED' });
564
+
565
+ const entries = await getAuditLog();
566
+ const entry = entries.find(e => e.eventType === 'PERMISSION_CHANGED');
567
+ expect(entry!.actorType).toBe('system');
568
+ });
569
+ });
570
+
571
+ describe('verifyChain method', () => {
572
+ it('reports valid chain when hash chain is enabled', async () => {
573
+ const chainFortress = createFortress({
574
+ jwt: { key: SECRET },
575
+ database: createTestAdapter(),
576
+ plugins: [auditLog({ hashChain: true })],
577
+ });
578
+
579
+ const verifyChain = chainFortress.plugins['audit-log'].verifyChain as () => Promise<ChainVerificationResult>;
580
+ const logCustomEvent = chainFortress.plugins['audit-log'].logCustomEvent as (event: any) => Promise<void>;
581
+
582
+ await chainFortress.auth.createUser({ email: 'a@b.com', name: 'A', password: 'password-123456' });
583
+ await chainFortress.auth.login('a@b.com', 'password-123456');
584
+ await logCustomEvent({ eventType: 'ROLE_CREATED', actorId: '1' });
585
+
586
+ const result = await verifyChain();
587
+ expect(result.valid).toBe(true);
588
+ expect(result.totalEntries).toBe(3);
589
+ expect(result.brokenLinks).toHaveLength(0);
590
+ });
591
+
592
+ it('reports empty chain as valid', async () => {
593
+ const verifyChain = fortress.plugins['audit-log'].verifyChain as () => Promise<ChainVerificationResult>;
594
+ const result = await verifyChain();
595
+ expect(result.valid).toBe(true);
596
+ expect(result.totalEntries).toBe(0);
597
+ });
598
+ });
599
+
600
+ describe('iAM event integration', () => {
601
+ it('logs ROLE_CREATED when a role is created', async () => {
602
+ const auditFortress = createFortress({
603
+ jwt: { key: SECRET },
604
+ database: createTestAdapter(),
605
+ plugins: [auditLog()],
606
+ });
607
+
608
+ const auditGetLog = auditFortress.plugins['audit-log'].getAuditLog as typeof getAuditLog;
609
+
610
+ await auditFortress.iam.createRole('editor', [
611
+ { resource: 'post', action: 'read' },
612
+ ]);
613
+
614
+ const entries = await auditGetLog();
615
+ const entry = entries.find(e => e.eventType === 'ROLE_CREATED');
616
+ expect(entry).toBeDefined();
617
+ expect(entry!.targetType).toBe('role');
618
+ });
619
+
620
+ it('logs ROLE_BOUND when a role is bound to a user', async () => {
621
+ const auditFortress = createFortress({
622
+ jwt: { key: SECRET },
623
+ database: createTestAdapter(),
624
+ plugins: [auditLog()],
625
+ });
626
+
627
+ const auditGetLog = auditFortress.plugins['audit-log'].getAuditLog as typeof getAuditLog;
628
+
629
+ const user = await auditFortress.auth.createUser({ email: 'x@y.com', name: 'X', password: 'password-123456' });
630
+ const role = await auditFortress.iam.createRole('viewer', [{ resource: 'post', action: 'read' }]);
631
+ await auditFortress.iam.bindRoleToUser(user.id, role.id);
632
+
633
+ const entries = await auditGetLog();
634
+ const entry = entries.find(e => e.eventType === 'ROLE_BOUND');
635
+ expect(entry).toBeDefined();
636
+ expect(entry!.actorId).toBe(user.id);
637
+ expect(entry!.targetId).toBe(role.id);
638
+ });
639
+
640
+ it('logs GROUP_CREATED when a group is created', async () => {
641
+ const auditFortress = createFortress({
642
+ jwt: { key: SECRET },
643
+ database: createTestAdapter(),
644
+ plugins: [auditLog()],
645
+ });
646
+
647
+ const auditGetLog = auditFortress.plugins['audit-log'].getAuditLog as typeof getAuditLog;
648
+
649
+ await auditFortress.iam.createGroup('editors', 'Content editors');
650
+
651
+ const entries = await auditGetLog();
652
+ const entry = entries.find(e => e.eventType === 'GROUP_CREATED');
653
+ expect(entry).toBeDefined();
654
+ expect(entry!.targetType).toBe('group');
655
+ });
656
+ });
657
+
658
+ describe('exportEntries method', () => {
659
+ it('exports entries as JSON by default', async () => {
660
+ const methods = fortress.plugins['audit-log'] as AuditLogMethods;
661
+
662
+ await fortress.auth.createUser({ email: 'export@example.com', name: 'Export', password: 'password-123456' });
663
+ await fortress.auth.login('export@example.com', 'password-123456');
664
+
665
+ const json = await methods.exportEntries();
666
+ const parsed = JSON.parse(json) as { eventType: string }[];
667
+ expect(parsed.length).toBeGreaterThanOrEqual(1);
668
+ expect(parsed.some(entry => entry.eventType === 'LOGIN_SUCCESS')).toBe(true);
669
+ });
670
+
671
+ it('exports entries as CSV with a stable header row', async () => {
672
+ const methods = fortress.plugins['audit-log'] as AuditLogMethods;
673
+
674
+ await fortress.auth.createUser({ email: 'csv@example.com', name: 'Csv', password: 'password-123456' });
675
+ await fortress.auth.login('csv@example.com', 'password-123456');
676
+
677
+ const csv = await methods.exportEntries('csv');
678
+ const lines = csv.split('\n');
679
+ expect(lines[0]).toBe('id,timestamp,eventType,actorId,actorType,targetId,targetType,ipAddress,userAgent,outcome,metadata,previousHash,createdAt');
680
+ expect(lines.length).toBeGreaterThanOrEqual(2);
681
+ expect(csv).toContain('LOGIN_SUCCESS');
682
+ });
683
+
684
+ it('escapes CSV cells containing commas and quotes (RFC 4180)', async () => {
685
+ const methods = fortress.plugins['audit-log'] as AuditLogMethods;
686
+
687
+ await methods.logCustomEvent({
688
+ eventType: 'ROLE_CREATED',
689
+ metadata: { note: 'a, b "quoted"' },
690
+ });
691
+
692
+ const csv = await methods.exportEntries('csv');
693
+ // The metadata JSON contains a comma and quotes, so the cell must be
694
+ // wrapped and its embedded quotes doubled.
695
+ expect(csv).toContain('""note""');
696
+ });
697
+
698
+ it('neutralizes spreadsheet formula prefixes in CSV cells', async () => {
699
+ const methods = fortress.plugins['audit-log'] as AuditLogMethods;
700
+ const dangerous = ['=cmd', '+cmd', '-cmd', '@cmd', '\tcmd', '\rcmd'];
701
+ await Promise.all(dangerous.map(actorType => methods.logCustomEvent({
702
+ eventType: 'ROLE_CREATED',
703
+ actorType,
704
+ })));
705
+
706
+ const csv = await methods.exportEntries('csv');
707
+ for (const value of dangerous)
708
+ expect(csv).toContain(`'${value}`);
709
+ });
710
+
711
+ it('honours query filters when exporting', async () => {
712
+ const methods = fortress.plugins['audit-log'] as AuditLogMethods;
713
+
714
+ await fortress.auth.createUser({ email: 'filter@example.com', name: 'Filter', password: 'password-123456' });
715
+ await fortress.auth.login('filter@example.com', 'password-123456');
716
+
717
+ const json = await methods.exportEntries('json', { eventType: 'REGISTER' });
718
+ const parsed = JSON.parse(json) as { eventType: string }[];
719
+ expect(parsed.length).toBeGreaterThanOrEqual(1);
720
+ expect(parsed.every(entry => entry.eventType === 'REGISTER')).toBe(true);
721
+ });
722
+ });
723
+
724
+ describe('event filtering', () => {
725
+ it('only logs configured event types when events array is provided', async () => {
726
+ const filteredFortress = createFortress({
727
+ jwt: { key: SECRET },
728
+ database: createTestAdapter(),
729
+ plugins: [auditLog({ events: ['LOGIN_SUCCESS'] })],
730
+ });
731
+
732
+ const filteredGetAuditLog = filteredFortress.plugins['audit-log'].getAuditLog as typeof getAuditLog;
733
+
734
+ await filteredFortress.auth.createUser({
735
+ email: 'heidi@example.com',
736
+ name: 'Heidi',
737
+ password: 'password-123456',
738
+ });
739
+
740
+ await filteredFortress.auth.login('heidi@example.com', 'password-123456');
741
+
742
+ const entries = await filteredGetAuditLog();
743
+
744
+ // Should only have LOGIN_SUCCESS, not REGISTER
745
+ expect(entries.length).toBe(1);
746
+ expect(entries[0].eventType).toBe('LOGIN_SUCCESS');
747
+ });
748
+ });
749
+ });