@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,288 @@
1
+ /**
2
+ * Compute the {@link PolicyPlan} that reconciles the live IAM state with
3
+ * a declarative {@link PolicyDocument}.
4
+ *
5
+ * The diff is intentionally additive-by-default: missing resources,
6
+ * actions, roles, permissions, groups, and service accounts produce
7
+ * `create-*` / `add-*` ops. Deletions only appear when `prune: true` is
8
+ * passed — most operators want their first apply to be safe.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import type { IamService } from '../iam/iam-service';
14
+ import type { Permission, Role, ServiceAccount } from '../types';
15
+ import type {
16
+ DiffPolicyOptions,
17
+ PolicyDocument,
18
+ PolicyOp,
19
+ PolicyPermission,
20
+ PolicyPlan,
21
+ } from './types';
22
+ import { Errors } from '../errors';
23
+
24
+ function permissionKey(perm: { resource: string; action: string; effect?: string }): string {
25
+ return `${perm.resource}::${perm.action}::${perm.effect ?? 'ALLOW'}`;
26
+ }
27
+
28
+ function describePerm(perm: PolicyPermission): string {
29
+ return `${perm.resource}:${perm.action}${perm.effect && perm.effect !== 'ALLOW' ? ` (${perm.effect})` : ''}`;
30
+ }
31
+
32
+ /**
33
+ * Compute the diff between a {@link PolicyDocument} (desired state) and
34
+ * the live IAM state. Pass the returned {@link PolicyPlan} to
35
+ * {@link applyPolicyPlan} to reconcile.
36
+ */
37
+ export async function diffPolicy(
38
+ policy: PolicyDocument,
39
+ iam: IamService,
40
+ options: DiffPolicyOptions = {},
41
+ ): Promise<PolicyPlan> {
42
+ const declaredCount = (policy.resources?.length ?? 0)
43
+ + (policy.roles?.length ?? 0)
44
+ + (policy.groups?.length ?? 0)
45
+ + (policy.serviceAccounts?.length ?? 0);
46
+ if (options.prune && declaredCount === 0 && !options.allowEmptyPrune) {
47
+ throw Errors.badRequest(
48
+ 'Refusing to prune with an empty policy; pass allowEmptyPrune: true to acknowledge deleting all managed IAM state',
49
+ );
50
+ }
51
+ const ops: PolicyOp[] = [];
52
+
53
+ // ── Resources ──────────────────────────────────────────────────────
54
+ const existingResources = await iam.getResources();
55
+ const existingResourceMap = existingResources.resources;
56
+ const declaredResourceNames = new Set<string>();
57
+
58
+ for (const resource of policy.resources ?? []) {
59
+ declaredResourceNames.add(resource.name);
60
+ const existing = existingResourceMap[resource.name];
61
+ if (!existing) {
62
+ ops.push({
63
+ kind: 'create-resource',
64
+ resource,
65
+ description: `Create resource '${resource.name}' with actions [${resource.actions.join(', ')}]`,
66
+ });
67
+ continue;
68
+ }
69
+ const existingActions = new Set(existing.actions);
70
+ for (const action of resource.actions) {
71
+ if (!existingActions.has(action)) {
72
+ ops.push({
73
+ kind: 'add-resource-action',
74
+ resource: resource.name,
75
+ action,
76
+ description: `Add action '${action}' to resource '${resource.name}'`,
77
+ });
78
+ }
79
+ }
80
+ }
81
+ // Note: we don't prune resources/actions even with `prune: true` because
82
+ // dropping a resource cascades to permissions/role-bindings. Operators
83
+ // should remove unused resources manually via the admin API.
84
+
85
+ // ── Roles ──────────────────────────────────────────────────────────
86
+ const existingRoles = await iam.getRoles();
87
+ const existingRoleByName = new Map<string, Role>(existingRoles.map(role => [role.name, role]));
88
+ const declaredRoleNames = new Set<string>();
89
+
90
+ for (const role of policy.roles ?? []) {
91
+ declaredRoleNames.add(role.name);
92
+ const existing = existingRoleByName.get(role.name);
93
+ if (!existing) {
94
+ ops.push({
95
+ kind: 'create-role',
96
+ role,
97
+ description: `Create role '${role.name}' with ${role.permissions.length} permission(s)`,
98
+ });
99
+ continue;
100
+ }
101
+ if ((existing.description ?? '') !== (role.description ?? '')) {
102
+ ops.push({
103
+ kind: 'update-role-description',
104
+ role: role.name,
105
+ from: existing.description ?? undefined,
106
+ to: role.description,
107
+ description: `Update description on role '${role.name}'`,
108
+ });
109
+ }
110
+ const detail = await iam.getRole(existing.id);
111
+ const existingPerms = new Map<string, Permission>(
112
+ detail.permissions.map(perm => [permissionKey(perm), perm]),
113
+ );
114
+ const desiredPerms = new Map<string, PolicyPermission>(
115
+ role.permissions.map(perm => [permissionKey(perm), perm]),
116
+ );
117
+ for (const [key, perm] of desiredPerms) {
118
+ if (!existingPerms.has(key)) {
119
+ ops.push({
120
+ kind: 'add-role-permission',
121
+ role: role.name,
122
+ permission: perm,
123
+ description: `Add permission ${describePerm(perm)} to role '${role.name}'`,
124
+ });
125
+ }
126
+ }
127
+ for (const [key, perm] of existingPerms) {
128
+ if (!desiredPerms.has(key)) {
129
+ ops.push({
130
+ kind: 'remove-role-permission',
131
+ role: role.name,
132
+ permission: {
133
+ resource: perm.resource,
134
+ action: perm.action,
135
+ effect: perm.effect,
136
+ },
137
+ description: `Remove permission ${perm.resource}:${perm.action} from role '${role.name}'`,
138
+ });
139
+ }
140
+ }
141
+ }
142
+ if (options.prune) {
143
+ for (const role of existingRoles) {
144
+ if (role.isSystem)
145
+ continue;
146
+ if (!declaredRoleNames.has(role.name)) {
147
+ ops.push({
148
+ kind: 'delete-role',
149
+ role: role.name,
150
+ description: `Delete role '${role.name}' (prune)`,
151
+ });
152
+ }
153
+ }
154
+ }
155
+
156
+ // ── Groups ─────────────────────────────────────────────────────────
157
+ const { groups: existingGroups } = await iam.listGroups({ limit: 10_000 });
158
+ const existingGroupByName = new Map(existingGroups.map(group => [group.name, group]));
159
+ const declaredGroupNames = new Set<string>();
160
+ for (const group of policy.groups ?? []) {
161
+ declaredGroupNames.add(group.name);
162
+ const existing = existingGroupByName.get(group.name);
163
+ if (!existing) {
164
+ ops.push({
165
+ kind: 'create-group',
166
+ group,
167
+ description: `Create group '${group.name}'`,
168
+ });
169
+ continue;
170
+ }
171
+ if ((existing.description ?? '') !== (group.description ?? '')) {
172
+ ops.push({
173
+ kind: 'update-group-description',
174
+ group: group.name,
175
+ from: existing.description ?? undefined,
176
+ to: group.description,
177
+ description: `Update description on group '${group.name}'`,
178
+ });
179
+ }
180
+ }
181
+ if (options.prune) {
182
+ for (const group of existingGroups) {
183
+ if (!declaredGroupNames.has(group.name)) {
184
+ ops.push({
185
+ kind: 'delete-group',
186
+ group: group.name,
187
+ description: `Delete group '${group.name}' (prune)`,
188
+ });
189
+ }
190
+ }
191
+ }
192
+
193
+ // ── Service accounts ───────────────────────────────────────────────
194
+ const { serviceAccounts: existingSAs } = await iam.listServiceAccounts({ limit: 10_000 });
195
+ const existingSaByName = new Map<string, ServiceAccount>(existingSAs.map(sa => [sa.name, sa]));
196
+ const declaredSaNames = new Set<string>();
197
+
198
+ for (const sa of policy.serviceAccounts ?? []) {
199
+ declaredSaNames.add(sa.name);
200
+ const existing = existingSaByName.get(sa.name);
201
+ if (!existing) {
202
+ ops.push({
203
+ kind: 'create-service-account',
204
+ serviceAccount: sa,
205
+ description: `Create service account '${sa.name}'`,
206
+ });
207
+ // bind roles after create — emit binding ops too.
208
+ for (const roleName of sa.roles ?? []) {
209
+ ops.push({
210
+ kind: 'bind-service-account-role',
211
+ serviceAccount: sa.name,
212
+ role: roleName,
213
+ description: `Bind role '${roleName}' to service account '${sa.name}'`,
214
+ });
215
+ }
216
+ continue;
217
+ }
218
+ const changes: Record<string, unknown> = {};
219
+ if (sa.displayName !== undefined && existing.displayName !== sa.displayName)
220
+ changes.displayName = sa.displayName;
221
+ if (sa.description !== undefined && existing.description !== sa.description)
222
+ changes.description = sa.description;
223
+ if (sa.isActive !== undefined && existing.isActive !== sa.isActive)
224
+ changes.isActive = sa.isActive;
225
+ if (Object.keys(changes).length > 0) {
226
+ ops.push({
227
+ kind: 'update-service-account',
228
+ serviceAccount: sa.name,
229
+ changes,
230
+ description: `Update fields on service account '${sa.name}'`,
231
+ });
232
+ }
233
+ // Policies manage global SA bindings. Read the authoritative binding rows
234
+ // rather than inferring roles from effective permissions (which is
235
+ // ambiguous for shared/direct permissions and invisible for empty roles).
236
+ const desiredRoles = new Set(sa.roles ?? []);
237
+ const allRoles = await iam.getRoles();
238
+ const roleByName = new Map(allRoles.map(role => [role.name, role]));
239
+ const roleNameById = new Map(allRoles.map(role => [role.id, role.name]));
240
+ const bindings = await iam.listRoleBindingsForSubject(
241
+ { type: 'SERVICE_ACCOUNT', id: existing.id },
242
+ null,
243
+ );
244
+ const currentRoleNames = new Set(
245
+ bindings
246
+ .map(binding => roleNameById.get(binding.roleId))
247
+ .filter((name): name is string => name !== undefined),
248
+ );
249
+ for (const desired of desiredRoles) {
250
+ if (
251
+ !currentRoleNames.has(desired)
252
+ && (roleByName.has(desired) || declaredRoleNames.has(desired))
253
+ ) {
254
+ ops.push({
255
+ kind: 'bind-service-account-role',
256
+ serviceAccount: sa.name,
257
+ role: desired,
258
+ description: `Bind role '${desired}' to service account '${sa.name}'`,
259
+ });
260
+ }
261
+ }
262
+ if (options.prune) {
263
+ for (const current of currentRoleNames) {
264
+ if (!desiredRoles.has(current)) {
265
+ ops.push({
266
+ kind: 'unbind-service-account-role',
267
+ serviceAccount: sa.name,
268
+ role: current,
269
+ description: `Unbind role '${current}' from service account '${sa.name}' (prune)`,
270
+ });
271
+ }
272
+ }
273
+ }
274
+ }
275
+ if (options.prune) {
276
+ for (const sa of existingSAs) {
277
+ if (!declaredSaNames.has(sa.name)) {
278
+ ops.push({
279
+ kind: 'delete-service-account',
280
+ serviceAccount: sa.name,
281
+ description: `Delete service account '${sa.name}' (prune)`,
282
+ });
283
+ }
284
+ }
285
+ }
286
+
287
+ return { ops, inSync: ops.length === 0 };
288
+ }
@@ -0,0 +1,63 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { loadPolicy, parsePolicyDocument, resolvePolicyPath } from './loader';
3
+
4
+ const tempDirs: string[] = [];
5
+ const sparseArray: unknown[] = [];
6
+ sparseArray.length = 1;
7
+
8
+ afterEach(async () => {
9
+ const { rm } = await import('node:fs/promises');
10
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })));
11
+ });
12
+
13
+ describe('policy loader', () => {
14
+ it('loads an env-specific file asynchronously and validates it', async () => {
15
+ const { mkdtemp, writeFile } = await import('node:fs/promises');
16
+ const { join } = await import('node:path');
17
+ const { tmpdir } = await import('node:os');
18
+ const cwd = await mkdtemp(join(tmpdir(), 'fortress-policy-'));
19
+ tempDirs.push(cwd);
20
+ await writeFile(join(cwd, 'fortress.policy.json'), JSON.stringify({ groups: [{ name: 'base' }] }));
21
+ await writeFile(join(cwd, 'fortress.policy.production.json'), JSON.stringify({
22
+ roles: [{ name: 'reader', permissions: [{ resource: 'article', action: 'read' }] }],
23
+ }));
24
+
25
+ expect(await resolvePolicyPath({ cwd, env: 'production' }))
26
+ .toBe(join(cwd, 'fortress.policy.production.json'));
27
+ const { policy } = await loadPolicy({ cwd, env: 'production' });
28
+ expect(policy.roles?.[0].name).toBe('reader');
29
+ });
30
+
31
+ it('allows annotations and deduplicates equivalent ALLOW permissions', () => {
32
+ const policy = parsePolicyDocument({
33
+ $comment: 'documented annotation',
34
+ roles: [{
35
+ name: 'reader',
36
+ permissions: [
37
+ { resource: 'article', action: 'read' },
38
+ { resource: 'article', action: 'read', effect: 'ALLOW' },
39
+ ],
40
+ }],
41
+ });
42
+ expect(policy.roles?.[0].permissions).toEqual([
43
+ { resource: 'article', action: 'read' },
44
+ ]);
45
+ });
46
+
47
+ it.each([
48
+ [null, 'must be an object'],
49
+ [[], 'must be an object'],
50
+ [{ groups: sparseArray }, 'dense array of objects'],
51
+ [{ unknown: [] }, 'Unknown policy field'],
52
+ [{ $comment: 1 }, '$comment must be a string'],
53
+ [{ $schema: false }, '$schema must be a string'],
54
+ [{ roles: [{ name: 'x' }] }, 'permissions must be an array'],
55
+ [{ roles: [{ name: 'x', permissions: [{ resource: 'r', action: 'a', effect: 'MAYBE' }] }] }, 'effect must be ALLOW or DENY'],
56
+ [{ groups: [{ name: 'x' }, { name: 'x' }] }, 'duplicate name'],
57
+ [{ serviceAccounts: [{ name: 'bot', roles: 'reader' }] }, 'roles must be an array'],
58
+ [{ roles: [{ name: 'x', permissions: [{ resource: 'r', action: 'a', effectt: 'DENY' }] }] }, 'Unknown field'],
59
+ [{ groups: [{ name: 'x', descriptino: 'typo' }] }, 'Unknown field'],
60
+ ])('rejects malformed policy documents', (value, message) => {
61
+ expect(() => parsePolicyDocument(value)).toThrow(message);
62
+ });
63
+ });
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Load a {@link PolicyDocument} from disk with optional env-specific
3
+ * override. Node filesystem/path modules are imported only when these
4
+ * file-oriented helpers run, keeping the package root import runtime-neutral.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ import type { PolicyDocument } from './types';
10
+ import { Errors } from '../errors';
11
+
12
+ export interface LoadPolicyOptions {
13
+ /** Override the default `fortress.policy.json` path. */
14
+ filePath?: string;
15
+ /** Pick an env-specific file (`fortress.policy.<env>.json`). Defaults to `process.env.FORTRESS_ENV`. */
16
+ env?: string;
17
+ /** Working directory when resolving the default file paths. Defaults to `process.cwd()`. */
18
+ cwd?: string;
19
+ }
20
+
21
+ /** Default base file name. */
22
+ export const DEFAULT_POLICY_FILE = 'fortress.policy.json';
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
26
+ }
27
+
28
+ function stringField(value: unknown, path: string): string {
29
+ if (typeof value !== 'string' || value.length === 0)
30
+ throw Errors.badRequest(`${path} must be a non-empty string`);
31
+ return value;
32
+ }
33
+
34
+ function optionalString(value: unknown, path: string): string | undefined {
35
+ if (value === undefined)
36
+ return undefined;
37
+ return stringField(value, path);
38
+ }
39
+
40
+ function collection(value: unknown, path: string): Record<string, unknown>[] {
41
+ if (value === undefined)
42
+ return [];
43
+ if (
44
+ !Array.isArray(value)
45
+ || value.some(item => !isRecord(item))
46
+ || Object.keys(value).length !== value.length
47
+ ) {
48
+ throw Errors.badRequest(`${path} must be a dense array of objects`);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ function assertAllowedFields(
54
+ value: Record<string, unknown>,
55
+ allowed: readonly string[],
56
+ path: string,
57
+ ): void {
58
+ const allowedSet = new Set(allowed);
59
+ for (const key of Object.keys(value)) {
60
+ if (!allowedSet.has(key))
61
+ throw Errors.badRequest(`Unknown field '${path}.${key}'`);
62
+ }
63
+ }
64
+
65
+ function assertUniqueNames(items: Array<{ name: string }>, path: string): void {
66
+ const names = new Set<string>();
67
+ for (const item of items) {
68
+ if (names.has(item.name))
69
+ throw Errors.badRequest(`${path} contains duplicate name '${item.name}'`);
70
+ names.add(item.name);
71
+ }
72
+ }
73
+
74
+ /** Validate and normalize an untrusted policy JSON value. */
75
+ export function parsePolicyDocument(value: unknown): PolicyDocument {
76
+ if (!isRecord(value))
77
+ throw Errors.badRequest('Policy document must be an object');
78
+ const allowed = new Set(['$comment', '$schema', 'resources', 'roles', 'groups', 'serviceAccounts']);
79
+ for (const annotation of ['$comment', '$schema'] as const) {
80
+ if (value[annotation] !== undefined && typeof value[annotation] !== 'string')
81
+ throw Errors.badRequest(`${annotation} must be a string`);
82
+ }
83
+ for (const key of Object.keys(value)) {
84
+ if (!allowed.has(key))
85
+ throw Errors.badRequest(`Unknown policy field '${key}'`);
86
+ }
87
+
88
+ const resources = collection(value.resources, 'resources').map((item, index) => {
89
+ assertAllowedFields(item, ['name', 'actions', 'description'], `resources[${index}]`);
90
+ if (!Array.isArray(item.actions) || item.actions.some(action => typeof action !== 'string' || action.length === 0))
91
+ throw Errors.badRequest(`resources[${index}].actions must be an array of non-empty strings`);
92
+ return {
93
+ name: stringField(item.name, `resources[${index}].name`),
94
+ actions: [...new Set(item.actions as string[])],
95
+ description: optionalString(item.description, `resources[${index}].description`),
96
+ };
97
+ });
98
+ const roles = collection(value.roles, 'roles').map((item, index) => {
99
+ assertAllowedFields(item, ['name', 'description', 'permissions'], `roles[${index}]`);
100
+ if (!Array.isArray(item.permissions))
101
+ throw Errors.badRequest(`roles[${index}].permissions must be an array of objects`);
102
+ const permissions = collection(item.permissions, `roles[${index}].permissions`).map((permission, permissionIndex) => {
103
+ assertAllowedFields(
104
+ permission,
105
+ ['resource', 'action', 'effect'],
106
+ `roles[${index}].permissions[${permissionIndex}]`,
107
+ );
108
+ const effect = permission.effect;
109
+ if (effect !== undefined && effect !== 'ALLOW' && effect !== 'DENY')
110
+ throw Errors.badRequest(`roles[${index}].permissions[${permissionIndex}].effect must be ALLOW or DENY`);
111
+ const normalizedEffect: 'ALLOW' | 'DENY' = effect === 'DENY' ? 'DENY' : 'ALLOW';
112
+ return {
113
+ resource: stringField(permission.resource, `roles[${index}].permissions[${permissionIndex}].resource`),
114
+ action: stringField(permission.action, `roles[${index}].permissions[${permissionIndex}].action`),
115
+ ...(normalizedEffect === 'DENY' ? { effect: normalizedEffect } : {}),
116
+ };
117
+ });
118
+ const uniquePermissions = new Map(
119
+ permissions.map(permission => [
120
+ `${permission.resource}\u0000${permission.action}\u0000${permission.effect ?? 'ALLOW'}`,
121
+ permission,
122
+ ]),
123
+ );
124
+ return {
125
+ name: stringField(item.name, `roles[${index}].name`),
126
+ description: optionalString(item.description, `roles[${index}].description`),
127
+ permissions: [...uniquePermissions.values()],
128
+ };
129
+ });
130
+ const groups = collection(value.groups, 'groups').map((item, index) => {
131
+ assertAllowedFields(item, ['name', 'description'], `groups[${index}]`);
132
+ return {
133
+ name: stringField(item.name, `groups[${index}].name`),
134
+ description: optionalString(item.description, `groups[${index}].description`),
135
+ };
136
+ });
137
+ const serviceAccounts = collection(value.serviceAccounts, 'serviceAccounts').map((item, index) => {
138
+ assertAllowedFields(
139
+ item,
140
+ ['name', 'displayName', 'description', 'isActive', 'roles'],
141
+ `serviceAccounts[${index}]`,
142
+ );
143
+ if (item.roles !== undefined && (!Array.isArray(item.roles) || item.roles.some(role => typeof role !== 'string' || role.length === 0)))
144
+ throw Errors.badRequest(`serviceAccounts[${index}].roles must be an array of non-empty strings`);
145
+ if (item.isActive !== undefined && typeof item.isActive !== 'boolean')
146
+ throw Errors.badRequest(`serviceAccounts[${index}].isActive must be a boolean`);
147
+ return {
148
+ name: stringField(item.name, `serviceAccounts[${index}].name`),
149
+ displayName: optionalString(item.displayName, `serviceAccounts[${index}].displayName`),
150
+ description: optionalString(item.description, `serviceAccounts[${index}].description`),
151
+ ...(typeof item.isActive === 'boolean' ? { isActive: item.isActive } : {}),
152
+ ...(Array.isArray(item.roles) ? { roles: [...new Set(item.roles as string[])] } : {}),
153
+ };
154
+ });
155
+
156
+ assertUniqueNames(resources, 'resources');
157
+ assertUniqueNames(roles, 'roles');
158
+ assertUniqueNames(groups, 'groups');
159
+ assertUniqueNames(serviceAccounts, 'serviceAccounts');
160
+ return {
161
+ ...(value.resources !== undefined ? { resources } : {}),
162
+ ...(value.roles !== undefined ? { roles } : {}),
163
+ ...(value.groups !== undefined ? { groups } : {}),
164
+ ...(value.serviceAccounts !== undefined ? { serviceAccounts } : {}),
165
+ };
166
+ }
167
+
168
+ async function exists(filePath: string): Promise<boolean> {
169
+ const { access } = await import('node:fs/promises');
170
+ try {
171
+ await access(filePath);
172
+ return true;
173
+ }
174
+ catch (err) {
175
+ if ((err as { code?: string }).code === 'ENOENT')
176
+ return false;
177
+ throw err;
178
+ }
179
+ }
180
+
181
+ /** Return the resolved policy-file path for an environment, or `null` if no file exists. */
182
+ export async function resolvePolicyPath(options: LoadPolicyOptions = {}): Promise<string | null> {
183
+ if (options.filePath)
184
+ return await exists(options.filePath) ? options.filePath : null;
185
+
186
+ const { join } = await import('node:path');
187
+ const cwd = options.cwd ?? process.cwd();
188
+ const env = options.env ?? process.env.FORTRESS_ENV;
189
+ if (env) {
190
+ const envPath = join(cwd, `fortress.policy.${env}.json`);
191
+ if (await exists(envPath))
192
+ return envPath;
193
+ }
194
+ const basePath = join(cwd, DEFAULT_POLICY_FILE);
195
+ return await exists(basePath) ? basePath : null;
196
+ }
197
+
198
+ /** Load and parse a policy document. Throws when the file is missing or unparseable. */
199
+ export async function loadPolicy(options: LoadPolicyOptions = {}): Promise<{ policy: PolicyDocument; filePath: string }> {
200
+ const filePath = await resolvePolicyPath(options);
201
+ if (!filePath)
202
+ throw Errors.notFound(`No policy file found (looked for fortress.policy.<env>.json then fortress.policy.json)`);
203
+ try {
204
+ const { readFile } = await import('node:fs/promises');
205
+ const text = await readFile(filePath, 'utf-8');
206
+ const parsed = JSON.parse(text) as unknown;
207
+ return { policy: parsePolicyDocument(parsed), filePath };
208
+ }
209
+ catch (err) {
210
+ if (err instanceof SyntaxError)
211
+ throw Errors.badRequest(`Failed to parse policy file ${filePath}: ${err.message}`);
212
+ throw err;
213
+ }
214
+ }