@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,548 @@
1
+ import type { DatabaseAdapter } from '../../adapters/database';
2
+ import type { FortressMigration, MigrationDialect } from './migrations';
3
+ import { Errors } from '../errors';
4
+ import { FORTRESS_INDEXES, FORTRESS_TABLES, getExpectedColumns, getFortressMigrations, getLatestMigrationVersion } from './migrations';
5
+
6
+ export interface MigrationStatus {
7
+ dialect: MigrationDialect;
8
+ currentVersion: number;
9
+ latestVersion: number;
10
+ pending: FortressMigration[];
11
+ applied: FortressMigration[];
12
+ upToDate: boolean;
13
+ hasVersionTable: boolean;
14
+ }
15
+
16
+ export interface MigrationApplyResult {
17
+ dialect: MigrationDialect;
18
+ fromVersion: number;
19
+ toVersion: number;
20
+ applied: FortressMigration[];
21
+ }
22
+
23
+ export interface MigrationDownResult {
24
+ dialect: MigrationDialect;
25
+ fromVersion: number;
26
+ toVersion: number;
27
+ rolledBack: FortressMigration[];
28
+ }
29
+
30
+ export interface MigrationDrift {
31
+ dialect: MigrationDialect;
32
+ currentVersion: number;
33
+ latestVersion: number;
34
+ missingVersionTable: boolean;
35
+ unknownFutureVersion: boolean;
36
+ pendingVersions: number[];
37
+ /**
38
+ * Fortress-owned tables expected by the runtime but missing from the
39
+ * live database. A non-empty list almost always means an upgrade was
40
+ * skipped or the database was provisioned from an out-of-date schema
41
+ * dump. Compared against {@link FORTRESS_TABLES}.
42
+ */
43
+ missingTables: string[];
44
+ /**
45
+ * Per-table columns that the bundled migration DDL defines but the live
46
+ * database is missing. Surfaces a partially-applied or hand-patched
47
+ * schema where the table exists but is missing a column added by a later
48
+ * migration. Expected columns are parsed from the migration SQL itself
49
+ * (see `getExpectedColumns`), so this works for any adapter. Only tables
50
+ * that are present in the live DB are inspected — fully missing tables are
51
+ * reported via {@link missingTables} instead.
52
+ */
53
+ missingColumns: { table: string; columns: string[] }[];
54
+ /** Required hot-path indexes absent from the live database. */
55
+ missingIndexes: string[];
56
+ }
57
+
58
+ function assertRawQuery(db: DatabaseAdapter): NonNullable<DatabaseAdapter['rawQuery']> {
59
+ if (!db.rawQuery) {
60
+ throw Errors.badRequest('Migration tooling requires a database adapter with rawQuery support');
61
+ }
62
+ return db.rawQuery.bind(db);
63
+ }
64
+
65
+ function splitSqlStatements(sql: string): string[] {
66
+ const withoutLineComments = sql
67
+ .split('\n')
68
+ .filter(line => !line.trimStart().startsWith('--'))
69
+ .join('\n');
70
+ return withoutLineComments
71
+ .split(';')
72
+ .map(statement => statement.trim())
73
+ .filter(statement => statement.length > 0);
74
+ }
75
+
76
+ async function executeSql(db: DatabaseAdapter, sql: string): Promise<void> {
77
+ const rawQuery = assertRawQuery(db);
78
+ for (const statement of splitSqlStatements(sql)) {
79
+ await rawQuery(statement);
80
+ }
81
+ }
82
+
83
+ async function tableExists(db: DatabaseAdapter, dialect: MigrationDialect): Promise<boolean> {
84
+ const rawQuery = assertRawQuery(db);
85
+ if (dialect === 'sqlite') {
86
+ const rows = await rawQuery<{ name: string }>(
87
+ 'SELECT name FROM sqlite_master WHERE type = \'table\' AND name = \'fortress_schema_version\'',
88
+ );
89
+ return rows.length > 0;
90
+ }
91
+
92
+ const rows = await rawQuery<{ table_name: string }>(
93
+ 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\' AND table_name = \'fortress_schema_version\'',
94
+ );
95
+ return rows.length > 0;
96
+ }
97
+
98
+ /**
99
+ * Enumerate every Fortress-owned table present in the live database. Used
100
+ * by drift detection to surface missing tables (e.g. an incomplete
101
+ * migration run or a freshly provisioned DB that never had Fortress
102
+ * tables created).
103
+ */
104
+ async function listFortressTables(
105
+ db: DatabaseAdapter,
106
+ dialect: MigrationDialect,
107
+ ): Promise<Set<string>> {
108
+ const rawQuery = assertRawQuery(db);
109
+ if (dialect === 'sqlite') {
110
+ const rows = await rawQuery<{ name: string }>(
111
+ 'SELECT name FROM sqlite_master WHERE type = \'table\' AND name LIKE \'fortress_%\'',
112
+ );
113
+ return new Set(rows.map(row => row.name));
114
+ }
115
+
116
+ const rows = await rawQuery<{ table_name: string }>(
117
+ 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\' AND table_name LIKE \'fortress_%\'',
118
+ );
119
+ return new Set(rows.map(row => row.table_name));
120
+ }
121
+
122
+ /**
123
+ * List the columns of a single Fortress table from the live database.
124
+ * Returns an empty set if the table does not exist. Uses catalog
125
+ * introspection (`PRAGMA table_info` / `information_schema.columns`) through
126
+ * `rawQuery`, so it stays adapter-agnostic. The table name is sourced from
127
+ * Fortress's own `FORTRESS_TABLES`, never user input.
128
+ */
129
+ async function listColumns(
130
+ db: DatabaseAdapter,
131
+ dialect: MigrationDialect,
132
+ table: string,
133
+ ): Promise<Set<string>> {
134
+ const rawQuery = assertRawQuery(db);
135
+ if (dialect === 'sqlite') {
136
+ const rows = await rawQuery<{ name: string }>(`PRAGMA table_info(${table})`);
137
+ return new Set(rows.map(row => row.name.toLowerCase()));
138
+ }
139
+
140
+ const rows = await rawQuery<{ column_name: string }>(
141
+ 'SELECT column_name FROM information_schema.columns WHERE table_schema = \'public\' AND table_name = ?',
142
+ [table],
143
+ );
144
+ return new Set(rows.map(row => row.column_name.toLowerCase()));
145
+ }
146
+
147
+ async function listFortressIndexes(
148
+ db: DatabaseAdapter,
149
+ dialect: MigrationDialect,
150
+ ): Promise<Set<string>> {
151
+ const rawQuery = assertRawQuery(db);
152
+ if (dialect === 'sqlite') {
153
+ const rows = await rawQuery<{ name: string }>(
154
+ 'SELECT name FROM sqlite_master WHERE type = \'index\' AND name NOT LIKE \'sqlite_autoindex_%\'',
155
+ );
156
+ return new Set(rows.map(row => row.name));
157
+ }
158
+
159
+ const rows = await rawQuery<{ indexname: string }>(
160
+ 'SELECT indexname FROM pg_indexes WHERE schemaname = \'public\' AND tablename LIKE \'fortress_%\'',
161
+ );
162
+ return new Set(rows.map(row => row.indexname));
163
+ }
164
+
165
+ async function readCurrentVersion(db: DatabaseAdapter, dialect: MigrationDialect): Promise<{ version: number; hasVersionTable: boolean }> {
166
+ const hasVersionTable = await tableExists(db, dialect);
167
+ if (!hasVersionTable)
168
+ return { version: 0, hasVersionTable };
169
+
170
+ const rawQuery = assertRawQuery(db);
171
+ const rows = await rawQuery<{ version: number | string }>('SELECT version FROM fortress_schema_version WHERE id = 1');
172
+ return { version: Number(rows[0]?.version ?? 0), hasVersionTable };
173
+ }
174
+
175
+ async function recordVersion(db: DatabaseAdapter, dialect: MigrationDialect, version: number): Promise<void> {
176
+ if (version <= 0)
177
+ return;
178
+ const rawQuery = assertRawQuery(db);
179
+ if (dialect === 'sqlite') {
180
+ await rawQuery(
181
+ 'INSERT INTO fortress_schema_version (id, version, applied_at) VALUES (1, ?, unixepoch()) ON CONFLICT(id) DO UPDATE SET version = excluded.version, applied_at = excluded.applied_at',
182
+ [version],
183
+ );
184
+ return;
185
+ }
186
+
187
+ await rawQuery(
188
+ 'INSERT INTO fortress_schema_version (id, version, applied_at) VALUES (1, ?, now()) ON CONFLICT(id) DO UPDATE SET version = excluded.version, applied_at = excluded.applied_at',
189
+ [version],
190
+ );
191
+ }
192
+
193
+ interface MigrationJournalRow {
194
+ version: number | string;
195
+ name: string;
196
+ dialect: string;
197
+ checksum: string;
198
+ }
199
+
200
+ /** SHA-256 of every immutable migration input, including runtime data-step identity. */
201
+ export async function computeMigrationChecksum(migration: FortressMigration): Promise<string> {
202
+ const payload = JSON.stringify([
203
+ migration.dialect,
204
+ migration.version,
205
+ migration.name,
206
+ migration.up,
207
+ migration.down,
208
+ migration.freshUp ?? null,
209
+ migration.dataStep ?? null,
210
+ migration.beforeUp?.toString() ?? null,
211
+ ]);
212
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
213
+ return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
214
+ }
215
+
216
+ async function ensureMigrationMetadata(db: DatabaseAdapter, dialect: MigrationDialect): Promise<void> {
217
+ const rawQuery = assertRawQuery(db);
218
+ if (dialect === 'sqlite') {
219
+ // The singleton write forces a database-level writer lock even for
220
+ // adapters whose transaction begins deferred. Separate processes then
221
+ // serialize before the checkpoint is re-read.
222
+ await rawQuery(`CREATE TABLE IF NOT EXISTS fortress_migration_lock (
223
+ id INTEGER PRIMARY KEY CHECK (id = 1),
224
+ touched_at INTEGER NOT NULL
225
+ )`);
226
+ await rawQuery(
227
+ `INSERT INTO fortress_migration_lock (id, touched_at) VALUES (1, unixepoch())
228
+ ON CONFLICT(id) DO UPDATE SET touched_at = excluded.touched_at`,
229
+ );
230
+ await rawQuery(`CREATE TABLE IF NOT EXISTS fortress_migration_journal (
231
+ version INTEGER PRIMARY KEY,
232
+ name TEXT NOT NULL,
233
+ dialect TEXT NOT NULL,
234
+ checksum TEXT NOT NULL,
235
+ applied_at INTEGER NOT NULL DEFAULT (unixepoch())
236
+ )`);
237
+ await rawQuery(`CREATE TABLE IF NOT EXISTS fortress_migration_state (
238
+ id INTEGER PRIMARY KEY CHECK (id = 1),
239
+ journal_initialized INTEGER NOT NULL DEFAULT 0
240
+ )`);
241
+ await rawQuery(
242
+ `INSERT INTO fortress_migration_state (id, journal_initialized) VALUES (1, 0)
243
+ ON CONFLICT(id) DO NOTHING`,
244
+ );
245
+ return;
246
+ }
247
+
248
+ // Transaction-scoped: releases automatically on commit and rollback and
249
+ // stays bound to the transaction adapter's one pooled connection.
250
+ await rawQuery('SELECT pg_advisory_xact_lock(117993, 0)');
251
+ const journalTable = await rawQuery<{ name: string | null }>(
252
+ `SELECT to_regclass('public.fortress_migration_journal') AS name`,
253
+ );
254
+ if (!journalTable[0]?.name) {
255
+ await rawQuery(`CREATE TABLE fortress_migration_journal (
256
+ version INTEGER PRIMARY KEY,
257
+ name TEXT NOT NULL,
258
+ dialect TEXT NOT NULL,
259
+ checksum VARCHAR(64) NOT NULL,
260
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
261
+ )`);
262
+ }
263
+ const stateTable = await rawQuery<{ name: string | null }>(
264
+ `SELECT to_regclass('public.fortress_migration_state') AS name`,
265
+ );
266
+ if (!stateTable[0]?.name) {
267
+ await rawQuery(`CREATE TABLE fortress_migration_state (
268
+ id INTEGER PRIMARY KEY CHECK (id = 1),
269
+ journal_initialized BOOLEAN NOT NULL DEFAULT false
270
+ )`);
271
+ }
272
+ await rawQuery(
273
+ `INSERT INTO fortress_migration_state (id, journal_initialized) VALUES (1, false)
274
+ ON CONFLICT(id) DO NOTHING`,
275
+ );
276
+ }
277
+
278
+ async function readJournal(db: DatabaseAdapter): Promise<MigrationJournalRow[]> {
279
+ return assertRawQuery(db)<MigrationJournalRow>(
280
+ 'SELECT version, name, dialect, checksum FROM fortress_migration_journal ORDER BY version',
281
+ );
282
+ }
283
+
284
+ async function isJournalInitialized(db: DatabaseAdapter): Promise<boolean> {
285
+ const rows = await assertRawQuery(db)<{ journal_initialized: boolean | number | string }>(
286
+ 'SELECT journal_initialized FROM fortress_migration_state WHERE id = 1',
287
+ );
288
+ const value = rows[0]?.journal_initialized;
289
+ return value === true || value === 1 || value === '1' || value === 'true';
290
+ }
291
+
292
+ async function markJournalInitialized(db: DatabaseAdapter, dialect: MigrationDialect): Promise<void> {
293
+ await assertRawQuery(db)(
294
+ `UPDATE fortress_migration_state SET journal_initialized = ${dialect === 'pg' ? 'true' : '1'} WHERE id = 1`,
295
+ );
296
+ }
297
+
298
+ async function insertJournal(
299
+ db: DatabaseAdapter,
300
+ dialect: MigrationDialect,
301
+ migration: FortressMigration,
302
+ ): Promise<void> {
303
+ const rawQuery = assertRawQuery(db);
304
+ const checksum = await computeMigrationChecksum(migration);
305
+ if (dialect === 'sqlite') {
306
+ await rawQuery(
307
+ `INSERT INTO fortress_migration_journal (version, name, dialect, checksum, applied_at)
308
+ VALUES (?, ?, ?, ?, unixepoch())`,
309
+ [migration.version, migration.name, dialect, checksum],
310
+ );
311
+ return;
312
+ }
313
+ await rawQuery(
314
+ `INSERT INTO fortress_migration_journal (version, name, dialect, checksum, applied_at)
315
+ VALUES (?, ?, ?, ?, now())`,
316
+ [migration.version, migration.name, dialect, checksum],
317
+ );
318
+ }
319
+
320
+ async function verifyOrBackfillJournal(
321
+ db: DatabaseAdapter,
322
+ dialect: MigrationDialect,
323
+ currentVersion: number,
324
+ ): Promise<void> {
325
+ const migrations = getFortressMigrations(dialect);
326
+ const latestVersion = getLatestMigrationVersion(dialect);
327
+ if (currentVersion > latestVersion) {
328
+ throw Errors.badRequest(
329
+ `Database schema version ${currentVersion} is newer than bundled version ${latestVersion}`,
330
+ );
331
+ }
332
+
333
+ let rows = await readJournal(db);
334
+ const initialized = await isJournalInitialized(db);
335
+ // Compatibility bridge for installations created before the journal. The
336
+ // persistent state marker makes this a one-time transition: once initialized,
337
+ // even complete row loss is corruption and must not be silently re-certified.
338
+ if (!initialized && rows.length === 0 && currentVersion > 0) {
339
+ for (const migration of migrations.filter(item => item.version <= currentVersion))
340
+ await insertJournal(db, dialect, migration);
341
+ rows = await readJournal(db);
342
+ }
343
+
344
+ const byVersion = new Map(rows.map(row => [Number(row.version), row]));
345
+ for (const row of rows) {
346
+ const version = Number(row.version);
347
+ const migration = migrations.find(item => item.version === version);
348
+ if (!migration || version > currentVersion) {
349
+ throw Errors.badRequest(`Migration journal contains unexpected version ${version}`);
350
+ }
351
+ const checksum = await computeMigrationChecksum(migration);
352
+ if (row.name !== migration.name || row.dialect !== dialect || row.checksum !== checksum) {
353
+ throw Errors.badRequest(`Migration journal integrity check failed for version ${version}`);
354
+ }
355
+ }
356
+ for (const migration of migrations.filter(item => item.version <= currentVersion)) {
357
+ if (!byVersion.has(migration.version)) {
358
+ throw Errors.badRequest(`Migration journal is missing version ${migration.version}`);
359
+ }
360
+ }
361
+ if (!initialized)
362
+ await markJournalInitialized(db, dialect);
363
+ }
364
+
365
+ async function deleteJournalVersion(db: DatabaseAdapter, version: number): Promise<void> {
366
+ await assertRawQuery(db)(
367
+ 'DELETE FROM fortress_migration_journal WHERE version = ?',
368
+ [version],
369
+ );
370
+ }
371
+
372
+ async function dropMigrationMetadata(db: DatabaseAdapter, dialect: MigrationDialect): Promise<void> {
373
+ const rawQuery = assertRawQuery(db);
374
+ await rawQuery('DROP TABLE IF EXISTS fortress_migration_journal');
375
+ await rawQuery('DROP TABLE IF EXISTS fortress_migration_state');
376
+ if (dialect === 'sqlite')
377
+ await rawQuery('DROP TABLE IF EXISTS fortress_migration_lock');
378
+ }
379
+
380
+ function assertTargetVersion(targetVersion: number): void {
381
+ if (!Number.isSafeInteger(targetVersion) || targetVersion < 0)
382
+ throw Errors.badRequest('Migration target version must be a non-negative safe integer');
383
+ }
384
+
385
+ // better-sqlite3 waits synchronously on a cross-connection writer lock, which
386
+ // can starve the first transaction's Promise continuation in the same process.
387
+ // Serialize local migration callers as well; the database write lock remains
388
+ // the cross-process authority.
389
+ let sqliteMigrationChain: Promise<void> = Promise.resolve();
390
+
391
+ function withMigrationTransaction<T>(
392
+ db: DatabaseAdapter,
393
+ dialect: MigrationDialect,
394
+ fn: (tx: DatabaseAdapter) => Promise<T>,
395
+ ): Promise<T> {
396
+ if (dialect === 'pg')
397
+ return db.transaction(fn);
398
+ const result = sqliteMigrationChain.then(
399
+ () => db.transaction(fn),
400
+ () => db.transaction(fn),
401
+ );
402
+ sqliteMigrationChain = result.then(() => undefined, () => undefined);
403
+ return result;
404
+ }
405
+
406
+ export async function getMigrationStatus(
407
+ db: DatabaseAdapter,
408
+ dialect: MigrationDialect = db.dialect === 'pg' ? 'pg' : 'sqlite',
409
+ ): Promise<MigrationStatus> {
410
+ const migrations = getFortressMigrations(dialect);
411
+ const latestVersion = getLatestMigrationVersion(dialect);
412
+ const { version: currentVersion, hasVersionTable } = await readCurrentVersion(db, dialect);
413
+ return {
414
+ dialect,
415
+ currentVersion,
416
+ latestVersion,
417
+ pending: migrations.filter(migration => migration.version > currentVersion),
418
+ applied: migrations.filter(migration => migration.version <= currentVersion),
419
+ upToDate: currentVersion >= latestVersion,
420
+ hasVersionTable,
421
+ };
422
+ }
423
+
424
+ export async function migrateUp(
425
+ db: DatabaseAdapter,
426
+ dialect: MigrationDialect = db.dialect === 'pg' ? 'pg' : 'sqlite',
427
+ targetVersion: number = getLatestMigrationVersion(dialect),
428
+ ): Promise<MigrationApplyResult> {
429
+ assertTargetVersion(targetVersion);
430
+ return withMigrationTransaction(db, dialect, async (tx) => {
431
+ await ensureMigrationMetadata(tx, dialect);
432
+ // Mandatory post-lock read: no plan computed before this point is trusted.
433
+ const { version: currentVersion } = await readCurrentVersion(tx, dialect);
434
+ await verifyOrBackfillJournal(tx, dialect, currentVersion);
435
+
436
+ const toApply = getFortressMigrations(dialect)
437
+ .filter(migration => migration.version > currentVersion && migration.version <= targetVersion);
438
+ for (const migration of toApply) {
439
+ await migration.beforeUp?.(tx);
440
+ await executeSql(tx, migration.up);
441
+ await insertJournal(tx, dialect, migration);
442
+ await recordVersion(tx, dialect, migration.version);
443
+ }
444
+
445
+ const toVersion = toApply.at(-1)?.version ?? currentVersion;
446
+ if (toVersion === 0)
447
+ await dropMigrationMetadata(tx, dialect);
448
+ return {
449
+ dialect,
450
+ fromVersion: currentVersion,
451
+ toVersion,
452
+ applied: toApply,
453
+ };
454
+ });
455
+ }
456
+
457
+ export async function migrateDown(
458
+ db: DatabaseAdapter,
459
+ dialect: MigrationDialect = db.dialect === 'pg' ? 'pg' : 'sqlite',
460
+ targetVersion: number = 0,
461
+ ): Promise<MigrationDownResult> {
462
+ assertTargetVersion(targetVersion);
463
+ return withMigrationTransaction(db, dialect, async (tx) => {
464
+ await ensureMigrationMetadata(tx, dialect);
465
+ const { version: currentVersion } = await readCurrentVersion(tx, dialect);
466
+ await verifyOrBackfillJournal(tx, dialect, currentVersion);
467
+
468
+ // A target above current is an idempotent no-op, never a version advance.
469
+ const effectiveTarget = Math.min(targetVersion, currentVersion);
470
+ const migrations = getFortressMigrations(dialect);
471
+ if (
472
+ effectiveTarget > 0
473
+ && effectiveTarget < currentVersion
474
+ && !migrations.some(migration => migration.version === effectiveTarget)
475
+ ) {
476
+ throw Errors.badRequest(`Unknown migration target version ${effectiveTarget}`);
477
+ }
478
+ const toRollback = migrations
479
+ .filter(migration => migration.version <= currentVersion && migration.version > effectiveTarget)
480
+ .sort((a, b) => b.version - a.version);
481
+
482
+ for (const migration of toRollback) {
483
+ await executeSql(tx, migration.down);
484
+ await deleteJournalVersion(tx, migration.version);
485
+ }
486
+ if (effectiveTarget === 0)
487
+ await dropMigrationMetadata(tx, dialect);
488
+ else if (toRollback.length > 0)
489
+ await recordVersion(tx, dialect, effectiveTarget);
490
+
491
+ return {
492
+ dialect,
493
+ fromVersion: currentVersion,
494
+ toVersion: effectiveTarget,
495
+ rolledBack: toRollback,
496
+ };
497
+ });
498
+ }
499
+
500
+ export async function detectMigrationDrift(
501
+ db: DatabaseAdapter,
502
+ dialect: MigrationDialect = db.dialect === 'pg' ? 'pg' : 'sqlite',
503
+ ): Promise<MigrationDrift> {
504
+ const status = await getMigrationStatus(db, dialect);
505
+ const present = await listFortressTables(db, dialect);
506
+ const missingTables = FORTRESS_TABLES.filter(name => !present.has(name));
507
+
508
+ // Column-level drift: inspect only tables that actually exist (fully
509
+ // missing tables are already reported above). Expected columns come from
510
+ // the bundled migration DDL, so this catches a table that was created
511
+ // from an older schema and never got a later migration's new column.
512
+ const expectedColumns = getExpectedColumns(dialect);
513
+ const missingColumns: { table: string; columns: string[] }[] = [];
514
+ for (const [table, columns] of Object.entries(expectedColumns)) {
515
+ if (!present.has(table))
516
+ continue;
517
+ const live = await listColumns(db, dialect, table);
518
+ const missing = columns.filter(column => !live.has(column));
519
+ if (missing.length > 0)
520
+ missingColumns.push({ table, columns: missing });
521
+ }
522
+
523
+ const presentIndexes = await listFortressIndexes(db, dialect);
524
+ const missingIndexes = FORTRESS_INDEXES
525
+ .map(index => index.name)
526
+ .filter(name => !presentIndexes.has(name));
527
+
528
+ return {
529
+ dialect,
530
+ currentVersion: status.currentVersion,
531
+ latestVersion: status.latestVersion,
532
+ missingVersionTable: !status.hasVersionTable,
533
+ unknownFutureVersion: status.currentVersion > status.latestVersion,
534
+ pendingVersions: status.pending.map(migration => migration.version),
535
+ missingTables,
536
+ missingColumns,
537
+ missingIndexes,
538
+ };
539
+ }
540
+
541
+ export function hasMigrationDrift(drift: MigrationDrift): boolean {
542
+ return drift.missingVersionTable
543
+ || drift.unknownFutureVersion
544
+ || drift.pendingVersions.length > 0
545
+ || drift.missingTables.length > 0
546
+ || drift.missingColumns.length > 0
547
+ || drift.missingIndexes.length > 0;
548
+ }