@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,1539 @@
1
+ import type { Sql } from 'postgres';
2
+ import type { StartedTestContainer } from 'testcontainers';
3
+ import type { DatabaseAdapter } from '../../adapters/database';
4
+ import type { Fortress } from '../../core/fortress';
5
+ import type { PolicyDocument } from '../../core/policy/types';
6
+ import type { FortressUser } from '../../core/types';
7
+ import type { AuditLogMethods } from '../../plugins/audit-log';
8
+ import { drizzle } from 'drizzle-orm/postgres-js';
9
+ import postgres from 'postgres';
10
+ import { GenericContainer, Wait } from 'testcontainers';
11
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
12
+
13
+ import { createFortress } from '../../core/fortress';
14
+ import { applyPolicyPlan } from '../../core/policy/apply';
15
+ import { diffPolicy } from '../../core/policy/diff';
16
+ import { assertSuccess } from '../../core/types';
17
+ import { accountLockout } from '../../plugins/account-lockout';
18
+ import { apiKey } from '../../plugins/api-key';
19
+ import { auditLog } from '../../plugins/audit-log';
20
+ import { dataIsolation } from '../../plugins/data-isolation';
21
+ import { emailVerification } from '../../plugins/email-verification';
22
+ import { magicLink } from '../../plugins/magic-link';
23
+ import { oauth } from '../../plugins/oauth';
24
+ import { socialLogin } from '../../plugins/social-login';
25
+ import { tenancy } from '../../plugins/tenancy';
26
+ import { generateTOTP, twoFactor } from '../../plugins/two-factor';
27
+ import { webhook } from '../../plugins/webhook';
28
+ import { createDrizzleAdapter } from '../adapter';
29
+
30
+ const WEBHOOK_SECRET_PREFIX = /^whsec_/;
31
+
32
+ const CREATE_TABLES_SQL = `
33
+ CREATE TABLE IF NOT EXISTS fortress_user (
34
+ id SERIAL PRIMARY KEY,
35
+ email VARCHAR(255) NOT NULL UNIQUE,
36
+ name VARCHAR(255) NOT NULL,
37
+ password_hash TEXT,
38
+ is_active BOOLEAN NOT NULL DEFAULT true,
39
+ email_verified BOOLEAN NOT NULL DEFAULT false,
40
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
41
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS fortress_login_identifier (
45
+ id SERIAL PRIMARY KEY,
46
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
47
+ type VARCHAR(20) NOT NULL,
48
+ value VARCHAR(255) NOT NULL UNIQUE
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS fortress_refresh_token (
52
+ id SERIAL PRIMARY KEY,
53
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
54
+ token_hash VARCHAR(64) NOT NULL UNIQUE,
55
+ token_family VARCHAR(64) NOT NULL,
56
+ family_created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
57
+ successor_token_hash VARCHAR(64),
58
+ rotated_at TIMESTAMPTZ,
59
+ is_revoked BOOLEAN NOT NULL DEFAULT false,
60
+ expires_at TIMESTAMPTZ NOT NULL,
61
+ ip_address VARCHAR(45),
62
+ user_agent TEXT,
63
+ device_name TEXT,
64
+ last_active_at TIMESTAMPTZ,
65
+ fingerprint_hash VARCHAR(64),
66
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS fortress_auth_continuation (
70
+ id SERIAL PRIMARY KEY,
71
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
72
+ token_hash VARCHAR(64) NOT NULL UNIQUE,
73
+ reason VARCHAR(32) NOT NULL,
74
+ expires_at TIMESTAMPTZ NOT NULL,
75
+ consumed_at TIMESTAMPTZ,
76
+ failed_attempts INTEGER NOT NULL DEFAULT 0,
77
+ last_failed_at TIMESTAMPTZ,
78
+ invalidated_at TIMESTAMPTZ,
79
+ max_attempts INTEGER NOT NULL DEFAULT 5,
80
+ cooldown_seconds INTEGER NOT NULL DEFAULT 1,
81
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
82
+ );
83
+ CREATE INDEX IF NOT EXISTS auth_continuation_failure_idx
84
+ ON fortress_auth_continuation (user_id, reason, last_failed_at);
85
+
86
+ CREATE TABLE IF NOT EXISTS fortress_group (
87
+ id SERIAL PRIMARY KEY,
88
+ name VARCHAR(100) NOT NULL UNIQUE,
89
+ description TEXT
90
+ );
91
+
92
+ CREATE TABLE IF NOT EXISTS fortress_group_user (
93
+ group_id INTEGER NOT NULL REFERENCES fortress_group(id) ON DELETE CASCADE,
94
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
95
+ PRIMARY KEY (group_id, user_id)
96
+ );
97
+
98
+ CREATE TABLE IF NOT EXISTS fortress_service_account (
99
+ id SERIAL PRIMARY KEY,
100
+ name VARCHAR(100) NOT NULL UNIQUE,
101
+ display_name VARCHAR(255),
102
+ description TEXT,
103
+ is_active BOOLEAN NOT NULL DEFAULT true,
104
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
105
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS fortress_resource (
109
+ name VARCHAR(100) PRIMARY KEY,
110
+ description TEXT
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS fortress_permission (
114
+ id SERIAL PRIMARY KEY,
115
+ resource VARCHAR(100) NOT NULL REFERENCES fortress_resource(name) ON DELETE CASCADE,
116
+ action VARCHAR(100) NOT NULL,
117
+ effect VARCHAR(10) NOT NULL DEFAULT 'ALLOW',
118
+ conditions JSONB,
119
+ description TEXT
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS fortress_role (
123
+ id SERIAL PRIMARY KEY,
124
+ name VARCHAR(100) NOT NULL UNIQUE,
125
+ description TEXT,
126
+ is_system BOOLEAN NOT NULL DEFAULT false
127
+ );
128
+
129
+ CREATE TABLE IF NOT EXISTS fortress_role_permission (
130
+ role_id INTEGER NOT NULL REFERENCES fortress_role(id) ON DELETE CASCADE,
131
+ permission_id INTEGER NOT NULL REFERENCES fortress_permission(id) ON DELETE CASCADE,
132
+ PRIMARY KEY (role_id, permission_id)
133
+ );
134
+
135
+ CREATE TABLE IF NOT EXISTS fortress_role_binding (
136
+ id SERIAL PRIMARY KEY,
137
+ role_id INTEGER NOT NULL REFERENCES fortress_role(id) ON DELETE CASCADE,
138
+ subject_type VARCHAR(20) NOT NULL,
139
+ subject_id INTEGER NOT NULL,
140
+ tenant_id VARCHAR(100)
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS fortress_direct_permission_binding (
144
+ id SERIAL PRIMARY KEY,
145
+ permission_id INTEGER NOT NULL REFERENCES fortress_permission(id) ON DELETE CASCADE,
146
+ subject_type VARCHAR(20) NOT NULL,
147
+ subject_id INTEGER NOT NULL,
148
+ tenant_id VARCHAR(100)
149
+ );
150
+
151
+ CREATE TABLE IF NOT EXISTS fortress_email_verification_token (
152
+ id SERIAL PRIMARY KEY,
153
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
154
+ token TEXT NOT NULL,
155
+ email VARCHAR(255) NOT NULL,
156
+ expires_at TIMESTAMPTZ NOT NULL,
157
+ used_at TIMESTAMPTZ,
158
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
159
+ );
160
+
161
+ CREATE TABLE IF NOT EXISTS fortress_magic_link_token (
162
+ id SERIAL PRIMARY KEY,
163
+ email VARCHAR(255) NOT NULL,
164
+ token VARCHAR(64) NOT NULL,
165
+ expires_at TIMESTAMPTZ NOT NULL,
166
+ used_at TIMESTAMPTZ,
167
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
168
+ );
169
+
170
+ CREATE TABLE IF NOT EXISTS fortress_api_key (
171
+ id SERIAL PRIMARY KEY,
172
+ subject_type VARCHAR(20) NOT NULL,
173
+ subject_id INTEGER NOT NULL,
174
+ name VARCHAR(255) NOT NULL,
175
+ key_hash VARCHAR(64) NOT NULL UNIQUE,
176
+ key_prefix VARCHAR(20) NOT NULL,
177
+ scopes TEXT,
178
+ expires_at TIMESTAMPTZ,
179
+ last_used_at TIMESTAMPTZ,
180
+ is_revoked BOOLEAN NOT NULL DEFAULT false,
181
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
182
+ );
183
+ CREATE INDEX IF NOT EXISTS api_key_subject_idx ON fortress_api_key (subject_type, subject_id);
184
+
185
+ CREATE TABLE IF NOT EXISTS fortress_two_factor_secret (
186
+ id SERIAL PRIMARY KEY,
187
+ user_id INTEGER NOT NULL UNIQUE REFERENCES fortress_user(id) ON DELETE CASCADE,
188
+ secret TEXT NOT NULL,
189
+ is_enabled BOOLEAN NOT NULL DEFAULT false,
190
+ last_used_counter INTEGER,
191
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
192
+ );
193
+
194
+ CREATE TABLE IF NOT EXISTS fortress_backup_code (
195
+ id SERIAL PRIMARY KEY,
196
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
197
+ code_hash TEXT NOT NULL,
198
+ is_used BOOLEAN NOT NULL DEFAULT false,
199
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
200
+ );
201
+
202
+ CREATE TABLE IF NOT EXISTS fortress_trusted_device (
203
+ id SERIAL PRIMARY KEY,
204
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
205
+ device_hash VARCHAR(64) NOT NULL,
206
+ expires_at TIMESTAMPTZ NOT NULL,
207
+ last_used_at TIMESTAMPTZ NOT NULL,
208
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
209
+ );
210
+
211
+ CREATE TABLE IF NOT EXISTS fortress_social_account (
212
+ id SERIAL PRIMARY KEY,
213
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
214
+ provider VARCHAR(50) NOT NULL,
215
+ provider_account_id VARCHAR(255) NOT NULL,
216
+ email VARCHAR(255),
217
+ access_token TEXT,
218
+ refresh_token TEXT,
219
+ token_expires_at TIMESTAMPTZ,
220
+ profile JSONB,
221
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
222
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
223
+ );
224
+
225
+ CREATE TABLE IF NOT EXISTS fortress_tenant (
226
+ id SERIAL PRIMARY KEY,
227
+ name VARCHAR(255) NOT NULL,
228
+ tax_id VARCHAR(100) NOT NULL UNIQUE,
229
+ description TEXT,
230
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
231
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
232
+ );
233
+
234
+ CREATE TABLE IF NOT EXISTS fortress_tenant_user (
235
+ tenant_id INTEGER NOT NULL REFERENCES fortress_tenant(id) ON DELETE CASCADE,
236
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
237
+ is_default BOOLEAN NOT NULL DEFAULT false,
238
+ PRIMARY KEY (tenant_id, user_id)
239
+ );
240
+ CREATE UNIQUE INDEX IF NOT EXISTS fortress_tenant_user_one_default_idx
241
+ ON fortress_tenant_user (user_id) WHERE is_default = true;
242
+
243
+ CREATE TABLE IF NOT EXISTS fortress_oauth_client (
244
+ id SERIAL PRIMARY KEY,
245
+ client_id VARCHAR(255) NOT NULL UNIQUE,
246
+ client_secret_hash TEXT NOT NULL,
247
+ name VARCHAR(255) NOT NULL,
248
+ redirect_uris TEXT NOT NULL,
249
+ grant_types TEXT NOT NULL,
250
+ allowed_scopes TEXT,
251
+ token_endpoint_auth_method TEXT,
252
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
253
+ );
254
+
255
+ CREATE TABLE IF NOT EXISTS fortress_oauth_authorization_code (
256
+ id SERIAL PRIMARY KEY,
257
+ code VARCHAR(255) NOT NULL UNIQUE,
258
+ client_id VARCHAR(255) NOT NULL,
259
+ user_id INTEGER NOT NULL,
260
+ redirect_uri TEXT NOT NULL,
261
+ scope TEXT,
262
+ code_challenge TEXT,
263
+ code_challenge_method VARCHAR(10),
264
+ nonce TEXT,
265
+ auth_time INTEGER,
266
+ expires_at TIMESTAMPTZ NOT NULL,
267
+ used_at TIMESTAMPTZ,
268
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
269
+ );
270
+
271
+ CREATE TABLE IF NOT EXISTS fortress_oauth_access_token (
272
+ id SERIAL PRIMARY KEY,
273
+ token VARCHAR(255) NOT NULL UNIQUE,
274
+ client_id VARCHAR(255) NOT NULL,
275
+ user_id INTEGER,
276
+ scope TEXT,
277
+ expires_at TIMESTAMPTZ NOT NULL,
278
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
279
+ );
280
+
281
+ CREATE TABLE IF NOT EXISTS fortress_oauth_refresh_token (
282
+ id SERIAL PRIMARY KEY,
283
+ token VARCHAR(255) NOT NULL UNIQUE,
284
+ family_id VARCHAR(64) NOT NULL,
285
+ client_id VARCHAR(255) NOT NULL,
286
+ user_id INTEGER NOT NULL,
287
+ scope TEXT,
288
+ issued_at TIMESTAMPTZ NOT NULL,
289
+ expires_at TIMESTAMPTZ NOT NULL,
290
+ used_at TIMESTAMPTZ,
291
+ parent_id INTEGER,
292
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
293
+ );
294
+
295
+ CREATE TABLE IF NOT EXISTS fortress_oauth_pending_flow (
296
+ id SERIAL PRIMARY KEY,
297
+ client_id VARCHAR(255) NOT NULL,
298
+ redirect_uri TEXT NOT NULL,
299
+ scope TEXT,
300
+ state VARCHAR(255) NOT NULL,
301
+ code_challenge TEXT,
302
+ code_challenge_method VARCHAR(10),
303
+ nonce TEXT,
304
+ expires_at TIMESTAMPTZ NOT NULL,
305
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
306
+ );
307
+
308
+ CREATE TABLE IF NOT EXISTS fortress_oauth_signing_key (
309
+ id SERIAL PRIMARY KEY,
310
+ kid VARCHAR(64) NOT NULL UNIQUE,
311
+ alg VARCHAR(16) NOT NULL,
312
+ public_jwk TEXT NOT NULL,
313
+ private_jwk TEXT NOT NULL,
314
+ rotated_at TIMESTAMPTZ,
315
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
316
+ );
317
+
318
+ CREATE TABLE IF NOT EXISTS fortress_user_scope_assignment (
319
+ id SERIAL PRIMARY KEY,
320
+ user_id INTEGER NOT NULL REFERENCES fortress_user(id) ON DELETE CASCADE,
321
+ scope_name VARCHAR(100) NOT NULL,
322
+ scope_value VARCHAR(255) NOT NULL,
323
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
324
+ );
325
+
326
+ CREATE TABLE IF NOT EXISTS fortress_account_lockout (
327
+ id SERIAL PRIMARY KEY,
328
+ identifier VARCHAR(255) NOT NULL UNIQUE,
329
+ failed_attempts INTEGER NOT NULL DEFAULT 0,
330
+ last_failed_at TIMESTAMPTZ,
331
+ locked_until TIMESTAMPTZ,
332
+ lockout_count INTEGER NOT NULL DEFAULT 0,
333
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
334
+ );
335
+
336
+ CREATE TABLE IF NOT EXISTS fortress_audit_log (
337
+ id SERIAL PRIMARY KEY,
338
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
339
+ event_type VARCHAR(100) NOT NULL,
340
+ actor_id INTEGER,
341
+ actor_type VARCHAR(20) NOT NULL DEFAULT 'USER',
342
+ target_id INTEGER,
343
+ target_type VARCHAR(50),
344
+ ip_address VARCHAR(45),
345
+ user_agent TEXT,
346
+ outcome VARCHAR(20) NOT NULL DEFAULT 'SUCCESS',
347
+ metadata JSONB,
348
+ previous_hash TEXT,
349
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
350
+ );
351
+
352
+ CREATE TABLE IF NOT EXISTS fortress_audit_chain_state (
353
+ id INTEGER PRIMARY KEY CHECK (id = 1),
354
+ last_hash VARCHAR(64),
355
+ entry_count INTEGER NOT NULL CHECK (entry_count >= 0),
356
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
357
+ CHECK ((entry_count = 0 AND last_hash IS NULL) OR (entry_count > 0 AND last_hash IS NOT NULL))
358
+ );
359
+ INSERT INTO fortress_audit_chain_state (id, last_hash, entry_count)
360
+ VALUES (1, NULL, 0) ON CONFLICT (id) DO NOTHING;
361
+
362
+ CREATE TABLE IF NOT EXISTS fortress_webhook_endpoint (
363
+ id SERIAL PRIMARY KEY,
364
+ url TEXT NOT NULL,
365
+ events TEXT NOT NULL,
366
+ secret TEXT NOT NULL,
367
+ is_active BOOLEAN NOT NULL DEFAULT true,
368
+ deactivated_reason TEXT,
369
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
370
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
371
+ );
372
+
373
+ CREATE TABLE IF NOT EXISTS fortress_webhook_delivery (
374
+ id SERIAL PRIMARY KEY,
375
+ endpoint_id INTEGER NOT NULL REFERENCES fortress_webhook_endpoint(id) ON DELETE CASCADE,
376
+ event_type VARCHAR(100) NOT NULL,
377
+ payload TEXT NOT NULL,
378
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
379
+ attempts INTEGER NOT NULL DEFAULT 0,
380
+ idempotency_key TEXT,
381
+ last_attempt_at TIMESTAMPTZ,
382
+ next_retry_at TIMESTAMPTZ,
383
+ response_status INTEGER,
384
+ response_body TEXT,
385
+ error_kind TEXT,
386
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
387
+ );
388
+
389
+ CREATE UNIQUE INDEX IF NOT EXISTS uniq_webhook_delivery_idempotency
390
+ ON fortress_webhook_delivery (endpoint_id, idempotency_key)
391
+ WHERE idempotency_key IS NOT NULL;
392
+
393
+ CREATE INDEX IF NOT EXISTS refresh_token_family_idx ON fortress_refresh_token (token_family);
394
+ CREATE INDEX IF NOT EXISTS refresh_token_user_idx ON fortress_refresh_token (user_id);
395
+ CREATE INDEX IF NOT EXISTS email_verification_token_token_idx ON fortress_email_verification_token (token);
396
+ CREATE INDEX IF NOT EXISTS magic_link_token_token_idx ON fortress_magic_link_token (token);
397
+ CREATE INDEX IF NOT EXISTS role_binding_subject_idx ON fortress_role_binding (subject_type, subject_id);
398
+ CREATE INDEX IF NOT EXISTS direct_permission_binding_subject_idx ON fortress_direct_permission_binding (subject_type, subject_id);
399
+ CREATE INDEX IF NOT EXISTS backup_code_user_idx ON fortress_backup_code (user_id);
400
+ CREATE INDEX IF NOT EXISTS trusted_device_user_idx ON fortress_trusted_device (user_id);
401
+ CREATE INDEX IF NOT EXISTS webhook_delivery_retry_idx ON fortress_webhook_delivery (status, next_retry_at);
402
+ CREATE INDEX IF NOT EXISTS audit_log_timestamp_idx ON fortress_audit_log (timestamp);
403
+ CREATE UNIQUE INDEX IF NOT EXISTS user_email_ci_unique ON fortress_user (lower(email));
404
+ CREATE UNIQUE INDEX IF NOT EXISTS login_identifier_email_ci_unique
405
+ ON fortress_login_identifier (lower(value)) WHERE type = 'email';
406
+ `;
407
+
408
+ const TRUNCATE_SQL = `
409
+ TRUNCATE
410
+ fortress_webhook_delivery,
411
+ fortress_webhook_endpoint,
412
+ fortress_audit_chain_state,
413
+ fortress_audit_log,
414
+ fortress_account_lockout,
415
+ fortress_user_scope_assignment,
416
+ fortress_oauth_signing_key,
417
+ fortress_oauth_pending_flow,
418
+ fortress_oauth_refresh_token,
419
+ fortress_oauth_access_token,
420
+ fortress_oauth_authorization_code,
421
+ fortress_oauth_client,
422
+ fortress_tenant_user,
423
+ fortress_tenant,
424
+ fortress_social_account,
425
+ fortress_trusted_device,
426
+ fortress_backup_code,
427
+ fortress_two_factor_secret,
428
+ fortress_api_key,
429
+ fortress_magic_link_token,
430
+ fortress_email_verification_token,
431
+ fortress_direct_permission_binding,
432
+ fortress_role_binding,
433
+ fortress_role_permission,
434
+ fortress_permission,
435
+ fortress_resource,
436
+ fortress_role,
437
+ fortress_service_account,
438
+ fortress_group_user,
439
+ fortress_group,
440
+ fortress_auth_continuation,
441
+ fortress_refresh_token,
442
+ fortress_login_identifier,
443
+ fortress_user
444
+ CASCADE;
445
+ INSERT INTO fortress_audit_chain_state (id, last_hash, entry_count)
446
+ VALUES (1, NULL, 0);
447
+ `;
448
+
449
+ const SECRET = 'pg-integration-test-secret-at-least-32!!';
450
+
451
+ let container: StartedTestContainer;
452
+ let pgClient: Sql;
453
+ let db: ReturnType<typeof drizzle>;
454
+
455
+ function createPgAdapter(): DatabaseAdapter {
456
+ return createDrizzleAdapter(db as any, { dialect: 'pg' });
457
+ }
458
+
459
+ beforeAll(async () => {
460
+ container = await new GenericContainer('postgres:16-alpine')
461
+ .withEnvironment({
462
+ POSTGRES_USER: 'test',
463
+ POSTGRES_PASSWORD: 'test',
464
+ POSTGRES_DB: 'fortress_test',
465
+ })
466
+ .withExposedPorts(5432)
467
+ .withWaitStrategy(Wait.forListeningPorts())
468
+ .start();
469
+
470
+ const connectionString = `postgres://test:test@${container.getHost()}:${container.getMappedPort(5432)}/fortress_test`;
471
+ pgClient = postgres(connectionString);
472
+ db = drizzle(pgClient);
473
+
474
+ await pgClient.unsafe(CREATE_TABLES_SQL);
475
+ }, 60_000);
476
+
477
+ afterAll(async () => {
478
+ if (pgClient)
479
+ await pgClient.end();
480
+ if (container)
481
+ await container.stop();
482
+ });
483
+
484
+ beforeEach(async () => {
485
+ await pgClient.unsafe(TRUNCATE_SQL);
486
+ });
487
+
488
+ // --- PG-specific date handling ---
489
+
490
+ describe('pg: date handling', () => {
491
+ it('returns Date objects for timestamp columns', async () => {
492
+ const adapter = createPgAdapter();
493
+ const user = await adapter.create<{ id: string; createdAt: Date; updatedAt: Date }>({
494
+ model: 'user',
495
+ data: { email: 'dates@test.com', name: 'Dates', passwordHash: 'h', isActive: true },
496
+ });
497
+
498
+ expect(user.createdAt).toBeInstanceOf(Date);
499
+ expect(user.updatedAt).toBeInstanceOf(Date);
500
+ });
501
+
502
+ it('round-trips Date objects through create and findOne', async () => {
503
+ const adapter = createPgAdapter();
504
+ const user = await adapter.create<{ id: string }>({
505
+ model: 'user',
506
+ data: { email: 'rt@test.com', name: 'RT', passwordHash: 'h', isActive: true },
507
+ });
508
+
509
+ const expiresAt = new Date('2099-06-15T12:00:00.000Z');
510
+ await adapter.create({
511
+ model: 'refresh_token',
512
+ data: {
513
+ userId: user.id,
514
+ tokenHash: 'pg-date-test',
515
+ tokenFamily: 'fam-pg',
516
+ isRevoked: false,
517
+ expiresAt,
518
+ },
519
+ });
520
+
521
+ const found = await adapter.findOne<{ expiresAt: Date }>({
522
+ model: 'refresh_token',
523
+ where: [{ field: 'tokenHash', operator: '=', value: 'pg-date-test' }],
524
+ });
525
+
526
+ expect(found).not.toBeNull();
527
+ expect(found!.expiresAt).toBeInstanceOf(Date);
528
+ expect(found!.expiresAt.getTime()).toBe(expiresAt.getTime());
529
+ });
530
+
531
+ it('supports date comparison operators (gt, lt)', async () => {
532
+ const adapter = createPgAdapter();
533
+ const user = await adapter.create<{ id: string }>({
534
+ model: 'user',
535
+ data: { email: 'cmp@test.com', name: 'Cmp', passwordHash: 'h', isActive: true },
536
+ });
537
+
538
+ const past = new Date('2020-01-01T00:00:00.000Z');
539
+ const future = new Date('2099-01-01T00:00:00.000Z');
540
+
541
+ await adapter.create({
542
+ model: 'refresh_token',
543
+ data: { userId: user.id, tokenHash: 'expired', tokenFamily: 'f1', isRevoked: false, expiresAt: past },
544
+ });
545
+ await adapter.create({
546
+ model: 'refresh_token',
547
+ data: { userId: user.id, tokenHash: 'valid', tokenFamily: 'f2', isRevoked: false, expiresAt: future },
548
+ });
549
+
550
+ const validTokens = await adapter.findMany<{ tokenHash: string }>({
551
+ model: 'refresh_token',
552
+ where: [{ field: 'expiresAt', operator: 'gt', value: new Date() }],
553
+ });
554
+
555
+ expect(validTokens).toHaveLength(1);
556
+ expect(validTokens[0].tokenHash).toBe('valid');
557
+ });
558
+
559
+ it('handles nullable timestamp columns', async () => {
560
+ const adapter = createPgAdapter();
561
+ const user = await adapter.create<{ id: string }>({
562
+ model: 'user',
563
+ data: { email: 'null-ts@test.com', name: 'NullTs', passwordHash: 'h', isActive: true },
564
+ });
565
+
566
+ await adapter.create({
567
+ model: 'refresh_token',
568
+ data: {
569
+ userId: user.id,
570
+ tokenHash: 'null-ts-test',
571
+ tokenFamily: 'fam',
572
+ isRevoked: false,
573
+ expiresAt: new Date('2099-01-01'),
574
+ lastActiveAt: null,
575
+ },
576
+ });
577
+
578
+ const found = await adapter.findOne<{ lastActiveAt: Date | null }>({
579
+ model: 'refresh_token',
580
+ where: [{ field: 'tokenHash', operator: '=', value: 'null-ts-test' }],
581
+ });
582
+
583
+ expect(found!.lastActiveAt).toBeNull();
584
+
585
+ // Update with a Date
586
+ await adapter.update({
587
+ model: 'refresh_token',
588
+ where: [{ field: 'tokenHash', operator: '=', value: 'null-ts-test' }],
589
+ data: { lastActiveAt: new Date() },
590
+ });
591
+
592
+ const updated = await adapter.findOne<{ lastActiveAt: Date | null }>({
593
+ model: 'refresh_token',
594
+ where: [{ field: 'tokenHash', operator: '=', value: 'null-ts-test' }],
595
+ });
596
+
597
+ expect(updated!.lastActiveAt).toBeInstanceOf(Date);
598
+ });
599
+
600
+ it('handles boolean values natively', async () => {
601
+ const adapter = createPgAdapter();
602
+ const user = await adapter.create<{ id: string; isActive: boolean }>({
603
+ model: 'user',
604
+ data: { email: 'bool@test.com', name: 'Bool', passwordHash: 'h', isActive: false },
605
+ });
606
+
607
+ expect(user.isActive).toBe(false);
608
+
609
+ const found = await adapter.findOne<{ isActive: boolean }>({
610
+ model: 'user',
611
+ where: [{ field: 'isActive', operator: '=', value: false }],
612
+ });
613
+
614
+ expect(found).not.toBeNull();
615
+ expect(found!.isActive).toBe(false);
616
+ });
617
+ });
618
+
619
+ // --- Full auth lifecycle on PG ---
620
+
621
+ describe('pg: auth lifecycle', () => {
622
+ let fortress: Fortress;
623
+
624
+ beforeEach(() => {
625
+ fortress = createFortress({
626
+ jwt: { key: SECRET },
627
+ database: createPgAdapter(),
628
+ });
629
+ });
630
+
631
+ it('creates user and logs in', async () => {
632
+ const user = await fortress.auth.createUser({
633
+ email: 'alice@test.com',
634
+ name: 'Alice',
635
+ password: 'password-123456',
636
+ });
637
+
638
+ expect(user.id).toBeDefined();
639
+ expect(user.email).toBe('alice@test.com');
640
+ expect(user.createdAt).toBeInstanceOf(Date);
641
+
642
+ const result = await fortress.auth.login('alice@test.com', 'password-123456');
643
+ assertSuccess(result);
644
+ expect(result.accessToken).toBeTruthy();
645
+ expect(result.refreshToken).toBeTruthy();
646
+ expect(result.user.email).toBe('alice@test.com');
647
+ });
648
+
649
+ it('serializes concurrent max-session enforcement per user', async () => {
650
+ const database = createPgAdapter();
651
+ const limited = createFortress({
652
+ jwt: { key: SECRET, session: { maxSessionsPerUser: 1 } },
653
+ database,
654
+ });
655
+ await limited.auth.createUser({
656
+ email: 'session-cap-race@test.com',
657
+ name: 'Session Cap Race',
658
+ password: 'password-123456',
659
+ });
660
+
661
+ await Promise.all([
662
+ limited.auth.login('session-cap-race@test.com', 'password-123456'),
663
+ limited.auth.login('session-cap-race@test.com', 'password-123456'),
664
+ limited.auth.login('session-cap-race@test.com', 'password-123456'),
665
+ ]);
666
+
667
+ expect(await database.count({
668
+ model: 'refresh_token',
669
+ where: [{ field: 'isRevoked', operator: '=', value: false }],
670
+ })).toBe(1);
671
+ });
672
+
673
+ it('rejects invalid credentials', async () => {
674
+ await fortress.auth.createUser({
675
+ email: 'bob@test.com',
676
+ name: 'Bob',
677
+ password: 'password-123456',
678
+ });
679
+
680
+ await expect(
681
+ fortress.auth.login('bob@test.com', 'wrong-password'),
682
+ ).rejects.toThrow('Invalid credentials');
683
+ });
684
+
685
+ it('refreshes tokens', async () => {
686
+ await fortress.auth.createUser({
687
+ email: 'refresh@test.com',
688
+ name: 'Refresh',
689
+ password: 'password-123456',
690
+ });
691
+
692
+ const login = await fortress.auth.login('refresh@test.com', 'password-123456');
693
+ assertSuccess(login);
694
+ // Wait 1s so the new JWT has a different iat/exp (JWT timestamps are in seconds)
695
+ await new Promise(r => setTimeout(r, 1100));
696
+ const refreshed = await fortress.auth.refresh(login.refreshToken as string);
697
+
698
+ expect(refreshed.accessToken).toBeTruthy();
699
+ expect(refreshed.refreshToken).toBeTruthy();
700
+ expect(refreshed.accessToken).not.toBe(login.accessToken as string);
701
+ expect(refreshed.refreshToken).not.toBe(login.refreshToken as string);
702
+ });
703
+
704
+ it('detects token reuse after refresh', async () => {
705
+ await fortress.auth.createUser({
706
+ email: 'reuse@test.com',
707
+ name: 'Reuse',
708
+ password: 'password-123456',
709
+ });
710
+
711
+ const login = await fortress.auth.login('reuse@test.com', 'password-123456');
712
+ assertSuccess(login);
713
+ await fortress.auth.refresh(login.refreshToken as string);
714
+
715
+ // Using the old refresh token again should fail
716
+ await expect(
717
+ fortress.auth.refresh(login.refreshToken as string),
718
+ ).rejects.toThrow();
719
+ });
720
+
721
+ it('logs out by revoking refresh token', async () => {
722
+ await fortress.auth.createUser({
723
+ email: 'logout@test.com',
724
+ name: 'Logout',
725
+ password: 'password-123456',
726
+ });
727
+
728
+ const login = await fortress.auth.login('logout@test.com', 'password-123456');
729
+ assertSuccess(login);
730
+ await fortress.auth.logout(login.refreshToken as string);
731
+
732
+ await expect(
733
+ fortress.auth.refresh(login.refreshToken as string),
734
+ ).rejects.toThrow();
735
+ });
736
+
737
+ it('verifies access tokens', async () => {
738
+ await fortress.auth.createUser({
739
+ email: 'verify@test.com',
740
+ name: 'Verify',
741
+ password: 'password-123456',
742
+ });
743
+
744
+ const login = await fortress.auth.login('verify@test.com', 'password-123456');
745
+ assertSuccess(login);
746
+ const claims = await fortress.auth.verifyToken(login.accessToken as string);
747
+
748
+ expect(claims.sub).toBe(login.user.id);
749
+ expect(claims.name).toBe('Verify');
750
+ });
751
+
752
+ it('lists active sessions', async () => {
753
+ await fortress.auth.createUser({
754
+ email: 'sessions@test.com',
755
+ name: 'Sessions',
756
+ password: 'password-123456',
757
+ });
758
+
759
+ const login1 = await fortress.auth.login('sessions@test.com', 'password-123456');
760
+ assertSuccess(login1);
761
+ await fortress.auth.login('sessions@test.com', 'password-123456');
762
+
763
+ const sessions = await fortress.auth.listSessions(login1.user.id);
764
+ expect(sessions.length).toBeGreaterThanOrEqual(2);
765
+ expect(sessions[0].createdAt).toBeInstanceOf(Date);
766
+ });
767
+
768
+ it('keeps the primary identifier synchronized across concurrent email updates', async () => {
769
+ const user = await fortress.auth.createUser({
770
+ email: 'concurrent-email@test.com',
771
+ name: 'Concurrent Email',
772
+ password: 'password-123456',
773
+ });
774
+
775
+ await Promise.all([
776
+ fortress.auth.updateUser(user.id, { email: 'first-email@test.com' }),
777
+ fortress.auth.updateUser(user.id, { email: 'second-email@test.com' }),
778
+ ]);
779
+
780
+ const finalUser = await fortress.auth.getUserById(user.id);
781
+ expect(['first-email@test.com', 'second-email@test.com']).toContain(finalUser.email);
782
+ const emailIdentifiers = (await fortress.auth.getLoginIdentifiers(user.id))
783
+ .filter(identifier => identifier.type === 'email');
784
+ expect(emailIdentifiers).toHaveLength(1);
785
+ expect(emailIdentifiers[0].value).toBe(finalUser.email);
786
+ });
787
+
788
+ it('prevents duplicate user creation', async () => {
789
+ await fortress.auth.createUser({
790
+ email: 'dupe@test.com',
791
+ name: 'Dupe',
792
+ password: 'password-123456',
793
+ });
794
+
795
+ await expect(
796
+ fortress.auth.createUser({ email: 'DUPE@TEST.COM', name: 'Dupe 2', password: 'pass' }),
797
+ ).rejects.toThrow('already exists');
798
+ });
799
+ });
800
+
801
+ // --- IAM on PG ---
802
+
803
+ describe('pg: IAM', () => {
804
+ let fortress: Fortress;
805
+ let user: FortressUser;
806
+
807
+ beforeEach(async () => {
808
+ fortress = createFortress({
809
+ jwt: { key: SECRET },
810
+ database: createPgAdapter(),
811
+ });
812
+
813
+ user = await fortress.auth.createUser({
814
+ email: 'iam@test.com',
815
+ name: 'IAM User',
816
+ password: 'password-123456',
817
+ });
818
+ });
819
+
820
+ it('creates roles with permissions and checks access', async () => {
821
+ const role = await fortress.iam.createRole('editor', [
822
+ { resource: 'post', action: 'read' },
823
+ { resource: 'post', action: 'write' },
824
+ ]);
825
+
826
+ await fortress.iam.bindRoleToUser(user.id, role.id);
827
+
828
+ const canRead = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'read');
829
+ const canWrite = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'write');
830
+ const canDelete = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'delete');
831
+
832
+ expect(canRead).toBe(true);
833
+ expect(canWrite).toBe(true);
834
+ expect(canDelete).toBe(false);
835
+ });
836
+
837
+ it('converges policy pruning after unbinding a retained service account', async () => {
838
+ const initial: PolicyDocument = {
839
+ roles: [{ name: 'pg-old-role', permissions: [] }],
840
+ serviceAccounts: [{ name: 'pg-retained-bot', roles: ['pg-old-role'] }],
841
+ };
842
+ const initialResult = await applyPolicyPlan(await diffPolicy(initial, fortress.iam), fortress.iam);
843
+ expect(initialResult.errors).toEqual([]);
844
+ const next: PolicyDocument = {
845
+ roles: [],
846
+ serviceAccounts: [{ name: 'pg-retained-bot', roles: [] }],
847
+ };
848
+
849
+ const result = await applyPolicyPlan(
850
+ await diffPolicy(next, fortress.iam, { prune: true }),
851
+ fortress.iam,
852
+ );
853
+ expect(result.errors).toEqual([]);
854
+ expect((await diffPolicy(next, fortress.iam, { prune: true })).inSync).toBe(true);
855
+ });
856
+
857
+ it('resolves permissions through group bindings', async () => {
858
+ const group = await fortress.iam.createGroup('admins');
859
+ await fortress.iam.addUserToGroup(group.id, user.id);
860
+
861
+ const role = await fortress.iam.createRole('admin', [
862
+ { resource: 'user', action: 'manage' },
863
+ ]);
864
+ await fortress.iam.bindRoleToGroup(group.id, role.id);
865
+
866
+ const allowed = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'user', 'manage');
867
+ expect(allowed).toBe(true);
868
+ });
869
+ });
870
+
871
+ // --- Plugins on PG ---
872
+
873
+ describe('pg: plugins', () => {
874
+ it('api-key plugin works with PG dates', async () => {
875
+ const fortress = createFortress({
876
+ jwt: { key: SECRET },
877
+ database: createPgAdapter(),
878
+ plugins: [apiKey({ prefix: 'test' })],
879
+ });
880
+
881
+ const user = await fortress.auth.createUser({
882
+ email: 'apikey@test.com',
883
+ name: 'ApiKey',
884
+ password: 'password-123456',
885
+ });
886
+
887
+ const methods = fortress.plugins['api-key'] as any;
888
+ const userSubject = { type: 'USER' as const, id: user.id };
889
+ const { key } = await methods.createKey({
890
+ subject: userSubject,
891
+ name: 'Test Key',
892
+ expiresAt: new Date(Date.now() + 3600000),
893
+ });
894
+
895
+ expect(key).toBeTruthy();
896
+
897
+ const resolved = await methods.resolveKey(key);
898
+ expect(resolved).not.toBeNull();
899
+ expect(resolved.subject.type).toBe('USER');
900
+ expect(resolved.subject.id).toBe(user.id);
901
+
902
+ const keys = await methods.listKeys({ subject: userSubject });
903
+ expect(keys).toHaveLength(1);
904
+ expect(keys[0].expiresAt).toBeInstanceOf(Date);
905
+ expect(keys[0].createdAt).toBeInstanceOf(Date);
906
+ });
907
+
908
+ it('email-verification plugin works with PG dates', async () => {
909
+ let deliveredToken = '';
910
+ const fortress = createFortress({
911
+ jwt: { key: SECRET },
912
+ database: createPgAdapter(),
913
+ plugins: [emailVerification({
914
+ requireVerification: false,
915
+ onSendVerification: async (_email, token) => {
916
+ deliveredToken = token;
917
+ },
918
+ })],
919
+ });
920
+
921
+ const user = await fortress.auth.createUser({
922
+ email: 'verify@test.com',
923
+ name: 'Verify',
924
+ password: 'password-123456',
925
+ });
926
+
927
+ const methods = fortress.plugins['email-verification'] as any;
928
+ await methods.sendVerification(user.id);
929
+ expect(deliveredToken).toBeTruthy();
930
+
931
+ const result = await methods.verify(deliveredToken);
932
+ expect(result.userId).toBe(user.id);
933
+ expect(result.email).toBe('verify@test.com');
934
+ });
935
+
936
+ it('audit-log plugin serializes a hash chain with PG timestamps', async () => {
937
+ const fortress = createFortress({
938
+ jwt: { key: SECRET },
939
+ database: createPgAdapter(),
940
+ plugins: [auditLog({ hashChain: true })],
941
+ });
942
+ const methods = fortress.plugins['audit-log'] as AuditLogMethods;
943
+
944
+ const concurrentVerifications = await Promise.all(Array.from({ length: 12 }, async (_, index) => {
945
+ const append = methods.logCustomEvent({
946
+ eventType: 'ROLE_CREATED',
947
+ targetId: String(index),
948
+ });
949
+ const verification = methods.verifyChain();
950
+ await append;
951
+ return verification;
952
+ }));
953
+ expect(concurrentVerifications.every(result => result.valid)).toBe(true);
954
+ const entries = await methods.getAuditLog();
955
+
956
+ expect(entries).toHaveLength(12);
957
+ expect(entries[0].timestamp).toBeInstanceOf(Date);
958
+ expect(entries[0].createdAt).toBeInstanceOf(Date);
959
+ expect(entries.filter(entry => entry.previousHash == null)).toHaveLength(1);
960
+ await expect(methods.verifyChain()).resolves.toMatchObject({ valid: true, totalEntries: 12 });
961
+ });
962
+ });
963
+
964
+ // --- PG transactions ---
965
+
966
+ describe('pg: transactions', () => {
967
+ it('commits on success with async PG transactions', async () => {
968
+ const adapter = createPgAdapter();
969
+
970
+ await adapter.transaction(async (tx) => {
971
+ await tx.create({ model: 'user', data: { email: 'tx1@test.com', name: 'TX1', passwordHash: 'h', isActive: true } });
972
+ await tx.create({ model: 'user', data: { email: 'tx2@test.com', name: 'TX2', passwordHash: 'h', isActive: true } });
973
+ });
974
+
975
+ const count = await adapter.count({ model: 'user' });
976
+ expect(count).toBe(2);
977
+ });
978
+
979
+ it('rolls back on error with async PG transactions', async () => {
980
+ const adapter = createPgAdapter();
981
+
982
+ await expect(
983
+ adapter.transaction(async (tx) => {
984
+ await tx.create({ model: 'user', data: { email: 'rollback@test.com', name: 'RB', passwordHash: 'h', isActive: true } });
985
+ throw new Error('forced rollback');
986
+ }),
987
+ ).rejects.toThrow('forced rollback');
988
+
989
+ const count = await adapter.count({ model: 'user' });
990
+ expect(count).toBe(0);
991
+ });
992
+ });
993
+
994
+ // --- Sorting ---
995
+
996
+ describe('pg: sorting', () => {
997
+ it('sorts by date field', async () => {
998
+ const adapter = createPgAdapter();
999
+
1000
+ await adapter.create({ model: 'user', data: { email: 'z@test.com', name: 'Z', passwordHash: 'h', isActive: true } });
1001
+ await adapter.create({ model: 'user', data: { email: 'a@test.com', name: 'A', passwordHash: 'h', isActive: true } });
1002
+
1003
+ const ascending = await adapter.findMany<{ email: string }>({
1004
+ model: 'user',
1005
+ sortBy: { field: 'email', direction: 'asc' },
1006
+ });
1007
+
1008
+ expect(ascending[0].email).toBe('a@test.com');
1009
+ expect(ascending[1].email).toBe('z@test.com');
1010
+
1011
+ const descending = await adapter.findMany<{ email: string }>({
1012
+ model: 'user',
1013
+ sortBy: { field: 'email', direction: 'desc' },
1014
+ });
1015
+
1016
+ expect(descending[0].email).toBe('z@test.com');
1017
+ expect(descending[1].email).toBe('a@test.com');
1018
+ });
1019
+ });
1020
+
1021
+ // --- Tenancy plugin on PG ---
1022
+
1023
+ describe('pg: tenancy plugin', () => {
1024
+ it('creates tenants and manages membership with PG dates', async () => {
1025
+ const fortress = createFortress({
1026
+ jwt: { key: SECRET },
1027
+ database: createPgAdapter(),
1028
+ plugins: [tenancy()],
1029
+ });
1030
+
1031
+ const user = await fortress.auth.createUser({
1032
+ email: 'tenant@test.com',
1033
+ name: 'Tenant User',
1034
+ password: 'password-123456',
1035
+ });
1036
+
1037
+ const tenant = await fortress.plugins.tenancy.createTenant({
1038
+ name: 'Acme Corp',
1039
+ taxId: 'acme',
1040
+ });
1041
+
1042
+ expect(tenant.id).toBeDefined();
1043
+ expect(tenant.name).toBe('Acme Corp');
1044
+ expect(tenant.createdAt).toBeInstanceOf(Date);
1045
+ expect(tenant.updatedAt).toBeInstanceOf(Date);
1046
+
1047
+ await fortress.plugins.tenancy.addUserToTenant(user.id, tenant.id);
1048
+
1049
+ const tenants = await fortress.plugins.tenancy.getUserTenants(user.id);
1050
+ expect(tenants).toHaveLength(1);
1051
+ expect(tenants[0].taxId).toBe('acme');
1052
+ expect(tenants[0].createdAt).toBeInstanceOf(Date);
1053
+ });
1054
+
1055
+ it('switches defaults in both directions under the partial unique index', async () => {
1056
+ const fortress = createFortress({
1057
+ jwt: { key: SECRET },
1058
+ database: createPgAdapter(),
1059
+ plugins: [tenancy()],
1060
+ });
1061
+ const user = await fortress.auth.createUser({
1062
+ email: 'tenant-switch@test.com',
1063
+ name: 'Tenant Switch',
1064
+ password: 'password-123456',
1065
+ });
1066
+ const first = await fortress.plugins.tenancy.createTenant({ name: 'First', taxId: 'first' });
1067
+ const second = await fortress.plugins.tenancy.createTenant({ name: 'Second', taxId: 'second' });
1068
+ await fortress.plugins.tenancy.addUserToTenant(user.id, first.id);
1069
+ await fortress.plugins.tenancy.addUserToTenant(user.id, second.id);
1070
+
1071
+ await fortress.plugins.tenancy.switchTenant({ taxId: 'second', userId: user.id });
1072
+ await fortress.plugins.tenancy.switchTenant({ taxId: 'first', userId: user.id });
1073
+
1074
+ const defaults = await fortress.config.database.findMany<{ tenantId: string }>({
1075
+ model: 'tenant_user',
1076
+ where: [
1077
+ { field: 'userId', operator: '=', value: user.id },
1078
+ { field: 'isDefault', operator: '=', value: true },
1079
+ ],
1080
+ });
1081
+ expect(defaults).toHaveLength(1);
1082
+ expect(defaults[0].tenantId).toBe(first.id);
1083
+ });
1084
+
1085
+ it('enriches JWT claims with tenant info after login', async () => {
1086
+ const fortress = createFortress({
1087
+ jwt: { key: SECRET },
1088
+ database: createPgAdapter(),
1089
+ plugins: [tenancy()],
1090
+ });
1091
+
1092
+ const user = await fortress.auth.createUser({
1093
+ email: 'claims@test.com',
1094
+ name: 'Claims User',
1095
+ password: 'password-123456',
1096
+ });
1097
+
1098
+ const tenant = await fortress.plugins.tenancy.createTenant({
1099
+ name: 'Beta Inc',
1100
+ taxId: 'beta',
1101
+ });
1102
+ await fortress.plugins.tenancy.addUserToTenant(user.id, tenant.id);
1103
+
1104
+ const login = await fortress.auth.login('claims@test.com', 'password-123456');
1105
+ assertSuccess(login);
1106
+ const claims = await fortress.auth.verifyToken(login.accessToken as string);
1107
+
1108
+ expect(claims.customClaims?.tenantId).toBe(tenant.id);
1109
+ expect(claims.customClaims?.tenantCode).toBe('beta');
1110
+ });
1111
+
1112
+ it('switches default tenant', async () => {
1113
+ const fortress = createFortress({
1114
+ jwt: { key: SECRET },
1115
+ database: createPgAdapter(),
1116
+ plugins: [tenancy()],
1117
+ });
1118
+
1119
+ const user = await fortress.auth.createUser({
1120
+ email: 'switch@test.com',
1121
+ name: 'Switch User',
1122
+ password: 'password-123456',
1123
+ });
1124
+
1125
+ const t1 = await fortress.plugins.tenancy.createTenant({ name: 'T1', taxId: 't1' });
1126
+ const t2 = await fortress.plugins.tenancy.createTenant({ name: 'T2', taxId: 't2' });
1127
+ await fortress.plugins.tenancy.addUserToTenant(user.id, t1.id);
1128
+ await fortress.plugins.tenancy.addUserToTenant(user.id, t2.id);
1129
+
1130
+ await fortress.plugins.tenancy.switchTenant({ taxId: 't2', userId: user.id });
1131
+
1132
+ const login = await fortress.auth.login('switch@test.com', 'password-123456');
1133
+ assertSuccess(login);
1134
+ const claims = await fortress.auth.verifyToken(login.accessToken as string);
1135
+ expect(claims.customClaims?.tenantCode).toBe('t2');
1136
+ });
1137
+
1138
+ it('isolates tenant data via the transaction-pinned search_path (H2/H3)', async () => {
1139
+ // Each tenant gets an `items` table in its own schema via onSchemaCreated.
1140
+ const fortress = createFortress({
1141
+ jwt: { key: SECRET },
1142
+ database: createPgAdapter(),
1143
+ plugins: [
1144
+ tenancy({
1145
+ onSchemaCreated: async (schemaName, rawQuery) => {
1146
+ await rawQuery(`CREATE TABLE IF NOT EXISTS ${schemaName}.items (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`);
1147
+ },
1148
+ }),
1149
+ ],
1150
+ });
1151
+
1152
+ const tenantPlugin = fortress.config.plugins![0];
1153
+ const base = fortress.config.database;
1154
+
1155
+ const tA = await fortress.plugins.tenancy.createTenant({ name: 'A', taxId: 'iso-a' });
1156
+ const tB = await fortress.plugins.tenancy.createTenant({ name: 'B', taxId: 'iso-b' });
1157
+
1158
+ const dbA = tenantPlugin.wrapAdapter!(base, { tenantId: tA.id });
1159
+ const dbB = tenantPlugin.wrapAdapter!(base, { tenantId: tB.id });
1160
+
1161
+ // Writes route to each tenant's schema because the wrapped transaction pins
1162
+ // `search_path` on the same connection before the unqualified INSERT runs.
1163
+ await dbA.transaction(async tx => tx.rawQuery!(`INSERT INTO items (name) VALUES ('a-only')`));
1164
+ await dbB.transaction(async tx => tx.rawQuery!(`INSERT INTO items (name) VALUES ('b-only')`));
1165
+
1166
+ const rowsA = await dbA.transaction(async tx => tx.rawQuery!<{ name: string }>(`SELECT name FROM items`));
1167
+ const rowsB = await dbB.transaction(async tx => tx.rawQuery!<{ name: string }>(`SELECT name FROM items`));
1168
+
1169
+ // The load-bearing assertion: A cannot see B's rows and vice versa.
1170
+ expect(rowsA.map(r => r.name)).toEqual(['a-only']);
1171
+ expect(rowsB.map(r => r.name)).toEqual(['b-only']);
1172
+
1173
+ // Fail closed: with no tenant claim, wrapAdapter is a pass-through. The
1174
+ // unqualified `items` table is not on the public search_path → it errors
1175
+ // rather than silently reading another tenant's schema.
1176
+ const dbNone = tenantPlugin.wrapAdapter!(base, {});
1177
+ expect(dbNone).toBe(base);
1178
+ await expect(
1179
+ dbNone.transaction(async tx => tx.rawQuery!(`SELECT name FROM items`)),
1180
+ ).rejects.toThrow();
1181
+ });
1182
+
1183
+ it('drops the tenant schema on delete only when opted in', async () => {
1184
+ const fortress = createFortress({
1185
+ jwt: { key: SECRET },
1186
+ database: createPgAdapter(),
1187
+ plugins: [
1188
+ tenancy({
1189
+ dropSchemaOnDelete: true,
1190
+ onSchemaCreated: async (schemaName, rawQuery) => {
1191
+ await rawQuery(`CREATE TABLE IF NOT EXISTS ${schemaName}.items (id SERIAL PRIMARY KEY)`);
1192
+ },
1193
+ }),
1194
+ ],
1195
+ });
1196
+
1197
+ const tenant = await fortress.plugins.tenancy.createTenant({ name: 'Drop', taxId: 'drop-me' });
1198
+ const schemaName = `tenant_${tenant.id}`;
1199
+
1200
+ const before = await pgClient.unsafe(
1201
+ `SELECT 1 FROM information_schema.schemata WHERE schema_name = '${schemaName}'`,
1202
+ );
1203
+ expect(before).toHaveLength(1);
1204
+
1205
+ await fortress.plugins.tenancy.deleteTenant({ id: tenant.id });
1206
+
1207
+ const after = await pgClient.unsafe(
1208
+ `SELECT 1 FROM information_schema.schemata WHERE schema_name = '${schemaName}'`,
1209
+ );
1210
+ expect(after).toHaveLength(0);
1211
+ });
1212
+ });
1213
+
1214
+ // --- Two-Factor plugin on PG ---
1215
+
1216
+ describe('pg: two-factor plugin', () => {
1217
+ it('enables, verifies, and disables 2FA with PG dates', async () => {
1218
+ const fortress = createFortress({
1219
+ jwt: { key: SECRET },
1220
+ database: createPgAdapter(),
1221
+ plugins: [twoFactor({ secretEncryptionKey: 'p'.repeat(32), totp: { issuer: 'PGTest' }, backupCodes: { count: 5 } })],
1222
+ });
1223
+
1224
+ const user = await fortress.auth.createUser({
1225
+ email: '2fa@test.com',
1226
+ name: '2FA User',
1227
+ password: 'password-123456',
1228
+ });
1229
+
1230
+ const methods = fortress.plugins['two-factor'] as any;
1231
+ const setup = await methods.enable(user.id);
1232
+
1233
+ expect(setup.secret).toBeTruthy();
1234
+ expect(setup.otpauthUrl).toContain('otpauth://totp/');
1235
+ expect(setup.backupCodes).toHaveLength(5);
1236
+
1237
+ // Verify with a real TOTP code
1238
+ const code = await generateTOTP(setup.secret, 30, 6);
1239
+ const result = await methods.confirmSetup(user.id, code);
1240
+ expect(result.verified).toBe(true);
1241
+
1242
+ // Disable 2FA
1243
+ await methods.disable(user.id);
1244
+
1245
+ // Should be able to enable again after disable
1246
+ const setup2 = await methods.enable(user.id);
1247
+ expect(setup2.secret).toBeTruthy();
1248
+ });
1249
+ });
1250
+
1251
+ // --- Magic Link plugin on PG ---
1252
+
1253
+ describe('pg: magic-link plugin', () => {
1254
+ it('sends and verifies magic link with PG timestamps', async () => {
1255
+ let capturedToken = '';
1256
+ const fortress = createFortress({
1257
+ jwt: { key: SECRET },
1258
+ database: createPgAdapter(),
1259
+ plugins: [magicLink({
1260
+ onSendMagicLink: async (_email, token) => { capturedToken = token; },
1261
+ })],
1262
+ });
1263
+
1264
+ // Create user so magic link can find them
1265
+ await fortress.auth.createUser({
1266
+ email: 'magic@test.com',
1267
+ name: 'Magic User',
1268
+ password: 'password-123456',
1269
+ });
1270
+
1271
+ const methods = fortress.plugins['magic-link'] as any;
1272
+ const sendResult = await methods.sendMagicLink('magic@test.com');
1273
+ expect(sendResult.sent).toBe(true);
1274
+ expect(capturedToken).toBeTruthy();
1275
+
1276
+ const verifyResult = await methods.verify(capturedToken);
1277
+ expect(verifyResult.user.email).toBe('magic@test.com');
1278
+ expect(verifyResult.accessToken).toBeTruthy();
1279
+ });
1280
+ });
1281
+
1282
+ // --- OAuth plugin on PG ---
1283
+
1284
+ describe('pg: oauth plugin', () => {
1285
+ it('creates client, issues auth code, and exchanges for token', async () => {
1286
+ const fortress = createFortress({
1287
+ jwt: { key: SECRET },
1288
+ database: createPgAdapter(),
1289
+ plugins: [oauth({ issuerUrl: 'http://localhost:3000' })],
1290
+ });
1291
+
1292
+ const user = await fortress.auth.createUser({
1293
+ email: 'oauth@test.com',
1294
+ name: 'OAuth User',
1295
+ password: 'password-123456',
1296
+ });
1297
+
1298
+ const methods = fortress.plugins.oauth as any;
1299
+
1300
+ // Create client
1301
+ const client = await methods.createClient({
1302
+ name: 'Test App',
1303
+ redirectUris: ['http://localhost:3000/callback'],
1304
+ grantTypes: ['authorization_code'],
1305
+ });
1306
+ expect(client.clientId).toBeTruthy();
1307
+ expect(client.clientSecret).toBeTruthy();
1308
+
1309
+ // Create auth code
1310
+ const { code } = await methods.createAuthorizationCode({
1311
+ clientId: client.clientId,
1312
+ userId: user.id,
1313
+ redirectUri: 'http://localhost:3000/callback',
1314
+ scope: 'read:users',
1315
+ });
1316
+ expect(code).toBeTruthy();
1317
+
1318
+ // Exchange code for token
1319
+ const tokenResult = await methods.exchangeCode({
1320
+ code,
1321
+ clientId: client.clientId,
1322
+ clientSecret: client.clientSecret,
1323
+ redirectUri: 'http://localhost:3000/callback',
1324
+ });
1325
+ expect(tokenResult.accessToken).toBeTruthy();
1326
+ expect(tokenResult.tokenType).toBe('Bearer');
1327
+ expect(tokenResult.expiresIn).toBeGreaterThan(0);
1328
+
1329
+ // Introspect token
1330
+ const introspection = await methods.introspectToken(tokenResult.accessToken);
1331
+ expect(introspection.active).toBe(true);
1332
+ expect(introspection.clientId).toBe(client.clientId);
1333
+ });
1334
+ });
1335
+
1336
+ // --- Social Login plugin on PG ---
1337
+
1338
+ describe('pg: social-login plugin', () => {
1339
+ it('round-trips JSONB profile data through PG', async () => {
1340
+ const adapter = createPgAdapter();
1341
+
1342
+ const user = await adapter.create<{ id: string }>({
1343
+ model: 'user',
1344
+ data: { email: 'social@test.com', name: 'Social', passwordHash: 'h', isActive: true },
1345
+ });
1346
+
1347
+ // Insert social account with JSONB profile
1348
+ const profile = { displayName: 'Social User', avatar: 'https://example.com/avatar.png', locale: 'en' };
1349
+ await adapter.create({
1350
+ model: 'social_account',
1351
+ data: {
1352
+ userId: user.id,
1353
+ provider: 'google',
1354
+ providerAccountId: 'google-123',
1355
+ email: 'social@test.com',
1356
+ accessToken: 'at',
1357
+ refreshToken: 'rt',
1358
+ tokenExpiresAt: new Date('2099-01-01'),
1359
+ profile: JSON.stringify(profile),
1360
+ },
1361
+ });
1362
+
1363
+ // Read back and verify JSONB round-trip
1364
+ const found = await adapter.findOne<{
1365
+ provider: string;
1366
+ profile: Record<string, unknown>;
1367
+ tokenExpiresAt: Date;
1368
+ createdAt: Date;
1369
+ }>({
1370
+ model: 'social_account',
1371
+ where: [{ field: 'userId', operator: '=', value: user.id }],
1372
+ });
1373
+
1374
+ expect(found).not.toBeNull();
1375
+ expect(found!.provider).toBe('google');
1376
+ expect(found!.tokenExpiresAt).toBeInstanceOf(Date);
1377
+ expect(found!.createdAt).toBeInstanceOf(Date);
1378
+ // PG returns JSONB as a parsed object, not a string
1379
+ expect(found!.profile).toEqual(profile);
1380
+ });
1381
+
1382
+ it('getLinkedAccounts works with PG adapter', async () => {
1383
+ const adapter = createPgAdapter();
1384
+
1385
+ const plugin = socialLogin({
1386
+ providers: [
1387
+ { name: 'google', clientId: 'id', clientSecret: 'secret' },
1388
+ ],
1389
+ });
1390
+
1391
+ const methods = plugin.methods!({
1392
+ db: adapter,
1393
+ config: { jwt: { key: SECRET }, database: adapter },
1394
+ }) as any;
1395
+
1396
+ const user = await adapter.create<{ id: string }>({
1397
+ model: 'user',
1398
+ data: { email: 'linked@test.com', name: 'Linked', passwordHash: 'h', isActive: true },
1399
+ });
1400
+
1401
+ await adapter.create({
1402
+ model: 'social_account',
1403
+ data: {
1404
+ userId: user.id,
1405
+ provider: 'google',
1406
+ providerAccountId: 'g-456',
1407
+ email: 'linked@test.com',
1408
+ profile: null,
1409
+ },
1410
+ });
1411
+
1412
+ const accounts = await methods.getLinkedAccounts(user.id);
1413
+ expect(accounts).toHaveLength(1);
1414
+ expect(accounts[0].provider).toBe('google');
1415
+ expect(accounts[0].providerAccountId).toBe('g-456');
1416
+ });
1417
+ });
1418
+
1419
+ // --- Account Lockout plugin on PG ---
1420
+
1421
+ describe('pg: account-lockout plugin', () => {
1422
+ it('locks out after failed attempts and returns PG timestamps', async () => {
1423
+ const fortress = createFortress({
1424
+ jwt: { key: SECRET },
1425
+ database: createPgAdapter(),
1426
+ plugins: [accountLockout({ maxFailedAttempts: 2, lockoutDurationSeconds: 60 })],
1427
+ });
1428
+
1429
+ await fortress.auth.createUser({
1430
+ email: 'lockout@test.com',
1431
+ name: 'Lockout',
1432
+ password: 'password-123456',
1433
+ });
1434
+
1435
+ // Trigger failed logins
1436
+ await fortress.auth.login('lockout@test.com', 'wrong').catch(() => {});
1437
+ await fortress.auth.login('lockout@test.com', 'wrong').catch(() => {});
1438
+
1439
+ const methods = fortress.plugins['account-lockout'] as any;
1440
+ const status = await methods.getLockoutStatus('lockout@test.com');
1441
+
1442
+ expect(status.isLocked).toBe(true);
1443
+ expect(status.failedAttempts).toBe(2);
1444
+ expect(status.lockedUntil).toBeInstanceOf(Date);
1445
+ expect(status.lastFailedAt).toBeInstanceOf(Date);
1446
+
1447
+ // Login should be blocked
1448
+ await expect(
1449
+ fortress.auth.login('lockout@test.com', 'password-123456'),
1450
+ ).rejects.toThrow();
1451
+
1452
+ // Reset lockout
1453
+ await methods.resetLockout('lockout@test.com');
1454
+ const cleared = await methods.getLockoutStatus('lockout@test.com');
1455
+ expect(cleared.isLocked).toBe(false);
1456
+ });
1457
+ });
1458
+
1459
+ // --- Webhook plugin on PG ---
1460
+
1461
+ describe('pg: webhook plugin', () => {
1462
+ it('registers endpoint and records delivery with PG timestamps', async () => {
1463
+ const delivered: string[] = [];
1464
+ const fortress = createFortress({
1465
+ jwt: { key: SECRET },
1466
+ database: createPgAdapter(),
1467
+ plugins: [webhook({
1468
+ delivery: {
1469
+ fetch: async (req) => {
1470
+ delivered.push(await req.text());
1471
+ return new Response(null, { status: 200 });
1472
+ },
1473
+ },
1474
+ })],
1475
+ });
1476
+
1477
+ const methods = fortress.plugins.webhook as any;
1478
+
1479
+ const endpoint = await methods.registerEndpoint(
1480
+ 'https://example.com/hook',
1481
+ ['auth.login.success'],
1482
+ );
1483
+ expect(endpoint.id).toBeDefined();
1484
+ expect(endpoint.secret).toMatch(WEBHOOK_SECRET_PREFIX);
1485
+ expect(endpoint.createdAt).toBeInstanceOf(Date);
1486
+
1487
+ const endpoints = await methods.listEndpoints();
1488
+ expect(endpoints).toHaveLength(1);
1489
+ expect(endpoints[0].createdAt).toBeInstanceOf(Date);
1490
+ expect(endpoints[0].secret).toBeUndefined(); // redacted
1491
+
1492
+ // Trigger a webhook delivery via login
1493
+ await fortress.auth.createUser({
1494
+ email: 'wh@test.com',
1495
+ name: 'WH',
1496
+ password: 'password-123456',
1497
+ });
1498
+ await fortress.auth.login('wh@test.com', 'password-123456');
1499
+
1500
+ // Delivery is queued out-of-band; wait for it.
1501
+ for (let i = 0; i < 100 && delivered.length < 1; i++)
1502
+ await new Promise(resolve => setTimeout(resolve, 10));
1503
+
1504
+ expect(delivered.length).toBeGreaterThanOrEqual(1);
1505
+ const payload = JSON.parse(delivered[0]);
1506
+ expect(payload.event).toBe('auth.login.success');
1507
+
1508
+ await methods.stop();
1509
+ });
1510
+ });
1511
+
1512
+ // --- Data Isolation plugin on PG ---
1513
+
1514
+ describe('pg: data-isolation plugin', () => {
1515
+ it('generates scope rules that filter queries on PG', async () => {
1516
+ const adapter = createPgAdapter();
1517
+
1518
+ const plugin = dataIsolation({
1519
+ scopes: [{
1520
+ name: 'active-only',
1521
+ field: 'isActive',
1522
+ models: ['user'],
1523
+ resolveValue: async () => true,
1524
+ }],
1525
+ });
1526
+
1527
+ // Create users with different isActive values
1528
+ await adapter.create({ model: 'user', data: { email: 'active@test.com', name: 'Active', passwordHash: 'h', isActive: true } });
1529
+ await adapter.create({ model: 'user', data: { email: 'inactive@test.com', name: 'Inactive', passwordHash: 'h', isActive: false } });
1530
+
1531
+ // Get scope rules
1532
+ const rules = await plugin.scopeRules!('1', 'user', { db: adapter, config: { jwt: { key: SECRET }, database: adapter } });
1533
+
1534
+ expect(rules).not.toBeNull();
1535
+ expect(rules!.filters).toHaveLength(1);
1536
+ expect(rules!.filters[0]).toEqual({ field: 'isActive', operator: '=', value: true });
1537
+ expect(rules!.defaults).toEqual({ isActive: true });
1538
+ });
1539
+ });