@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,829 @@
1
+ import type { Fortress } from '../fortress';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { createTestAdapter } from '../../testing';
4
+ import { createFortress } from '../fortress';
5
+
6
+ const SECRET = 'iam-service-test-secret-32chars!';
7
+
8
+ describe('iam-service: isSystem flag', () => {
9
+ let fortress: Fortress;
10
+
11
+ beforeEach(() => {
12
+ fortress = createFortress({
13
+ jwt: { key: SECRET },
14
+ database: createTestAdapter(),
15
+ });
16
+ });
17
+
18
+ it('creates a role with isSystem flag', async () => {
19
+ const role = await fortress.iam.createRole(
20
+ 'super-admin',
21
+ [{ resource: 'users', action: 'read' }],
22
+ 'System admin role',
23
+ );
24
+
25
+ expect(role.id).toBeDefined();
26
+ expect(role.name).toBe('super-admin');
27
+ // By default isSystem should be false (or falsy in SQLite)
28
+ expect(role.isSystem).toBeFalsy();
29
+ });
30
+
31
+ it('deleteRole throws for system roles', async () => {
32
+ // Create a role, then manually set isSystem via raw DB
33
+ const role = await fortress.iam.createRole(
34
+ 'built-in-admin',
35
+ [{ resource: 'users', action: 'manage' }],
36
+ );
37
+
38
+ // Mark as system role via database update
39
+ await fortress.config.database.update({
40
+ model: 'role',
41
+ where: [{ field: 'id', operator: '=', value: role.id }],
42
+ data: { isSystem: true },
43
+ });
44
+
45
+ await expect(fortress.iam.deleteRole(role.id)).rejects.toThrow(
46
+ 'Cannot delete a system role',
47
+ );
48
+ });
49
+
50
+ it('deleteRole succeeds for non-system roles', async () => {
51
+ const role = await fortress.iam.createRole(
52
+ 'temp-role',
53
+ [{ resource: 'reports', action: 'read' }],
54
+ );
55
+
56
+ await expect(fortress.iam.deleteRole(role.id)).resolves.toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe('inline permissions (direct binding)', () => {
61
+ let fortress: Fortress;
62
+
63
+ beforeEach(() => {
64
+ fortress = createFortress({
65
+ jwt: { key: SECRET },
66
+ database: createTestAdapter(),
67
+ });
68
+ });
69
+
70
+ it('bindPermissionToUser is idempotent for global bindings', async () => {
71
+ const user = await fortress.auth.createUser({
72
+ email: 'idem-direct@test.com',
73
+ name: 'Idem Direct',
74
+ password: 'password-123456',
75
+ });
76
+
77
+ await fortress.iam.bindPermissionToUser(user.id, { resource: 'post', action: 'read' });
78
+ await fortress.iam.bindPermissionToUser(user.id, { resource: 'post', action: 'read' });
79
+
80
+ const perms = await fortress.config.database.findMany<{ id: string }>({
81
+ model: 'permission',
82
+ where: [
83
+ { field: 'resource', operator: '=', value: 'post' },
84
+ { field: 'action', operator: '=', value: 'read' },
85
+ ],
86
+ });
87
+ const count = await fortress.config.database.count({
88
+ model: 'direct_permission_binding',
89
+ where: [
90
+ { field: 'permissionId', operator: '=', value: perms[0].id },
91
+ { field: 'subjectType', operator: '=', value: 'USER' },
92
+ { field: 'subjectId', operator: '=', value: user.id },
93
+ { field: 'tenantId', operator: 'isNull', value: null },
94
+ ],
95
+ });
96
+ expect(count).toBe(1);
97
+ });
98
+
99
+ it('bindPermissionToUser grants access without a role', async () => {
100
+ const user = await fortress.auth.createUser({
101
+ email: 'alice@test.com',
102
+ name: 'Alice',
103
+ password: 'password-123456',
104
+ });
105
+
106
+ await fortress.iam.bindPermissionToUser(user.id, { resource: 'post', action: 'read' });
107
+
108
+ const allowed = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'read');
109
+ expect(allowed).toBe(true);
110
+
111
+ const denied = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'delete');
112
+ expect(denied).toBe(false);
113
+ });
114
+
115
+ it('bindPermissionToGroup grants access to group members', async () => {
116
+ const user = await fortress.auth.createUser({
117
+ email: 'bob@test.com',
118
+ name: 'Bob',
119
+ password: 'password-123456',
120
+ });
121
+
122
+ const group = await fortress.iam.createGroup('editors');
123
+ await fortress.iam.addUserToGroup(group.id, user.id);
124
+ await fortress.iam.bindPermissionToGroup(group.id, { resource: 'post', action: 'update' });
125
+
126
+ const allowed = await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'update');
127
+ expect(allowed).toBe(true);
128
+ });
129
+
130
+ it('unbindPermissionFromUser revokes access', async () => {
131
+ const user = await fortress.auth.createUser({
132
+ email: 'carol@test.com',
133
+ name: 'Carol',
134
+ password: 'password-123456',
135
+ });
136
+
137
+ await fortress.iam.bindPermissionToUser(user.id, { resource: 'post', action: 'read' });
138
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'read')).toBe(true);
139
+
140
+ const perms = await fortress.iam.getPermissionsForSubject({ type: 'USER', id: user.id });
141
+ const perm = perms.find(p => p.resource === 'post' && p.action === 'read');
142
+ await fortress.iam.unbindPermissionFromUser(user.id, perm!.id);
143
+
144
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'read')).toBe(false);
145
+ });
146
+
147
+ it('bindRoleToUser is idempotent for global bindings', async () => {
148
+ const user = await fortress.auth.createUser({
149
+ email: 'idem-role@test.com',
150
+ name: 'Idem Role',
151
+ password: 'password-123456',
152
+ });
153
+ const role = await fortress.iam.createRole('idem-viewer', [{ resource: 'post', action: 'read' }]);
154
+
155
+ await fortress.iam.bindRoleToUser(user.id, role.id);
156
+ await fortress.iam.bindRoleToUser(user.id, role.id);
157
+
158
+ const count = await fortress.config.database.count({
159
+ model: 'role_binding',
160
+ where: [
161
+ { field: 'roleId', operator: '=', value: role.id },
162
+ { field: 'subjectType', operator: '=', value: 'USER' },
163
+ { field: 'subjectId', operator: '=', value: user.id },
164
+ { field: 'tenantId', operator: 'isNull', value: null },
165
+ ],
166
+ });
167
+ expect(count).toBe(1);
168
+ });
169
+
170
+ it('direct and role-based permissions combine', async () => {
171
+ const user = await fortress.auth.createUser({
172
+ email: 'dave@test.com',
173
+ name: 'Dave',
174
+ password: 'password-123456',
175
+ });
176
+
177
+ const role = await fortress.iam.createRole('viewer', [{ resource: 'post', action: 'read' }]);
178
+ await fortress.iam.bindRoleToUser(user.id, role.id);
179
+ await fortress.iam.bindPermissionToUser(user.id, { resource: 'post', action: 'update' });
180
+
181
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'read')).toBe(true);
182
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'update')).toBe(true);
183
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'post', 'delete')).toBe(false);
184
+ });
185
+ });
186
+
187
+ describe('tenant-scoped IAM', () => {
188
+ let fortress: Fortress;
189
+
190
+ beforeEach(() => {
191
+ fortress = createFortress({
192
+ jwt: { key: SECRET },
193
+ database: createTestAdapter(),
194
+ });
195
+ });
196
+
197
+ it('user can have different roles per tenant', async () => {
198
+ const user = await fortress.auth.createUser({
199
+ email: 'alice@test.com',
200
+ name: 'Alice',
201
+ password: 'password-123456',
202
+ });
203
+
204
+ const adminRole = await fortress.iam.createRole('admin', [
205
+ { resource: 'settings', action: 'write' },
206
+ ]);
207
+ const viewerRole = await fortress.iam.createRole('viewer', [
208
+ { resource: 'settings', action: 'read' },
209
+ ]);
210
+
211
+ await fortress.iam.bindRoleToUser(user.id, adminRole.id, 'tenant-a');
212
+ await fortress.iam.bindRoleToUser(user.id, viewerRole.id, 'tenant-b');
213
+
214
+ // Admin in tenant A
215
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'settings', 'write', { tenantId: 'tenant-a' })).toBe(true);
216
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'settings', 'read', { tenantId: 'tenant-a' })).toBe(false);
217
+
218
+ // Viewer in tenant B
219
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'settings', 'write', { tenantId: 'tenant-b' })).toBe(false);
220
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'settings', 'read', { tenantId: 'tenant-b' })).toBe(true);
221
+ });
222
+
223
+ it('unbindRole distinguishes global-only null from omitted all-scopes', async () => {
224
+ const user = await fortress.auth.createUser({
225
+ email: 'tri-state@example.com',
226
+ name: 'Tri State',
227
+ password: 'password-123456',
228
+ });
229
+ const role = await fortress.iam.createRole('tri-state-role', [{ resource: 'reports', action: 'read' }]);
230
+ await fortress.iam.bindRole('USER', user.id, role.id);
231
+ await fortress.iam.bindRole('USER', user.id, role.id, 'tenant-a');
232
+
233
+ await fortress.iam.unbindRole('USER', user.id, role.id, null);
234
+ expect(await fortress.config.database.count({
235
+ model: 'role_binding',
236
+ where: [
237
+ { field: 'subjectType', operator: '=', value: 'USER' },
238
+ { field: 'subjectId', operator: '=', value: user.id },
239
+ { field: 'roleId', operator: '=', value: role.id },
240
+ ],
241
+ })).toBe(1);
242
+
243
+ await fortress.iam.unbindRole('USER', user.id, role.id);
244
+ expect(await fortress.config.database.count({
245
+ model: 'role_binding',
246
+ where: [
247
+ { field: 'subjectType', operator: '=', value: 'USER' },
248
+ { field: 'subjectId', operator: '=', value: user.id },
249
+ { field: 'roleId', operator: '=', value: role.id },
250
+ ],
251
+ })).toBe(0);
252
+ });
253
+
254
+ it('global role bindings apply across all tenants', async () => {
255
+ const user = await fortress.auth.createUser({
256
+ email: 'bob@test.com',
257
+ name: 'Bob',
258
+ password: 'password-123456',
259
+ });
260
+
261
+ const globalRole = await fortress.iam.createRole('auditor', [
262
+ { resource: 'audit', action: 'read' },
263
+ ]);
264
+
265
+ // Bind without tenantId → global
266
+ await fortress.iam.bindRoleToUser(user.id, globalRole.id);
267
+
268
+ // Should be accessible in any tenant context
269
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'audit', 'read', { tenantId: 'tenant-a' })).toBe(true);
270
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'audit', 'read', { tenantId: 'tenant-b' })).toBe(true);
271
+ // And without tenant context
272
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'audit', 'read')).toBe(true);
273
+ });
274
+
275
+ it('tenant-scoped direct permission binding', async () => {
276
+ const user = await fortress.auth.createUser({
277
+ email: 'carol@test.com',
278
+ name: 'Carol',
279
+ password: 'password-123456',
280
+ });
281
+
282
+ await fortress.iam.bindPermissionToUser(user.id, { resource: 'billing', action: 'manage' }, 'tenant-a');
283
+
284
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'billing', 'manage', { tenantId: 'tenant-a' })).toBe(true);
285
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'billing', 'manage', { tenantId: 'tenant-b' })).toBe(false);
286
+ });
287
+
288
+ it('getUserPermissions filters by tenant', async () => {
289
+ const user = await fortress.auth.createUser({
290
+ email: 'dave@test.com',
291
+ name: 'Dave',
292
+ password: 'password-123456',
293
+ });
294
+
295
+ const roleA = await fortress.iam.createRole('role-a', [{ resource: 'docs', action: 'write' }]);
296
+ const roleB = await fortress.iam.createRole('role-b', [{ resource: 'docs', action: 'read' }]);
297
+
298
+ await fortress.iam.bindRoleToUser(user.id, roleA.id, 'tenant-a');
299
+ await fortress.iam.bindRoleToUser(user.id, roleB.id, 'tenant-b');
300
+
301
+ const permsA = await fortress.iam.getPermissionsForSubject({ type: 'USER', id: user.id }, 'tenant-a');
302
+ expect(permsA).toHaveLength(1);
303
+ expect(permsA[0].action).toBe('write');
304
+
305
+ const permsB = await fortress.iam.getPermissionsForSubject({ type: 'USER', id: user.id }, 'tenant-b');
306
+ expect(permsB).toHaveLength(1);
307
+ expect(permsB[0].action).toBe('read');
308
+ });
309
+ });
310
+
311
+ // ── Admin CRUD ────────────────────────────────────────────────────
312
+
313
+ describe('iam-service: admin CRUD', () => {
314
+ let fortress: Fortress;
315
+ let database: ReturnType<typeof createTestAdapter>;
316
+
317
+ beforeEach(() => {
318
+ database = createTestAdapter();
319
+ fortress = createFortress({
320
+ jwt: { key: SECRET },
321
+ database,
322
+ });
323
+ });
324
+
325
+ // ── getRole ──────────────────────────────────────────────────
326
+
327
+ describe('getRole', () => {
328
+ it('returns role with permissions', async () => {
329
+ const role = await fortress.iam.createRole('editor', [
330
+ { resource: 'post', action: 'read' },
331
+ { resource: 'post', action: 'update' },
332
+ ]);
333
+
334
+ const result = await fortress.iam.getRole(role.id);
335
+
336
+ expect(result.name).toBe('editor');
337
+ expect(result.permissions).toHaveLength(2);
338
+ expect(result.permissions.map(p => p.action).sort()).toEqual(['read', 'update']);
339
+ });
340
+
341
+ it('throws NOT_FOUND for missing role', async () => {
342
+ await expect(fortress.iam.getRole('99999')).rejects.toThrow('Role not found');
343
+ });
344
+ });
345
+
346
+ // ── updateRole ───────────────────────────────────────────────
347
+
348
+ describe('updateRole', () => {
349
+ it('updates role name', async () => {
350
+ const role = await fortress.iam.createRole('old-name', []);
351
+
352
+ const updated = await fortress.iam.updateRole(role.id, { name: 'new-name' });
353
+
354
+ expect(updated.name).toBe('new-name');
355
+ });
356
+
357
+ it('throws for system roles', async () => {
358
+ const role = await fortress.iam.createRole('sys-role', []);
359
+ await fortress.config.database.update({
360
+ model: 'role',
361
+ where: [{ field: 'id', operator: '=', value: role.id }],
362
+ data: { isSystem: true },
363
+ });
364
+
365
+ await expect(fortress.iam.updateRole(role.id, { name: 'hacked' })).rejects.toThrow(
366
+ 'Cannot update a system role',
367
+ );
368
+ });
369
+
370
+ it('throws NOT_FOUND for missing role', async () => {
371
+ await expect(fortress.iam.updateRole('99999', { name: 'x' })).rejects.toThrow('Role not found');
372
+ });
373
+ });
374
+
375
+ // ── listGroups ───────────────────────────────────────────────
376
+
377
+ describe('listGroups', () => {
378
+ it('returns paginated groups with total', async () => {
379
+ await fortress.iam.createGroup('group-a');
380
+ await fortress.iam.createGroup('group-b');
381
+ await fortress.iam.createGroup('group-c');
382
+
383
+ const result = await fortress.iam.listGroups({ limit: 2, offset: 0 });
384
+
385
+ expect(result.groups).toHaveLength(2);
386
+ expect(result.total).toBe(3);
387
+ });
388
+
389
+ it('returns empty when no groups', async () => {
390
+ const result = await fortress.iam.listGroups();
391
+
392
+ expect(result.groups).toEqual([]);
393
+ expect(result.total).toBe(0);
394
+ });
395
+ });
396
+
397
+ // ── getGroup ─────────────────────────────────────────────────
398
+
399
+ describe('getGroup', () => {
400
+ it('returns group with users', async () => {
401
+ const group = await fortress.iam.createGroup('devs');
402
+ const user = await fortress.auth.createUser({
403
+ email: 'dev@test.com',
404
+ name: 'Dev',
405
+ password: 'password-123456',
406
+ });
407
+ await fortress.iam.addUserToGroup(group.id, user.id);
408
+
409
+ const result = await fortress.iam.getGroup(group.id);
410
+
411
+ expect(result.name).toBe('devs');
412
+ expect(result.users).toHaveLength(1);
413
+ expect(result.users[0].email).toBe('dev@test.com');
414
+ expect(result.users[0]).not.toHaveProperty('passwordHash');
415
+ });
416
+
417
+ it('throws NOT_FOUND for missing group', async () => {
418
+ await expect(fortress.iam.getGroup('99999')).rejects.toThrow('Group not found');
419
+ });
420
+ });
421
+
422
+ // ── updateGroup ──────────────────────────────────────────────
423
+
424
+ describe('updateGroup', () => {
425
+ it('updates group name and emits GROUP_UPDATED', async () => {
426
+ const group = await fortress.iam.createGroup('old-name');
427
+ const events: string[] = [];
428
+ fortress.iam.addIamObserver((event) => {
429
+ events.push(event.eventType);
430
+ });
431
+
432
+ const updated = await fortress.iam.updateGroup(group.id, { name: 'new-name' });
433
+
434
+ expect(updated.name).toBe('new-name');
435
+ expect(events).toEqual(['GROUP_UPDATED']);
436
+ });
437
+
438
+ it('throws NOT_FOUND for missing group', async () => {
439
+ await expect(fortress.iam.updateGroup('99999', { name: 'x' })).rejects.toThrow('Group not found');
440
+ });
441
+ });
442
+
443
+ // ── deleteGroup ──────────────────────────────────────────────
444
+
445
+ describe('deleteGroup', () => {
446
+ it('atomically deletes group dependants and emits GROUP_DELETED', async () => {
447
+ const group = await fortress.iam.createGroup('temp');
448
+ const user = await fortress.auth.createUser({
449
+ email: 'group-delete@test.com',
450
+ name: 'Group Delete',
451
+ password: 'password-123456',
452
+ });
453
+ const role = await fortress.iam.createRole('group-role', [{ resource: 'group-test', action: 'read' }]);
454
+ const permission = await fortress.iam.createPermission({ resource: 'group-test', action: 'write' });
455
+ await fortress.iam.addUserToGroup(group.id, user.id);
456
+ await fortress.iam.bindRoleToGroup(group.id, role.id);
457
+ await fortress.iam.bindPermissionToGroup(group.id, permission);
458
+ const events: string[] = [];
459
+ fortress.iam.addIamObserver((event) => {
460
+ events.push(event.eventType);
461
+ });
462
+
463
+ await fortress.iam.deleteGroup(group.id);
464
+
465
+ const result = await fortress.iam.listGroups();
466
+ expect(result.total).toBe(0);
467
+ await expect(database.count({ model: 'group_user' })).resolves.toBe(0);
468
+ await expect(database.count({ model: 'role_binding' })).resolves.toBe(0);
469
+ await expect(database.count({ model: 'direct_permission_binding' })).resolves.toBe(0);
470
+ expect(events).toEqual(['GROUP_DELETED']);
471
+ });
472
+
473
+ it('serializes concurrent GROUP binding so deletion cannot leave an orphan', async () => {
474
+ const raceDatabase = createTestAdapter();
475
+ const originalTransaction = raceDatabase.transaction.bind(raceDatabase);
476
+ let markGroupDeleted!: () => void;
477
+ const groupDeleted = new Promise<void>((resolve) => {
478
+ markGroupDeleted = resolve;
479
+ });
480
+ let releaseDelete!: () => void;
481
+ const deleteGate = new Promise<void>((resolve) => {
482
+ releaseDelete = resolve;
483
+ });
484
+ raceDatabase.transaction = callback => originalTransaction(async (tx) => {
485
+ const wrapped = {
486
+ ...tx,
487
+ async delete(options: Parameters<typeof tx.delete>[0]) {
488
+ const result = await tx.delete(options);
489
+ if (options.model === 'group') {
490
+ markGroupDeleted();
491
+ await deleteGate;
492
+ }
493
+ return result;
494
+ },
495
+ };
496
+ return callback(wrapped);
497
+ });
498
+ const raceFortress = createFortress({ jwt: { key: SECRET }, database: raceDatabase });
499
+ const group = await raceFortress.iam.createGroup('race-group');
500
+ const role = await raceFortress.iam.createRole('race-role', []);
501
+
502
+ const deletion = raceFortress.iam.deleteGroup(group.id);
503
+ await groupDeleted;
504
+ const binding = raceFortress.iam.bindRoleToGroup(group.id, role.id);
505
+ releaseDelete();
506
+
507
+ await deletion;
508
+ await expect(binding).rejects.toThrow('Group not found');
509
+ await expect(raceDatabase.count({ model: 'role_binding' })).resolves.toBe(0);
510
+ });
511
+
512
+ it('emits one deletion event under concurrent duplicate deletes', async () => {
513
+ const group = await fortress.iam.createGroup('duplicate-delete');
514
+ const events: string[] = [];
515
+ fortress.iam.addIamObserver((event) => {
516
+ events.push(event.eventType);
517
+ });
518
+
519
+ const results = await Promise.allSettled([
520
+ fortress.iam.deleteGroup(group.id),
521
+ fortress.iam.deleteGroup(group.id),
522
+ ]);
523
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
524
+ expect(events).toEqual(['GROUP_DELETED']);
525
+ });
526
+
527
+ it('throws NOT_FOUND for missing group', async () => {
528
+ await expect(fortress.iam.deleteGroup('99999')).rejects.toThrow('Group not found');
529
+ });
530
+ });
531
+
532
+ // ── getGroupUsers ────────────────────────────────────────────
533
+
534
+ describe('getGroupUsers', () => {
535
+ it('returns group members without passwordHash', async () => {
536
+ const group = await fortress.iam.createGroup('team');
537
+ const user = await fortress.auth.createUser({
538
+ email: 'member@test.com',
539
+ name: 'Member',
540
+ password: 'password-123456',
541
+ });
542
+ await fortress.iam.addUserToGroup(group.id, user.id);
543
+
544
+ const users = await fortress.iam.getGroupUsers(group.id);
545
+
546
+ expect(users).toHaveLength(1);
547
+ expect(users[0].email).toBe('member@test.com');
548
+ expect(users[0]).not.toHaveProperty('passwordHash');
549
+ });
550
+
551
+ it('returns empty for group with no members', async () => {
552
+ const group = await fortress.iam.createGroup('empty');
553
+
554
+ const users = await fortress.iam.getGroupUsers(group.id);
555
+
556
+ expect(users).toEqual([]);
557
+ });
558
+ });
559
+
560
+ // ── listPermissions ──────────────────────────────────────────
561
+
562
+ describe('listPermissions', () => {
563
+ it('lists all permissions', async () => {
564
+ await fortress.iam.createRole('role1', [
565
+ { resource: 'post', action: 'read' },
566
+ { resource: 'comment', action: 'write' },
567
+ ]);
568
+
569
+ const perms = await fortress.iam.listPermissions();
570
+
571
+ expect(perms.length).toBeGreaterThanOrEqual(2);
572
+ });
573
+
574
+ it('filters by resource', async () => {
575
+ await fortress.iam.createRole('role2', [
576
+ { resource: 'post', action: 'read' },
577
+ { resource: 'comment', action: 'write' },
578
+ ]);
579
+
580
+ const perms = await fortress.iam.listPermissions({ resource: 'post' });
581
+
582
+ expect(perms.every(p => p.resource === 'post')).toBe(true);
583
+ });
584
+ });
585
+
586
+ // ── createPermission ─────────────────────────────────────────
587
+
588
+ describe('createPermission', () => {
589
+ it('emits PERMISSION_CREATED only for the inserting call', async () => {
590
+ const events: string[] = [];
591
+ fortress.iam.addIamObserver((event) => {
592
+ events.push(event.eventType);
593
+ });
594
+ const [perm, existing] = await Promise.all([
595
+ fortress.iam.createPermission({ resource: 'invoice', action: 'create' }),
596
+ fortress.iam.createPermission({ resource: 'invoice', action: 'create' }),
597
+ ]);
598
+
599
+ expect(perm.id).toBeDefined();
600
+ expect(perm.resource).toBe('invoice');
601
+ expect(perm.action).toBe('create');
602
+ expect(existing.id).toBe(perm.id);
603
+ expect(events).toEqual(['PERMISSION_CREATED']);
604
+ });
605
+ });
606
+
607
+ // ── deletePermission ─────────────────────────────────────────
608
+
609
+ describe('deletePermission', () => {
610
+ it('deletes a permission and emits PERMISSION_DELETED', async () => {
611
+ const perm = await fortress.iam.createPermission({
612
+ resource: 'invoice',
613
+ action: 'delete',
614
+ });
615
+ const events: string[] = [];
616
+ fortress.iam.addIamObserver((event) => {
617
+ events.push(event.eventType);
618
+ });
619
+
620
+ await fortress.iam.deletePermission(perm.id);
621
+
622
+ const remaining = await fortress.iam.listPermissions({ resource: 'invoice' });
623
+ expect(remaining.find(p => p.action === 'delete')).toBeUndefined();
624
+ expect(events).toEqual(['PERMISSION_DELETED']);
625
+ });
626
+
627
+ it('throws NOT_FOUND for missing permission', async () => {
628
+ await expect(fortress.iam.deletePermission('99999')).rejects.toThrow('Permission not found');
629
+ });
630
+ });
631
+
632
+ // ── addPermissionToRole ──────────────────────────────────────
633
+
634
+ describe('addPermissionToRole', () => {
635
+ it('adds permission to existing role', async () => {
636
+ const role = await fortress.iam.createRole('base', [{ resource: 'post', action: 'read' }]);
637
+
638
+ await fortress.iam.addPermissionToRole(role.id, { resource: 'post', action: 'write' });
639
+
640
+ const detail = await fortress.iam.getRole(role.id);
641
+ expect(detail.permissions).toHaveLength(2);
642
+ expect(detail.permissions.map(p => p.action).sort()).toEqual(['read', 'write']);
643
+ });
644
+
645
+ it('is idempotent (adding same permission twice)', async () => {
646
+ const role = await fortress.iam.createRole('idem', [{ resource: 'post', action: 'read' }]);
647
+
648
+ await fortress.iam.addPermissionToRole(role.id, { resource: 'post', action: 'read' });
649
+
650
+ const detail = await fortress.iam.getRole(role.id);
651
+ expect(detail.permissions).toHaveLength(1);
652
+ });
653
+
654
+ it('throws NOT_FOUND for missing role', async () => {
655
+ await expect(
656
+ fortress.iam.addPermissionToRole('99999', { resource: 'x', action: 'y' }),
657
+ ).rejects.toThrow('Role not found');
658
+ });
659
+ });
660
+ });
661
+
662
+ describe('remediation regressions (P3.1, P3.3)', () => {
663
+ it('deleteRole invalidates the permission cache (P3.1/M6)', async () => {
664
+ // With caching enabled, a global checkPermission warms the cache. Without
665
+ // the deleteRole invalidation, the granted decision would survive in cache
666
+ // for up to the TTL even after the role (and its access) is gone.
667
+ const fortress = createFortress({
668
+ jwt: { key: SECRET },
669
+ database: createTestAdapter(),
670
+ rbac: { cache: { ttlSeconds: 300 } },
671
+ });
672
+ const user = await fortress.auth.createUser({
673
+ email: 'cache@test.com',
674
+ name: 'Cache',
675
+ password: 'password-123456',
676
+ });
677
+ const role = await fortress.iam.createRole('reporter', [{ resource: 'reports', action: 'read' }]);
678
+ await fortress.iam.bindRoleToUser(user.id, role.id);
679
+
680
+ // Warm the cache with a granted decision.
681
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'reports', 'read')).toBe(true);
682
+
683
+ await fortress.iam.deleteRole(role.id);
684
+
685
+ // Must be denied immediately — not after the 300s TTL.
686
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'reports', 'read')).toBe(false);
687
+ });
688
+
689
+ it('generic bindRole invalidates cached decisions', async () => {
690
+ const fortress = createFortress({
691
+ jwt: { key: SECRET },
692
+ database: createTestAdapter(),
693
+ rbac: { evaluationMode: 'deny-overrides', cache: { ttlSeconds: 300 } },
694
+ });
695
+ const user = await fortress.auth.createUser({
696
+ email: 'generic-bind-cache@test.com',
697
+ name: 'Generic Bind Cache',
698
+ password: 'password-123456',
699
+ });
700
+ const allow = await fortress.iam.createRole('allow-report', [{ resource: 'report', action: 'read' }]);
701
+ await fortress.iam.bindRoleToUser(user.id, allow.id);
702
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'report', 'read')).toBe(true);
703
+
704
+ const deny = await fortress.iam.createRole('deny-report', [{ resource: 'report', action: 'read', effect: 'DENY' }]);
705
+ await fortress.iam.bindRole('USER', user.id, deny.id);
706
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'report', 'read')).toBe(false);
707
+ });
708
+
709
+ it('permission identity includes effect (ALLOW and DENY are distinct rows)', async () => {
710
+ const fortress = createFortress({
711
+ jwt: { key: SECRET },
712
+ database: createTestAdapter(),
713
+ });
714
+ const role = await fortress.iam.createRole('effect-identity', [
715
+ { resource: 'doc', action: 'archive', effect: 'ALLOW' },
716
+ { resource: 'doc', action: 'archive', effect: 'DENY' },
717
+ ]);
718
+
719
+ const detail = await fortress.iam.getRole(role.id);
720
+ expect(detail.permissions).toHaveLength(2);
721
+ expect(detail.permissions.map(p => p.effect).sort()).toEqual(['ALLOW', 'DENY']);
722
+
723
+ const count = await fortress.config.database.count({
724
+ model: 'permission',
725
+ where: [
726
+ { field: 'resource', operator: '=', value: 'doc' },
727
+ { field: 'action', operator: '=', value: 'archive' },
728
+ ],
729
+ });
730
+ expect(count).toBe(2);
731
+ });
732
+
733
+ it('permission identity includes stable serialized conditions', async () => {
734
+ const fortress = createFortress({
735
+ jwt: { key: SECRET },
736
+ database: createTestAdapter(),
737
+ });
738
+ const role = await fortress.iam.createRole('conditions-identity', [
739
+ {
740
+ resource: 'invoice',
741
+ action: 'read',
742
+ conditions: [{ field: 'resource.region', operator: 'eq', value: 'eu' }],
743
+ },
744
+ {
745
+ resource: 'invoice',
746
+ action: 'read',
747
+ conditions: [{ field: 'resource.region', operator: 'eq', value: 'us' }],
748
+ },
749
+ ]);
750
+
751
+ const detail = await fortress.iam.getRole(role.id);
752
+ expect(detail.permissions).toHaveLength(2);
753
+
754
+ const rows = await fortress.config.database.findMany<{ conditions: string }>({
755
+ model: 'permission',
756
+ where: [
757
+ { field: 'resource', operator: '=', value: 'invoice' },
758
+ { field: 'action', operator: '=', value: 'read' },
759
+ ],
760
+ });
761
+ expect(rows).toHaveLength(2);
762
+ const serialized = rows.map(row => row.conditions).sort();
763
+ expect(serialized[0]).toContain('eu');
764
+ expect(serialized[1]).toContain('us');
765
+ });
766
+
767
+ it('pins authoritative subject identity over caller-supplied condition context', async () => {
768
+ const fortress = createFortress({
769
+ jwt: { key: SECRET },
770
+ database: createTestAdapter(),
771
+ });
772
+ const user = await fortress.auth.createUser({
773
+ email: 'identity-context@test.com',
774
+ name: 'Identity Context',
775
+ password: 'password-123456',
776
+ });
777
+ const role = await fortress.iam.createRole('owner', [{
778
+ resource: 'document',
779
+ action: 'read',
780
+ conditions: [{ field: 'resource.ownerId', operator: 'eq', value: { ref: 'user.id' } }],
781
+ }]);
782
+ await fortress.iam.bindRoleToUser(user.id, role.id);
783
+
784
+ expect(await fortress.iam.checkPermission(
785
+ { type: 'USER', id: user.id },
786
+ 'document',
787
+ 'read',
788
+ { resource: { ownerId: 'attacker' }, user: { id: 'attacker' } },
789
+ )).toBe(false);
790
+ expect(await fortress.iam.checkPermission(
791
+ { type: 'USER', id: user.id },
792
+ 'document',
793
+ 'read',
794
+ { resource: { ownerId: user.id }, user: { id: 'attacker' } },
795
+ )).toBe(true);
796
+ });
797
+
798
+ it('findOrCreatePermission resolves concurrent creates to one row (P3.3/M8)', async () => {
799
+ const fortress = createFortress({
800
+ jwt: { key: SECRET },
801
+ database: createTestAdapter(),
802
+ });
803
+ const user = await fortress.auth.createUser({
804
+ email: 'race@test.com',
805
+ name: 'Race',
806
+ password: 'password-123456',
807
+ });
808
+
809
+ // Two concurrent binds referencing the same (resource, action, no-conditions)
810
+ // permission. The partial unique index forbids a duplicate row; the
811
+ // find-or-create must absorb the lost race instead of throwing a raw DB error.
812
+ await expect(Promise.all([
813
+ fortress.iam.bindPermissionToUser(user.id, { resource: 'doc', action: 'read' }),
814
+ fortress.iam.bindPermissionToUser(user.id, { resource: 'doc', action: 'read' }),
815
+ ])).resolves.toBeDefined();
816
+
817
+ const count = await fortress.config.database.count({
818
+ model: 'permission',
819
+ where: [
820
+ { field: 'resource', operator: '=', value: 'doc' },
821
+ { field: 'action', operator: '=', value: 'read' },
822
+ { field: 'effect', operator: '=', value: 'ALLOW' },
823
+ { field: 'conditions', operator: 'isNull', value: null },
824
+ ],
825
+ });
826
+ expect(count).toBe(1);
827
+ expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'doc', 'read')).toBe(true);
828
+ });
829
+ });