@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,80 @@
1
+ // --- Social Login Provider Types ---
2
+
3
+ /** Normalized user profile returned by all providers */
4
+ export interface ProviderProfile {
5
+ /** Provider's unique user ID */
6
+ id: string;
7
+ email: string;
8
+ /** Whether the provider has verified ownership of `email`. */
9
+ emailVerified: boolean;
10
+ name?: string;
11
+ displayName?: string;
12
+ avatar?: string;
13
+ /** Full raw response from the provider (for custom mapping) */
14
+ raw: Record<string, unknown>;
15
+ }
16
+
17
+ /** Pre-configured provider definition with OAuth/OIDC endpoints and profile mapping */
18
+ export interface ProviderDefinition {
19
+ name: string;
20
+ /** OIDC discovery URL (.well-known/openid-configuration). Undefined for non-OIDC providers (e.g., GitHub). */
21
+ discoveryUrl?: string;
22
+ authorizationUrl: string;
23
+ tokenUrl: string;
24
+ /** User info endpoint. Undefined for Apple (profile comes from ID token only). */
25
+ userInfoUrl?: string;
26
+ /** Expected issuer for ID tokens. Resolved from discovery when omitted. */
27
+ issuer?: string;
28
+ /** JWKS URI for ID token verification. Resolved from discovery when omitted. */
29
+ jwksUri?: string;
30
+ defaultScopes: string[];
31
+ /** Maps the raw provider response to a normalized ProviderProfile */
32
+ mapProfile: (raw: Record<string, unknown>) => ProviderProfile;
33
+ /** Optional provider-specific profile fetcher (e.g. GitHub verified primary email). */
34
+ fetchProfile?: (accessToken: string) => Promise<Record<string, unknown>>;
35
+ }
36
+
37
+ /** User-facing config for a single provider (passed to socialLogin plugin) */
38
+ export interface ProviderConfig {
39
+ name: string;
40
+ clientId: string;
41
+ clientSecret: string;
42
+ scopes?: string[];
43
+ /** Microsoft: tenant ID, 'common', or 'organizations' */
44
+ tenant?: string;
45
+ /** Restrict registration to specific email domains */
46
+ allowedDomains?: string[];
47
+ /** Generic OIDC: issuer URL for discovery */
48
+ issuer?: string;
49
+ /** Override discovered authorization endpoint for custom OIDC providers. */
50
+ authorizationUrl?: string;
51
+ /** Override discovered token endpoint for custom OIDC providers. */
52
+ tokenUrl?: string;
53
+ /** Override discovered userinfo endpoint for custom OIDC providers. */
54
+ userInfoUrl?: string;
55
+ /** Override discovered JWKS endpoint for custom OIDC providers. */
56
+ jwksUri?: string;
57
+ /** Apple Sign In: team ID used to generate the ES256 client-secret JWT. */
58
+ teamId?: string;
59
+ /** Apple Sign In: key ID used to generate the ES256 client-secret JWT. */
60
+ keyId?: string;
61
+ /** Apple Sign In: PKCS#8 private key used to generate the ES256 client-secret JWT. */
62
+ privateKey?: string;
63
+ }
64
+
65
+ /** Plugin-level config for the social login plugin */
66
+ export interface SocialLoginConfig {
67
+ providers: ProviderConfig[];
68
+ /** Auto-create user on first social login (default: true) */
69
+ autoRegister?: boolean;
70
+ /** Link social identity to existing user by email (default: true) */
71
+ linkAccounts?: boolean;
72
+ /** Map provider profile fields to Fortress user fields */
73
+ mapProfile?: (provider: string, profile: ProviderProfile) => { email: string; name: string };
74
+ /** Called on first-ever login for a social user */
75
+ onFirstLogin?: (user: { id: string }, provider: string, profile: ProviderProfile) => Promise<void>;
76
+ /** Persist encrypted provider access/refresh tokens. Default: false (explicit opt-in). */
77
+ persistTokens?: boolean;
78
+ /** 32-byte AES-256-GCM key (raw bytes as base64/base64url/hex, or a UTF-8 string of at least 32 bytes). */
79
+ tokenEncryptionKey?: string;
80
+ }
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Schema-per-tenant tenancy plugin for fortress (PostgreSQL only).
3
+ *
4
+ * Switches the active PostgreSQL `search_path` per request based on the
5
+ * resolved tenant, providing strong data isolation between tenants without
6
+ * touching application code. Requires a PostgreSQL-backed Drizzle adapter.
7
+ *
8
+ * The active tenant is read from the **verified** `tenantId` JWT claim (set by
9
+ * {@link enrichTokenClaims} from the user's default `tenant_user` membership),
10
+ * never from a client-supplied header — so a caller can only ever reach a
11
+ * tenant they belong to. Switching tenants requires {@link switchTenant} plus a
12
+ * token refresh.
13
+ *
14
+ * Isolation is enforced atomically: each database operation runs inside a
15
+ * transaction that first pins `search_path` via a bound `set_config(..., true)`
16
+ * call, so the schema selection and the query share one pooled connection and
17
+ * the path cannot leak or be discarded.
18
+ *
19
+ * HTTP endpoints are opt-in: pass `tenancy({ routes: true })` to mount the
20
+ * routes under `/tenancy/*`. The programmatic methods on
21
+ * `fortress.plugins.tenancy` are always available regardless of the flag.
22
+ *
23
+ * @module
24
+ */
25
+
26
+ import type { DatabaseAdapter } from '../../adapters/database';
27
+ import type { FortressPlugin, PluginContext, PluginRouteContext } from '../../core/plugin';
28
+ import { Errors } from '../../core/errors';
29
+ import { arr, bool, endpoint, id, obj, ref, str } from '../../core/schema-builder';
30
+
31
+ /**
32
+ * Callback invoked once, inside the creation transaction, after a tenant's
33
+ * schema is created — the hook for per-tenant table DDL / migrations.
34
+ */
35
+ export type OnSchemaCreated = (
36
+ schemaName: string,
37
+ rawQuery: <T>(sql: string, params?: unknown[]) => Promise<T[]>,
38
+ ) => Promise<void>;
39
+
40
+ export interface TenancyConfig {
41
+ /**
42
+ * Schema prefix for tenant schemas (default: 'tenant_'). Must match
43
+ * `^[a-z_][a-z0-9_]*$` — validated at factory time.
44
+ */
45
+ schemaPrefix?: string;
46
+ /**
47
+ * Mount HTTP routes under `/tenancy/*`. Default `false`. The programmatic
48
+ * methods on `fortress.plugins.tenancy` are always available; this flag only
49
+ * controls HTTP mounting.
50
+ */
51
+ routes?: boolean;
52
+ /**
53
+ * Run once inside the `createTenant` transaction, right after the tenant's
54
+ * PostgreSQL schema is created. Use it to create the tenant's business
55
+ * tables (or run a migration) against the new schema. The supplied
56
+ * `rawQuery` is bound to the same connection/transaction as the schema
57
+ * creation, so statements should reference the schema explicitly (e.g.
58
+ * `CREATE TABLE "${schemaName}".widgets (...)`).
59
+ */
60
+ onSchemaCreated?: OnSchemaCreated;
61
+ /**
62
+ * When `true`, `deleteTenant` also issues `DROP SCHEMA IF EXISTS <schema>
63
+ * CASCADE`, destroying all tenant data. Default `false` — destructive drops
64
+ * must be opted into explicitly.
65
+ */
66
+ dropSchemaOnDelete?: boolean;
67
+ }
68
+
69
+ interface TenantRecord {
70
+ id: string;
71
+ name: string;
72
+ taxId: string;
73
+ description: string | null;
74
+ createdAt: Date;
75
+ updatedAt: Date;
76
+ }
77
+
78
+ interface TenantUserRecord {
79
+ tenantId: string;
80
+ userId: string;
81
+ isDefault: boolean;
82
+ }
83
+
84
+ export interface TenancyMethods {
85
+ createTenant: (
86
+ input: { name: string; taxId: string; description?: string },
87
+ routeCtx?: PluginRouteContext,
88
+ ) => Promise<TenantRecord>;
89
+ deleteTenant: (
90
+ input: { id: string },
91
+ routeCtx?: PluginRouteContext,
92
+ ) => Promise<{ ok: true }>;
93
+ addUserToTenant: (userId: string, tenantId: string) => Promise<void>;
94
+ getUserTenants: (userId: string) => Promise<TenantRecord[]>;
95
+ getMyTenants: (
96
+ input: { userId?: string },
97
+ routeCtx?: PluginRouteContext,
98
+ ) => Promise<{ tenants: TenantRecord[] }>;
99
+ switchTenant: (
100
+ input: { taxId: string; userId?: string },
101
+ routeCtx?: PluginRouteContext,
102
+ ) => Promise<{ ok: true }>;
103
+ }
104
+
105
+ const SAFE_SCHEMA_PREFIX = /^[a-z_][a-z0-9_]*$/;
106
+
107
+ /**
108
+ * Subject-id strings that are safe to interpolate into a Postgres schema
109
+ * name. The tenant claim is verified-JWT-supplied but is *user-controlled*
110
+ * at sign-up time, so any character that isn't safe for an identifier must
111
+ * be rejected before {@link tenantSchemaName} concatenates it.
112
+ */
113
+ const SAFE_TENANT_ID = /^[\w-]+$/;
114
+
115
+ /**
116
+ * Validate the configured schema prefix once, at factory time. The full
117
+ * schema name is `${prefix}${numericId}`, so a safe prefix guarantees a safe
118
+ * identifier — no per-value escaping is needed.
119
+ */
120
+ function assertSafeSchemaPrefix(prefix: string): void {
121
+ if (!SAFE_SCHEMA_PREFIX.test(prefix))
122
+ throw Errors.badRequest(`Invalid tenancy schemaPrefix '${prefix}': must match ${SAFE_SCHEMA_PREFIX}`);
123
+ }
124
+
125
+ // ── Routes ──────────────────────────────────────────────────────────
126
+
127
+ const errorRef = ref('ErrorResponse');
128
+
129
+ const tenantResponse = obj({
130
+ id: id('Tenant id'),
131
+ name: str('Tenant name'),
132
+ taxId: str('Unique tenant tax id / external code'),
133
+ description: str('Optional description'),
134
+ createdAt: str('ISO 8601 creation timestamp'),
135
+ updatedAt: str('ISO 8601 update timestamp'),
136
+ }, 'id', 'name', 'taxId');
137
+
138
+ const tenancyRoutes = {
139
+ createTenant: endpoint('POST', '/tenancy/tenants')
140
+ .summary('Create a tenant')
141
+ .description('Create a new tenant and its PostgreSQL schema. Requires the `fortress:manageTenants` permission.')
142
+ .tags('Tenancy')
143
+ .security('bearer')
144
+ .permission('fortress', 'manageTenants')
145
+ .body(obj({
146
+ name: str('Human-readable tenant name'),
147
+ taxId: str('Unique tenant tax id / external code'),
148
+ description: str('Optional description'),
149
+ }, 'name', 'taxId'))
150
+ .response(201, 'Tenant created', tenantResponse)
151
+ .response(401, 'Not authenticated', errorRef)
152
+ .response(403, 'Insufficient permissions', errorRef)
153
+ .response(409, 'taxId already exists', errorRef)
154
+ .handler('createTenant')
155
+ .build(),
156
+
157
+ deleteTenant: endpoint('DELETE', '/tenancy/tenants/:id')
158
+ .summary('Delete a tenant')
159
+ .description('Remove a tenant, its memberships, and (when `dropSchemaOnDelete` is enabled) its schema. Requires the `fortress:manageTenants` permission.')
160
+ .tags('Tenancy')
161
+ .security('bearer')
162
+ .permission('fortress', 'manageTenants')
163
+ .params(obj({ id: str('Tenant id') }, 'id'))
164
+ .response(200, 'Tenant deleted', obj({ ok: bool('Always true') }, 'ok'))
165
+ .response(401, 'Not authenticated', errorRef)
166
+ .response(403, 'Insufficient permissions', errorRef)
167
+ .response(404, 'Tenant not found', errorRef)
168
+ .handler('deleteTenant')
169
+ .build(),
170
+
171
+ getMyTenants: endpoint('GET', '/tenancy/tenants/mine')
172
+ .summary('List the caller\'s tenants')
173
+ .description('Return the tenants the authenticated caller belongs to.')
174
+ .tags('Tenancy')
175
+ .security('bearer')
176
+ .response(200, 'Tenants', obj({ tenants: arr(tenantResponse, 'Tenants the caller belongs to') }, 'tenants'))
177
+ .response(401, 'Not authenticated', errorRef)
178
+ .handler('getMyTenants')
179
+ .build(),
180
+
181
+ switchTenant: endpoint('POST', '/tenancy/switch')
182
+ .summary('Switch the caller\'s default tenant')
183
+ .description('Set the authenticated caller\'s default tenant. The new tenant takes effect on the next token refresh.')
184
+ .tags('Tenancy')
185
+ .security('bearer')
186
+ .body(obj({ taxId: str('Tax id of the tenant to switch to') }, 'taxId'))
187
+ .response(200, 'Switched', obj({ ok: bool('Always true') }, 'ok'))
188
+ .response(401, 'Not authenticated', errorRef)
189
+ .response(403, 'Caller does not belong to this tenant', errorRef)
190
+ .response(404, 'Tenant not found', errorRef)
191
+ .handler('switchTenant')
192
+ .build(),
193
+ } as const;
194
+
195
+ // ── Plugin Factory ──────────────────────────────────────────────────
196
+
197
+ /**
198
+ * Tenancy plugin factory (PostgreSQL only). Returns a {@link FortressPlugin}
199
+ * that switches the active PostgreSQL `search_path` per request based on the
200
+ * verified `tenantId` JWT claim, providing schema-level isolation between
201
+ * tenants. Pass `{ routes: true }` to mount the HTTP routes under `/tenancy/*`.
202
+ */
203
+ export function tenancy(config: TenancyConfig = {}): FortressPlugin & { readonly name: 'tenancy' } {
204
+ const schemaPrefix = config.schemaPrefix ?? 'tenant_';
205
+ assertSafeSchemaPrefix(schemaPrefix);
206
+ const mountRoutes = config.routes === true;
207
+
208
+ /**
209
+ * Schema name for a tenant. `id` is numeric, so the result is always a
210
+ * valid, non-injectable identifier.
211
+ */
212
+ const tenantSchemaName = (id: string): string => `${schemaPrefix}${id}`;
213
+
214
+ return {
215
+ name: 'tenancy',
216
+
217
+ models: [
218
+ {
219
+ name: 'tenant',
220
+ fields: {
221
+ id: { type: 'number', required: true },
222
+ name: { type: 'string', required: true },
223
+ taxId: { type: 'string', required: true, unique: true },
224
+ description: { type: 'string' },
225
+ createdAt: { type: 'date', required: true },
226
+ updatedAt: { type: 'date', required: true },
227
+ },
228
+ },
229
+ {
230
+ name: 'tenant_user',
231
+ fields: {
232
+ tenantId: { type: 'number', required: true, references: { model: 'tenant', field: 'id' } },
233
+ userId: { type: 'number', required: true, references: { model: 'user', field: 'id' } },
234
+ isDefault: { type: 'boolean', required: true },
235
+ },
236
+ },
237
+ ],
238
+
239
+ ...(mountRoutes ? { routes: tenancyRoutes } : {}),
240
+
241
+ async enrichTokenClaims(userId: string, ctx: PluginContext): Promise<Record<string, unknown>> {
242
+ // Find user's default tenant
243
+ const membership = await ctx.db.findOne<TenantUserRecord>({
244
+ model: 'tenant_user',
245
+ where: [
246
+ { field: 'userId', operator: '=', value: userId },
247
+ { field: 'isDefault', operator: '=', value: true },
248
+ ],
249
+ });
250
+
251
+ if (!membership)
252
+ return {};
253
+
254
+ const tenant = await ctx.db.findOne<TenantRecord>({
255
+ model: 'tenant',
256
+ where: [{ field: 'id', operator: '=', value: membership.tenantId }],
257
+ });
258
+
259
+ if (!tenant)
260
+ return {};
261
+
262
+ return {
263
+ tenantId: tenant.id,
264
+ tenantCode: tenant.taxId,
265
+ };
266
+ },
267
+
268
+ wrapAdapter(adapter: DatabaseAdapter, requestContext: Record<string, unknown>): DatabaseAdapter {
269
+ const tenantId = requestContext.tenantId as string | undefined;
270
+ // Fail closed: no verified tenant claim ⇒ no schema switch. Business
271
+ // tables live only in tenant schemas, so they are simply not on the
272
+ // search path — there is no silent fallback to another tenant's data.
273
+ if (tenantId == null)
274
+ return adapter;
275
+ // Validate: alphanumeric + underscore + hyphen only. The id is
276
+ // interpolated into a Postgres schema name (via tenantSchemaName), so
277
+ // any character that isn't safe for an identifier MUST be rejected
278
+ // here — there is no second sanitization step downstream.
279
+ if (!SAFE_TENANT_ID.test(tenantId))
280
+ throw Errors.forbidden('Invalid tenant claim');
281
+
282
+ const isPg = adapter.dialect === 'pg' && !!adapter.rawQuery;
283
+ // For SQLite there is no schema-per-tenant model; pass through.
284
+ if (!isPg)
285
+ return adapter;
286
+
287
+ const schemaName = tenantSchemaName(tenantId);
288
+
289
+ // Pin search_path on the transaction's pinned connection. `?` is the
290
+ // adapter-canonical bound placeholder, and `set_config(..., true)` is transaction-local,
291
+ // so the path applies to exactly the operation that follows on the same
292
+ // connection.
293
+ const setPath = (tx: DatabaseAdapter): Promise<unknown> =>
294
+ tx.rawQuery!(`SELECT set_config('search_path', ?, true)`, [`${schemaName}, public`]);
295
+
296
+ return {
297
+ ...adapter,
298
+ async create<T>(params: Parameters<DatabaseAdapter['create']>[0]): Promise<T> {
299
+ return adapter.transaction(async (tx) => {
300
+ await setPath(tx);
301
+ return tx.create<T>(params);
302
+ });
303
+ },
304
+ async findOne<T>(params: Parameters<DatabaseAdapter['findOne']>[0]): Promise<T | null> {
305
+ return adapter.transaction(async (tx) => {
306
+ await setPath(tx);
307
+ return tx.findOne<T>(params);
308
+ });
309
+ },
310
+ async findMany<T>(params: Parameters<DatabaseAdapter['findMany']>[0]): Promise<T[]> {
311
+ return adapter.transaction(async (tx) => {
312
+ await setPath(tx);
313
+ return tx.findMany<T>(params);
314
+ });
315
+ },
316
+ async update<T>(params: Parameters<DatabaseAdapter['update']>[0]): Promise<T | null> {
317
+ return adapter.transaction(async (tx) => {
318
+ await setPath(tx);
319
+ return tx.update<T>(params);
320
+ });
321
+ },
322
+ async delete(params: Parameters<DatabaseAdapter['delete']>[0]): Promise<void> {
323
+ return adapter.transaction(async (tx) => {
324
+ await setPath(tx);
325
+ return tx.delete(params);
326
+ });
327
+ },
328
+ async count(params: Parameters<DatabaseAdapter['count']>[0]): Promise<number> {
329
+ return adapter.transaction(async (tx) => {
330
+ await setPath(tx);
331
+ return tx.count(params);
332
+ });
333
+ },
334
+ async transaction<T>(fn: (tx: DatabaseAdapter) => Promise<T>): Promise<T> {
335
+ // Pin once for the whole transaction so every op inside the caller's
336
+ // callback runs against the tenant schema.
337
+ return adapter.transaction(async (tx) => {
338
+ await setPath(tx);
339
+ return fn(tx);
340
+ });
341
+ },
342
+ };
343
+ },
344
+
345
+ methods: (ctx) => {
346
+ const findTenantByTaxId = (taxId: string): Promise<TenantRecord | null> =>
347
+ ctx.db.findOne<TenantRecord>({
348
+ model: 'tenant',
349
+ where: [{ field: 'taxId', operator: '=', value: taxId }],
350
+ });
351
+
352
+ const listUserTenants = async (userId: string): Promise<TenantRecord[]> => {
353
+ const memberships = await ctx.db.findMany<TenantUserRecord>({
354
+ model: 'tenant_user',
355
+ where: [{ field: 'userId', operator: '=', value: userId }],
356
+ });
357
+
358
+ if (memberships.length === 0)
359
+ return [];
360
+
361
+ const tenantIds = memberships.map(m => m.tenantId);
362
+ return ctx.db.findMany<TenantRecord>({
363
+ model: 'tenant',
364
+ where: [{ field: 'id', operator: 'in', value: tenantIds }],
365
+ });
366
+ };
367
+
368
+ const requireUserId = (input: { userId?: string }, routeCtx?: PluginRouteContext): string => {
369
+ const userId = routeCtx?.userId ?? input.userId;
370
+ if (userId == null) {
371
+ if (routeCtx)
372
+ throw Errors.unauthorized('Not authenticated');
373
+ throw Errors.badRequest('userId is required for programmatic calls');
374
+ }
375
+ return userId;
376
+ };
377
+
378
+ const defaultTenantQueues = new Map<string, Promise<void>>();
379
+ const withDefaultTenantLock = async <T>(
380
+ userId: string,
381
+ operation: (tx: DatabaseAdapter) => Promise<T>,
382
+ ): Promise<T> => {
383
+ const previous = defaultTenantQueues.get(userId) ?? Promise.resolve();
384
+ let release!: () => void;
385
+ const current = new Promise<void>((resolve) => {
386
+ release = resolve;
387
+ });
388
+ defaultTenantQueues.set(userId, current);
389
+ await previous;
390
+ try {
391
+ return await ctx.db.transaction(async (tx) => {
392
+ if (tx.dialect === 'pg' && tx.rawQuery) {
393
+ await tx.rawQuery('SELECT pg_advisory_xact_lock(hashtext(?))', [`fortress:tenant-default:${userId}`]);
394
+ }
395
+ return operation(tx);
396
+ });
397
+ }
398
+ finally {
399
+ release();
400
+ if (defaultTenantQueues.get(userId) === current)
401
+ defaultTenantQueues.delete(userId);
402
+ }
403
+ };
404
+
405
+ return {
406
+ async createTenant(input: { name: string; taxId: string; description?: string }): Promise<TenantRecord> {
407
+ const existing = await findTenantByTaxId(input.taxId);
408
+ if (existing)
409
+ throw Errors.conflict(`Tenant with taxId '${input.taxId}' already exists`);
410
+
411
+ // Create the row and its schema atomically: PostgreSQL DDL is
412
+ // transactional, so a failed schema/DDL step rolls the tenant row back.
413
+ return ctx.db.transaction(async (tx) => {
414
+ const tenant = await tx.create<TenantRecord>({
415
+ model: 'tenant',
416
+ data: {
417
+ name: input.name,
418
+ taxId: input.taxId,
419
+ description: input.description ?? null,
420
+ },
421
+ });
422
+
423
+ if (tx.dialect === 'pg' && tx.rawQuery) {
424
+ const schemaName = tenantSchemaName(tenant.id); // numeric id ⇒ safe
425
+ await tx.rawQuery(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
426
+ if (config.onSchemaCreated)
427
+ await config.onSchemaCreated(schemaName, tx.rawQuery.bind(tx));
428
+ }
429
+
430
+ return tenant;
431
+ });
432
+ },
433
+
434
+ async deleteTenant(input: { id: string }): Promise<{ ok: true }> {
435
+ const id = String(input.id ?? '');
436
+ if (!id)
437
+ throw Errors.badRequest('id is required');
438
+
439
+ const tenant = await ctx.db.findOne<TenantRecord>({
440
+ model: 'tenant',
441
+ where: [{ field: 'id', operator: '=', value: id }],
442
+ });
443
+ if (!tenant)
444
+ throw Errors.notFound(`Tenant '${id}' not found`);
445
+
446
+ await ctx.db.transaction(async (tx) => {
447
+ await tx.delete({
448
+ model: 'tenant_user',
449
+ where: [{ field: 'tenantId', operator: '=', value: id }],
450
+ });
451
+ await tx.delete({
452
+ model: 'tenant',
453
+ where: [{ field: 'id', operator: '=', value: id }],
454
+ });
455
+ if (config.dropSchemaOnDelete && tx.dialect === 'pg' && tx.rawQuery) {
456
+ const schemaName = tenantSchemaName(id); // numeric id ⇒ safe
457
+ await tx.rawQuery(`DROP SCHEMA IF EXISTS ${schemaName} CASCADE`);
458
+ }
459
+ });
460
+
461
+ return { ok: true };
462
+ },
463
+
464
+ async addUserToTenant(userId: string, tenantId: string): Promise<void> {
465
+ await withDefaultTenantLock(userId, async (tx) => {
466
+ const existing = await tx.findOne<TenantUserRecord>({
467
+ model: 'tenant_user',
468
+ where: [
469
+ { field: 'userId', operator: '=', value: userId },
470
+ { field: 'tenantId', operator: '=', value: tenantId },
471
+ ],
472
+ });
473
+ if (existing)
474
+ return;
475
+
476
+ const memberships = await tx.findMany<TenantUserRecord>({
477
+ model: 'tenant_user',
478
+ where: [{ field: 'userId', operator: '=', value: userId }],
479
+ });
480
+ await tx.create({
481
+ model: 'tenant_user',
482
+ data: {
483
+ tenantId,
484
+ userId,
485
+ isDefault: memberships.length === 0,
486
+ },
487
+ });
488
+ });
489
+ },
490
+
491
+ async getUserTenants(userId: string): Promise<TenantRecord[]> {
492
+ return listUserTenants(userId);
493
+ },
494
+
495
+ async getMyTenants(input: { userId?: string }, routeCtx?: PluginRouteContext): Promise<{ tenants: TenantRecord[] }> {
496
+ return { tenants: await listUserTenants(requireUserId(input, routeCtx)) };
497
+ },
498
+
499
+ async switchTenant(input: { taxId: string; userId?: string }, routeCtx?: PluginRouteContext): Promise<{ ok: true }> {
500
+ const userId = requireUserId(input, routeCtx);
501
+
502
+ const tenant = await findTenantByTaxId(input.taxId);
503
+ if (!tenant)
504
+ throw Errors.notFound(`Tenant '${input.taxId}' not found`);
505
+
506
+ await withDefaultTenantLock(userId, async (tx) => {
507
+ const membership = await tx.findOne<TenantUserRecord>({
508
+ model: 'tenant_user',
509
+ where: [
510
+ { field: 'userId', operator: '=', value: userId },
511
+ { field: 'tenantId', operator: '=', value: tenant.id },
512
+ ],
513
+ });
514
+ if (!membership)
515
+ throw Errors.forbidden('User does not belong to this tenant');
516
+
517
+ // Partial unique indexes are checked during each row update on
518
+ // both SQLite and PostgreSQL, so a one-statement CASE flip can
519
+ // transiently collide when the target row is visited first. Two
520
+ // phases inside this serialized transaction avoid that hazard.
521
+ await tx.update({
522
+ model: 'tenant_user',
523
+ where: [
524
+ { field: 'userId', operator: '=', value: userId },
525
+ { field: 'isDefault', operator: '=', value: true },
526
+ ],
527
+ data: { isDefault: false },
528
+ });
529
+ await tx.update({
530
+ model: 'tenant_user',
531
+ where: [
532
+ { field: 'userId', operator: '=', value: userId },
533
+ { field: 'tenantId', operator: '=', value: tenant.id },
534
+ ],
535
+ data: { isDefault: true },
536
+ });
537
+ });
538
+
539
+ return { ok: true };
540
+ },
541
+ };
542
+ },
543
+ };
544
+ }